├── .github └── workflows │ └── pypi-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── imgs ├── c1.png ├── logo.png ├── s1.png ├── s2.png └── s3.png ├── nonebot_plugin_sideload ├── __init__.py └── config.py ├── pyproject.toml └── sideload_webui ├── .metadata ├── README.md ├── analysis_options.yaml ├── lib ├── assets │ └── logo.png ├── main.dart ├── pages │ ├── chat_group.dart │ ├── main_page.dart │ ├── mobile │ │ ├── group.dart │ │ ├── main_page.dart │ │ └── private.dart │ └── private_chat.dart └── utils │ ├── core.dart │ ├── global.dart │ ├── widgets.dart │ └── ws_handler.dart ├── pubspec.lock ├── pubspec.yaml ├── sideload_webui.iml └── web ├── favicon.png ├── icons └── icon.png ├── index.html └── manifest.json /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | name: Publish 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | submodules: true 19 | 20 | 21 | - name: Fetch Chrome 22 | uses: browser-actions/setup-chrome@v1 23 | id: setup-chrome 24 | - run: export CHROME_EXECUTABLE=${{ steps.setup-chrome.outputs.chrome-path }} 25 | 26 | - name: Setup Flutter SDK 27 | uses: flutter-actions/setup-flutter@v4 28 | with: 29 | channel: stable 30 | version: 3.22.1 31 | 32 | 33 | - name: Build 34 | run: | 35 | cd sideload_webui/ 36 | flutter pub get 37 | flutter build web --pwa-strategy none --web-renderer canvaskit --release --base-href "/nbgui/v1/sideload/" 38 | 39 | - name: Copy build files 40 | run: | 41 | cd sideload_webui/ 42 | rm build/web/flutter_service_worker.js 43 | cp build/web ../nonebot_plugin_sideload/ -r 44 | cd ../ 45 | 46 | - name: Set up Python 47 | uses: actions/setup-python@v1 48 | with: 49 | python-version: "3.x" 50 | - name: Install pypa/build 51 | run: >- 52 | python -m 53 | pip install 54 | build 55 | --user 56 | - name: Build a binary wheel and a source tarball 57 | run: >- 58 | python -m 59 | build 60 | --sdist 61 | --wheel 62 | --outdir dist/ 63 | . 64 | - name: Publish distribution to PyPI 65 | uses: pypa/gh-action-pypi-publish@release/v1 66 | with: 67 | password: ${{ secrets.PYPI_API_TOKEN }} 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | sideload_webui/*.class 3 | sideload_webui/*.log 4 | sideload_webui/*.pyc 5 | sideload_webui/*.swp 6 | sideload_webui/.DS_Store 7 | sideload_webui/.atom/ 8 | sideload_webui/.buildlog/ 9 | sideload_webui/.history 10 | sideload_webui/.svn/ 11 | sideload_webui/migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | sideload_webui*/.iml 15 | sideload_webui*/.ipr 16 | sideload_webui*/.iws 17 | sideload_webui/.idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | sideload_webui/**/doc/api/ 26 | sideload_webui/**/ios/Flutter/.last_build_id 27 | sideload_webui/.dart_tool/ 28 | sideload_webui/.flutter-plugins 29 | sideload_webui/.flutter-plugins-dependencies 30 | sideload_webui/.pub-cache/ 31 | sideload_webui/.pub/ 32 | sideload_webui/build/ 33 | 34 | # Symbolication related 35 | sideload_webui/app.*.symbols 36 | 37 | # Obfuscation related 38 | sideload_webui/app.*.map.json 39 | 40 | # Android Studio will place build artifacts here 41 | sideload_webui/android/app/debug 42 | sideload_webui/android/app/profile 43 | sideload_webui/android/app/release 44 | 45 | sideload_webui/.vscode 46 | sideload_webui/build.sh -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | NoneBotPluginLogo 3 |
4 |

NoneBotPluginText

5 |
6 | 7 |
8 | 9 | # nonebot-plugin-sideload 10 | 11 | _✨ 为你的NoneBot侧载一个OneBot V11 Web聊天面板 ✨_ 12 | 13 | 14 | 15 | license 16 | 17 | 18 | pypi 19 | 20 | python 21 | QQ Chat Group 22 | 23 |
24 | 25 | 26 | 27 | ## 📖 介绍 28 | 29 | 为你的NoneBot侧载一个OneBot V11 Web聊天面板,连接到Bot即可使用 30 | 31 | ## 💿 安装 32 | 33 |
34 | 使用 nb-cli 安装 35 | 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装 36 | 37 | nb plugin install nonebot-plugin-sideload 38 | 39 |
40 | 41 |
42 | 使用包管理器安装 43 | 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令 44 | 45 |
46 | pip 47 | 48 | pip install nonebot-plugin-sideload 49 |
50 |
51 | pdm 52 | 53 | pdm add nonebot-plugin-sideload 54 |
55 |
56 | poetry 57 | 58 | poetry add nonebot-plugin-sideload 59 |
60 |
61 | conda 62 | 63 | conda install nonebot-plugin-sideload 64 |
65 | 66 | 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入 67 | 68 | plugins = ["nonebot_plugin_sideload"] 69 | 70 |
71 | 72 | ## ⚙️ 配置 73 | 74 | | 配置项 | 必填 | 默认值 | 说明 | 75 | |:-----:|:----:|:----:|:----:| 76 | | sideload_password | 是 | abc123456 | 面板的访问密码 | 77 | 78 | 79 | ## 🎉 使用 80 | 81 | Bot连接后,控制台会输出面板的地址,在浏览器中打开即可访问,一般来说,它是长这样子的: 82 | 83 | ``` 84 | http://ip:port/nbgui/v1/sideload 85 | ``` 86 | 87 | 88 | ## 📑 TODO List 89 | 90 | - [x] 图片显示 91 | - [x] +1按钮 92 | - [x] 消息时间显示 93 | - [x] 性能优化 94 | - [ ] 持久化登录 95 | - [ ] 用户自定义设置 96 | - [ ] 好友/加群申请 97 | - [ ] 群成员管理 98 | - [ ] 消息右键菜单 99 | - [ ] 用户信息 100 | - [ ] 群信息 101 | - [ ] 发送图片 102 | - [ ] 消息撤回 103 | - [ ] 修复图片错位问题 104 | - [X] 暂时想不到了qwp 105 | 106 | 107 | 108 | ## 🖼️ 效果图 109 | 110 | ![image](imgs/s1.png) 111 | 112 | ![image](imgs/s2.png) 113 | 114 | ![image](imgs/s3.png) 115 | -------------------------------------------------------------------------------- /imgs/c1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/imgs/c1.png -------------------------------------------------------------------------------- /imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/imgs/logo.png -------------------------------------------------------------------------------- /imgs/s1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/imgs/s1.png -------------------------------------------------------------------------------- /imgs/s2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/imgs/s2.png -------------------------------------------------------------------------------- /imgs/s3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/imgs/s3.png -------------------------------------------------------------------------------- /nonebot_plugin_sideload/__init__.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import nonebot.exception 3 | from nonebot.plugin import PluginMetadata 4 | from nonebot.log import logger 5 | from nonebot import get_driver, on_notice 6 | from nonebot.adapters.onebot.v11 import Bot, Event, PrivateMessageEvent, GroupMessageEvent, GroupRecallNoticeEvent, FriendRecallNoticeEvent 7 | from os import path 8 | import nonebot 9 | from nonebot.drivers import URL, ASGIMixin, WebSocket, WebSocketServerSetup, HTTPServerSetup, Request, Response 10 | import httpx 11 | from nonebot.message import event_preprocessor 12 | import aiosqlite 13 | from nonebot import require 14 | require("nonebot_plugin_localstore") 15 | import nonebot_plugin_localstore as store 16 | from nonebot import get_plugin_config 17 | from .config import Config 18 | import datetime 19 | import json 20 | from pathlib import Path 21 | import os 22 | 23 | plugin_config = get_plugin_config(Config) 24 | password = plugin_config.sideload_password 25 | data_dir = store.get_plugin_data_dir() 26 | driver = get_driver() 27 | 28 | 29 | __plugin_meta__ = PluginMetadata( 30 | name="Web侧载", 31 | description="为你的NoneBot侧载一个OneBot V11 Web聊天面板", 32 | usage="连接Bot即可使用", 33 | type="application", 34 | homepage="https://github.com/NonebotGUI/nonebot-plugin-sideload", 35 | config=Config, 36 | supported_adapters={"~onebot.v11"}, 37 | ) 38 | 39 | 40 | # 画个LOGO(?) 41 | logger.success(' _ _ ____ _ ') 42 | logger.success(' | \ | | ___ _ __ ___| __ ) ___ | |_ ') 43 | logger.success(' | \| |/ _ \| _ \ / _ \ _ \ / _ \| __| ') 44 | logger.success(' | |\ | (_) | | | | __/ |_) | (_) | |_ ') 45 | logger.success(' |_| \_|\___/|_| |_|\___|____/ \___/ \__| ') 46 | logger.success(' / ___|(_) __| | ___| | ___ __ _ __| |') 47 | logger.success(' \___ \| |/ _` |/ _ \ | / _ \ / _` |/ _` |') 48 | logger.success(' ___) | | (_| | __/ |__| (_) | (_| | (_| |') 49 | logger.success(' |____/|_|\__,_|\___|_____\___/ \__,_|\__,_|') 50 | 51 | logger.warning('等待Bot连接...') 52 | global is_connected 53 | is_connected = False 54 | 55 | 56 | 57 | # 初始化数据库 58 | @driver.on_bot_connect 59 | async def handle_bot_connect(bot: Bot): 60 | global group_avatar_dir, friend_avatar_dir, image_dir, is_connected 61 | # 创建目录 62 | group_avatar_dir = Path(f'{str(data_dir)}/group_avatar') 63 | if not group_avatar_dir.exists(): 64 | os.makedirs(group_avatar_dir) 65 | friend_avatar_dir = Path(f'{str(data_dir)}/friend_avatar') 66 | if not friend_avatar_dir.exists(): 67 | os.makedirs(friend_avatar_dir) 68 | image_dir = Path(f'{str(data_dir)}/image') 69 | if not image_dir.exists(): 70 | os.makedirs(image_dir) 71 | is_connected = True 72 | global bot_id, bot_nickname 73 | bot_id = bot.self_id 74 | nickname = await bot.call_api('get_login_info') 75 | nickname = nickname['nickname'] 76 | bot_nickname = str(nickname) 77 | # 尝试拿用户ip 78 | try: 79 | import netifaces 80 | def get_all_network_ips(): 81 | """获取所有网络接口的IP地址(排除回环接口)""" 82 | ipv4_list = [] 83 | ipv6_list = [] 84 | interfaces = netifaces.interfaces() 85 | for interface in interfaces: 86 | 87 | if interface.startswith('lo'): 88 | continue 89 | try: 90 | addresses = netifaces.ifaddresses(interface) 91 | if netifaces.AF_INET in addresses: 92 | for addr in addresses[netifaces.AF_INET]: 93 | ip = addr['addr'] 94 | if not ip.startswith('127.'): 95 | ipv4_list.append(ip) 96 | if netifaces.AF_INET6 in addresses: 97 | for addr in addresses[netifaces.AF_INET6]: 98 | ip = addr['addr'] 99 | if not ip.startswith('fe80:'): 100 | if '%' in ip: 101 | ip = ip.split('%')[0] 102 | ipv6_list.append(ip) 103 | except Exception: 104 | pass 105 | return {"ipv4": ipv4_list, "ipv6": ipv6_list} 106 | public_ips = get_all_network_ips() 107 | 108 | logger.success("=====================================================") 109 | if public_ips["ipv4"] or public_ips["ipv6"]: 110 | logger.success(f"Bot {bot_id} 已连接,现在可以访问以下地址进入WebUI:") 111 | for ip in public_ips["ipv4"]: 112 | logger.success(f"http://{ip}:{nonebot.get_driver().config.port}/nbgui/v1/sideload") 113 | logger.success(f"WebSocket 地址: ws://{ip}:{nonebot.get_driver().config.port}/nbgui/v1/sideload/ws") 114 | for ip in public_ips["ipv6"]: 115 | logger.success(f"http://[{ip}]:{nonebot.get_driver().config.port}/nbgui/v1/sideload") 116 | logger.success(f"WebSocket 地址: ws://[{ip}]:{nonebot.get_driver().config.port}/nbgui/v1/sideload/ws") 117 | else: 118 | logger.warning('获取IP地址失败') 119 | logger.success(f"Bot {bot_id} 已连接,现在可以访问 http://ip:port/nbgui/v1/sideload 进入 WebUI") 120 | logger.success(f"Websocket 地址为 ws://ip:port/nbgui/v1/sideload/ws") 121 | logger.success("=====================================================") 122 | except ImportError: 123 | # 如果没有安装netifaces,则使用socket模块作为备选 124 | try: 125 | import socket 126 | hostname = socket.gethostname() 127 | ip_addresses = socket.gethostbyname_ex(hostname)[2] 128 | public_ips = [ip for ip in ip_addresses if not ip.startswith('127.')] 129 | 130 | logger.success("=====================================================") 131 | if public_ips: 132 | logger.success(f"Bot {bot_id} 已连接,现在可以访问以下地址进入WebUI:") 133 | for ip in public_ips: 134 | logger.success(f"http://{ip}:{nonebot.get_driver().config.port}/nbgui/v1/sideload") 135 | logger.success(f"WebSocket 地址: ws://{ip}:{nonebot.get_driver().config.port}/nbgui/v1/sideload/ws") 136 | else: 137 | logger.warning('获取IP地址失败') 138 | logger.success(f"Bot {bot_id} 已连接,现在可以访问 http://ip:port/nbgui/v1/sideload 进入 WebUI") 139 | logger.success(f"Websocket 地址为 ws://ip:port/nbgui/v1/sideload/ws") 140 | logger.success("=====================================================") 141 | except Exception as e: 142 | logger.error(f"获取IP地址失败: {e}") 143 | logger.success(f"Bot {bot_id} 已连接,现在可以访问 http://ip:port/nbgui/v1/sideload 进入 WebUI") 144 | db_path = str(data_dir) + f'/{bot_id}.db' 145 | global db, cursor, rec, sen 146 | # 连接数据库 147 | db = await aiosqlite.connect(db_path) 148 | cursor = await db.cursor() 149 | await cursor.execute(''' 150 | CREATE TABLE IF NOT EXISTS total( 151 | id text, 152 | nickname text, 153 | sended int, 154 | received int, 155 | group_list text, 156 | friend_list text 157 | ) 158 | ''') 159 | 160 | await cursor.execute(''' 161 | CREATE TABLE IF NOT EXISTS groups( 162 | id text, 163 | group_name text, 164 | sender text, 165 | message text, 166 | type text, 167 | msg_id text, 168 | time text, 169 | drawed text 170 | )''') 171 | 172 | await cursor.execute(''' 173 | CREATE TABLE IF NOT EXISTS friends( 174 | id text, 175 | nickname text, 176 | message text, 177 | sender text, 178 | type text, 179 | msg_id text, 180 | time text, 181 | drawed text 182 | )''') 183 | 184 | await cursor.execute('SELECT COUNT(*) FROM total WHERE id = ? AND nickname = ?', (bot_id, nickname)) 185 | result = await cursor.fetchone() 186 | if result[0] == 0: 187 | await cursor.execute('INSERT INTO total (id, nickname, sended, received) VALUES (?, ?, 0, 0)', (bot_id, nickname)) 188 | 189 | await cursor.execute('SELECT received FROM total WHERE id = ?', (bot_id,)) 190 | received = (await cursor.fetchone())[0] 191 | rec = received 192 | 193 | await cursor.execute('SELECT sended FROM total WHERE id = ?', (bot_id,)) 194 | sended = (await cursor.fetchone())[0] 195 | sen = sended 196 | 197 | group_list = await bot.call_api('get_group_list') 198 | friend_list = await bot.call_api('get_friend_list') 199 | 200 | await cursor.execute('UPDATE total SET group_list = ? WHERE id = ?', (str(group_list), bot_id)) 201 | await cursor.execute('UPDATE total SET friend_list = ? WHERE id = ?', (str(friend_list), bot_id)) 202 | await db.commit() 203 | 204 | @driver.on_bot_disconnect 205 | async def handle_bot_disconnect(bot: Bot): 206 | global is_connected 207 | is_connected = False 208 | if db: 209 | await cursor.close() 210 | await db.close() 211 | 212 | # 统计接收消息数量 213 | @event_preprocessor 214 | async def _(bot: Bot, event: Event): 215 | if event.get_type() == "message": 216 | # global rec 217 | # rec += 1 218 | await cursor.execute('UPDATE total SET received = ? WHERE id = ?', (rec, bot.self_id)) 219 | await db.commit() 220 | return 221 | 222 | # 记录群消息 223 | @event_preprocessor 224 | async def handle_group_message(bot: Bot, event: GroupMessageEvent): 225 | time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 226 | group_id = event.group_id 227 | uid = event.get_user_id() 228 | nickname = event.sender.nickname 229 | sender = { 230 | "user_id": uid, 231 | "nickname": nickname 232 | } 233 | msg_id = event.message_id 234 | 235 | message = "暂不支持该消息类型" 236 | msg_type = "unknown" 237 | 238 | for i in event.message: 239 | if i.type == 'image': 240 | msg_type = 'image' 241 | message = i.data['url'].replace('https://', 'http://') 242 | await cursor.execute('INSERT INTO groups (id, group_name, message, sender, type, msg_id, time, drawed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 243 | (group_id, group_id, message, str(sender), msg_type, msg_id, time, '0')) 244 | await db.commit() 245 | elif i.type == 'text': 246 | msg_type = 'text' 247 | message = i.data['text'] 248 | await cursor.execute('INSERT INTO groups (id, group_name, message, sender, type, msg_id, time, drawed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 249 | (group_id, group_id, message, str(sender), msg_type, msg_id, time, '0')) 250 | await db.commit() 251 | return 252 | 253 | # 监听群事件 254 | group_notice = on_notice(rule=lambda event: isinstance(event, GroupRecallNoticeEvent)) 255 | @group_notice.handle() 256 | async def handle_group_notice(bot: Bot, event: GroupRecallNoticeEvent): 257 | mid = event.message_id 258 | gid = event.group_id 259 | await cursor.execute('UPDATE groups SET drawed = ? WHERE msg_id = ? AND id = ?', ('1', mid, gid)) 260 | await db.commit() 261 | return 262 | 263 | # 记录私聊消息 264 | @event_preprocessor 265 | async def handle_private_message(bot: Bot, event: PrivateMessageEvent): 266 | time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 267 | id = event.get_user_id() 268 | nickname = event.sender.nickname 269 | msg_id = event.message_id 270 | sender = { 271 | "user_id": id, 272 | "nickname": nickname 273 | } 274 | message = "暂不支持该消息类型" 275 | msg_type = "unknown" 276 | for i in event.message: 277 | if i.type == 'image': 278 | msg_type = 'image' 279 | message = i.data['url'].replace('https://', 'http://') 280 | await cursor.execute('INSERT INTO friends (id, nickname, message, sender, type, msg_id, time, drawed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 281 | (id, nickname, message, str(sender), msg_type, msg_id, time, '0')) 282 | await db.commit() 283 | elif i.type == 'text': 284 | msg_type = 'text' 285 | message = i.data['text'] 286 | await cursor.execute('INSERT INTO friends (id, nickname, message, sender, type, msg_id, time, drawed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 287 | (id, nickname, message, str(sender), msg_type, msg_id, time, '0')) 288 | await db.commit() 289 | elif i.type == 'video': 290 | msg_type = 'video' 291 | message = i.data['url'] 292 | await cursor.execute('INSERT INTO friends (id, nickname, message, sender, type, msg_id, time, drawed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 293 | (id, nickname, message, str(sender), msg_type, msg_id, time, '0')) 294 | await db.commit() 295 | return 296 | 297 | # 监听私聊事件 298 | friend_notice = on_notice(rule=lambda event: isinstance(event, FriendRecallNoticeEvent)) 299 | @friend_notice.handle() 300 | async def handle_friend_notice(bot: Bot, event: FriendRecallNoticeEvent): 301 | mid = event.message_id 302 | fid = event.user_id 303 | await cursor.execute('UPDATE friends SET drawed = ? WHERE msg_id = ? AND id = ?', ('1', mid, fid)) 304 | await db.commit() 305 | return 306 | 307 | 308 | async def webui_main(request: Request) -> Response: 309 | if (is_connected): 310 | webui_path = path.join(path.dirname(__file__), "web") 311 | file_path = path.join(webui_path, request.url.path.replace("/nbgui/v1/sideload", "").lstrip("/")) 312 | if path.isdir(file_path): 313 | file_path = path.join(file_path, "index.html") 314 | if not path.exists(file_path): 315 | return Response(404, content="File not found") 316 | file_extension = path.splitext(file_path)[1].lower() 317 | mime_types = { 318 | '.html': 'text/html', 319 | '.css': 'text/css', 320 | '.js': 'application/javascript', 321 | '.json': 'application/json', 322 | '.png': 'image/png', 323 | '.jpg': 'image/jpeg', 324 | '.jpeg': 'image/jpeg', 325 | '.gif': 'image/gif', 326 | '.svg': 'image/svg+xml', 327 | '.ttf': 'font/ttf', 328 | '.woff': 'font/woff', 329 | '.woff2': 'font/woff2', 330 | '.ico': 'image/x-icon' 331 | } 332 | content_type = mime_types.get(file_extension, 'application/octet-stream') 333 | if content_type.startswith('image/') or content_type.startswith('font/') or content_type == 'application/octet-stream': 334 | with open(file_path, "rb") as file: 335 | content = file.read() 336 | else: 337 | with open(file_path, "r", encoding="utf-8") as file: 338 | content = file.read() 339 | 340 | return Response(200, content=content, headers={"Content-Type": content_type}) 341 | else: 342 | return Response(503, content="Bot is not connected") 343 | 344 | 345 | # 沟槽的跨域 346 | # 用户头像 347 | async def user_avatar(request: Request) -> Response: 348 | file_path = path.join(friend_avatar_dir, request.url.path.replace("/sideload/avatars/user", "").lstrip("/")) 349 | if path.isdir(file_path): 350 | return Response(404, content="File not found") 351 | if not path.exists(file_path): 352 | httpx_client = httpx.AsyncClient() 353 | res = await httpx_client.get('http://q1.qlogo.cn/g?b=qq&nk='+request.url.path.replace("/sideload/avatars/user", "").replace(".png", "").lstrip("/")+'&s=100', headers={ 354 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}) 355 | if res.status_code != 200: 356 | return Response(404, content="File not found") 357 | # 保存头像 358 | with open(file_path, "wb") as f: 359 | f.write(res.content) 360 | file_extension = path.splitext(file_path)[1].lower() 361 | mime_types = { 362 | '.png': 'image/png', 363 | '.gif': 'image/gif' 364 | } 365 | content_type = mime_types.get(file_extension, 'application/octet-stream') 366 | if content_type.startswith('image/'): 367 | with open(file_path, "rb") as file: 368 | content = file.read() 369 | else: 370 | with open(file_path, "r", encoding="utf-8") as file: 371 | content = file.read() 372 | 373 | return Response(200, content=content, headers={"Content-Type": content_type}) 374 | 375 | # 群组头像 376 | async def group_avatar(request: Request) -> Response: 377 | file_path = path.join(group_avatar_dir, request.url.path.replace("/sideload/avatars/group", "").lstrip("/")) 378 | if path.isdir(file_path): 379 | return Response(404, content="File not found") 380 | if not path.exists(file_path): 381 | httpx_client = httpx.AsyncClient() 382 | gid = request.url.path.replace("/sideload/avatars/group", "").replace(".png", "").lstrip("/") 383 | res = await httpx_client.get(f'https://p.qlogo.cn/gh/{gid}/{gid}/100/', headers={ 384 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}) 385 | if res.status_code != 200: 386 | return Response(404, content="File not found") 387 | with open(file_path, "wb") as f: 388 | f.write(res.content) 389 | file_extension = path.splitext(file_path)[1].lower() 390 | mime_types = { 391 | '.png': 'image/png', 392 | '.gif': 'image/gif' 393 | } 394 | content_type = mime_types.get(file_extension, 'application/octet-stream') 395 | if content_type.startswith('image/'): 396 | with open(file_path, "rb") as file: 397 | content = file.read() 398 | else: 399 | with open(file_path, "r", encoding="utf-8") as file: 400 | content = file.read() 401 | 402 | return Response(200, content=content, headers={"Content-Type": content_type}) 403 | 404 | # 图片 405 | async def image(request: Request) -> Response: 406 | file_path = path.join(image_dir, request.url.path.replace("/sideload/image", "").lstrip("/")) 407 | if path.isdir(file_path): 408 | return Response(404, content="File not found") 409 | if not path.exists(file_path): 410 | httpx_client = httpx.AsyncClient() 411 | msg_id = request.url.path.replace("/sideload/image", "").lstrip("/") 412 | await cursor.execute('SELECT message FROM groups WHERE msg_id = ?', (msg_id,)) 413 | result = await cursor.fetchone() 414 | if result: 415 | img = result[0] 416 | else: 417 | await cursor.execute('SELECT message FROM friends WHERE msg_id = ?', (msg_id,)) 418 | result = await cursor.fetchone() 419 | if result: 420 | img = result[0] 421 | else: 422 | return Response(404, content="Image URL not found") 423 | try: 424 | res = await httpx_client.get(f'{img}', headers={ 425 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}) 426 | if res.status_code != 200: 427 | return Response(404, content="Failed to fetch image") 428 | with open(file_path, "wb") as f: 429 | f.write(res.content) 430 | except Exception as e: 431 | logger.error(f"Error fetching image: {e}") 432 | return Response(500, content=f"Error fetching image: {e}") 433 | try: 434 | with open(file_path, "rb") as file: 435 | content = file.read() 436 | content_type = 'image/jpeg' 437 | return Response(200, content=content, headers={"Content-Type": content_type}) 438 | except Exception as e: 439 | logger.error(f"Error reading image file: {e}") 440 | return Response(500, content=f"Error reading image file: {e}") 441 | 442 | # 验证密码 443 | async def auth(request: Request) -> Response: 444 | body = request.content 445 | if isinstance(body, bytes): 446 | body_str = body.decode('utf-8') 447 | else: 448 | body_str = body 449 | data = json.loads(body_str) 450 | if 'password' not in data or data['password'] != password: 451 | return Response(403, content="Unauthorized") 452 | return Response(200, content="OK") 453 | 454 | 455 | # WebSocket处理 456 | async def ws_handler(ws: WebSocket): 457 | await ws.accept() 458 | try: 459 | while True: 460 | rec_msg = await ws.receive_text() 461 | rec_msg = json.loads(rec_msg) 462 | bot = nonebot.get_bot() 463 | if rec_msg['password'] == password: 464 | type = rec_msg['type'] 465 | if type == 'get_total': 466 | await cursor.execute('SELECT * FROM total WHERE id = ?', (bot_id,)) 467 | total = await cursor.fetchone() 468 | group_list = ast.literal_eval(total[4]) if total[4] else [] 469 | friend_list = ast.literal_eval(total[5]) if total[5] else [] 470 | res = { 471 | 'type': 'total', 472 | "data": { 473 | "id": str(total[0]), 474 | "nickname": total[1], 475 | "sended": total[2], 476 | "received": total[3], 477 | "group_list": group_list, 478 | "friend_list": friend_list 479 | } 480 | } 481 | await ws.send_text(json.dumps(res, ensure_ascii=False)) 482 | elif type == 'send_private_msg': 483 | time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 484 | user_id = rec_msg['user_id'] 485 | message = rec_msg['message'] 486 | res = await bot.call_api(api='send_private_msg', user_id=user_id, message=message) 487 | id = bot_id 488 | nickname = bot_nickname 489 | sender = { 490 | "user_id": id, 491 | "nickname": nickname 492 | } 493 | await cursor.execute('INSERT INTO friends (id, nickname, message, sender, type, msg_id, time, drawed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (user_id, user_id, message, str(sender), "text", str(res['message_id']), time, '0')) 494 | await db.commit() 495 | ws_res = { 496 | 'type': 'send_private_msg', 497 | 'data': res 498 | } 499 | await ws.send_text(json.dumps(ws_res, ensure_ascii=False)) 500 | elif type == 'send_group_msg': 501 | time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") 502 | group_id = rec_msg['group_id'] 503 | message = rec_msg['message'] 504 | res = await bot.call_api(api='send_group_msg', group_id=group_id, message=message) 505 | id = bot_id 506 | nickname = bot_nickname 507 | sender = { 508 | "user_id": id, 509 | "nickname": nickname 510 | } 511 | await cursor.execute('INSERT INTO groups (id, group_name, message, sender, type, msg_id, time, drawed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (group_id, group_id, message, str(sender), "text", str(res['message_id']), time, '0')) 512 | await db.commit() 513 | ws_res = { 514 | 'type': 'send_group_msg', 515 | 'data': res 516 | } 517 | await ws.send_text(json.dumps(ws_res, ensure_ascii=False)) 518 | elif type == 'get_group_message': 519 | group_id = rec_msg['group_id'] 520 | await cursor.execute('SELECT * FROM groups WHERE id = ?', (group_id,)) 521 | messages = await cursor.fetchall() 522 | res = { 523 | 'type': 'group_msg', 524 | 'data': [] 525 | } 526 | for message in messages: 527 | res['data'].append({ 528 | 'group_name': message[1], 529 | 'sender': ast.literal_eval(message[2]), 530 | 'message': message[3], 531 | 'type': message[4], 532 | 'msg_id': message[5], 533 | 'time': message[6], 534 | 'drawed': message[7] 535 | }) 536 | await ws.send_text(json.dumps(res, ensure_ascii=False)) 537 | elif type == 'get_friend_message': 538 | user_id = rec_msg['user_id'] 539 | await cursor.execute('SELECT * FROM friends WHERE id = ?', (user_id,)) 540 | messages = await cursor.fetchall() 541 | res = { 542 | 'type': 'friend_msg', 543 | 'data': [] 544 | } 545 | for message in messages: 546 | res['data'].append({ 547 | 'nickname': message[1], 548 | 'message': message[2], 549 | 'sender': ast.literal_eval(message[3]), 550 | 'type': message[4], 551 | 'msg_id': message[5], 552 | 'time': message[6], 553 | 'drawed': message[7] 554 | }) 555 | await ws.send_text(json.dumps(res, ensure_ascii=False)) 556 | elif type == 'send_like': 557 | user_id = rec_msg['user_id'] 558 | time = rec_msg['time'] 559 | await bot.call_api(api='send_like', user_id=user_id, time=time) 560 | await ws.send_text(json.dumps(res, ensure_ascii=False)) 561 | else: 562 | await ws.send_text("Unknown type") 563 | break 564 | else: 565 | await ws.send_text("Unauthorized") 566 | break 567 | except nonebot.exception.WebSocketClosed: 568 | logger.info("WebSocket连接关闭") 569 | return 570 | 571 | try: 572 | if isinstance((driver := get_driver()), ASGIMixin): 573 | driver.setup_http_server( 574 | HTTPServerSetup( 575 | path=URL("/nbgui/v1/sideload{path:path}"), 576 | method="GET", 577 | name="webui", 578 | handle_func=webui_main, 579 | ) 580 | ) 581 | 582 | 583 | if isinstance((driver := get_driver()), ASGIMixin): 584 | driver.setup_http_server( 585 | HTTPServerSetup( 586 | path=URL("/sideload/avatars/user{path:path}"), 587 | method="GET", 588 | name="file_server", 589 | handle_func=user_avatar, 590 | ) 591 | ) 592 | 593 | if isinstance((driver := get_driver()), ASGIMixin): 594 | driver.setup_http_server( 595 | HTTPServerSetup( 596 | path=URL("/sideload/avatars/group{path:path}"), 597 | method="GET", 598 | name="file_server", 599 | handle_func=group_avatar, 600 | ) 601 | ) 602 | 603 | if isinstance((driver := get_driver()), ASGIMixin): 604 | driver.setup_http_server( 605 | HTTPServerSetup( 606 | path=URL("/sideload/image{path:path}"), 607 | method="GET", 608 | name="file_server", 609 | handle_func=image, 610 | ) 611 | ) 612 | 613 | if isinstance((driver := get_driver()), ASGIMixin): 614 | driver.setup_http_server( 615 | HTTPServerSetup( 616 | path=URL("/sideload/auth"), 617 | method="POST", 618 | name="auth", 619 | handle_func=auth, 620 | ) 621 | ) 622 | 623 | 624 | if isinstance((driver := get_driver()), ASGIMixin): 625 | driver.setup_websocket_server( 626 | WebSocketServerSetup( 627 | path=URL("/nbgui/v1/sideload/ws"), 628 | name="ws", 629 | handle_func=ws_handler, 630 | ) 631 | ) 632 | except NotImplementedError: 633 | logger.warning("似乎启动失败咯?") -------------------------------------------------------------------------------- /nonebot_plugin_sideload/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | class Config(BaseModel): 4 | 5 | # 访问密码 6 | sideload_password: str = 'abc123456' -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "nonebot-plugin-sideload" 3 | version = "0.1.8" 4 | description = "为你的NoneBot侧载一个OneBot V11 Web聊天面板" 5 | authors = ["【夜风】NightWind <2125714976@qq.com>"] 6 | license = "GPL3" 7 | readme = "README.md" 8 | packages = [ 9 | {include = "nonebot_plugin_sideload"}, 10 | ] 11 | include = ["nonebot_plugin_sideload/web/**/*"] 12 | homepage = "https://github.com/NonebotGUI/nonebot-plugin-sideload" 13 | repository = "https://github.com/NonebotGUI/nonebot-plugin-sideload" 14 | documentation = "https://github.com/NonebotGUI/nonebot-plugin-sideload#README.md" 15 | 16 | [[tool.poetry.source]] 17 | name = "tsinghua" 18 | url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" 19 | default = true 20 | 21 | [tool.poetry.dependencies] 22 | python = ">=3.9" 23 | nonebot2 = ">=2.4.0,<3.0.0" 24 | httpx = ">=0.22.0" 25 | nonebot-adapter-onebot = ">=2.4.5" 26 | aiosqlite = ">=0.21.0" 27 | nonebot-plugin-localstore = ">=0.7.3" 28 | netifaces = ">=0.11.0" 29 | 30 | [build-system] 31 | requires = ["poetry-core"] 32 | build-backend = "poetry.core.masonry.api" -------------------------------------------------------------------------------- /sideload_webui/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "ba393198430278b6595976de84fe170f553cc728" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: ba393198430278b6595976de84fe170f553cc728 17 | base_revision: ba393198430278b6595976de84fe170f553cc728 18 | - platform: android 19 | create_revision: ba393198430278b6595976de84fe170f553cc728 20 | base_revision: ba393198430278b6595976de84fe170f553cc728 21 | - platform: ios 22 | create_revision: ba393198430278b6595976de84fe170f553cc728 23 | base_revision: ba393198430278b6595976de84fe170f553cc728 24 | - platform: linux 25 | create_revision: ba393198430278b6595976de84fe170f553cc728 26 | base_revision: ba393198430278b6595976de84fe170f553cc728 27 | - platform: macos 28 | create_revision: ba393198430278b6595976de84fe170f553cc728 29 | base_revision: ba393198430278b6595976de84fe170f553cc728 30 | - platform: web 31 | create_revision: ba393198430278b6595976de84fe170f553cc728 32 | base_revision: ba393198430278b6595976de84fe170f553cc728 33 | - platform: windows 34 | create_revision: ba393198430278b6595976de84fe170f553cc728 35 | base_revision: ba393198430278b6595976de84fe170f553cc728 36 | 37 | # User provided section 38 | 39 | # List of Local paths (relative to this file) that should be 40 | # ignored by the migrate tool. 41 | # 42 | # Files that are not part of the templates will be ignored by default. 43 | unmanaged_files: 44 | - 'lib/main.dart' 45 | - 'ios/Runner.xcodeproj/project.pbxproj' 46 | -------------------------------------------------------------------------------- /sideload_webui/README.md: -------------------------------------------------------------------------------- 1 | # sideload_webui 2 | 3 | A new Flutter project. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) 13 | 14 | For help getting started with Flutter development, view the 15 | [online documentation](https://docs.flutter.dev/), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | -------------------------------------------------------------------------------- /sideload_webui/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /sideload_webui/lib/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/sideload_webui/lib/assets/logo.png -------------------------------------------------------------------------------- /sideload_webui/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:http/http.dart' as http; 6 | import 'package:sideload_webui/utils/core.dart'; 7 | import 'package:sideload_webui/utils/global.dart'; 8 | import 'package:shared_preferences/shared_preferences.dart'; 9 | import 'package:sideload_webui/pages/main_page.dart'; 10 | import 'package:sideload_webui/pages/mobile/main_page.dart'; 11 | // ignore: avoid_web_libraries_in_flutter 12 | import 'dart:html' as html; 13 | import 'package:provider/provider.dart'; 14 | 15 | // void main() async { 16 | // WidgetsFlutterBinding.ensureInitialized(); 17 | // version = '0.1.0'; 18 | // debug = true; 19 | 20 | // runApp( 21 | // ChangeNotifierProvider( 22 | // create: (context) => ThemeNotifier(initialThemeMode), 23 | // child: const MyApp(), 24 | // ), 25 | // ); 26 | // } 27 | 28 | void main() { 29 | version = '0.1.0'; 30 | debug = false; 31 | FlutterError.onError = (FlutterErrorDetails details) { 32 | FlutterError.dumpErrorToConsole(details, forceReport: true); 33 | }; 34 | runZonedGuarded(() { 35 | runApp( 36 | ChangeNotifierProvider( 37 | create: (context) => ThemeNotifier(initialThemeMode), 38 | child: const MyApp(), 39 | ), 40 | ); 41 | }, (Object error, StackTrace stack) { 42 | print('未捕获的异步错误: $error'); 43 | print('堆栈信息: $stack'); 44 | }); 45 | WidgetsFlutterBinding.ensureInitialized(); 46 | } 47 | 48 | class ThemeNotifier extends ChangeNotifier { 49 | ThemeData _themeData; 50 | String _themeMode; 51 | 52 | ThemeNotifier(String initialMode) 53 | : _themeMode = initialMode, 54 | _themeData = _getTheme(initialMode); 55 | 56 | ThemeData get themeData => _themeData; 57 | 58 | void toggleTheme() { 59 | if (_themeMode == 'light') { 60 | _themeMode = 'dark'; 61 | _themeData = _getTheme('dark'); 62 | } else { 63 | _themeMode = 'light'; 64 | _themeData = _getTheme('light'); 65 | } 66 | notifyListeners(); 67 | } 68 | } 69 | 70 | class MyApp extends StatelessWidget { 71 | const MyApp({super.key}); 72 | 73 | @override 74 | Widget build(BuildContext context) { 75 | final themeNotifier = Provider.of(context); 76 | 77 | return MaterialApp( 78 | theme: themeNotifier.themeData, 79 | home: const LoginPage(), 80 | ); 81 | } 82 | } 83 | 84 | ///颜色主题 85 | ThemeData _getTheme(mode) { 86 | switch (mode) { 87 | case 'light': 88 | return ThemeData.light().copyWith( 89 | canvasColor: const Color.fromRGBO(254, 239, 239, 1), 90 | primaryColor: const Color.fromRGBO(0, 153, 255, 1), 91 | buttonTheme: const ButtonThemeData( 92 | buttonColor: Color.fromRGBO(0, 153, 255, 1), 93 | ), 94 | checkboxTheme: CheckboxThemeData( 95 | fillColor: 96 | MaterialStateProperty.resolveWith((Set states) { 97 | if (states.contains(MaterialState.selected)) { 98 | return const Color.fromRGBO(0, 153, 255, 1); 99 | } 100 | return Colors.white; 101 | }), 102 | checkColor: MaterialStateProperty.all(Colors.white), 103 | ), 104 | progressIndicatorTheme: const ProgressIndicatorThemeData( 105 | color: Color.fromRGBO(0, 153, 255, 1)), 106 | appBarTheme: const AppBarTheme(color: Color.fromRGBO(0, 153, 255, 1)), 107 | floatingActionButtonTheme: const FloatingActionButtonThemeData( 108 | backgroundColor: Color.fromRGBO(0, 153, 255, 1)), 109 | switchTheme: const SwitchThemeData( 110 | trackColor: 111 | MaterialStatePropertyAll(Color.fromRGBO(0, 153, 255, 1)))); 112 | case 'dark': 113 | return ThemeData.dark().copyWith( 114 | canvasColor: const Color.fromRGBO(18, 18, 18, 1), 115 | primaryColor: const Color.fromRGBO(147, 112, 219, 1), 116 | buttonTheme: const ButtonThemeData( 117 | buttonColor: Color.fromRGBO(147, 112, 219, 1), 118 | ), 119 | checkboxTheme: const CheckboxThemeData( 120 | checkColor: MaterialStatePropertyAll( 121 | Color.fromRGBO(147, 112, 219, 1), 122 | )), 123 | progressIndicatorTheme: const ProgressIndicatorThemeData( 124 | color: Color.fromRGBO(147, 112, 219, 1), 125 | ), 126 | appBarTheme: const AppBarTheme( 127 | color: Color.fromRGBO(147, 112, 219, 1), 128 | ), 129 | floatingActionButtonTheme: const FloatingActionButtonThemeData( 130 | backgroundColor: Color.fromRGBO(147, 112, 219, 1)), 131 | switchTheme: const SwitchThemeData( 132 | trackColor: 133 | MaterialStatePropertyAll(Color.fromRGBO(147, 112, 219, 1)))); 134 | default: 135 | return ThemeData.light().copyWith( 136 | canvasColor: const Color.fromRGBO(254, 239, 239, 1), 137 | primaryColor: const Color.fromRGBO(0, 153, 255, 1), 138 | buttonTheme: 139 | const ButtonThemeData(buttonColor: Color.fromRGBO(0, 153, 255, 1)), 140 | checkboxTheme: const CheckboxThemeData( 141 | checkColor: 142 | MaterialStatePropertyAll(Color.fromRGBO(0, 153, 255, 1))), 143 | progressIndicatorTheme: const ProgressIndicatorThemeData( 144 | color: Color.fromRGBO(0, 153, 255, 1)), 145 | appBarTheme: const AppBarTheme(color: Color.fromRGBO(0, 153, 255, 1)), 146 | floatingActionButtonTheme: const FloatingActionButtonThemeData( 147 | backgroundColor: Color.fromRGBO(0, 153, 255, 1)), 148 | switchTheme: const SwitchThemeData( 149 | trackColor: 150 | MaterialStatePropertyAll(Color.fromRGBO(0, 153, 255, 1))), 151 | ); 152 | } 153 | } 154 | 155 | class LoginPage extends StatefulWidget { 156 | const LoginPage({super.key}); 157 | 158 | @override 159 | State createState() => _LoginPageState(); 160 | } 161 | 162 | class _LoginPageState extends State { 163 | final myController = TextEditingController(); 164 | 165 | @override 166 | void initState() { 167 | super.initState(); 168 | if (!debug) { 169 | autoLogin(); 170 | } 171 | // getConfig(); 172 | } 173 | 174 | // 获取用户配置文件 175 | // Future getConfig() async { 176 | // final prefs = await SharedPreferences.getInstance(); 177 | // final config = await prefs.getString('config'); 178 | // if (config == null) { 179 | // String cfg = Config.user.toString(); 180 | // await prefs.setString('config', cfg); 181 | // } else { 182 | // Config.user = jsonDecode(config); 183 | // } 184 | // initialThemeMode = Config.user['color'] ?? 'light'; 185 | // setState(() {}); 186 | // } 187 | 188 | // 自动登录 189 | Future autoLogin() async { 190 | final prefs = await SharedPreferences.getInstance(); 191 | final token = await prefs.getString('token'); 192 | if (token != null) { 193 | final res = await http.get(Uri.parse("/config"), 194 | headers: {"Authorization": 'Bearer $token'}); 195 | if (res.statusCode == 200) { 196 | final config = jsonDecode(res.body); 197 | final connection = config['connection']; 198 | Config.token = connection['token']; 199 | Navigator.pushAndRemoveUntil( 200 | context, 201 | MaterialPageRoute( 202 | builder: (context) => (MediaQuery.of(context).size.width > 203 | MediaQuery.of(context).size.height) 204 | ? const MainPage() 205 | : const MainPageMobile()), 206 | (Route route) => false, 207 | ); 208 | } else { 209 | ScaffoldMessenger.of(context).showSnackBar(const SnackBar( 210 | content: Text('自动登录失败'), 211 | )); 212 | } 213 | } 214 | } 215 | 216 | @override 217 | Widget build(BuildContext context) { 218 | // 获取屏幕的尺寸 219 | dynamic screenSize = MediaQuery.of(context).size; 220 | double screenWidth = screenSize.width; 221 | double screenHeight = screenSize.height; 222 | double logoSize = 223 | (screenHeight > screenWidth) ? screenWidth * 0.4 : screenHeight * 0.4; 224 | double inputFieldWidth = 225 | (screenHeight > screenWidth) ? screenWidth * 0.75 : screenHeight * 0.5; 226 | double buttonWidth = 227 | (screenHeight > screenWidth) ? screenWidth * 0.09 : screenHeight * 0.07; 228 | html.document.title = 'NoneBot SideLoad WebUI'; 229 | return Scaffold( 230 | body: Center( 231 | child: Column( 232 | mainAxisAlignment: MainAxisAlignment.center, 233 | children: [ 234 | SizedBox( 235 | width: screenWidth, 236 | height: screenHeight * 0.075, 237 | ), 238 | Config.user['img'] == 'default' 239 | ? Image.asset( 240 | 'lib/assets/logo.png', 241 | width: logoSize, 242 | height: logoSize, 243 | ) 244 | : Image.network( 245 | Config.user['img'], 246 | width: logoSize, 247 | height: logoSize, 248 | ), 249 | // const SizedBox( 250 | // height: 4, 251 | // ), 252 | Text( 253 | Config.user['text'] == 'default' 254 | ? '登录到 NoneBot SideLoad WebUI' 255 | : Config.user['text'], 256 | style: const TextStyle( 257 | fontSize: 24, 258 | ), 259 | ), 260 | const SizedBox(height: 16), 261 | SizedBox( 262 | width: inputFieldWidth, 263 | height: inputFieldWidth * 0.175, 264 | child: TextField( 265 | controller: myController, 266 | obscureText: true, 267 | decoration: const InputDecoration( 268 | border: OutlineInputBorder(), 269 | labelText: '密码', 270 | ), 271 | onSubmitted: (String value) async { 272 | final password = myController.text; 273 | final res = await http.post(Uri.parse('/sideload/auth'), 274 | body: jsonEncode({'password': password}), 275 | headers: {"Content-Type": "application/json"}); 276 | if (res.statusCode == 200) { 277 | // final getConfig = await http.get( 278 | // Uri.parse('/config'), 279 | // headers: {"Authorization": 'Bearer ${res.body}'}, 280 | // ); 281 | // final config = jsonDecode(getConfig.body); 282 | // final connection = config['connection']; 283 | Config.password = password; 284 | // final prefs = await SharedPreferences.getInstance(); 285 | // await prefs.setString('token', res.body); 286 | Future.delayed(const Duration(milliseconds: 500), () async { 287 | connectToWebSocket(); 288 | Completer completer = Completer(); 289 | socket.onOpen.listen((event) { 290 | completer.complete(); 291 | }); 292 | completer.future.then((_) { 293 | socket.send( 294 | '{"type":"get_total","password":"${Config.password}"}', 295 | ); 296 | setState(() { 297 | ScaffoldMessenger.of(context).showSnackBar( 298 | const SnackBar( 299 | content: Text('欢迎回来!'), 300 | duration: Duration(seconds: 3), 301 | ), 302 | ); 303 | 304 | Navigator.pushAndRemoveUntil( 305 | context, 306 | MaterialPageRoute( 307 | builder: (context) => 308 | (MediaQuery.of(context).size.width > 309 | MediaQuery.of(context).size.height) 310 | ? const MainPage() 311 | : const MainPageMobile(), 312 | ), 313 | (Route route) => false, 314 | ); 315 | }); 316 | }); 317 | }); 318 | } else { 319 | ScaffoldMessenger.of(context).showSnackBar(const SnackBar( 320 | content: Text('验证失败了喵'), 321 | )); 322 | } 323 | }, 324 | ), 325 | ), 326 | const SizedBox( 327 | height: 16, 328 | ), 329 | SizedBox( 330 | child: ElevatedButton( 331 | onPressed: () async { 332 | if (debug) { 333 | Config.token = '114514'; 334 | Future.delayed(const Duration(milliseconds: 500), () async { 335 | connectToWebSocket(); 336 | Completer completer = Completer(); 337 | socket.onOpen.listen((event) { 338 | completer.complete(); 339 | }); 340 | completer.future.then((_) { 341 | socket.send( 342 | '{"type":"get_total","password":"${Config.password}"}', 343 | ); 344 | setState(() { 345 | ScaffoldMessenger.of(context).showSnackBar( 346 | const SnackBar( 347 | content: Text('高贵的debugger不需要密码'), 348 | duration: Duration(seconds: 3), 349 | ), 350 | ); 351 | 352 | Navigator.pushAndRemoveUntil( 353 | context, 354 | MaterialPageRoute( 355 | builder: (context) => 356 | (MediaQuery.of(context).size.width > 357 | MediaQuery.of(context).size.height) 358 | ? const MainPage() 359 | : const MainPageMobile(), 360 | ), 361 | (Route route) => false, 362 | ); 363 | }); 364 | }); 365 | }); 366 | } else { 367 | final password = myController.text; 368 | final res = await http.post(Uri.parse('/sideload/auth'), 369 | body: jsonEncode({'password': password}), 370 | headers: {"Content-Type": "application/json"}); 371 | if (res.statusCode == 200) { 372 | // final getConfig = await http.get( 373 | // Uri.parse('/config'), 374 | // headers: {"Authorization": 'Bearer ${res.body}'}, 375 | // ); 376 | // final config = jsonDecode(getConfig.body); 377 | // final connection = config['connection']; 378 | Config.password = password; 379 | // final prefs = await SharedPreferences.getInstance(); 380 | // await prefs.setString('token', res.body); 381 | Future.delayed(const Duration(milliseconds: 500), 382 | () async { 383 | connectToWebSocket(); 384 | Completer completer = Completer(); 385 | socket.onOpen.listen((event) { 386 | completer.complete(); 387 | }); 388 | completer.future.then((_) { 389 | socket.send( 390 | '{"type":"get_total","password":"${Config.password}"}', 391 | ); 392 | setState(() { 393 | ScaffoldMessenger.of(context).showSnackBar( 394 | const SnackBar( 395 | content: Text('欢迎回来!'), 396 | duration: Duration(seconds: 3), 397 | ), 398 | ); 399 | 400 | Navigator.pushAndRemoveUntil( 401 | context, 402 | MaterialPageRoute( 403 | builder: (context) => 404 | (MediaQuery.of(context).size.width > 405 | MediaQuery.of(context).size.height) 406 | ? const MainPage() 407 | : const MainPageMobile(), 408 | ), 409 | (Route route) => false, 410 | ); 411 | }); 412 | }); 413 | }); 414 | } else { 415 | ScaffoldMessenger.of(context).showSnackBar(const SnackBar( 416 | content: Text('验证失败了喵'), 417 | )); 418 | } 419 | } 420 | }, 421 | style: ButtonStyle( 422 | backgroundColor: MaterialStateProperty.all( 423 | Config.user['color'] == 'default' || 424 | Config.user['color'] == 'light' 425 | ? const Color.fromRGBO(0, 153, 255, 1) 426 | : const Color.fromRGBO(147, 112, 219, 1)), 427 | shape: MaterialStateProperty.all(const CircleBorder()), 428 | iconSize: MaterialStateProperty.all(24), 429 | minimumSize: 430 | MaterialStateProperty.all(Size(buttonWidth, buttonWidth)), 431 | ), 432 | child: Icon( 433 | Icons.chevron_right_rounded, 434 | color: Colors.white, 435 | size: buttonWidth * 0.75, 436 | ), 437 | ), 438 | ), 439 | Expanded(child: Container()), 440 | const Align( 441 | alignment: Alignment.bottomCenter, 442 | child: Padding( 443 | padding: EdgeInsets.all(2), 444 | child: Text( 445 | 'Powered by Flutter', 446 | style: TextStyle( 447 | color: Colors.grey, 448 | ), 449 | ), 450 | ), 451 | ), 452 | const Align( 453 | alignment: Alignment.bottomCenter, 454 | child: Padding( 455 | padding: EdgeInsets.all(2), 456 | child: Text( 457 | 'Released under the GPL-3 License', 458 | style: TextStyle( 459 | color: Colors.grey, 460 | ), 461 | ), 462 | ), 463 | ), 464 | ], 465 | ), 466 | ), 467 | ); 468 | } 469 | } 470 | -------------------------------------------------------------------------------- /sideload_webui/lib/pages/chat_group.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_web_libraries_in_flutter 2 | 3 | import 'dart:async'; 4 | import 'dart:html'; 5 | import 'package:sideload_webui/utils/widgets.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:sideload_webui/utils/global.dart'; 8 | 9 | class ChatGroup extends StatefulWidget { 10 | const ChatGroup({super.key}); 11 | 12 | @override 13 | State createState() => _HomeScreenState(); 14 | } 15 | 16 | class _HomeScreenState extends State { 17 | final myController = TextEditingController(); 18 | final ScrollController _scrollController = ScrollController(); 19 | bool _isAtBottom = true; 20 | bool _showScrollToBottomButton = false; 21 | 22 | List _visibleMessages = []; 23 | final int _pageSize = 20; 24 | bool _isLoading = false; 25 | bool _hasMoreData = true; 26 | Timer? _debounceTimer; 27 | 28 | @override 29 | void initState() { 30 | super.initState(); 31 | setState(() { 32 | Future.delayed(const Duration(milliseconds: 100), () { 33 | setState(() { 34 | socket.send( 35 | '{"type":"get_total","password":"${Config.password}"}', 36 | ); 37 | }); 38 | _loadInitialMessages(); 39 | }); 40 | startTimer(); 41 | }); 42 | 43 | // 监听滚动事件 44 | _scrollController.addListener(_scrollListener); 45 | } 46 | 47 | void _scrollListener() { 48 | final isAtBottom = _scrollController.position.pixels >= 49 | (_scrollController.position.maxScrollExtent - 20); 50 | 51 | if (_scrollController.position.pixels <= 50 && 52 | !_isLoading && 53 | _hasMoreData) { 54 | _loadMoreMessages(); 55 | } 56 | 57 | if (isAtBottom != _isAtBottom) { 58 | setState(() { 59 | _isAtBottom = isAtBottom; 60 | _showScrollToBottomButton = !isAtBottom; 61 | }); 62 | } 63 | } 64 | 65 | void _scrollToBottom() { 66 | if (_scrollController.hasClients) { 67 | _scrollController.animateTo( 68 | _scrollController.position.maxScrollExtent, 69 | duration: const Duration(milliseconds: 300), 70 | curve: Curves.easeOut, 71 | ); 72 | } 73 | } 74 | 75 | void _loadInitialMessages() { 76 | if (Data.groupMessage.isEmpty) return; 77 | 78 | setState(() { 79 | _visibleMessages = Data.groupMessage.length > _pageSize 80 | ? Data.groupMessage.sublist(Data.groupMessage.length - _pageSize) 81 | : [...Data.groupMessage]; 82 | 83 | // 确保从底部开始显示 84 | WidgetsBinding.instance.addPostFrameCallback((_) { 85 | _scrollToBottom(); 86 | }); 87 | }); 88 | } 89 | 90 | void _loadMoreMessages() { 91 | if (_isLoading || _visibleMessages.isEmpty) return; 92 | 93 | setState(() { 94 | _isLoading = true; 95 | }); 96 | 97 | // 计算下一页的起始索引 98 | int endIndex = Data.groupMessage.indexOf(_visibleMessages.first); 99 | if (endIndex <= 0) { 100 | setState(() { 101 | _isLoading = false; 102 | _hasMoreData = false; 103 | }); 104 | return; 105 | } 106 | 107 | int startIndex = (endIndex - _pageSize) > 0 ? (endIndex - _pageSize) : 0; 108 | 109 | if (startIndex < endIndex) { 110 | List moreMessages = 111 | Data.groupMessage.sublist(startIndex, endIndex); 112 | 113 | Future.delayed(const Duration(milliseconds: 300), () { 114 | if (mounted) { 115 | setState(() { 116 | _visibleMessages.insertAll(0, moreMessages); 117 | _isLoading = false; 118 | if (startIndex == 0) { 119 | _hasMoreData = false; 120 | } 121 | }); 122 | 123 | // 保持滚动位置 124 | WidgetsBinding.instance.addPostFrameCallback((_) { 125 | if (_scrollController.hasClients) { 126 | final currentPosition = _scrollController.position.pixels; 127 | final scrollAmount = 50.0 * moreMessages.length; 128 | _scrollController.jumpTo(currentPosition + scrollAmount); 129 | } 130 | }); 131 | } 132 | }); 133 | } else { 134 | setState(() { 135 | _isLoading = false; 136 | _hasMoreData = false; 137 | }); 138 | } 139 | } 140 | 141 | void _updateMessages() { 142 | if (Data.groupMessage.isEmpty) return; 143 | 144 | if (_visibleMessages.isEmpty) { 145 | _loadInitialMessages(); 146 | return; 147 | } 148 | 149 | // 检查是否有新消息 150 | if (Data.groupMessage.isNotEmpty && 151 | (_visibleMessages.isEmpty || 152 | Data.groupMessage.last != _visibleMessages.last)) { 153 | int lastVisibleIndex = -1; 154 | if (_visibleMessages.isNotEmpty) { 155 | for (int i = Data.groupMessage.length - 1; i >= 0; i--) { 156 | if (Data.groupMessage[i] == _visibleMessages.last) { 157 | lastVisibleIndex = i; 158 | break; 159 | } 160 | } 161 | } 162 | 163 | // 加载新消息 164 | setState(() { 165 | if (lastVisibleIndex >= 0 && 166 | lastVisibleIndex < Data.groupMessage.length - 1) { 167 | List newMessages = 168 | Data.groupMessage.sublist(lastVisibleIndex + 1); 169 | _visibleMessages.addAll(newMessages); 170 | } else if (lastVisibleIndex == -1) { 171 | _visibleMessages = Data.groupMessage.length > _pageSize 172 | ? Data.groupMessage.sublist(Data.groupMessage.length - _pageSize) 173 | : [...Data.groupMessage]; 174 | } 175 | 176 | if (_isAtBottom) { 177 | WidgetsBinding.instance.addPostFrameCallback((_) { 178 | _scrollToBottom(); 179 | }); 180 | } 181 | }); 182 | } 183 | } 184 | 185 | void _debouncedUpdateMessages() { 186 | if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); 187 | _debounceTimer = Timer(const Duration(milliseconds: 300), () { 188 | _updateMessages(); 189 | }); 190 | } 191 | 192 | Timer? timer; 193 | void startTimer() { 194 | timer = Timer.periodic(const Duration(seconds: 1), (timer) { 195 | if (groupOnOpen.isNotEmpty) { 196 | socket.send( 197 | '{"type":"get_group_message","group_id":"$groupOnOpen", "password":"${Config.password}"}', 198 | ); 199 | _debouncedUpdateMessages(); 200 | } 201 | }); 202 | } 203 | 204 | @override 205 | void dispose() { 206 | timer?.cancel(); 207 | _debounceTimer?.cancel(); 208 | myController.dispose(); 209 | _scrollController.removeListener(_scrollListener); 210 | _scrollController.dispose(); 211 | groupOnOpen = ''; 212 | super.dispose(); 213 | } 214 | 215 | @override 216 | Widget build(BuildContext context) { 217 | return Scaffold( 218 | body: Row( 219 | children: [ 220 | Expanded( 221 | flex: 2, 222 | child: Container( 223 | alignment: Alignment.topCenter, 224 | child: ListView.builder( 225 | itemCount: Data.groupList.length, 226 | shrinkWrap: true, 227 | padding: EdgeInsets.zero, 228 | physics: const AlwaysScrollableScrollPhysics(), 229 | itemBuilder: (context, index) { 230 | return Container( 231 | color: Data.groupList[index]['group_id'].toString() == 232 | groupOnOpen 233 | ? Config.user['color'] == 'default' || 234 | Config.user['color'] == 'light' 235 | ? const Color.fromRGBO(0, 153, 255, 1) 236 | : const Color.fromRGBO(147, 112, 219, 1) 237 | : Colors.transparent, 238 | child: ListTile( 239 | leading: CircleAvatar( 240 | backgroundImage: NetworkImage( 241 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/avatars/group/${Data.groupList[index]['group_id']}.png', 242 | ), 243 | ), 244 | title: Text( 245 | Data.groupList[index]['group_name'].toString(), 246 | style: TextStyle( 247 | fontSize: 16, 248 | fontWeight: FontWeight.bold, 249 | color: Data.groupList[index]['group_id'] 250 | .toString() == 251 | groupOnOpen 252 | ? Colors.white 253 | : Config.user['color'] == 'default' || 254 | Config.user['color'] == 'light' 255 | ? Colors.black 256 | : Colors.white), 257 | ), 258 | subtitle: Text( 259 | Data.groupList[index]['group_id'].toString(), 260 | style: TextStyle( 261 | fontSize: 14, 262 | color: Data.groupList[index]['group_id'] 263 | .toString() == 264 | groupOnOpen 265 | ? Colors.grey[200] 266 | : Colors.grey[600], 267 | ), 268 | ), 269 | onTap: () { 270 | setState(() { 271 | groupOnOpen = 272 | Data.groupList[index]['group_id'].toString(); 273 | Data.groupMessage = []; 274 | _visibleMessages = []; 275 | _hasMoreData = true; 276 | socket.send( 277 | '{"type":"get_group_message","group_id":"${Data.groupList[index]['group_id'].toString()}", "password":"${Config.password}"}', 278 | ); 279 | Future.delayed(const Duration(milliseconds: 500), 280 | () { 281 | _loadInitialMessages(); 282 | }); 283 | }); 284 | }, 285 | )); 286 | }, 287 | ), 288 | )), 289 | const VerticalDivider( 290 | width: 1, 291 | thickness: 1, 292 | color: Colors.grey, 293 | ), 294 | Expanded( 295 | flex: 11, 296 | child: groupOnOpen.isEmpty 297 | ? Container( 298 | alignment: Alignment.center, 299 | child: const Text( 300 | 'Select a group to chat', 301 | style: TextStyle( 302 | fontSize: 20, 303 | fontWeight: FontWeight.bold, 304 | color: Colors.grey, 305 | ), 306 | )) 307 | : Stack( 308 | children: [ 309 | Column( 310 | mainAxisAlignment: MainAxisAlignment.start, 311 | children: [ 312 | Expanded( 313 | flex: 4, 314 | child: Stack( 315 | children: [ 316 | ListView.builder( 317 | controller: _scrollController, 318 | itemCount: _visibleMessages.length + 1, 319 | shrinkWrap: false, 320 | padding: const EdgeInsets.all(24), 321 | physics: 322 | const AlwaysScrollableScrollPhysics(), 323 | itemBuilder: (context, index) { 324 | if (index == 0) { 325 | return _isLoading 326 | ? const SizedBox( 327 | height: 60, 328 | ) 329 | : _hasMoreData 330 | ? const SizedBox(height: 20) 331 | : const Padding( 332 | padding: 333 | EdgeInsets.all(8.0), 334 | child: Center( 335 | child: Text( 336 | '没有更多了', 337 | style: TextStyle( 338 | color: Colors.grey, 339 | fontSize: 12, 340 | ), 341 | ), 342 | ), 343 | ); 344 | } 345 | 346 | final data = _visibleMessages[index - 1]; 347 | final userId = 348 | data['sender']['user_id'].toString(); 349 | 350 | return MessageBubble( 351 | message: data, 352 | isCurrentUser: 353 | userId == Data.botInfo['id'], 354 | onResend: () async { 355 | setState(() { 356 | socket.send( 357 | '{"type":"send_group_msg","group_id":"$groupOnOpen","message":"${data['message'].toString().replaceAll('\n', '\\n')}","password":"${Config.password}"}', 358 | ); 359 | socket.send( 360 | '{"type":"get_group_message","group_id":"$groupOnOpen", "password":"${Config.password}"}', 361 | ); 362 | }); 363 | }, 364 | ); 365 | }, 366 | ), 367 | if (_visibleMessages.isEmpty) 368 | const Center( 369 | child: CircularProgressIndicator(), 370 | ), 371 | ], 372 | ), 373 | ), 374 | const Divider( 375 | height: 1, 376 | thickness: 1, 377 | color: Colors.grey, 378 | ), 379 | Expanded( 380 | flex: 1, 381 | child: Container( 382 | padding: const EdgeInsets.all(10), 383 | child: Stack( 384 | children: [ 385 | TextField( 386 | controller: myController, 387 | maxLines: null, 388 | expands: true, 389 | textAlignVertical: 390 | TextAlignVertical.top, 391 | decoration: const InputDecoration( 392 | hintText: 'Say something...', 393 | hintStyle: TextStyle( 394 | color: Colors.grey, 395 | ), 396 | border: InputBorder.none, 397 | contentPadding: EdgeInsets.all(10), 398 | ), 399 | ), 400 | Positioned( 401 | right: 0, 402 | bottom: 0, 403 | child: IconButton( 404 | icon: const Icon(Icons.send), 405 | color: Config.user['color'] == 406 | 'default' || 407 | Config.user['color'] == 408 | 'light' 409 | ? const Color.fromRGBO( 410 | 0, 153, 255, 1) 411 | : const Color.fromRGBO( 412 | 147, 112, 219, 1), 413 | onPressed: () { 414 | if (myController.text.isNotEmpty) { 415 | final messageText = myController 416 | .text 417 | .replaceAll('\n', '\\n'); 418 | setState(() { 419 | socket.send( 420 | '{"type":"send_group_msg","group_id":"$groupOnOpen","message":"$messageText","password":"${Config.password}"}', 421 | ); 422 | }); 423 | setState(() { 424 | socket.send( 425 | '{"type":"get_group_message","group_id":"$groupOnOpen", "password":"${Config.password}"}', 426 | ); 427 | }); 428 | myController.clear(); 429 | Future.delayed( 430 | const Duration( 431 | milliseconds: 300), () { 432 | _scrollToBottom(); 433 | }); 434 | } 435 | }, 436 | ), 437 | ) 438 | ], 439 | )), 440 | ) 441 | ], 442 | ), 443 | if (_showScrollToBottomButton) 444 | Positioned( 445 | right: 16, 446 | bottom: 80, 447 | child: FloatingActionButton( 448 | mini: true, 449 | backgroundColor: Theme.of(context).primaryColor, 450 | onPressed: () { 451 | _scrollToBottom(); 452 | }, 453 | child: const Icon( 454 | Icons.arrow_downward, 455 | color: Colors.white, 456 | ), 457 | ), 458 | ), 459 | ], 460 | )) 461 | ], 462 | ), 463 | ); 464 | } 465 | } 466 | -------------------------------------------------------------------------------- /sideload_webui/lib/pages/main_page.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_web_libraries_in_flutter 2 | import 'dart:async'; 3 | import 'dart:convert'; 4 | import 'dart:html'; 5 | import 'package:sideload_webui/main.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:sideload_webui/pages/chat_group.dart'; 8 | import 'package:sideload_webui/pages/private_chat.dart'; 9 | import 'package:sideload_webui/utils/global.dart'; 10 | import 'dart:html' as html; 11 | import 'package:provider/provider.dart'; 12 | import 'package:shared_preferences/shared_preferences.dart'; 13 | 14 | class MainPage extends StatefulWidget { 15 | const MainPage({super.key}); 16 | 17 | @override 18 | State createState() => _HomeScreenState(); 19 | } 20 | 21 | class _HomeScreenState extends State { 22 | final myController = TextEditingController(); 23 | int _selectedIndex = 0; 24 | Timer? timer; 25 | Timer? timer2; 26 | int runningCount = 0; 27 | String title = '主页'; 28 | 29 | @override 30 | void initState() { 31 | super.initState(); 32 | setState(() { 33 | Future.delayed(const Duration(milliseconds: 100), () { 34 | setState(() {}); 35 | }); 36 | }); 37 | socket.onMessage.listen((MessageEvent msg) async { 38 | String? msg0 = msg.data; 39 | if (msg0 != null) { 40 | Map msgJson = jsonDecode(msg0); 41 | String type = msgJson['type']; 42 | switch (type) { 43 | case 'total': 44 | Map data = msgJson['data']; 45 | Data.botInfo = data; 46 | setState(() { 47 | Data.groupList = data['group_list']; 48 | Data.friendList = data['friend_list']; 49 | }); 50 | break; 51 | case 'group_msg': 52 | List data = msgJson['data']; 53 | Data.groupMessage = data; 54 | setState(() {}); 55 | break; 56 | case 'friend_msg': 57 | List data = msgJson['data']; 58 | Data.friendMessage = data; 59 | setState(() {}); 60 | break; 61 | } 62 | } 63 | }); 64 | } 65 | 66 | @override 67 | void dispose() { 68 | timer?.cancel(); 69 | timer2?.cancel(); 70 | super.dispose(); 71 | } 72 | 73 | logout() async { 74 | final prefs = await SharedPreferences.getInstance(); 75 | await prefs.remove('token'); 76 | } 77 | 78 | @override 79 | Widget build(BuildContext context) { 80 | final themeNotifier = Provider.of(context); 81 | dynamic size = MediaQuery.of(context).size; 82 | double height = size.height; 83 | html.document.title = '$title | NoneBot SideLoad WebUI'; 84 | return Scaffold( 85 | appBar: AppBar( 86 | title: const Text('NoneBot SideLoad', 87 | style: TextStyle(color: Colors.white)), 88 | automaticallyImplyLeading: false, 89 | actions: [ 90 | IconButton( 91 | icon: const Icon(Icons.brightness_6), 92 | onPressed: () { 93 | themeNotifier.toggleTheme(); 94 | setState(() { 95 | if (Config.user['color'] == 'light' || 96 | Config.user['color'] == 'default') { 97 | Config.user['color'] = 'dark'; 98 | } else { 99 | Config.user['color'] = 'light'; 100 | } 101 | }); 102 | }, 103 | color: Colors.white, 104 | ), 105 | IconButton( 106 | icon: const Icon(Icons.logout), 107 | color: Colors.white, 108 | tooltip: '登出', 109 | onPressed: () { 110 | //logout(); 111 | html.window.location.reload(); 112 | // _showNotification(); 113 | }, 114 | ) 115 | ], 116 | ), 117 | body: Row( 118 | children: [ 119 | NavigationRail( 120 | leading: CircleAvatar( 121 | backgroundImage: NetworkImage( 122 | "${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/avatars/user/${Data.botInfo['id']}.png")), 123 | useIndicator: false, 124 | selectedIconTheme: IconThemeData( 125 | color: Config.user['color'] == 'light' || 126 | Config.user['color'] == 'default' 127 | ? const Color.fromRGBO(0, 153, 255, 1) 128 | : const Color.fromRGBO(147, 112, 219, 1), 129 | size: height * 0.03), 130 | selectedLabelTextStyle: TextStyle( 131 | color: Config.user['color'] == 'light' || 132 | Config.user['color'] == 'default' 133 | ? const Color.fromRGBO(0, 153, 255, 1) 134 | : const Color.fromRGBO(147, 112, 219, 1), 135 | fontSize: height * 0.02, 136 | ), 137 | unselectedLabelTextStyle: TextStyle( 138 | fontSize: height * 0.02, 139 | color: Config.user['color'] == 'light' || 140 | Config.user['color'] == 'default' 141 | ? Colors.grey[600] 142 | : Colors.white, 143 | ), 144 | unselectedIconTheme: IconThemeData( 145 | color: Config.user['color'] == 'light' || 146 | Config.user['color'] == 'default' 147 | ? Colors.grey[600] 148 | : Colors.white, 149 | size: height * 0.03), 150 | elevation: 2, 151 | indicatorShape: const RoundedRectangleBorder(), 152 | onDestinationSelected: (int index) { 153 | setState(() { 154 | _selectedIndex = index; 155 | switch (index) { 156 | case 0: 157 | title = '好友'; 158 | break; 159 | case 1: 160 | title = '群组'; 161 | break; 162 | case 2: 163 | title = '开源许可证'; 164 | break; 165 | case 3: 166 | title = '设置'; 167 | break; 168 | default: 169 | title = 'Unknown'; 170 | break; 171 | } 172 | }); 173 | }, 174 | selectedIndex: _selectedIndex, 175 | extended: false, 176 | destinations: [ 177 | NavigationRailDestination( 178 | icon: Tooltip( 179 | message: '好友', 180 | child: Icon( 181 | _selectedIndex == 0 182 | ? Icons.person_rounded 183 | : Icons.person_outline_rounded, 184 | ), 185 | ), 186 | label: const Text('好友'), 187 | ), 188 | NavigationRailDestination( 189 | icon: Tooltip( 190 | message: '群组', 191 | child: Icon( 192 | _selectedIndex == 1 193 | ? Icons.group_rounded 194 | : Icons.group_outlined, 195 | ), 196 | ), 197 | label: const Text('群组'), 198 | ), 199 | // NavigationRailDestination( 200 | // icon: Tooltip( 201 | // message: '设置', 202 | // child: Icon( 203 | // _selectedIndex == 2 204 | // ? Icons.settings_rounded 205 | // : Icons.settings_outlined, 206 | // ), 207 | // ), 208 | // label: const Text('设置'), 209 | // ), 210 | // NavigationRailDestination( 211 | // icon: Tooltip( 212 | // message: '开源许可证', 213 | // child: Icon( 214 | // _selectedIndex == 3 215 | // ? Icons.balance_rounded 216 | // : Icons.balance_outlined, 217 | // ), 218 | // ), 219 | // label: const Text('开源许可证'), 220 | // ), 221 | ], 222 | ), 223 | const VerticalDivider(thickness: 1, width: 1), 224 | Expanded( 225 | child: IndexedStack( 226 | index: _selectedIndex, 227 | children: const [ 228 | ChatPrivate(), 229 | ChatGroup(), 230 | // Text('设置'), 231 | // Text('开源许可证') 232 | ], 233 | ), 234 | ) 235 | ], 236 | )); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /sideload_webui/lib/pages/mobile/group.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_web_libraries_in_flutter 2 | 3 | import 'dart:async'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:sideload_webui/main.dart'; 6 | import 'package:sideload_webui/utils/widgets.dart'; 7 | import 'package:flutter/material.dart'; 8 | import 'package:sideload_webui/utils/global.dart'; 9 | 10 | class ChatGroupMobile extends StatefulWidget { 11 | const ChatGroupMobile({super.key}); 12 | 13 | @override 14 | State createState() => _HomeScreenState(); 15 | } 16 | 17 | class _HomeScreenState extends State { 18 | final myController = TextEditingController(); 19 | final ScrollController _scrollController = ScrollController(); 20 | bool _isAtBottom = true; 21 | bool _showScrollToBottomButton = false; 22 | 23 | List _visibleMessages = []; 24 | final int _pageSize = 20; 25 | bool _isLoading = false; 26 | bool _hasMoreData = true; 27 | Timer? _debounceTimer; 28 | 29 | @override 30 | void initState() { 31 | super.initState(); 32 | setState(() { 33 | Future.delayed(const Duration(milliseconds: 100), () { 34 | _loadInitialMessages(); 35 | }); 36 | startTimer(); 37 | }); 38 | 39 | // 监听滚动事件 40 | _scrollController.addListener(_scrollListener); 41 | } 42 | 43 | void _scrollListener() { 44 | final isAtBottom = _scrollController.position.pixels >= 45 | (_scrollController.position.maxScrollExtent - 20); 46 | 47 | if (_scrollController.position.pixels <= 50 && 48 | !_isLoading && 49 | _hasMoreData) { 50 | _loadMoreMessages(); 51 | } 52 | 53 | if (isAtBottom != _isAtBottom) { 54 | setState(() { 55 | _isAtBottom = isAtBottom; 56 | _showScrollToBottomButton = !isAtBottom; 57 | }); 58 | } 59 | } 60 | 61 | void _scrollToBottom() { 62 | if (_scrollController.hasClients) { 63 | _scrollController.animateTo( 64 | _scrollController.position.maxScrollExtent, 65 | duration: const Duration(milliseconds: 300), 66 | curve: Curves.easeOut, 67 | ); 68 | } 69 | } 70 | 71 | void _loadInitialMessages() { 72 | if (Data.groupMessage.isEmpty) return; 73 | 74 | setState(() { 75 | _visibleMessages = Data.groupMessage.length > _pageSize 76 | ? Data.groupMessage.sublist(Data.groupMessage.length - _pageSize) 77 | : [...Data.groupMessage]; 78 | 79 | WidgetsBinding.instance.addPostFrameCallback((_) { 80 | _scrollToBottom(); 81 | }); 82 | }); 83 | } 84 | 85 | void _loadMoreMessages() { 86 | if (_isLoading || _visibleMessages.isEmpty) return; 87 | 88 | setState(() { 89 | _isLoading = true; 90 | }); 91 | 92 | int endIndex = Data.groupMessage.indexOf(_visibleMessages.first); 93 | if (endIndex <= 0) { 94 | setState(() { 95 | _isLoading = false; 96 | _hasMoreData = false; 97 | }); 98 | return; 99 | } 100 | 101 | int startIndex = (endIndex - _pageSize) > 0 ? (endIndex - _pageSize) : 0; 102 | 103 | if (startIndex < endIndex) { 104 | List moreMessages = 105 | Data.groupMessage.sublist(startIndex, endIndex); 106 | 107 | Future.delayed(const Duration(milliseconds: 300), () { 108 | if (mounted) { 109 | setState(() { 110 | _visibleMessages.insertAll(0, moreMessages); 111 | _isLoading = false; 112 | if (startIndex == 0) { 113 | _hasMoreData = false; 114 | } 115 | }); 116 | 117 | WidgetsBinding.instance.addPostFrameCallback((_) { 118 | if (_scrollController.hasClients) { 119 | final currentPosition = _scrollController.position.pixels; 120 | final scrollAmount = 50.0 * moreMessages.length; 121 | _scrollController.jumpTo(currentPosition + scrollAmount); 122 | } 123 | }); 124 | } 125 | }); 126 | } else { 127 | setState(() { 128 | _isLoading = false; 129 | _hasMoreData = false; 130 | }); 131 | } 132 | } 133 | 134 | void _updateMessages() { 135 | if (Data.groupMessage.isEmpty) return; 136 | 137 | if (_visibleMessages.isEmpty) { 138 | _loadInitialMessages(); 139 | return; 140 | } 141 | 142 | // 检查是否有新消息 143 | if (Data.groupMessage.isNotEmpty && 144 | (_visibleMessages.isEmpty || 145 | Data.groupMessage.last != _visibleMessages.last)) { 146 | int lastVisibleIndex = -1; 147 | if (_visibleMessages.isNotEmpty) { 148 | for (int i = Data.groupMessage.length - 1; i >= 0; i--) { 149 | if (Data.groupMessage[i] == _visibleMessages.last) { 150 | lastVisibleIndex = i; 151 | break; 152 | } 153 | } 154 | } 155 | 156 | // 加载新消息 157 | setState(() { 158 | if (lastVisibleIndex >= 0 && 159 | lastVisibleIndex < Data.groupMessage.length - 1) { 160 | List newMessages = 161 | Data.groupMessage.sublist(lastVisibleIndex + 1); 162 | _visibleMessages.addAll(newMessages); 163 | } else if (lastVisibleIndex == -1) { 164 | _visibleMessages = Data.groupMessage.length > _pageSize 165 | ? Data.groupMessage.sublist(Data.groupMessage.length - _pageSize) 166 | : [...Data.groupMessage]; 167 | } 168 | 169 | if (_isAtBottom) { 170 | WidgetsBinding.instance.addPostFrameCallback((_) { 171 | _scrollToBottom(); 172 | }); 173 | } 174 | }); 175 | } 176 | } 177 | 178 | void _debouncedUpdateMessages() { 179 | if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); 180 | _debounceTimer = Timer(const Duration(milliseconds: 300), () { 181 | _updateMessages(); 182 | }); 183 | } 184 | 185 | Timer? timer; 186 | void startTimer() { 187 | timer = Timer.periodic(const Duration(seconds: 1), (timer) { 188 | if (groupOnOpen.isNotEmpty) { 189 | socket.send( 190 | '{"type":"get_group_message","group_id":"$groupOnOpen", "password":"${Config.password}"}', 191 | ); 192 | _debouncedUpdateMessages(); 193 | } 194 | }); 195 | } 196 | 197 | @override 198 | void dispose() { 199 | timer?.cancel(); 200 | _debounceTimer?.cancel(); 201 | myController.dispose(); 202 | _scrollController.removeListener(_scrollListener); 203 | _scrollController.dispose(); 204 | groupOnOpen = ''; 205 | groupOnOpenName = ''; 206 | super.dispose(); 207 | } 208 | 209 | @override 210 | Widget build(BuildContext context) { 211 | final viewInsets = MediaQuery.of(context).viewInsets; 212 | final themeNotifier = Provider.of(context); 213 | 214 | return Scaffold( 215 | resizeToAvoidBottomInset: true, 216 | appBar: AppBar( 217 | title: 218 | Text(groupOnOpenName, style: const TextStyle(color: Colors.white)), 219 | automaticallyImplyLeading: false, 220 | leading: IconButton( 221 | icon: const Icon(Icons.arrow_back, color: Colors.white), 222 | tooltip: '返回', 223 | onPressed: () { 224 | groupOnOpen = ''; 225 | groupOnOpenName = ''; 226 | Navigator.pop(context); 227 | }, 228 | ), 229 | actions: [ 230 | IconButton( 231 | icon: const Icon(Icons.brightness_6), 232 | onPressed: () { 233 | themeNotifier.toggleTheme(); 234 | setState(() { 235 | if (Config.user['color'] == 'light' || 236 | Config.user['color'] == 'default') { 237 | Config.user['color'] = 'dark'; 238 | } else { 239 | Config.user['color'] = 'light'; 240 | } 241 | }); 242 | }, 243 | color: Colors.white, 244 | ), 245 | ], 246 | ), 247 | body: Stack( 248 | children: [ 249 | Column( 250 | mainAxisAlignment: MainAxisAlignment.start, 251 | children: [ 252 | Flexible( 253 | flex: 4, 254 | child: _visibleMessages.isEmpty && !_isLoading 255 | ? const Center(child: Text('')) 256 | : ListView.builder( 257 | controller: _scrollController, 258 | itemCount: _visibleMessages.length + 1, 259 | shrinkWrap: false, 260 | padding: const EdgeInsets.all(16), 261 | physics: const AlwaysScrollableScrollPhysics(), 262 | itemBuilder: (context, index) { 263 | if (index == 0) { 264 | return _isLoading 265 | ? const SizedBox( 266 | height: 60, 267 | child: Center( 268 | child: CircularProgressIndicator(), 269 | ), 270 | ) 271 | : _hasMoreData 272 | ? const SizedBox(height: 10) 273 | : const Padding( 274 | padding: EdgeInsets.all(8.0), 275 | child: Center( 276 | child: Text( 277 | '没有更多了', 278 | style: TextStyle( 279 | color: Colors.grey, 280 | fontSize: 12, 281 | ), 282 | ), 283 | ), 284 | ); 285 | } 286 | 287 | final data = _visibleMessages[index - 1]; 288 | final userId = data['sender']['user_id'].toString(); 289 | 290 | return MessageBubble( 291 | message: data, 292 | isCurrentUser: userId == Data.botInfo['id'], 293 | onResend: () async { 294 | setState(() { 295 | socket.send( 296 | '{"type":"send_group_msg","group_id":"$groupOnOpen","message":"${data['message'].toString()}","password":"${Config.password}"}', 297 | ); 298 | socket.send( 299 | '{"type":"get_group_message","group_id":"$groupOnOpen", "password":"${Config.password}"}', 300 | ); 301 | }); 302 | }, 303 | ); 304 | }, 305 | ), 306 | ), 307 | const Divider( 308 | height: 1, 309 | thickness: 1, 310 | color: Colors.grey, 311 | ), 312 | Container( 313 | height: 60, 314 | padding: EdgeInsets.only( 315 | bottom: viewInsets.bottom > 0 ? 0 : 10, 316 | left: 10, 317 | right: 10, 318 | top: 10, 319 | ), 320 | child: Stack( 321 | children: [ 322 | TextField( 323 | controller: myController, 324 | maxLines: null, 325 | expands: true, 326 | textAlignVertical: TextAlignVertical.top, 327 | decoration: const InputDecoration( 328 | hintText: 'Say something...', 329 | hintStyle: TextStyle( 330 | color: Colors.grey, 331 | ), 332 | border: InputBorder.none, 333 | contentPadding: EdgeInsets.all(10), 334 | ), 335 | ), 336 | Positioned( 337 | right: 0, 338 | bottom: 0, 339 | child: IconButton( 340 | icon: const Icon(Icons.send), 341 | color: Config.user['color'] == 'default' || 342 | Config.user['color'] == 'light' 343 | ? const Color.fromRGBO(0, 153, 255, 1) 344 | : const Color.fromRGBO(147, 112, 219, 1), 345 | onPressed: () { 346 | if (myController.text.isNotEmpty) { 347 | final messageText = 348 | myController.text.replaceAll('\n', '\\n'); 349 | setState(() { 350 | socket.send( 351 | '{"type":"send_group_msg","group_id":"$groupOnOpen","message":"$messageText","password":"${Config.password}"}', 352 | ); 353 | }); 354 | setState(() { 355 | socket.send( 356 | '{"type":"get_group_message","group_id":"$groupOnOpen", "password":"${Config.password}"}', 357 | ); 358 | }); 359 | myController.clear(); 360 | Future.delayed(const Duration(milliseconds: 300), 361 | () { 362 | _scrollToBottom(); 363 | }); 364 | } 365 | }, 366 | ), 367 | ) 368 | ], 369 | ), 370 | ), 371 | ], 372 | ), 373 | if (_showScrollToBottomButton) 374 | Positioned( 375 | right: 16, 376 | bottom: 80, 377 | child: FloatingActionButton( 378 | mini: true, 379 | backgroundColor: Theme.of(context).primaryColor, 380 | onPressed: () { 381 | _scrollToBottom(); 382 | }, 383 | child: const Icon( 384 | Icons.arrow_downward, 385 | color: Colors.white, 386 | ), 387 | ), 388 | ), 389 | ], 390 | ), 391 | ); 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /sideload_webui/lib/pages/mobile/main_page.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_web_libraries_in_flutter 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | import 'dart:html'; 6 | import 'package:sideload_webui/main.dart'; 7 | import 'package:flutter/material.dart'; 8 | import 'package:sideload_webui/pages/mobile/group.dart'; 9 | import 'package:sideload_webui/pages/mobile/private.dart'; 10 | import 'package:sideload_webui/utils/global.dart'; 11 | import 'dart:html' as html; 12 | import 'package:provider/provider.dart'; 13 | import 'package:shared_preferences/shared_preferences.dart'; 14 | 15 | class MainPageMobile extends StatefulWidget { 16 | const MainPageMobile({super.key}); 17 | 18 | @override 19 | State createState() => _HomeScreenState(); 20 | } 21 | 22 | class _HomeScreenState extends State { 23 | final myController = TextEditingController(); 24 | int _selectedIndex = 0; 25 | Timer? timer; 26 | Timer? timer2; 27 | 28 | @override 29 | void initState() { 30 | super.initState(); 31 | setState(() { 32 | Future.delayed(const Duration(milliseconds: 100), () { 33 | setState(() {}); 34 | }); 35 | }); 36 | socket.onMessage.listen((MessageEvent msg) async { 37 | String? msg0 = msg.data; 38 | if (msg0 != null) { 39 | Map msgJson = jsonDecode(msg0); 40 | String type = msgJson['type']; 41 | switch (type) { 42 | case 'total': 43 | Map data = msgJson['data']; 44 | Data.botInfo = data; 45 | setState(() { 46 | Data.groupList = data['group_list']; 47 | Data.friendList = data['friend_list']; 48 | }); 49 | break; 50 | case 'group_msg': 51 | List data = msgJson['data']; 52 | Data.groupMessage = data; 53 | setState(() {}); 54 | break; 55 | case 'friend_msg': 56 | List data = msgJson['data']; 57 | Data.friendMessage = data; 58 | setState(() {}); 59 | break; 60 | } 61 | } 62 | }); 63 | } 64 | 65 | @override 66 | void dispose() { 67 | timer?.cancel(); 68 | timer2?.cancel(); 69 | super.dispose(); 70 | } 71 | 72 | logout() async { 73 | // 从 SharedPreference 中删除 token 74 | final prefs = await SharedPreferences.getInstance(); 75 | await prefs.remove('token'); 76 | } 77 | 78 | void _onItemTapped(int index) { 79 | setState(() { 80 | _selectedIndex = index; 81 | }); 82 | } 83 | 84 | @override 85 | Widget build(BuildContext context) { 86 | final themeNotifier = Provider.of(context); 87 | html.document.title = 'NoneBot SideLoad WebUI'; 88 | return Scaffold( 89 | appBar: AppBar( 90 | title: const Text('NoneBot SideLoad', 91 | style: TextStyle(color: Colors.white)), 92 | automaticallyImplyLeading: false, 93 | actions: [ 94 | IconButton( 95 | icon: const Icon(Icons.brightness_6), 96 | onPressed: () { 97 | themeNotifier.toggleTheme(); 98 | setState(() { 99 | if (Config.user['color'] == 'light' || 100 | Config.user['color'] == 'default') { 101 | Config.user['color'] = 'dark'; 102 | } else { 103 | Config.user['color'] = 'light'; 104 | } 105 | }); 106 | }, 107 | color: Colors.white, 108 | ), 109 | IconButton( 110 | icon: const Icon(Icons.logout), 111 | color: Colors.white, 112 | tooltip: '登出', 113 | onPressed: () { 114 | //logout(); 115 | html.window.location.reload(); 116 | }, 117 | ) 118 | ], 119 | ), 120 | bottomNavigationBar: BottomNavigationBar( 121 | items: const [ 122 | BottomNavigationBarItem( 123 | icon: Icon(Icons.person_rounded), 124 | label: '好友', 125 | ), 126 | BottomNavigationBarItem( 127 | icon: Icon(Icons.group_rounded), 128 | label: '群组', 129 | ), 130 | ], 131 | currentIndex: _selectedIndex, 132 | selectedItemColor: const Color.fromRGBO(0, 153, 255, 1), 133 | backgroundColor: Config.user['color'] == 'default' || 134 | Config.user['color'] == 'light' 135 | ? Colors.white 136 | : const Color.fromRGBO(18, 18, 18, 1), 137 | onTap: _onItemTapped, 138 | ), 139 | body: Container( 140 | margin: const EdgeInsets.all(4), 141 | child: Column( 142 | children: [ 143 | Expanded( 144 | child: _selectedIndex == 1 145 | ? ListView.builder( 146 | itemCount: Data.groupList.length, 147 | shrinkWrap: false, 148 | padding: EdgeInsets.zero, 149 | physics: const AlwaysScrollableScrollPhysics(), 150 | itemBuilder: (context, index) { 151 | return ListTile( 152 | leading: CircleAvatar( 153 | backgroundImage: NetworkImage( 154 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/avatars/group/${Data.groupList[index]['group_id']}.png', 155 | ), 156 | ), 157 | title: Text( 158 | Data.groupList[index]['group_name'].toString(), 159 | style: TextStyle( 160 | fontSize: 16, 161 | fontWeight: FontWeight.bold, 162 | color: Config.user['color'] == 'default' || 163 | Config.user['color'] == 'light' 164 | ? Colors.black 165 | : Colors.white), 166 | ), 167 | subtitle: Text( 168 | Data.groupList[index]['group_id'].toString(), 169 | style: TextStyle( 170 | fontSize: 14, 171 | color: Data.groupList[index]['group_id'] 172 | .toString() == 173 | groupOnOpen 174 | ? Colors.grey[200] 175 | : Colors.grey[600], 176 | ), 177 | ), 178 | onTap: () { 179 | groupOnOpen = 180 | Data.groupList[index]['group_id'].toString(); 181 | groupOnOpenName = Data.groupList[index] 182 | ['group_name'] 183 | .toString(); 184 | Navigator.push(context, 185 | MaterialPageRoute(builder: (context) { 186 | return const ChatGroupMobile(); 187 | })); 188 | }, 189 | ); 190 | }, 191 | ) 192 | : ListView.builder( 193 | itemCount: Data.friendList.length, 194 | shrinkWrap: false, 195 | padding: EdgeInsets.zero, 196 | physics: const AlwaysScrollableScrollPhysics(), 197 | itemBuilder: (context, index) { 198 | return ListTile( 199 | leading: CircleAvatar( 200 | backgroundImage: NetworkImage( 201 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/avatars/user/${Data.friendList[index]['user_id']}.png', 202 | ), 203 | ), 204 | title: Text( 205 | Data.friendList[index]['nickname'].toString(), 206 | style: TextStyle( 207 | fontSize: 16, 208 | fontWeight: FontWeight.bold, 209 | color: Config.user['color'] == 'default' || 210 | Config.user['color'] == 'light' 211 | ? Colors.black 212 | : Colors.white), 213 | ), 214 | subtitle: Text( 215 | Data.friendList[index]['user_id'].toString(), 216 | style: TextStyle( 217 | fontSize: 14, 218 | color: Data.friendList[index]['user_id'] 219 | .toString() == 220 | friendOnOpen 221 | ? Colors.grey[200] 222 | : Colors.grey[600], 223 | ), 224 | ), 225 | onTap: () { 226 | friendOnOpen = 227 | Data.friendList[index]['user_id'].toString(); 228 | friendOnOpenName = 229 | Data.friendList[index]['nickname'].toString(); 230 | Navigator.push(context, 231 | MaterialPageRoute(builder: (context) { 232 | return const ChatPrivateMobile(); 233 | })); 234 | }, 235 | ); 236 | }, 237 | ), 238 | ), 239 | ], 240 | ), 241 | )); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /sideload_webui/lib/pages/mobile/private.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_web_libraries_in_flutter 2 | 3 | import 'dart:async'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:sideload_webui/main.dart'; 6 | import 'package:sideload_webui/utils/widgets.dart'; 7 | import 'package:flutter/material.dart'; 8 | import 'package:sideload_webui/utils/global.dart'; 9 | 10 | class ChatPrivateMobile extends StatefulWidget { 11 | const ChatPrivateMobile({super.key}); 12 | 13 | @override 14 | State createState() => _HomeScreenState(); 15 | } 16 | 17 | class _HomeScreenState extends State { 18 | final myController = TextEditingController(); 19 | final ScrollController _scrollController = ScrollController(); 20 | bool _isAtBottom = true; 21 | bool _showScrollToBottomButton = false; 22 | 23 | List _visibleMessages = []; 24 | final int _pageSize = 20; 25 | bool _isLoading = false; 26 | bool _hasMoreData = true; 27 | Timer? _debounceTimer; 28 | 29 | @override 30 | void initState() { 31 | super.initState(); 32 | setState(() { 33 | Future.delayed(const Duration(milliseconds: 100), () { 34 | _loadInitialMessages(); 35 | }); 36 | startTimer(); 37 | }); 38 | 39 | // 监听滚动事件 40 | _scrollController.addListener(_scrollListener); 41 | } 42 | 43 | void _scrollListener() { 44 | final isAtBottom = _scrollController.position.pixels >= 45 | (_scrollController.position.maxScrollExtent - 20); 46 | 47 | if (_scrollController.position.pixels <= 50 && 48 | !_isLoading && 49 | _hasMoreData) { 50 | _loadMoreMessages(); 51 | } 52 | 53 | if (isAtBottom != _isAtBottom) { 54 | setState(() { 55 | _isAtBottom = isAtBottom; 56 | _showScrollToBottomButton = !isAtBottom; 57 | }); 58 | } 59 | } 60 | 61 | void _scrollToBottom() { 62 | if (_scrollController.hasClients) { 63 | _scrollController.animateTo( 64 | _scrollController.position.maxScrollExtent, 65 | duration: const Duration(milliseconds: 300), 66 | curve: Curves.easeOut, 67 | ); 68 | } 69 | } 70 | 71 | void _loadInitialMessages() { 72 | if (Data.friendMessage.isEmpty) return; 73 | 74 | setState(() { 75 | _visibleMessages = Data.friendMessage.length > _pageSize 76 | ? Data.friendMessage.sublist(Data.friendMessage.length - _pageSize) 77 | : [...Data.friendMessage]; 78 | 79 | WidgetsBinding.instance.addPostFrameCallback((_) { 80 | _scrollToBottom(); 81 | }); 82 | }); 83 | } 84 | 85 | void _loadMoreMessages() { 86 | if (_isLoading || _visibleMessages.isEmpty) return; 87 | 88 | setState(() { 89 | _isLoading = true; 90 | }); 91 | 92 | int endIndex = Data.friendMessage.indexOf(_visibleMessages.first); 93 | if (endIndex <= 0) { 94 | setState(() { 95 | _isLoading = false; 96 | _hasMoreData = false; 97 | }); 98 | return; 99 | } 100 | 101 | int startIndex = (endIndex - _pageSize) > 0 ? (endIndex - _pageSize) : 0; 102 | 103 | if (startIndex < endIndex) { 104 | List moreMessages = 105 | Data.friendMessage.sublist(startIndex, endIndex); 106 | 107 | Future.delayed(const Duration(milliseconds: 300), () { 108 | if (mounted) { 109 | setState(() { 110 | _visibleMessages.insertAll(0, moreMessages); 111 | _isLoading = false; 112 | if (startIndex == 0) { 113 | _hasMoreData = false; 114 | } 115 | }); 116 | 117 | WidgetsBinding.instance.addPostFrameCallback((_) { 118 | if (_scrollController.hasClients) { 119 | final currentPosition = _scrollController.position.pixels; 120 | final scrollAmount = 50.0 * moreMessages.length; 121 | _scrollController.jumpTo(currentPosition + scrollAmount); 122 | } 123 | }); 124 | } 125 | }); 126 | } else { 127 | setState(() { 128 | _isLoading = false; 129 | _hasMoreData = false; 130 | }); 131 | } 132 | } 133 | 134 | void _updateMessages() { 135 | if (Data.friendMessage.isEmpty) return; 136 | 137 | if (_visibleMessages.isEmpty) { 138 | _loadInitialMessages(); 139 | return; 140 | } 141 | 142 | // 检查是否有新消息 143 | if (Data.friendMessage.isNotEmpty && 144 | (_visibleMessages.isEmpty || 145 | Data.friendMessage.last != _visibleMessages.last)) { 146 | int lastVisibleIndex = -1; 147 | if (_visibleMessages.isNotEmpty) { 148 | for (int i = Data.friendMessage.length - 1; i >= 0; i--) { 149 | if (Data.friendMessage[i] == _visibleMessages.last) { 150 | lastVisibleIndex = i; 151 | break; 152 | } 153 | } 154 | } 155 | 156 | // 加载新消息 157 | setState(() { 158 | if (lastVisibleIndex >= 0 && 159 | lastVisibleIndex < Data.friendMessage.length - 1) { 160 | List newMessages = 161 | Data.friendMessage.sublist(lastVisibleIndex + 1); 162 | _visibleMessages.addAll(newMessages); 163 | } else if (lastVisibleIndex == -1) { 164 | _visibleMessages = Data.friendMessage.length > _pageSize 165 | ? Data.friendMessage 166 | .sublist(Data.friendMessage.length - _pageSize) 167 | : [...Data.friendMessage]; 168 | } 169 | 170 | if (_isAtBottom) { 171 | WidgetsBinding.instance.addPostFrameCallback((_) { 172 | _scrollToBottom(); 173 | }); 174 | } 175 | }); 176 | } 177 | } 178 | 179 | void _debouncedUpdateMessages() { 180 | if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); 181 | _debounceTimer = Timer(const Duration(milliseconds: 300), () { 182 | _updateMessages(); 183 | }); 184 | } 185 | 186 | Timer? timer; 187 | void startTimer() { 188 | timer = Timer.periodic(const Duration(seconds: 1), (timer) { 189 | if (friendOnOpen.isNotEmpty) { 190 | socket.send( 191 | '{"type":"get_friend_message","user_id":"$friendOnOpen", "password":"${Config.password}"}', 192 | ); 193 | _debouncedUpdateMessages(); 194 | } 195 | }); 196 | } 197 | 198 | @override 199 | void dispose() { 200 | timer?.cancel(); 201 | _debounceTimer?.cancel(); 202 | myController.dispose(); 203 | _scrollController.removeListener(_scrollListener); 204 | _scrollController.dispose(); 205 | friendOnOpen = ''; 206 | friendOnOpenName = ''; 207 | super.dispose(); 208 | } 209 | 210 | @override 211 | Widget build(BuildContext context) { 212 | final viewInsets = MediaQuery.of(context).viewInsets; 213 | final themeNotifier = Provider.of(context); 214 | 215 | return Scaffold( 216 | resizeToAvoidBottomInset: true, 217 | appBar: AppBar( 218 | title: 219 | Text(friendOnOpenName, style: const TextStyle(color: Colors.white)), 220 | automaticallyImplyLeading: false, 221 | leading: IconButton( 222 | icon: const Icon(Icons.arrow_back, color: Colors.white), 223 | tooltip: '返回', 224 | onPressed: () { 225 | friendOnOpen = ''; 226 | friendOnOpenName = ''; 227 | Navigator.pop(context); 228 | }, 229 | ), 230 | actions: [ 231 | IconButton( 232 | icon: const Icon(Icons.brightness_6), 233 | onPressed: () { 234 | themeNotifier.toggleTheme(); 235 | setState(() { 236 | if (Config.user['color'] == 'light' || 237 | Config.user['color'] == 'default') { 238 | Config.user['color'] = 'dark'; 239 | } else { 240 | Config.user['color'] = 'light'; 241 | } 242 | }); 243 | }, 244 | color: Colors.white, 245 | ), 246 | ], 247 | ), 248 | body: Stack( 249 | children: [ 250 | Column( 251 | mainAxisAlignment: MainAxisAlignment.start, 252 | children: [ 253 | Flexible( 254 | flex: 4, 255 | child: _visibleMessages.isEmpty && !_isLoading 256 | ? const Center(child: Text('')) 257 | : ListView.builder( 258 | controller: _scrollController, 259 | itemCount: _visibleMessages.length + 1, 260 | shrinkWrap: false, 261 | padding: const EdgeInsets.all(16), 262 | physics: const AlwaysScrollableScrollPhysics(), 263 | itemBuilder: (context, index) { 264 | if (index == 0) { 265 | return _isLoading 266 | ? const SizedBox( 267 | height: 60, 268 | child: Center( 269 | child: CircularProgressIndicator(), 270 | ), 271 | ) 272 | : _hasMoreData 273 | ? const SizedBox(height: 10) 274 | : const Padding( 275 | padding: EdgeInsets.all(8.0), 276 | child: Center( 277 | child: Text( 278 | '没有更多了', 279 | style: TextStyle( 280 | color: Colors.grey, 281 | fontSize: 12, 282 | ), 283 | ), 284 | ), 285 | ); 286 | } 287 | 288 | final data = _visibleMessages[index - 1]; 289 | final userId = data['sender']['user_id'].toString(); 290 | 291 | return MessageBubble( 292 | message: data, 293 | isCurrentUser: userId == Data.botInfo['id'], 294 | onResend: () async { 295 | setState(() { 296 | socket.send( 297 | '{"type":"send_private_msg","user_id":"$friendOnOpen","message":"${data['message'].toString().replaceAll('\n', '\\n')}","password":"${Config.password}"}', 298 | ); 299 | socket.send( 300 | '{"type":"get_friend_message","user_id":"$friendOnOpen", "password":"${Config.password}"}', 301 | ); 302 | }); 303 | }, 304 | ); 305 | }, 306 | ), 307 | ), 308 | const Divider( 309 | height: 1, 310 | thickness: 1, 311 | color: Colors.grey, 312 | ), 313 | Container( 314 | height: 60, 315 | padding: EdgeInsets.only( 316 | bottom: viewInsets.bottom > 0 ? 0 : 10, 317 | left: 10, 318 | right: 10, 319 | top: 10, 320 | ), 321 | child: Stack( 322 | children: [ 323 | TextField( 324 | controller: myController, 325 | maxLines: null, 326 | expands: true, 327 | textAlignVertical: TextAlignVertical.top, 328 | decoration: const InputDecoration( 329 | hintText: 'Say something...', 330 | hintStyle: TextStyle( 331 | color: Colors.grey, 332 | ), 333 | border: InputBorder.none, 334 | contentPadding: EdgeInsets.all(10), 335 | ), 336 | ), 337 | Positioned( 338 | right: 0, 339 | bottom: 0, 340 | child: IconButton( 341 | icon: const Icon(Icons.send), 342 | color: Config.user['color'] == 'default' || 343 | Config.user['color'] == 'light' 344 | ? const Color.fromRGBO(0, 153, 255, 1) 345 | : const Color.fromRGBO(147, 112, 219, 1), 346 | onPressed: () { 347 | if (myController.text.isNotEmpty) { 348 | final messageText = 349 | myController.text.replaceAll('\n', '\\n'); 350 | setState(() { 351 | socket.send( 352 | '{"type":"send_private_msg","user_id":"$friendOnOpen","message":"$messageText","password":"${Config.password}"}', 353 | ); 354 | }); 355 | setState(() { 356 | socket.send( 357 | '{"type":"get_friend_message","user_id":"$friendOnOpen", "password":"${Config.password}"}', 358 | ); 359 | }); 360 | myController.clear(); 361 | Future.delayed(const Duration(milliseconds: 300), 362 | () { 363 | _scrollToBottom(); 364 | }); 365 | } 366 | }, 367 | ), 368 | ) 369 | ], 370 | ), 371 | ), 372 | ], 373 | ), 374 | if (_showScrollToBottomButton) 375 | Positioned( 376 | right: 16, 377 | bottom: 80, 378 | child: FloatingActionButton( 379 | mini: true, 380 | backgroundColor: Theme.of(context).primaryColor, 381 | onPressed: () { 382 | _scrollToBottom(); 383 | }, 384 | child: const Icon( 385 | Icons.arrow_downward, 386 | color: Colors.white, 387 | ), 388 | ), 389 | ), 390 | ], 391 | ), 392 | ); 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /sideload_webui/lib/pages/private_chat.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_web_libraries_in_flutter 2 | 3 | import 'dart:async'; 4 | import 'dart:html'; 5 | import 'package:sideload_webui/utils/widgets.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:sideload_webui/utils/global.dart'; 8 | 9 | class ChatPrivate extends StatefulWidget { 10 | const ChatPrivate({super.key}); 11 | 12 | @override 13 | State createState() => _HomeScreenState(); 14 | } 15 | 16 | class _HomeScreenState extends State { 17 | final myController = TextEditingController(); 18 | final ScrollController _scrollController = ScrollController(); 19 | bool _isAtBottom = true; 20 | bool _showScrollToBottomButton = false; 21 | 22 | List _visibleMessages = []; 23 | final int _pageSize = 20; 24 | bool _isLoading = false; 25 | bool _hasMoreData = true; 26 | Timer? _debounceTimer; 27 | 28 | @override 29 | void initState() { 30 | super.initState(); 31 | setState(() { 32 | Future.delayed(const Duration(milliseconds: 100), () { 33 | setState(() { 34 | socket.send( 35 | '{"type":"get_total","password":"${Config.password}"}', 36 | ); 37 | }); 38 | _loadInitialMessages(); 39 | }); 40 | startTimer(); 41 | }); 42 | 43 | // 监听滚动事件 44 | _scrollController.addListener(_scrollListener); 45 | } 46 | 47 | void _scrollListener() { 48 | final isAtBottom = _scrollController.position.pixels >= 49 | (_scrollController.position.maxScrollExtent - 20); 50 | 51 | if (_scrollController.position.pixels <= 50 && 52 | !_isLoading && 53 | _hasMoreData) { 54 | _loadMoreMessages(); 55 | } 56 | 57 | if (isAtBottom != _isAtBottom) { 58 | setState(() { 59 | _isAtBottom = isAtBottom; 60 | _showScrollToBottomButton = !isAtBottom; 61 | }); 62 | } 63 | } 64 | 65 | void _scrollToBottom() { 66 | if (_scrollController.hasClients) { 67 | _scrollController.animateTo( 68 | _scrollController.position.maxScrollExtent, 69 | duration: const Duration(milliseconds: 300), 70 | curve: Curves.easeOut, 71 | ); 72 | } 73 | } 74 | 75 | void _loadInitialMessages() { 76 | if (Data.friendMessage.isEmpty) return; 77 | 78 | setState(() { 79 | _visibleMessages = Data.friendMessage.length > _pageSize 80 | ? Data.friendMessage.sublist(Data.friendMessage.length - _pageSize) 81 | : [...Data.friendMessage]; 82 | 83 | // 确保从底部开始显示 84 | WidgetsBinding.instance.addPostFrameCallback((_) { 85 | _scrollToBottom(); 86 | }); 87 | }); 88 | } 89 | 90 | void _loadMoreMessages() { 91 | if (_isLoading || _visibleMessages.isEmpty) return; 92 | 93 | setState(() { 94 | _isLoading = true; 95 | }); 96 | 97 | // 计算下一页的起始索引 98 | int endIndex = Data.friendMessage.indexOf(_visibleMessages.first); 99 | if (endIndex <= 0) { 100 | setState(() { 101 | _isLoading = false; 102 | _hasMoreData = false; 103 | }); 104 | return; 105 | } 106 | 107 | int startIndex = (endIndex - _pageSize) > 0 ? (endIndex - _pageSize) : 0; 108 | 109 | if (startIndex < endIndex) { 110 | List moreMessages = 111 | Data.friendMessage.sublist(startIndex, endIndex); 112 | 113 | Future.delayed(const Duration(milliseconds: 300), () { 114 | if (mounted) { 115 | setState(() { 116 | _visibleMessages.insertAll(0, moreMessages); 117 | _isLoading = false; 118 | if (startIndex == 0) { 119 | _hasMoreData = false; 120 | } 121 | }); 122 | 123 | // 保持滚动位置 124 | WidgetsBinding.instance.addPostFrameCallback((_) { 125 | if (_scrollController.hasClients) { 126 | final currentPosition = _scrollController.position.pixels; 127 | final scrollAmount = 50.0 * moreMessages.length; 128 | _scrollController.jumpTo(currentPosition + scrollAmount); 129 | } 130 | }); 131 | } 132 | }); 133 | } else { 134 | setState(() { 135 | _isLoading = false; 136 | _hasMoreData = false; 137 | }); 138 | } 139 | } 140 | 141 | void _updateMessages() { 142 | if (Data.friendMessage.isEmpty) return; 143 | 144 | if (_visibleMessages.isEmpty) { 145 | _loadInitialMessages(); 146 | return; 147 | } 148 | 149 | // 检查是否有新消息 150 | if (Data.friendMessage.isNotEmpty && 151 | (_visibleMessages.isEmpty || 152 | Data.friendMessage.last != _visibleMessages.last)) { 153 | int lastVisibleIndex = -1; 154 | if (_visibleMessages.isNotEmpty) { 155 | for (int i = Data.friendMessage.length - 1; i >= 0; i--) { 156 | if (Data.friendMessage[i] == _visibleMessages.last) { 157 | lastVisibleIndex = i; 158 | break; 159 | } 160 | } 161 | } 162 | 163 | // 加载新消息 164 | setState(() { 165 | if (lastVisibleIndex >= 0 && 166 | lastVisibleIndex < Data.friendMessage.length - 1) { 167 | List newMessages = 168 | Data.friendMessage.sublist(lastVisibleIndex + 1); 169 | _visibleMessages.addAll(newMessages); 170 | } else if (lastVisibleIndex == -1) { 171 | _visibleMessages = Data.friendMessage.length > _pageSize 172 | ? Data.friendMessage 173 | .sublist(Data.friendMessage.length - _pageSize) 174 | : [...Data.friendMessage]; 175 | } 176 | 177 | if (_isAtBottom) { 178 | WidgetsBinding.instance.addPostFrameCallback((_) { 179 | _scrollToBottom(); 180 | }); 181 | } 182 | }); 183 | } 184 | } 185 | 186 | void _debouncedUpdateMessages() { 187 | if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); 188 | _debounceTimer = Timer(const Duration(milliseconds: 300), () { 189 | _updateMessages(); 190 | }); 191 | } 192 | 193 | Timer? timer; 194 | void startTimer() { 195 | timer = Timer.periodic(const Duration(seconds: 1), (timer) { 196 | if (friendOnOpen.isNotEmpty) { 197 | socket.send( 198 | '{"type":"get_friend_message","user_id":"$friendOnOpen", "password":"${Config.password}"}', 199 | ); 200 | _debouncedUpdateMessages(); 201 | } 202 | }); 203 | } 204 | 205 | @override 206 | void dispose() { 207 | timer?.cancel(); 208 | _debounceTimer?.cancel(); 209 | myController.dispose(); 210 | _scrollController.removeListener(_scrollListener); 211 | _scrollController.dispose(); 212 | friendOnOpen = ''; 213 | super.dispose(); 214 | } 215 | 216 | @override 217 | Widget build(BuildContext context) { 218 | return Scaffold( 219 | body: Row( 220 | children: [ 221 | Expanded( 222 | flex: 2, 223 | child: Container( 224 | alignment: Alignment.topCenter, 225 | child: ListView.builder( 226 | itemCount: Data.friendList.length, 227 | shrinkWrap: true, 228 | padding: EdgeInsets.zero, 229 | physics: const AlwaysScrollableScrollPhysics(), 230 | itemBuilder: (context, index) { 231 | return Container( 232 | color: Data.friendList[index]['user_id'].toString() == 233 | friendOnOpen 234 | ? Config.user['color'] == 'default' || 235 | Config.user['color'] == 'light' 236 | ? const Color.fromRGBO(0, 153, 255, 1) 237 | : const Color.fromRGBO(147, 112, 219, 1) 238 | : Colors.transparent, 239 | child: ListTile( 240 | leading: CircleAvatar( 241 | backgroundImage: NetworkImage( 242 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/avatars/user/${Data.friendList[index]['user_id']}.png', 243 | ), 244 | ), 245 | title: Text( 246 | Data.friendList[index]['nickname'].toString(), 247 | style: TextStyle( 248 | fontSize: 16, 249 | fontWeight: FontWeight.bold, 250 | color: Data.friendList[index]['user_id'] 251 | .toString() == 252 | friendOnOpen 253 | ? Colors.white 254 | : Config.user['color'] == 'default' || 255 | Config.user['color'] == 'light' 256 | ? Colors.black 257 | : Colors.white), 258 | ), 259 | subtitle: Text( 260 | Data.friendList[index]['user_id'].toString(), 261 | style: TextStyle( 262 | fontSize: 14, 263 | color: Data.friendList[index]['user_id'] 264 | .toString() == 265 | friendOnOpen 266 | ? Colors.grey[200] 267 | : Colors.grey[600], 268 | ), 269 | ), 270 | onTap: () { 271 | setState(() { 272 | friendOnOpen = 273 | Data.friendList[index]['user_id'].toString(); 274 | Data.friendMessage = []; 275 | _visibleMessages = []; 276 | _hasMoreData = true; 277 | socket.send( 278 | '{"type":"get_friend_message","user_id":"${Data.friendList[index]['user_id'].toString()}", "password":"${Config.password}"}', 279 | ); 280 | Future.delayed(const Duration(milliseconds: 500), 281 | () { 282 | _loadInitialMessages(); 283 | }); 284 | }); 285 | }, 286 | )); 287 | }, 288 | ), 289 | )), 290 | const VerticalDivider( 291 | width: 1, 292 | thickness: 1, 293 | color: Colors.grey, 294 | ), 295 | Expanded( 296 | flex: 11, 297 | child: friendOnOpen.isEmpty 298 | ? Container( 299 | alignment: Alignment.center, 300 | child: const Text( 301 | 'Select a friend to chat', 302 | style: TextStyle( 303 | fontSize: 20, 304 | fontWeight: FontWeight.bold, 305 | color: Colors.grey, 306 | ), 307 | )) 308 | : Stack( 309 | children: [ 310 | Column( 311 | mainAxisAlignment: MainAxisAlignment.start, 312 | children: [ 313 | Expanded( 314 | flex: 4, 315 | child: Stack( 316 | children: [ 317 | ListView.builder( 318 | controller: _scrollController, 319 | itemCount: _visibleMessages.length + 1, 320 | shrinkWrap: false, 321 | padding: const EdgeInsets.all(24), 322 | physics: 323 | const AlwaysScrollableScrollPhysics(), 324 | itemBuilder: (context, index) { 325 | if (index == 0) { 326 | return _isLoading 327 | ? const SizedBox( 328 | height: 60, 329 | ) 330 | : _hasMoreData 331 | ? const SizedBox(height: 20) 332 | : const Padding( 333 | padding: 334 | EdgeInsets.all(8.0), 335 | child: Center( 336 | child: Text( 337 | '没有更多了', 338 | style: TextStyle( 339 | color: Colors.grey, 340 | fontSize: 12, 341 | ), 342 | ), 343 | ), 344 | ); 345 | } 346 | 347 | final data = _visibleMessages[index - 1]; 348 | final userId = 349 | data['sender']['user_id'].toString(); 350 | 351 | return MessageBubble( 352 | message: data, 353 | isCurrentUser: 354 | userId == Data.botInfo['id'], 355 | onResend: () async { 356 | setState(() { 357 | socket.send( 358 | '{"type":"send_private_msg","user_id":"$friendOnOpen","message":"${data['message'].toString().replaceAll('\n', '\\n')}","password":"${Config.password}"}', 359 | ); 360 | socket.send( 361 | '{"type":"get_friend_message","user_id":"$friendOnOpen", "password":"${Config.password}"}', 362 | ); 363 | }); 364 | }, 365 | ); 366 | }, 367 | ), 368 | if (_visibleMessages.isEmpty) 369 | const Center( 370 | child: CircularProgressIndicator(), 371 | ), 372 | ], 373 | ), 374 | ), 375 | const Divider( 376 | height: 1, 377 | thickness: 1, 378 | color: Colors.grey, 379 | ), 380 | Expanded( 381 | flex: 1, 382 | child: Container( 383 | padding: const EdgeInsets.all(10), 384 | child: Stack( 385 | children: [ 386 | TextField( 387 | controller: myController, 388 | maxLines: null, 389 | expands: true, 390 | textAlignVertical: 391 | TextAlignVertical.top, 392 | decoration: const InputDecoration( 393 | hintText: 'Say something...', 394 | hintStyle: TextStyle( 395 | color: Colors.grey, 396 | ), 397 | border: InputBorder.none, 398 | contentPadding: EdgeInsets.all(10), 399 | ), 400 | ), 401 | Positioned( 402 | right: 0, 403 | bottom: 0, 404 | child: IconButton( 405 | icon: const Icon(Icons.send), 406 | color: Config.user['color'] == 407 | 'default' || 408 | Config.user['color'] == 409 | 'light' 410 | ? const Color.fromRGBO( 411 | 0, 153, 255, 1) 412 | : const Color.fromRGBO( 413 | 147, 112, 219, 1), 414 | onPressed: () { 415 | if (myController.text.isNotEmpty) { 416 | final messageText = myController 417 | .text 418 | .replaceAll('\n', '\\n'); 419 | setState(() { 420 | socket.send( 421 | '{"type":"send_private_msg","user_id":"$friendOnOpen","message":"$messageText","password":"${Config.password}"}', 422 | ); 423 | }); 424 | setState(() { 425 | socket.send( 426 | '{"type":"get_friend_message","user_id":"$friendOnOpen", "password":"${Config.password}"}', 427 | ); 428 | }); 429 | myController.clear(); 430 | Future.delayed( 431 | const Duration( 432 | milliseconds: 300), () { 433 | _scrollToBottom(); 434 | }); 435 | } 436 | }, 437 | ), 438 | ) 439 | ], 440 | )), 441 | ) 442 | ], 443 | ), 444 | if (_showScrollToBottomButton) 445 | Positioned( 446 | right: 16, 447 | bottom: 80, 448 | child: FloatingActionButton( 449 | mini: true, 450 | backgroundColor: Theme.of(context).primaryColor, 451 | onPressed: () { 452 | _scrollToBottom(); 453 | }, 454 | child: const Icon( 455 | Icons.arrow_downward, 456 | color: Colors.white, 457 | ), 458 | ), 459 | ), 460 | ], 461 | )) 462 | ], 463 | ), 464 | ); 465 | } 466 | } 467 | -------------------------------------------------------------------------------- /sideload_webui/lib/utils/core.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | // ignore: avoid_web_libraries_in_flutter 3 | import 'dart:html'; 4 | import 'package:sideload_webui/utils/global.dart'; 5 | import 'package:sideload_webui/utils/ws_handler.dart'; 6 | 7 | Timer? timer; 8 | 9 | void connectToWebSocket() { 10 | //连接到WebSocket 11 | late String wsUrl; 12 | if (debug) { 13 | wsUrl = 'ws://192.168.0.100:8081/nbgui/v1/sideload/ws'; 14 | } else { 15 | wsUrl = (window.location.protocol == 'https:') 16 | ? 'wss://${window.location.hostname}:${Uri.base.port}/nbgui/v1/sideload/ws' 17 | : 'ws://${window.location.hostname}:${Uri.base.port}/nbgui/v1/sideload/ws'; 18 | } 19 | socket = WebSocket(wsUrl); 20 | // socket = WebSocket('ws://localhost:2519/nbgui/v1/ws'); 21 | socket.onMessage.listen((event) { 22 | MessageEvent msg = event; 23 | wsHandler(msg); 24 | }, cancelOnError: false); 25 | } 26 | -------------------------------------------------------------------------------- /sideload_webui/lib/utils/global.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | 3 | late WebSocket socket; 4 | late String version; 5 | String groupOnOpen = ''; 6 | String groupOnOpenName = ''; 7 | String friendOnOpen = ''; 8 | String friendOnOpenName = ''; 9 | String initialThemeMode = 'light'; 10 | 11 | /// 配置文件 12 | class Config { 13 | /// 访问密码 14 | static String password = 'Xt114514'; 15 | 16 | /// token 17 | static String token = ''; 18 | 19 | /// 用户个人配置 20 | static Map user = { 21 | "color": "light", 22 | "img": "default", 23 | "text": "default", 24 | "anti_recall": true, 25 | "display_time": true, 26 | "+1": true 27 | }; 28 | } 29 | 30 | /// 应用程序数据 31 | class Data { 32 | /// 群组列表 33 | static List groupList = []; 34 | 35 | /// 好友列表 36 | static List friendList = []; 37 | 38 | /// 群组消息 39 | static List groupMessage = []; 40 | 41 | /// 好友消息 42 | static List friendMessage = []; 43 | 44 | /// Bot 基本信息 45 | static Map botInfo = {}; 46 | 47 | static String userAvatar = ''; 48 | } 49 | 50 | // 神秘开关 51 | bool debug = false; 52 | -------------------------------------------------------------------------------- /sideload_webui/lib/utils/widgets.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:sideload_webui/utils/global.dart'; 5 | 6 | class MessageBubble extends StatelessWidget { 7 | final Map message; 8 | final bool isCurrentUser; 9 | final Function()? onResend; 10 | 11 | const MessageBubble({ 12 | super.key, 13 | required this.message, 14 | required this.isCurrentUser, 15 | this.onResend, 16 | }); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final data = message; 21 | final sender = data['sender']; 22 | final nickname = sender['nickname'].toString(); 23 | final userId = sender['user_id']; 24 | final isRecalled = data['drawed'] == "1"; 25 | final hideIfRecalled = !Config.user["anti_recall"] && isRecalled; 26 | final messageType = data['type'] ?? "text"; 27 | final messageContent = data['message'].toString(); 28 | final msgId = data['msg_id'].toString(); 29 | 30 | Widget buildMessageContent() { 31 | switch (messageType) { 32 | case "image": 33 | return Container( 34 | constraints: BoxConstraints( 35 | maxWidth: MediaQuery.of(context).size.width * 0.4, 36 | maxHeight: 300, 37 | ), 38 | child: InkWell( 39 | onTap: () { 40 | showDialog( 41 | context: context, 42 | builder: (context) => Dialog( 43 | child: Image.network( 44 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/image/$msgId', 45 | fit: BoxFit.contain, 46 | ), 47 | ), 48 | ); 49 | }, 50 | child: Image.network( 51 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/image/$msgId', 52 | fit: BoxFit.cover, 53 | loadingBuilder: (context, child, loadingProgress) { 54 | if (loadingProgress == null) return child; 55 | return Container( 56 | width: 200, 57 | height: 150, 58 | alignment: Alignment.center, 59 | child: CircularProgressIndicator( 60 | value: loadingProgress.expectedTotalBytes != null 61 | ? loadingProgress.cumulativeBytesLoaded / 62 | loadingProgress.expectedTotalBytes! 63 | : null, 64 | ), 65 | ); 66 | }, 67 | errorBuilder: (context, error, stackTrace) { 68 | return Container( 69 | width: 150, 70 | height: 100, 71 | color: Colors.grey[300], 72 | child: const Icon(Icons.broken_image, size: 40), 73 | ); 74 | }, 75 | ), 76 | ), 77 | ); 78 | case "text": 79 | default: 80 | return Container( 81 | constraints: BoxConstraints( 82 | maxWidth: MediaQuery.of(context).size.width * 0.6, 83 | ), 84 | child: SelectableText( 85 | messageContent, 86 | style: TextStyle( 87 | color: isCurrentUser ? Colors.white : null, 88 | ), 89 | toolbarOptions: const ToolbarOptions( 90 | copy: true, 91 | selectAll: true, 92 | cut: false, 93 | paste: false, 94 | ), 95 | ), 96 | ); 97 | } 98 | } 99 | 100 | if (hideIfRecalled) { 101 | return Container( 102 | padding: const EdgeInsets.symmetric(vertical: 5), 103 | child: Row( 104 | crossAxisAlignment: CrossAxisAlignment.center, 105 | children: [ 106 | Text( 107 | "$nickname撤回了一条消息", 108 | style: const TextStyle(fontSize: 12, color: Colors.grey), 109 | ), 110 | ], 111 | ), 112 | ); 113 | } 114 | 115 | if (isCurrentUser) { 116 | return Container( 117 | padding: const EdgeInsets.symmetric(vertical: 5), 118 | child: Row( 119 | mainAxisAlignment: MainAxisAlignment.end, 120 | crossAxisAlignment: CrossAxisAlignment.start, 121 | children: [ 122 | Column( 123 | crossAxisAlignment: CrossAxisAlignment.end, 124 | children: [ 125 | Text( 126 | nickname, 127 | style: const TextStyle(fontSize: 16, color: Colors.grey), 128 | ), 129 | const SizedBox(height: 4), 130 | Card( 131 | color: messageType == "text" 132 | ? const Color.fromRGBO(0, 153, 255, 1) 133 | : null, 134 | shape: RoundedRectangleBorder( 135 | borderRadius: BorderRadius.circular(8), 136 | ), 137 | child: Padding( 138 | padding: const EdgeInsets.all(12.0), 139 | child: buildMessageContent(), 140 | ), 141 | ), 142 | Row( 143 | children: [ 144 | if (Config.user['display_time']) 145 | Text( 146 | data['time'].toString(), 147 | style: const TextStyle( 148 | fontSize: 12, 149 | color: Colors.grey, 150 | ), 151 | ), 152 | if (Config.user['display_time'] && isRecalled) 153 | const SizedBox(width: 10), 154 | if (isRecalled) 155 | const Text( 156 | "已撤回", 157 | style: TextStyle(fontSize: 12, color: Colors.grey), 158 | ), 159 | ], 160 | ), 161 | ], 162 | ), 163 | const SizedBox(width: 10), 164 | CircleAvatar( 165 | backgroundImage: NetworkImage( 166 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/avatars/user/$userId.png', 167 | ), 168 | ), 169 | ], 170 | ), 171 | ); 172 | } else { 173 | return Container( 174 | padding: const EdgeInsets.symmetric(vertical: 5), 175 | child: Row( 176 | crossAxisAlignment: CrossAxisAlignment.start, 177 | children: [ 178 | CircleAvatar( 179 | backgroundImage: NetworkImage( 180 | '${window.location.protocol}//${window.location.hostname}:${Uri.base.port}/sideload/avatars/user/$userId.png', 181 | ), 182 | ), 183 | const SizedBox(width: 10), 184 | Column( 185 | crossAxisAlignment: CrossAxisAlignment.start, 186 | children: [ 187 | Text( 188 | nickname, 189 | style: const TextStyle(fontSize: 16, color: Colors.grey), 190 | ), 191 | const SizedBox(height: 4), 192 | Row( 193 | crossAxisAlignment: CrossAxisAlignment.end, 194 | children: [ 195 | Card( 196 | shape: RoundedRectangleBorder( 197 | borderRadius: BorderRadius.circular(8), 198 | ), 199 | child: Padding( 200 | padding: const EdgeInsets.all(12.0), 201 | child: buildMessageContent(), 202 | ), 203 | ), 204 | if (Config.user['+1'] && !isRecalled) 205 | Padding( 206 | padding: const EdgeInsets.only(left: 8.0, bottom: 2.0), 207 | child: ResendButton(onPressed: onResend), 208 | ), 209 | ], 210 | ), 211 | if (Config.user['display_time'] || isRecalled) 212 | const SizedBox(height: 2), 213 | Row( 214 | children: [ 215 | if (Config.user['display_time']) 216 | Text( 217 | data['time'].toString(), 218 | style: const TextStyle( 219 | fontSize: 12, 220 | color: Colors.grey, 221 | ), 222 | ), 223 | if (Config.user['display_time'] && isRecalled) 224 | const SizedBox(width: 10), 225 | if (isRecalled) 226 | const Text( 227 | "已撤回", 228 | style: TextStyle(fontSize: 12, color: Colors.grey), 229 | ), 230 | ], 231 | ), 232 | ], 233 | ), 234 | ], 235 | ), 236 | ); 237 | } 238 | } 239 | } 240 | 241 | class ResendButton extends StatelessWidget { 242 | final Function()? onPressed; 243 | const ResendButton({Key? key, this.onPressed}) : super(key: key); 244 | 245 | @override 246 | Widget build(BuildContext context) { 247 | return SizedBox( 248 | child: ElevatedButton( 249 | onPressed: onPressed, 250 | style: ButtonStyle( 251 | backgroundColor: MaterialStateProperty.all( 252 | Config.user['color'] == 'default' || Config.user['color'] == 'light' 253 | ? const Color.fromRGBO(0, 153, 255, 1) 254 | : const Color.fromRGBO(147, 112, 219, 1), 255 | ), 256 | shape: MaterialStateProperty.all(const CircleBorder()), 257 | iconSize: MaterialStateProperty.all(24), 258 | minimumSize: MaterialStateProperty.all(const Size(32, 32)), 259 | ), 260 | child: const Text('+1', style: TextStyle(color: Colors.white)), 261 | ), 262 | ); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /sideload_webui/lib/utils/ws_handler.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | // ignore: avoid_web_libraries_in_flutter 3 | import 'dart:html'; 4 | import 'package:sideload_webui/utils/global.dart'; 5 | 6 | /// 处理WebSocket消息 7 | Future wsHandler(MessageEvent msg) async { 8 | /// 解析json 9 | String? msg0 = msg.data; 10 | // 检查是否为json 11 | if (msg0 != null) { 12 | Map msgJson = jsonDecode(msg0); 13 | String type = msgJson['type']; 14 | switch (type) { 15 | case 'total': 16 | Map data = msgJson['data']; 17 | Data.botInfo = data; 18 | Data.groupList = data['group_list']; 19 | Data.friendList = data['friend_list']; 20 | break; 21 | case 'group_msg': 22 | List data = msgJson['data']; 23 | Data.groupMessage = data; 24 | break; 25 | case 'friend_msg': 26 | List data = msgJson['data']; 27 | Data.friendMessage = data; 28 | break; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sideload_webui/pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | args: 5 | dependency: transitive 6 | description: 7 | name: args 8 | sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 9 | url: "https://pub.flutter-io.cn" 10 | source: hosted 11 | version: "2.7.0" 12 | async: 13 | dependency: transitive 14 | description: 15 | name: async 16 | sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" 17 | url: "https://pub.flutter-io.cn" 18 | source: hosted 19 | version: "2.11.0" 20 | boolean_selector: 21 | dependency: transitive 22 | description: 23 | name: boolean_selector 24 | sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" 25 | url: "https://pub.flutter-io.cn" 26 | source: hosted 27 | version: "2.1.1" 28 | cached_network_image: 29 | dependency: "direct main" 30 | description: 31 | name: cached_network_image 32 | sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819" 33 | url: "https://pub.flutter-io.cn" 34 | source: hosted 35 | version: "3.4.0" 36 | cached_network_image_platform_interface: 37 | dependency: transitive 38 | description: 39 | name: cached_network_image_platform_interface 40 | sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" 41 | url: "https://pub.flutter-io.cn" 42 | source: hosted 43 | version: "4.1.1" 44 | cached_network_image_web: 45 | dependency: transitive 46 | description: 47 | name: cached_network_image_web 48 | sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996" 49 | url: "https://pub.flutter-io.cn" 50 | source: hosted 51 | version: "1.3.0" 52 | characters: 53 | dependency: transitive 54 | description: 55 | name: characters 56 | sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" 57 | url: "https://pub.flutter-io.cn" 58 | source: hosted 59 | version: "1.3.0" 60 | clock: 61 | dependency: transitive 62 | description: 63 | name: clock 64 | sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf 65 | url: "https://pub.flutter-io.cn" 66 | source: hosted 67 | version: "1.1.1" 68 | collection: 69 | dependency: transitive 70 | description: 71 | name: collection 72 | sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a 73 | url: "https://pub.flutter-io.cn" 74 | source: hosted 75 | version: "1.18.0" 76 | crypto: 77 | dependency: transitive 78 | description: 79 | name: crypto 80 | sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab 81 | url: "https://pub.flutter-io.cn" 82 | source: hosted 83 | version: "3.0.3" 84 | cupertino_icons: 85 | dependency: "direct main" 86 | description: 87 | name: cupertino_icons 88 | sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 89 | url: "https://pub.flutter-io.cn" 90 | source: hosted 91 | version: "1.0.8" 92 | fake_async: 93 | dependency: transitive 94 | description: 95 | name: fake_async 96 | sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" 97 | url: "https://pub.flutter-io.cn" 98 | source: hosted 99 | version: "1.3.1" 100 | ffi: 101 | dependency: transitive 102 | description: 103 | name: ffi 104 | sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" 105 | url: "https://pub.flutter-io.cn" 106 | source: hosted 107 | version: "2.1.3" 108 | file: 109 | dependency: transitive 110 | description: 111 | name: file 112 | sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 113 | url: "https://pub.flutter-io.cn" 114 | source: hosted 115 | version: "7.0.1" 116 | fixnum: 117 | dependency: transitive 118 | description: 119 | name: fixnum 120 | sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be 121 | url: "https://pub.flutter-io.cn" 122 | source: hosted 123 | version: "1.1.1" 124 | flutter: 125 | dependency: "direct main" 126 | description: flutter 127 | source: sdk 128 | version: "0.0.0" 129 | flutter_cache_manager: 130 | dependency: transitive 131 | description: 132 | name: flutter_cache_manager 133 | sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" 134 | url: "https://pub.flutter-io.cn" 135 | source: hosted 136 | version: "3.4.1" 137 | flutter_lints: 138 | dependency: "direct dev" 139 | description: 140 | name: flutter_lints 141 | sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" 142 | url: "https://pub.flutter-io.cn" 143 | source: hosted 144 | version: "3.0.2" 145 | flutter_svg: 146 | dependency: "direct main" 147 | description: 148 | name: flutter_svg 149 | sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" 150 | url: "https://pub.flutter-io.cn" 151 | source: hosted 152 | version: "2.0.10+1" 153 | flutter_test: 154 | dependency: "direct dev" 155 | description: flutter 156 | source: sdk 157 | version: "0.0.0" 158 | flutter_web_plugins: 159 | dependency: transitive 160 | description: flutter 161 | source: sdk 162 | version: "0.0.0" 163 | http: 164 | dependency: "direct main" 165 | description: 166 | name: http 167 | sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 168 | url: "https://pub.flutter-io.cn" 169 | source: hosted 170 | version: "1.2.2" 171 | http_parser: 172 | dependency: transitive 173 | description: 174 | name: http_parser 175 | sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" 176 | url: "https://pub.flutter-io.cn" 177 | source: hosted 178 | version: "4.0.2" 179 | leak_tracker: 180 | dependency: transitive 181 | description: 182 | name: leak_tracker 183 | sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" 184 | url: "https://pub.flutter-io.cn" 185 | source: hosted 186 | version: "10.0.0" 187 | leak_tracker_flutter_testing: 188 | dependency: transitive 189 | description: 190 | name: leak_tracker_flutter_testing 191 | sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 192 | url: "https://pub.flutter-io.cn" 193 | source: hosted 194 | version: "2.0.1" 195 | leak_tracker_testing: 196 | dependency: transitive 197 | description: 198 | name: leak_tracker_testing 199 | sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 200 | url: "https://pub.flutter-io.cn" 201 | source: hosted 202 | version: "2.0.1" 203 | lints: 204 | dependency: transitive 205 | description: 206 | name: lints 207 | sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 208 | url: "https://pub.flutter-io.cn" 209 | source: hosted 210 | version: "3.0.0" 211 | matcher: 212 | dependency: transitive 213 | description: 214 | name: matcher 215 | sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb 216 | url: "https://pub.flutter-io.cn" 217 | source: hosted 218 | version: "0.12.16+1" 219 | material_color_utilities: 220 | dependency: transitive 221 | description: 222 | name: material_color_utilities 223 | sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" 224 | url: "https://pub.flutter-io.cn" 225 | source: hosted 226 | version: "0.8.0" 227 | meta: 228 | dependency: transitive 229 | description: 230 | name: meta 231 | sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 232 | url: "https://pub.flutter-io.cn" 233 | source: hosted 234 | version: "1.11.0" 235 | nested: 236 | dependency: transitive 237 | description: 238 | name: nested 239 | sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" 240 | url: "https://pub.flutter-io.cn" 241 | source: hosted 242 | version: "1.0.0" 243 | octo_image: 244 | dependency: transitive 245 | description: 246 | name: octo_image 247 | sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" 248 | url: "https://pub.flutter-io.cn" 249 | source: hosted 250 | version: "2.1.0" 251 | path: 252 | dependency: transitive 253 | description: 254 | name: path 255 | sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" 256 | url: "https://pub.flutter-io.cn" 257 | source: hosted 258 | version: "1.9.0" 259 | path_parsing: 260 | dependency: transitive 261 | description: 262 | name: path_parsing 263 | sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" 264 | url: "https://pub.flutter-io.cn" 265 | source: hosted 266 | version: "1.1.0" 267 | path_provider: 268 | dependency: transitive 269 | description: 270 | name: path_provider 271 | sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 272 | url: "https://pub.flutter-io.cn" 273 | source: hosted 274 | version: "2.1.4" 275 | path_provider_android: 276 | dependency: transitive 277 | description: 278 | name: path_provider_android 279 | sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d 280 | url: "https://pub.flutter-io.cn" 281 | source: hosted 282 | version: "2.2.4" 283 | path_provider_foundation: 284 | dependency: transitive 285 | description: 286 | name: path_provider_foundation 287 | sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" 288 | url: "https://pub.flutter-io.cn" 289 | source: hosted 290 | version: "2.4.1" 291 | path_provider_linux: 292 | dependency: transitive 293 | description: 294 | name: path_provider_linux 295 | sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 296 | url: "https://pub.flutter-io.cn" 297 | source: hosted 298 | version: "2.2.1" 299 | path_provider_platform_interface: 300 | dependency: transitive 301 | description: 302 | name: path_provider_platform_interface 303 | sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" 304 | url: "https://pub.flutter-io.cn" 305 | source: hosted 306 | version: "2.1.2" 307 | path_provider_windows: 308 | dependency: transitive 309 | description: 310 | name: path_provider_windows 311 | sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 312 | url: "https://pub.flutter-io.cn" 313 | source: hosted 314 | version: "2.3.0" 315 | petitparser: 316 | dependency: transitive 317 | description: 318 | name: petitparser 319 | sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 320 | url: "https://pub.flutter-io.cn" 321 | source: hosted 322 | version: "6.0.2" 323 | platform: 324 | dependency: transitive 325 | description: 326 | name: platform 327 | sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" 328 | url: "https://pub.flutter-io.cn" 329 | source: hosted 330 | version: "3.1.6" 331 | plugin_platform_interface: 332 | dependency: transitive 333 | description: 334 | name: plugin_platform_interface 335 | sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" 336 | url: "https://pub.flutter-io.cn" 337 | source: hosted 338 | version: "2.1.8" 339 | provider: 340 | dependency: "direct main" 341 | description: 342 | name: provider 343 | sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c 344 | url: "https://pub.flutter-io.cn" 345 | source: hosted 346 | version: "6.1.2" 347 | rxdart: 348 | dependency: transitive 349 | description: 350 | name: rxdart 351 | sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" 352 | url: "https://pub.flutter-io.cn" 353 | source: hosted 354 | version: "0.28.0" 355 | shared_preferences: 356 | dependency: "direct main" 357 | description: 358 | name: shared_preferences 359 | sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 360 | url: "https://pub.flutter-io.cn" 361 | source: hosted 362 | version: "2.2.3" 363 | shared_preferences_android: 364 | dependency: transitive 365 | description: 366 | name: shared_preferences_android 367 | sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" 368 | url: "https://pub.flutter-io.cn" 369 | source: hosted 370 | version: "2.2.2" 371 | shared_preferences_foundation: 372 | dependency: transitive 373 | description: 374 | name: shared_preferences_foundation 375 | sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" 376 | url: "https://pub.flutter-io.cn" 377 | source: hosted 378 | version: "2.5.3" 379 | shared_preferences_linux: 380 | dependency: transitive 381 | description: 382 | name: shared_preferences_linux 383 | sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" 384 | url: "https://pub.flutter-io.cn" 385 | source: hosted 386 | version: "2.4.1" 387 | shared_preferences_platform_interface: 388 | dependency: transitive 389 | description: 390 | name: shared_preferences_platform_interface 391 | sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" 392 | url: "https://pub.flutter-io.cn" 393 | source: hosted 394 | version: "2.4.1" 395 | shared_preferences_web: 396 | dependency: transitive 397 | description: 398 | name: shared_preferences_web 399 | sha256: "59dc807b94d29d52ddbb1b3c0d3b9d0a67fc535a64e62a5542c8db0513fcb6c2" 400 | url: "https://pub.flutter-io.cn" 401 | source: hosted 402 | version: "2.4.1" 403 | shared_preferences_windows: 404 | dependency: transitive 405 | description: 406 | name: shared_preferences_windows 407 | sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" 408 | url: "https://pub.flutter-io.cn" 409 | source: hosted 410 | version: "2.4.1" 411 | sky_engine: 412 | dependency: transitive 413 | description: flutter 414 | source: sdk 415 | version: "0.0.99" 416 | source_span: 417 | dependency: transitive 418 | description: 419 | name: source_span 420 | sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" 421 | url: "https://pub.flutter-io.cn" 422 | source: hosted 423 | version: "1.10.0" 424 | sprintf: 425 | dependency: transitive 426 | description: 427 | name: sprintf 428 | sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" 429 | url: "https://pub.flutter-io.cn" 430 | source: hosted 431 | version: "7.0.0" 432 | sqflite: 433 | dependency: transitive 434 | description: 435 | name: sqflite 436 | sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d 437 | url: "https://pub.flutter-io.cn" 438 | source: hosted 439 | version: "2.3.3+1" 440 | sqflite_common: 441 | dependency: transitive 442 | description: 443 | name: sqflite_common 444 | sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" 445 | url: "https://pub.flutter-io.cn" 446 | source: hosted 447 | version: "2.5.4" 448 | stack_trace: 449 | dependency: transitive 450 | description: 451 | name: stack_trace 452 | sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" 453 | url: "https://pub.flutter-io.cn" 454 | source: hosted 455 | version: "1.11.1" 456 | stream_channel: 457 | dependency: transitive 458 | description: 459 | name: stream_channel 460 | sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 461 | url: "https://pub.flutter-io.cn" 462 | source: hosted 463 | version: "2.1.2" 464 | string_scanner: 465 | dependency: transitive 466 | description: 467 | name: string_scanner 468 | sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" 469 | url: "https://pub.flutter-io.cn" 470 | source: hosted 471 | version: "1.2.0" 472 | synchronized: 473 | dependency: transitive 474 | description: 475 | name: synchronized 476 | sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" 477 | url: "https://pub.flutter-io.cn" 478 | source: hosted 479 | version: "3.1.0+1" 480 | term_glyph: 481 | dependency: transitive 482 | description: 483 | name: term_glyph 484 | sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 485 | url: "https://pub.flutter-io.cn" 486 | source: hosted 487 | version: "1.2.1" 488 | test_api: 489 | dependency: transitive 490 | description: 491 | name: test_api 492 | sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" 493 | url: "https://pub.flutter-io.cn" 494 | source: hosted 495 | version: "0.6.1" 496 | typed_data: 497 | dependency: transitive 498 | description: 499 | name: typed_data 500 | sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c 501 | url: "https://pub.flutter-io.cn" 502 | source: hosted 503 | version: "1.3.2" 504 | uuid: 505 | dependency: transitive 506 | description: 507 | name: uuid 508 | sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff 509 | url: "https://pub.flutter-io.cn" 510 | source: hosted 511 | version: "4.5.1" 512 | vector_graphics: 513 | dependency: transitive 514 | description: 515 | name: vector_graphics 516 | sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" 517 | url: "https://pub.flutter-io.cn" 518 | source: hosted 519 | version: "1.1.11+1" 520 | vector_graphics_codec: 521 | dependency: transitive 522 | description: 523 | name: vector_graphics_codec 524 | sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da 525 | url: "https://pub.flutter-io.cn" 526 | source: hosted 527 | version: "1.1.11+1" 528 | vector_graphics_compiler: 529 | dependency: transitive 530 | description: 531 | name: vector_graphics_compiler 532 | sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" 533 | url: "https://pub.flutter-io.cn" 534 | source: hosted 535 | version: "1.1.11+1" 536 | vector_math: 537 | dependency: transitive 538 | description: 539 | name: vector_math 540 | sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" 541 | url: "https://pub.flutter-io.cn" 542 | source: hosted 543 | version: "2.1.4" 544 | vm_service: 545 | dependency: transitive 546 | description: 547 | name: vm_service 548 | sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 549 | url: "https://pub.flutter-io.cn" 550 | source: hosted 551 | version: "13.0.0" 552 | web: 553 | dependency: transitive 554 | description: 555 | name: web 556 | sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" 557 | url: "https://pub.flutter-io.cn" 558 | source: hosted 559 | version: "0.5.1" 560 | xdg_directories: 561 | dependency: transitive 562 | description: 563 | name: xdg_directories 564 | sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" 565 | url: "https://pub.flutter-io.cn" 566 | source: hosted 567 | version: "1.1.0" 568 | xml: 569 | dependency: transitive 570 | description: 571 | name: xml 572 | sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 573 | url: "https://pub.flutter-io.cn" 574 | source: hosted 575 | version: "6.5.0" 576 | sdks: 577 | dart: ">=3.3.1 <4.0.0" 578 | flutter: ">=3.19.0" 579 | -------------------------------------------------------------------------------- /sideload_webui/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: sideload_webui 2 | description: "A new Flutter project." 3 | # The following line prevents the package from being accidentally published to 4 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 6 | 7 | # The following defines the version and build number for your application. 8 | # A version number is three numbers separated by dots, like 1.2.43 9 | # followed by an optional build number separated by a +. 10 | # Both the version and the builder number may be overridden in flutter 11 | # build by specifying --build-name and --build-number, respectively. 12 | # In Android, build-name is used as versionName while build-number used as versionCode. 13 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 14 | # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 15 | # Read more about iOS versioning at 16 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 | # In Windows, build-name is used as the major, minor, and patch parts 18 | # of the product and file versions while build-number is used as the build suffix. 19 | version: 1.0.0+1 20 | 21 | environment: 22 | sdk: '>=3.3.1 <4.0.0' 23 | 24 | # Dependencies specify other packages that your package needs in order to work. 25 | # To automatically upgrade your package dependencies to the latest versions 26 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 27 | # dependencies can be manually updated by changing the version numbers below to 28 | # the latest version available on pub.dev. To see which dependencies have newer 29 | # versions available, run `flutter pub outdated`. 30 | dependencies: 31 | flutter: 32 | sdk: flutter 33 | 34 | 35 | # The following adds the Cupertino Icons font to your application. 36 | # Use with the CupertinoIcons class for iOS style icons. 37 | cupertino_icons: ^1.0.6 38 | shared_preferences: ^2.2.3 39 | http: ^1.2.2 40 | provider: ^6.1.2 41 | flutter_svg: ^2.0.10+1 42 | cached_network_image: ^3.4.0 43 | 44 | dev_dependencies: 45 | flutter_test: 46 | sdk: flutter 47 | 48 | # The "flutter_lints" package below contains a set of recommended lints to 49 | # encourage good coding practices. The lint set provided by the package is 50 | # activated in the `analysis_options.yaml` file located at the root of your 51 | # package. See that file for information about deactivating specific lint 52 | # rules and activating additional ones. 53 | flutter_lints: ^3.0.0 54 | 55 | # For information on the generic Dart part of this file, see the 56 | # following page: https://dart.dev/tools/pub/pubspec 57 | 58 | # The following section is specific to Flutter packages. 59 | flutter: 60 | 61 | # The following line ensures that the Material Icons font is 62 | # included with your application, so that you can use the icons in 63 | # the material Icons class. 64 | uses-material-design: true 65 | 66 | # To add assets to your application, add an assets section, like this: 67 | assets: 68 | - lib/assets/logo.png 69 | 70 | # An image asset can refer to one or more resolution-specific "variants", see 71 | # https://flutter.dev/assets-and-images/#resolution-aware 72 | 73 | # For details regarding adding assets from package dependencies, see 74 | # https://flutter.dev/assets-and-images/#from-packages 75 | 76 | # To add custom fonts to your application, add a fonts section here, 77 | # in this "flutter" section. Each entry in this list should have a 78 | # "family" key with the font family name, and a "fonts" key with a 79 | # list giving the asset and other descriptors for the font. For 80 | # example: 81 | # fonts: 82 | # - family: Schyler 83 | # fonts: 84 | # - asset: fonts/Schyler-Regular.ttf 85 | # - asset: fonts/Schyler-Italic.ttf 86 | # style: italic 87 | # - family: Trajan Pro 88 | # fonts: 89 | # - asset: fonts/TrajanPro.ttf 90 | # - asset: fonts/TrajanPro_Bold.ttf 91 | # weight: 700 92 | # 93 | # For details regarding fonts from package dependencies, 94 | # see https://flutter.dev/custom-fonts/#from-packages 95 | -------------------------------------------------------------------------------- /sideload_webui/sideload_webui.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /sideload_webui/web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/sideload_webui/web/favicon.png -------------------------------------------------------------------------------- /sideload_webui/web/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NonebotGUI/nonebot-plugin-sideload/fe079a2511b18bfa57e27fb415b7b10d01d43bba/sideload_webui/web/icons/icon.png -------------------------------------------------------------------------------- /sideload_webui/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | NoneBot WebUI 33 | 34 | 35 | 39 | 40 | 41 | 86 | 87 | 88 |
89 |
90 |
91 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /sideload_webui/web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sideload_webui", 3 | "short_name": "sideload_webui", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | --------------------------------------------------------------------------------