Procházet zdrojové kódy

Merge remote-tracking branch 'origin/alpha' into alpha

CaIon před 7 měsíci
rodič
revize
6c4ada5098
100 změnil soubory, kde provedl 5794 přidání a 8096 odebrání
  1. 2 1
      .dockerignore
  2. 2 1
      .gitignore
  3. 103 201
      LICENSE
  4. 18 0
      README.en.md
  5. 18 0
      README.md
  6. 20 0
      controller/swag_video.go
  7. 16 7
      controller/task_video.go
  8. 55 0
      docs/images/cherry-studio.svg
  9. binární
      docs/images/pku.png
  10. 0 0
      docs/images/ucloud.svg
  11. 1 1
      i18n/zh-cn.json
  12. 4 0
      middleware/kling_adapter.go
  13. 28 34
      relay/channel/task/kling/adaptor.go
  14. 1 1
      relay/constant/relay_mode.go
  15. 2 0
      router/video-router.go
  16. 34 0
      web/.eslintrc.cjs
  17. 146 17
      web/bun.lock
  18. 5 0
      web/package.json
  19. 19 0
      web/postcss.config.js
  20. 23 37
      web/src/App.js
  21. 20 1
      web/src/components/auth/LoginForm.js
  22. 20 1
      web/src/components/auth/OAuth2Callback.js
  23. 20 1
      web/src/components/auth/PasswordResetConfirm.js
  24. 20 1
      web/src/components/auth/PasswordResetForm.js
  25. 20 1
      web/src/components/auth/RegisterForm.js
  26. 0 16
      web/src/components/common/Loading.js
  27. 19 0
      web/src/components/common/logo/LinuxDoIcon.js
  28. 19 0
      web/src/components/common/logo/OIDCIcon.js
  29. 19 0
      web/src/components/common/logo/WeChatIcon.js
  30. 19 0
      web/src/components/common/markdown/MarkdownRenderer.js
  31. 213 0
      web/src/components/common/ui/CardPro.js
  32. 237 0
      web/src/components/common/ui/CardTable.js
  33. 68 0
      web/src/components/common/ui/CompactModeToggle.js
  34. 35 0
      web/src/components/common/ui/Loading.js
  35. 220 0
      web/src/components/common/ui/ScrollableContainer.js
  36. 107 0
      web/src/components/dashboard/AnnouncementsPanel.jsx
  37. 117 0
      web/src/components/dashboard/ApiInfoPanel.jsx
  38. 117 0
      web/src/components/dashboard/ChartsPanel.jsx
  39. 61 0
      web/src/components/dashboard/DashboardHeader.jsx
  40. 81 0
      web/src/components/dashboard/FaqPanel.jsx
  41. 93 0
      web/src/components/dashboard/StatsCards.jsx
  42. 136 0
      web/src/components/dashboard/UptimePanel.jsx
  43. 247 0
      web/src/components/dashboard/index.jsx
  44. 101 0
      web/src/components/dashboard/modals/SearchModal.jsx
  45. 19 0
      web/src/components/layout/Footer.js
  46. 42 13
      web/src/components/layout/HeaderBar.js
  47. 19 0
      web/src/components/layout/NoticeModal.js
  48. 22 3
      web/src/components/layout/PageLayout.js
  49. 19 0
      web/src/components/layout/SetupCheck.js
  50. 24 5
      web/src/components/layout/SiderBar.js
  51. 19 0
      web/src/components/playground/ChatArea.js
  52. 19 0
      web/src/components/playground/CodeViewer.js
  53. 19 0
      web/src/components/playground/ConfigManager.js
  54. 19 0
      web/src/components/playground/CustomInputRender.js
  55. 19 0
      web/src/components/playground/CustomRequestEditor.js
  56. 19 0
      web/src/components/playground/DebugPanel.js
  57. 20 1
      web/src/components/playground/FloatingButtons.js
  58. 19 0
      web/src/components/playground/ImageUrlInput.js
  59. 19 0
      web/src/components/playground/MessageActions.js
  60. 19 0
      web/src/components/playground/MessageContent.js
  61. 19 0
      web/src/components/playground/OptimizedComponents.js
  62. 19 0
      web/src/components/playground/ParameterControl.js
  63. 22 3
      web/src/components/playground/SettingsPanel.js
  64. 19 0
      web/src/components/playground/ThinkingContent.js
  65. 19 0
      web/src/components/playground/configStorage.js
  66. 19 0
      web/src/components/playground/index.js
  67. 20 6
      web/src/components/settings/ChannelSelectorModal.js
  68. 19 0
      web/src/components/settings/ChatsSetting.js
  69. 19 0
      web/src/components/settings/DashboardSetting.js
  70. 19 0
      web/src/components/settings/DrawingSetting.js
  71. 19 0
      web/src/components/settings/ModelSetting.js
  72. 19 0
      web/src/components/settings/OperationSetting.js
  73. 19 0
      web/src/components/settings/OtherSetting.js
  74. 19 0
      web/src/components/settings/PaymentSetting.js
  75. 20 1
      web/src/components/settings/PersonalSetting.js
  76. 19 0
      web/src/components/settings/RateLimitSetting.js
  77. 19 0
      web/src/components/settings/RatioSetting.js
  78. 19 0
      web/src/components/settings/SystemSetting.js
  79. 0 2212
      web/src/components/table/ChannelsTable.js
  80. 0 1464
      web/src/components/table/LogsTable.js
  81. 0 982
      web/src/components/table/MjLogsTable.js
  82. 19 6
      web/src/components/table/ModelPricing.js
  83. 0 629
      web/src/components/table/RedemptionsTable.js
  84. 0 813
      web/src/components/table/TaskLogsTable.js
  85. 0 924
      web/src/components/table/TokensTable.js
  86. 0 686
      web/src/components/table/UsersTable.js
  87. 257 0
      web/src/components/table/channels/ChannelsActions.jsx
  88. 623 0
      web/src/components/table/channels/ChannelsColumnDefs.js
  89. 159 0
      web/src/components/table/channels/ChannelsFilters.jsx
  90. 159 0
      web/src/components/table/channels/ChannelsTable.jsx
  91. 89 0
      web/src/components/table/channels/ChannelsTabs.jsx
  92. 80 0
      web/src/components/table/channels/index.jsx
  93. 60 0
      web/src/components/table/channels/modals/BatchTagModal.jsx
  94. 133 0
      web/src/components/table/channels/modals/ColumnSelectorModal.jsx
  95. 42 23
      web/src/components/table/channels/modals/EditChannelModal.jsx
  96. 25 4
      web/src/components/table/channels/modals/EditTagModal.jsx
  97. 276 0
      web/src/components/table/channels/modals/ModelTestModal.jsx
  98. 64 0
      web/src/components/table/mj-logs/MjLogsActions.jsx
  99. 496 0
      web/src/components/table/mj-logs/MjLogsColumnDefs.js
  100. 123 0
      web/src/components/table/mj-logs/MjLogsFilters.jsx

+ 2 - 1
.dockerignore

@@ -4,4 +4,5 @@
 .vscode
 .gitignore
 Makefile
-docs
+docs
+.eslintcache

+ 2 - 1
.gitignore

@@ -10,4 +10,5 @@ web/dist
 .env
 one-api
 .DS_Store
-tiktoken_cache
+tiktoken_cache
+.eslintcache

+ 103 - 201
LICENSE

@@ -1,201 +1,103 @@
-                                 Apache License
-                           Version 2.0, January 2004
-                        http://www.apache.org/licenses/
-
-   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-   1. Definitions.
-
-      "License" shall mean the terms and conditions for use, reproduction,
-      and distribution as defined by Sections 1 through 9 of this document.
-
-      "Licensor" shall mean the copyright owner or entity authorized by
-      the copyright owner that is granting the License.
-
-      "Legal Entity" shall mean the union of the acting entity and all
-      other entities that control, are controlled by, or are under common
-      control with that entity. For the purposes of this definition,
-      "control" means (i) the power, direct or indirect, to cause the
-      direction or management of such entity, whether by contract or
-      otherwise, or (ii) ownership of fifty percent (50%) or more of the
-      outstanding shares, or (iii) beneficial ownership of such entity.
-
-      "You" (or "Your") shall mean an individual or Legal Entity
-      exercising permissions granted by this License.
-
-      "Source" form shall mean the preferred form for making modifications,
-      including but not limited to software source code, documentation
-      source, and configuration files.
-
-      "Object" form shall mean any form resulting from mechanical
-      transformation or translation of a Source form, including but
-      not limited to compiled object code, generated documentation,
-      and conversions to other media types.
-
-      "Work" shall mean the work of authorship, whether in Source or
-      Object form, made available under the License, as indicated by a
-      copyright notice that is included in or attached to the work
-      (an example is provided in the Appendix below).
-
-      "Derivative Works" shall mean any work, whether in Source or Object
-      form, that is based on (or derived from) the Work and for which the
-      editorial revisions, annotations, elaborations, or other modifications
-      represent, as a whole, an original work of authorship. For the purposes
-      of this License, Derivative Works shall not include works that remain
-      separable from, or merely link (or bind by name) to the interfaces of,
-      the Work and Derivative Works thereof.
-
-      "Contribution" shall mean any work of authorship, including
-      the original version of the Work and any modifications or additions
-      to that Work or Derivative Works thereof, that is intentionally
-      submitted to Licensor for inclusion in the Work by the copyright owner
-      or by an individual or Legal Entity authorized to submit on behalf of
-      the copyright owner. For the purposes of this definition, "submitted"
-      means any form of electronic, verbal, or written communication sent
-      to the Licensor or its representatives, including but not limited to
-      communication on electronic mailing lists, source code control systems,
-      and issue tracking systems that are managed by, or on behalf of, the
-      Licensor for the purpose of discussing and improving the Work, but
-      excluding communication that is conspicuously marked or otherwise
-      designated in writing by the copyright owner as "Not a Contribution."
-
-      "Contributor" shall mean Licensor and any individual or Legal Entity
-      on behalf of whom a Contribution has been received by Licensor and
-      subsequently incorporated within the Work.
-
-   2. Grant of Copyright License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      copyright license to reproduce, prepare Derivative Works of,
-      publicly display, publicly perform, sublicense, and distribute the
-      Work and such Derivative Works in Source or Object form.
-
-   3. Grant of Patent License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      (except as stated in this section) patent license to make, have made,
-      use, offer to sell, sell, import, and otherwise transfer the Work,
-      where such license applies only to those patent claims licensable
-      by such Contributor that are necessarily infringed by their
-      Contribution(s) alone or by combination of their Contribution(s)
-      with the Work to which such Contribution(s) was submitted. If You
-      institute patent litigation against any entity (including a
-      cross-claim or counterclaim in a lawsuit) alleging that the Work
-      or a Contribution incorporated within the Work constitutes direct
-      or contributory patent infringement, then any patent licenses
-      granted to You under this License for that Work shall terminate
-      as of the date such litigation is filed.
-
-   4. Redistribution. You may reproduce and distribute copies of the
-      Work or Derivative Works thereof in any medium, with or without
-      modifications, and in Source or Object form, provided that You
-      meet the following conditions:
-
-      (a) You must give any other recipients of the Work or
-          Derivative Works a copy of this License; and
-
-      (b) You must cause any modified files to carry prominent notices
-          stating that You changed the files; and
-
-      (c) You must retain, in the Source form of any Derivative Works
-          that You distribute, all copyright, patent, trademark, and
-          attribution notices from the Source form of the Work,
-          excluding those notices that do not pertain to any part of
-          the Derivative Works; and
-
-      (d) If the Work includes a "NOTICE" text file as part of its
-          distribution, then any Derivative Works that You distribute must
-          include a readable copy of the attribution notices contained
-          within such NOTICE file, excluding those notices that do not
-          pertain to any part of the Derivative Works, in at least one
-          of the following places: within a NOTICE text file distributed
-          as part of the Derivative Works; within the Source form or
-          documentation, if provided along with the Derivative Works; or,
-          within a display generated by the Derivative Works, if and
-          wherever such third-party notices normally appear. The contents
-          of the NOTICE file are for informational purposes only and
-          do not modify the License. You may add Your own attribution
-          notices within Derivative Works that You distribute, alongside
-          or as an addendum to the NOTICE text from the Work, provided
-          that such additional attribution notices cannot be construed
-          as modifying the License.
-
-      You may add Your own copyright statement to Your modifications and
-      may provide additional or different license terms and conditions
-      for use, reproduction, or distribution of Your modifications, or
-      for any such Derivative Works as a whole, provided Your use,
-      reproduction, and distribution of the Work otherwise complies with
-      the conditions stated in this License.
-
-   5. Submission of Contributions. Unless You explicitly state otherwise,
-      any Contribution intentionally submitted for inclusion in the Work
-      by You to the Licensor shall be under the terms and conditions of
-      this License, without any additional terms or conditions.
-      Notwithstanding the above, nothing herein shall supersede or modify
-      the terms of any separate license agreement you may have executed
-      with Licensor regarding such Contributions.
-
-   6. Trademarks. This License does not grant permission to use the trade
-      names, trademarks, service marks, or product names of the Licensor,
-      except as required for reasonable and customary use in describing the
-      origin of the Work and reproducing the content of the NOTICE file.
-
-   7. Disclaimer of Warranty. Unless required by applicable law or
-      agreed to in writing, Licensor provides the Work (and each
-      Contributor provides its Contributions) on an "AS IS" BASIS,
-      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-      implied, including, without limitation, any warranties or conditions
-      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-      PARTICULAR PURPOSE. You are solely responsible for determining the
-      appropriateness of using or redistributing the Work and assume any
-      risks associated with Your exercise of permissions under this License.
-
-   8. Limitation of Liability. In no event and under no legal theory,
-      whether in tort (including negligence), contract, or otherwise,
-      unless required by applicable law (such as deliberate and grossly
-      negligent acts) or agreed to in writing, shall any Contributor be
-      liable to You for damages, including any direct, indirect, special,
-      incidental, or consequential damages of any character arising as a
-      result of this License or out of the use or inability to use the
-      Work (including but not limited to damages for loss of goodwill,
-      work stoppage, computer failure or malfunction, or any and all
-      other commercial damages or losses), even if such Contributor
-      has been advised of the possibility of such damages.
-
-   9. Accepting Warranty or Additional Liability. While redistributing
-      the Work or Derivative Works thereof, You may choose to offer,
-      and charge a fee for, acceptance of support, warranty, indemnity,
-      or other liability obligations and/or rights consistent with this
-      License. However, in accepting such obligations, You may act only
-      on Your own behalf and on Your sole responsibility, not on behalf
-      of any other Contributor, and only if You agree to indemnify,
-      defend, and hold each Contributor harmless for any liability
-      incurred by, or claims asserted against, such Contributor by reason
-      of your accepting any such warranty or additional liability.
-
-   END OF TERMS AND CONDITIONS
-
-   APPENDIX: How to apply the Apache License to your work.
-
-      To apply the Apache License to your work, attach the following
-      boilerplate notice, with the fields enclosed by brackets "[]"
-      replaced with your own identifying information. (Don't include
-      the brackets!)  The text should be enclosed in the appropriate
-      comment syntax for the file format. We also recommend that a
-      file or class name and description of purpose be included on the
-      same "printed page" as the copyright notice for easier
-      identification within third-party archives.
-
-   Copyright [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
+# **New API 许可协议 (Licensing)**
+
+本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。
+
+**核心原则:**
+
+- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。
+- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。
+
+---
+
+## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用**
+
+- 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
+- **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。
+- **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。
+- 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。
+
+## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求**
+
+在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API:
+
+- **场景一:移除品牌和版权信息**  
+  您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。
+
+- **场景二:规避 AGPLv3 开源义务**  
+  您基于 New API 进行了修改,并希望:  
+    - 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。  
+    - 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。
+
+- **场景三:企业政策与集成需求**  
+    - 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。  
+    - 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。
+
+- **场景四:需要商业支持与保障**  
+    您需要 AGPLv3 未提供的商业保障,如官方技术支持等。
+
+**获取商业许可:**  
+请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。
+
+## **3. 贡献 (Contributions)**
+
+- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。
+- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
+- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。
+
+## **4. 其他条款 (Other Terms)**
+
+- 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。
+- 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
+
+---
+
+# **New API Licensing**
+
+This project uses a **Usage-Based Dual Licensing** model.
+
+**Core Principles:**
+
+- **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below.
+- **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**.
+
+---
+
+## **1. Open Source License: AGPLv3 – For Basic Usage**
+
+- Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
+- **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license.
+- **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the project’s code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License.
+- Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use.
+
+## **2. Commercial License – For Advanced Scenarios & Closed Source Needs**
+
+You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API:
+
+- **Scenario 1: Removal of Branding and Copyright**  
+  You wish to remove the New API logo, copyright statement, or other branding elements from your product or service.
+
+- **Scenario 2: Avoidance of AGPLv3 Open Source Obligations**  
+  You have modified New API and wish to:
+    - Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users.
+    - Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code.
+
+- **Scenario 3: Enterprise Policy & Integration Needs**  
+    - Your organization’s policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software.
+    - You require OEM integration and need to redistribute New API as part of your closed-source commercial product.
+
+- **Scenario 4: Commercial Support and Assurances**  
+    You require commercial assurances not provided by AGPLv3, such as official technical support.
+
+**Obtaining a Commercial License:**  
+Please contact the New API team via email at **support@quantumnous.com** to discuss commercial licensing.
+
+## **3. Contributions**
+
+- We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license.
+- By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
+- You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License.
+
+## **4. Other Terms**
+
+- The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties.
+- Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website).

+ 18 - 0
README.en.md

@@ -189,6 +189,24 @@ If you have any questions, please refer to [Help and Support](https://docs.newap
 - [Issue Feedback](https://docs.newapi.pro/support/feedback-issues)
 - [FAQ](https://docs.newapi.pro/support/faq)
 
+## 🤝 Trusted Partners
+
+<p align="center">
+  <a href="https://www.cherry-ai.com/" target="_blank"><img
+    src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
+  /></a>
+  &nbsp;&nbsp;&nbsp;&nbsp;
+  <a href="https://bda.pku.edu.cn/" target="_blank"><img
+    src="./docs/images/pku.png" alt="Peking University" height="58"
+  /></a>
+  &nbsp;&nbsp;&nbsp;&nbsp;
+  <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
+    src="./docs/images/ucloud.svg" alt="UCloud" height="58"
+  /></a>
+</p>
+
+<p align="center"><em>No particular order</em></p>
+
 ## 🌟 Star History
 
 [![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

+ 18 - 0
README.md

@@ -188,6 +188,24 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
 - [反馈问题](https://docs.newapi.pro/support/feedback-issues)
 - [常见问题](https://docs.newapi.pro/support/faq)
 
+## 🤝 我们信任的合作伙伴
+
+<p align="center">
+  <a href="https://www.cherry-ai.com/" target="_blank"><img
+    src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
+  /></a>
+  &nbsp;&nbsp;&nbsp;&nbsp;
+  <a href="https://bda.pku.edu.cn/" target="_blank"><img
+    src="./docs/images/pku.png" alt="北京大学" height="58"
+  /></a>
+  &nbsp;&nbsp;&nbsp;&nbsp;
+  <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
+    src="./docs/images/ucloud.svg" alt="UCloud 优刻得" height="58"
+  /></a>
+</p>
+
+<p align="center"><em>排名不分先后</em></p>
+
 ## 🌟 Star History
 
 [![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

+ 20 - 0
controller/swag_video.go

@@ -114,3 +114,23 @@ type KlingImage2VideoRequest struct {
 	CallbackURL    string              `json:"callback_url,omitempty" example:"https://your.domain/callback"`
 	ExternalTaskId string              `json:"external_task_id,omitempty" example:"custom-task-002"`
 }
+
+// KlingImage2videoTaskId godoc
+// @Summary 可灵任务查询--图生视频
+// @Description Query the status and result of a Kling video generation task by task ID
+// @Tags Origin
+// @Accept json
+// @Produce json
+// @Param task_id path string true "Task ID"
+// @Router /kling/v1/videos/image2video/{task_id} [get]
+func KlingImage2videoTaskId(c *gin.Context) {}
+
+// KlingText2videoTaskId godoc
+// @Summary 可灵任务查询--文生视频
+// @Description Query the status and result of a Kling text-to-video generation task by task ID
+// @Tags Origin
+// @Accept json
+// @Produce json
+// @Param task_id path string true "Task ID"
+// @Router /kling/v1/videos/text2video/{task_id} [get]
+func KlingText2videoTaskId(c *gin.Context) {}

+ 16 - 7
controller/task_video.go

@@ -2,13 +2,16 @@ package controller
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"one-api/common"
 	"one-api/constant"
+	"one-api/dto"
 	"one-api/model"
 	"one-api/relay"
 	"one-api/relay/channel"
+	relaycommon "one-api/relay/common"
 	"time"
 )
 
@@ -77,13 +80,21 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 		return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
 	}
 
-	taskResult, err := adaptor.ParseTaskResult(responseBody)
-	if err != nil {
+	taskResult := &relaycommon.TaskInfo{}
+	// try parse as New API response format
+	var responseItems dto.TaskResponse[model.Task]
+	if err = json.Unmarshal(responseBody, &responseItems); err == nil {
+		t := responseItems.Data
+		taskResult.TaskID = t.TaskID
+		taskResult.Status = string(t.Status)
+		taskResult.Url = t.FailReason
+		taskResult.Progress = t.Progress
+		taskResult.Reason = t.FailReason
+	} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
 		return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
+	} else {
+		task.Data = responseBody
 	}
-	//if taskResult.Code != 0 {
-	//	return fmt.Errorf("video task fetch failed for task %s", taskId)
-	//}
 
 	now := time.Now().Unix()
 	if taskResult.Status == "" {
@@ -128,8 +139,6 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 	if taskResult.Progress != "" {
 		task.Progress = taskResult.Progress
 	}
-
-	task.Data = responseBody
 	if err := task.Update(); err != nil {
 		common.SysError("UpdateVideoTask task error: " + err.Error())
 	}

+ 55 - 0
docs/images/cherry-studio.svg

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.45 66.73">
+  <defs>
+    <style>
+      .cls-1 {
+        fill: #ea5e5d;
+      }
+
+      .cls-2 {
+        fill: #23af69;
+      }
+
+      .cls-3 {
+        fill: #ea5756;
+      }
+    </style>
+  </defs>
+  <g id="_图层_1-2" data-name="图层_1">
+    <g>
+      <g>
+        <g>
+          <path class="cls-1" d="M16.72,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
+          <path class="cls-1" d="M32.05,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
+        </g>
+        <path class="cls-2" d="M32.05,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
+      </g>
+      <g>
+        <path class="cls-3" d="M93.93,24.6l.55-.39c.69-.4,1.17-.61,1.46-.61.63,0,1.3.57,2.03,1.7.44.71.67,1.27.67,1.7s-.14.78-.41,1.06c-.27.28-.59.54-.96.76-.36.22-.71.43-1.05.64-.33.2-1.02.47-2.05.79-1.03.32-2.03.49-2.99.49s-1.93-.13-2.91-.38c-.98-.25-1.99-.68-3.03-1.27-1.04-.6-1.98-1.32-2.81-2.18-.83-.86-1.51-1.96-2.05-3.31-.54-1.35-.8-2.81-.8-4.38s.26-3.01.79-4.29c.53-1.28,1.2-2.35,2.02-3.19.82-.84,1.75-1.54,2.81-2.11,1.98-1.09,3.97-1.64,5.98-1.64.95,0,1.92.15,2.9.44.98.29,1.72.59,2.23.9l.73.42c.36.22.65.4.85.55.53.42.79.91.79,1.44s-.21,1.1-.64,1.68c-.79,1.09-1.5,1.64-2.12,1.64-.36,0-.88-.22-1.55-.67-.85-.69-1.98-1.03-3.4-1.03-1.31,0-2.61.46-3.88,1.36-.61.44-1.11,1.07-1.52,1.88-.4.81-.61,1.72-.61,2.75s.2,1.94.61,2.75c.4.81.92,1.45,1.55,1.91,1.23.89,2.52,1.34,3.85,1.34.63,0,1.22-.08,1.77-.24.56-.16.96-.32,1.2-.49Z"/>
+        <path class="cls-3" d="M114.38,9.07c.16-.3.43-.52.82-.64.38-.12.87-.18,1.46-.18s1.05.05,1.4.15c.34.1.61.22.79.36.18.14.32.34.42.61.1.34.15.87.15,1.58v16.84c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58v-6.16h-8.04v6.19c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V10.92c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v6.19h8.04v-6.22c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8Z"/>
+        <path class="cls-3" d="M127.21,25.1h9.34c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-12.01c-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55V10.9c0-1.03.19-1.73.58-2.11.38-.37,1.11-.56,2.18-.56h11.95c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-9.31v3.06h6.01c.46,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.38,2.25-1.15,2.49-.34.12-.87.18-1.58.18h-5.95v3.06Z"/>
+        <path class="cls-3" d="M196.96,8.79c.99.69,1.49,1.35,1.49,2,0,.38-.23.92-.7,1.61l-6.55,9.8v5.79c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.16.3-.43.52-.82.64-.38.12-.9.18-1.55.18s-1.16-.06-1.55-.18c-.38-.12-.66-.34-.82-.65-.16-.31-.26-.59-.29-.82-.03-.23-.05-.59-.05-1.08v-5.73l-6.55-9.8c-.47-.69-.7-1.22-.7-1.61,0-.65.44-1.27,1.33-1.87.89-.6,1.53-.9,1.91-.9s.69.08.91.24c.34.22.71.64,1.09,1.24l4.7,7.52,4.7-7.52c.38-.61.72-1.01,1-1.2s.61-.29.99-.29.97.25,1.77.76Z"/>
+        <g>
+          <path class="cls-3" d="M81.93,56.63c-.53-.65-.79-1.23-.79-1.74s.43-1.2,1.3-2.05c.51-.49,1.04-.73,1.61-.73s1.36.51,2.37,1.52c.28.34.69.67,1.21.99.53.31,1.01.47,1.46.47,1.88,0,2.82-.77,2.82-2.31,0-.46-.26-.85-.77-1.17-.52-.31-1.16-.54-1.93-.68-.77-.14-1.6-.37-2.49-.68-.89-.31-1.72-.68-2.49-1.11-.77-.42-1.41-1.1-1.93-2.02-.52-.92-.77-2.03-.77-3.32,0-1.78.66-3.33,1.99-4.66s3.13-1.99,5.42-1.99c1.21,0,2.32.16,3.32.47,1,.31,1.69.63,2.08.96l.76.58c.63.59.94,1.08.94,1.49s-.24.96-.73,1.67c-.69,1.01-1.4,1.52-2.12,1.52-.42,0-.95-.2-1.58-.61-.06-.04-.18-.14-.35-.3-.17-.16-.33-.29-.47-.39-.42-.26-.97-.39-1.62-.39s-1.2.16-1.64.47c-.43.31-.65.75-.65,1.3s.26,1.01.77,1.35c.52.34,1.16.58,1.93.7.77.12,1.61.31,2.52.56.91.25,1.75.56,2.52.93.77.36,1.41,1,1.93,1.9.52.9.77,2.01.77,3.32s-.26,2.47-.79,3.47c-.53,1-1.21,1.77-2.06,2.32-1.64,1.07-3.39,1.61-5.25,1.61-.95,0-1.85-.12-2.7-.35-.85-.23-1.54-.52-2.06-.86-1.07-.65-1.82-1.27-2.24-1.88l-.27-.33Z"/>
+          <path class="cls-3" d="M100.74,37.49h16.87c.65,0,1.12.08,1.43.23.3.15.51.39.61.71.1.32.15.75.15,1.27s-.05.95-.15,1.26c-.1.31-.27.53-.52.65-.36.18-.88.27-1.55.27h-5.79v15.26c0,.47-.02.81-.05,1.03s-.12.48-.27.77c-.15.29-.42.5-.8.62-.38.12-.89.18-1.52.18s-1.13-.06-1.5-.18c-.37-.12-.64-.33-.79-.62-.15-.29-.24-.56-.27-.79-.03-.23-.05-.58-.05-1.05v-15.23h-5.82c-.65,0-1.12-.08-1.43-.23-.3-.15-.51-.39-.61-.71-.1-.32-.15-.75-.15-1.27s.05-.95.15-1.26c.1-.31.27-.53.52-.65.36-.18.88-.27,1.55-.27Z"/>
+          <path class="cls-3" d="M135.99,38.34c.2-.32.5-.55.88-.67.38-.12.86-.18,1.44-.18s1.04.05,1.38.15c.34.1.61.22.79.36.18.14.31.35.39.64.12.34.18.87.18,1.58v9.16c0,2.67-.83,5.1-2.49,7.28-.81,1.03-1.85,1.87-3.12,2.5s-2.68.96-4.23.96-2.95-.32-4.22-.97c-1.26-.65-2.29-1.5-3.08-2.55-1.64-2.14-2.46-4.57-2.46-7.28v-9.13c0-.49.02-.84.05-1.08.03-.23.13-.5.29-.8.16-.3.43-.52.82-.64.38-.12.9-.18,1.55-.18s1.16.06,1.55.18c.38.12.65.33.79.64.24.47.36,1.1.36,1.91v9.1c0,1.23.3,2.41.91,3.52.3.57.76,1.02,1.37,1.36.61.34,1.32.52,2.15.52,1.48,0,2.58-.55,3.31-1.64.73-1.09,1.09-2.36,1.09-3.79v-9.28c0-.79.1-1.34.3-1.67Z"/>
+          <path class="cls-3" d="M146.18,37.49l5.61.03c2.93,0,5.51,1.06,7.74,3.17,2.22,2.11,3.34,4.71,3.34,7.8s-1.09,5.73-3.26,7.93c-2.17,2.2-4.81,3.31-7.9,3.31h-5.55c-1.23,0-2-.25-2.31-.76-.24-.42-.36-1.07-.36-1.94v-16.87c0-.49.02-.84.05-1.06s.13-.49.29-.79c.28-.55,1.07-.82,2.37-.82ZM151.79,54.35c1.46,0,2.77-.54,3.94-1.62,1.17-1.08,1.76-2.44,1.76-4.08s-.57-3.01-1.71-4.11c-1.14-1.1-2.48-1.65-4.02-1.65h-2.91v11.47h2.94Z"/>
+          <path class="cls-3" d="M164.84,40.19c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v16.87c0,.49-.02.84-.05,1.06s-.13.49-.29.79c-.28.55-1.07.82-2.37.82-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55v-16.87Z"/>
+          <path class="cls-3" d="M183.07,37.24c2.99,0,5.59,1.08,7.8,3.25,2.2,2.16,3.31,4.85,3.31,8.05s-1.05,5.94-3.16,8.19c-2.1,2.26-4.69,3.38-7.77,3.38s-5.69-1.11-7.84-3.34c-2.15-2.22-3.23-4.87-3.23-7.95,0-1.68.3-3.25.91-4.72.61-1.47,1.42-2.7,2.43-3.69,1.01-.99,2.17-1.77,3.49-2.34,1.31-.57,2.67-.85,4.07-.85ZM177.55,48.68c0,1.8.58,3.26,1.74,4.38,1.16,1.12,2.46,1.68,3.9,1.68s2.73-.55,3.88-1.64c1.15-1.09,1.73-2.56,1.73-4.4s-.58-3.32-1.74-4.43c-1.16-1.11-2.46-1.67-3.9-1.67s-2.73.56-3.88,1.68c-1.15,1.12-1.73,2.58-1.73,4.38Z"/>
+        </g>
+        <g>
+          <path class="cls-3" d="M176.92,11.06c-.03-.23-.13-.5-.29-.8-.28-.55-1.07-.82-2.37-.82h-6.55c-1.78,0-3.51.65-5.19,1.94-.81.63-1.48,1.48-2,2.55-.53,1.07-.79,2.27-.79,3.58,0,2.29.76,4.17,2.28,5.64-.44,1.07-1.13,2.66-2.06,4.76-.3.73-.45,1.25-.45,1.58,0,.77.63,1.42,1.88,1.94.65.28,1.17.43,1.56.43s.72-.1.97-.29c.25-.19.44-.39.56-.59.2-.38.99-2.21,2.37-5.49l.94.06h3.82v3.43c0,.47.02.81.05,1.05.03.23.13.5.29.8.28.55,1.07.82,2.37.82,1.42,0,2.25-.37,2.49-1.12.12-.34.18-.87.18-1.58V12.11c0-.46-.02-.81-.05-1.05ZM172.81,19.44c-.09.14-.48.77-1.24.91-.2.04-.37.03-.48.02-.02.14-.04.26-.06.38-.16.83-.38,1.05-.57,1.07-.29.05-.51-.35-.93-.9-.23.01-.46.02-.69.02-.51,0-1.01-.03-1.49-.09-.25-.03-.5-.07-.74-.11-1.18-.32-2.03-1.27-2.03-2.4v-1.37c0-1.13.86-2.08,2.03-2.4.24-.04.49-.08.74-.11.48-.06.98-.09,1.49-.09s1.01.03,1.49.09c.25.03.5.07.74.11.6.16,1.12.49,1.49.93.34.41.55.92.55,1.47v1.37c0,.23-.01.66-.29,1.1Z"/>
+          <circle class="cls-2" cx="167.24" cy="17.67" r=".49"/>
+          <circle class="cls-2" cx="168.88" cy="17.71" r=".49"/>
+          <circle class="cls-2" cx="170.59" cy="17.71" r=".49"/>
+        </g>
+        <g>
+          <path class="cls-3" d="M141.01,8.24c.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82h6.55c1.78,0,3.51.65,5.19,1.94.81.63,1.48,1.48,2,2.55.53,1.07.79,2.27.79,3.58,0,2.29-.76,4.17-2.28,5.64.44,1.07,1.13,2.66,2.06,4.76.3.73.45,1.25.45,1.58,0,.77-.63,1.42-1.88,1.94-.65.28-1.17.43-1.56.43s-.72-.1-.97-.29c-.25-.19-.44-.39-.56-.59-.2-.38-.99-2.21-2.37-5.49l-.94.06h-3.82v3.43c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V9.28c0-.46.02-.81.05-1.05ZM145.12,16.62c.09.14.48.77,1.24.91.2.04.37.03.48.02.02.14.04.26.06.38.16.83.38,1.05.57,1.07.29.05.51-.35.93-.9.23.01.46.02.69.02.51,0,1.01-.03,1.49-.09.25-.03.5-.07.74-.11,1.18-.32,2.03-1.27,2.03-2.4v-1.37c0-1.13-.86-2.08-2.03-2.4-.24-.04-.49-.08-.74-.11-.48-.06-.98-.09-1.49-.09s-1.01.03-1.49.09c-.25.03-.5.07-.74.11-.6.16-1.12.49-1.49.93-.34.41-.55.92-.55,1.47v1.37c0,.23.01.66.29,1.1Z"/>
+          <circle class="cls-2" cx="150.69" cy="14.84" r=".49"/>
+          <circle class="cls-2" cx="149.05" cy="14.89" r=".49"/>
+          <circle class="cls-2" cx="147.35" cy="14.89" r=".49"/>
+        </g>
+      </g>
+    </g>
+  </g>
+</svg>

binární
docs/images/pku.png


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
docs/images/ucloud.svg


+ 1 - 1
i18n/zh-cn.json

@@ -70,7 +70,7 @@
   "关于": "关于",
   "注销成功!": "注销成功!",
   "个人设置": "个人设置",
-  "API令牌": "API令牌",
+  "令牌管理": "令牌管理",
   "退出": "退出",
   "关闭侧边栏": "关闭侧边栏",
   "打开侧边栏": "打开侧边栏",

+ 4 - 0
middleware/kling_adapter.go

@@ -18,7 +18,11 @@ func KlingRequestConvert() func(c *gin.Context) {
 			return
 		}
 
+		// Support both model_name and model fields
 		model, _ := originalReq["model_name"].(string)
+		if model == "" {
+			model, _ = originalReq["model"].(string)
+		}
 		prompt, _ := originalReq["prompt"].(string)
 
 		unifiedReq := map[string]interface{}{

+ 28 - 34
relay/channel/task/kling/adaptor.go

@@ -44,12 +44,14 @@ type requestPayload struct {
 	Duration    string  `json:"duration,omitempty"`
 	AspectRatio string  `json:"aspect_ratio,omitempty"`
 	ModelName   string  `json:"model_name,omitempty"`
+	Model       string  `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
 	CfgScale    float64 `json:"cfg_scale,omitempty"`
 }
 
 type responsePayload struct {
 	Code      int    `json:"code"`
 	Message   string `json:"message"`
+	TaskId    string `json:"task_id"`
 	RequestId string `json:"request_id"`
 	Data      struct {
 		TaskId        string `json:"task_id"`
@@ -73,21 +75,16 @@ type responsePayload struct {
 
 type TaskAdaptor struct {
 	ChannelType int
-	accessKey   string
-	secretKey   string
+	apiKey      string
 	baseURL     string
 }
 
 func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
 	a.ChannelType = info.ChannelType
 	a.baseURL = info.BaseUrl
+	a.apiKey = info.ApiKey
 
 	// apiKey format: "access_key|secret_key"
-	keyParts := strings.Split(info.ApiKey, "|")
-	if len(keyParts) == 2 {
-		a.accessKey = strings.TrimSpace(keyParts[0])
-		a.secretKey = strings.TrimSpace(keyParts[1])
-	}
 }
 
 // ValidateRequestAndSetAction parses body, validates fields and sets default action.
@@ -166,27 +163,19 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
 		return
 	}
 
-	// Attempt Kling response parse first.
 	var kResp responsePayload
-	if err := json.Unmarshal(responseBody, &kResp); err == nil && kResp.Code == 0 {
-		c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskId})
-		return kResp.Data.TaskId, responseBody, nil
-	}
-
-	// Fallback generic task response.
-	var generic dto.TaskResponse[string]
-	if err := json.Unmarshal(responseBody, &generic); err != nil {
-		taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
+	err = json.Unmarshal(responseBody, &kResp)
+	if err != nil {
+		taskErr = service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
 		return
 	}
-
-	if !generic.IsSuccess() {
-		taskErr = service.TaskErrorWrapper(fmt.Errorf(generic.Message), generic.Code, http.StatusInternalServerError)
+	if kResp.Code != 0 {
+		taskErr = service.TaskErrorWrapperLocal(fmt.Errorf(kResp.Message), "task_failed", http.StatusBadRequest)
 		return
 	}
-
-	c.JSON(http.StatusOK, gin.H{"task_id": generic.Data})
-	return generic.Data, responseBody, nil
+	kResp.TaskId = kResp.Data.TaskId
+	c.JSON(http.StatusOK, kResp)
+	return kResp.Data.TaskId, responseBody, nil
 }
 
 // FetchTask fetch task status
@@ -239,6 +228,7 @@ func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload,
 		Duration:    fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
 		AspectRatio: a.getAspectRatio(req.Size),
 		ModelName:   req.Model,
+		Model:       req.Model, // Keep consistent with model_name, double writing improves compatibility
 		CfgScale:    0.5,
 	}
 	if r.ModelName == "" {
@@ -288,21 +278,25 @@ func defaultInt(v int, def int) int {
 // ============================
 
 func (a *TaskAdaptor) createJWTToken() (string, error) {
-	return a.createJWTTokenWithKeys(a.accessKey, a.secretKey)
+	return a.createJWTTokenWithKey(a.apiKey)
 }
 
+//func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
+//	parts := strings.Split(apiKey, "|")
+//	if len(parts) != 2 {
+//		return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'")
+//	}
+//	return a.createJWTTokenWithKey(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
+//}
+
 func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
-	parts := strings.Split(apiKey, "|")
-	if len(parts) != 2 {
-		return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'")
-	}
-	return a.createJWTTokenWithKeys(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
-}
 
-func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (string, error) {
-	if accessKey == "" || secretKey == "" {
-		return "", fmt.Errorf("access key and secret key are required")
+	keyParts := strings.Split(apiKey, "|")
+	accessKey := strings.TrimSpace(keyParts[0])
+	if len(keyParts) == 1 {
+		return accessKey, nil
 	}
+	secretKey := strings.TrimSpace(keyParts[1])
 	now := time.Now().Unix()
 	claims := jwt.MapClaims{
 		"iss": accessKey,
@@ -315,12 +309,12 @@ func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (strin
 }
 
 func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
+	taskInfo := &relaycommon.TaskInfo{}
 	resPayload := responsePayload{}
 	err := json.Unmarshal(respBody, &resPayload)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to unmarshal response body")
 	}
-	taskInfo := &relaycommon.TaskInfo{}
 	taskInfo.Code = resPayload.Code
 	taskInfo.TaskID = resPayload.Data.TaskId
 	taskInfo.Reason = resPayload.Message

+ 1 - 1
relay/constant/relay_mode.go

@@ -150,7 +150,7 @@ func Path2RelayKling(method, path string) int {
 	relayMode := RelayModeUnknown
 	if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") {
 		relayMode = RelayModeKlingSubmit
-	} else if method == http.MethodGet && strings.Contains(path, "/video/generations/") {
+	} else if method == http.MethodGet && (strings.Contains(path, "/video/generations")) {
 		relayMode = RelayModeKlingFetchByID
 	}
 	return relayMode

+ 2 - 0
router/video-router.go

@@ -20,5 +20,7 @@ func SetVideoRouter(router *gin.Engine) {
 	{
 		klingV1Router.POST("/videos/text2video", controller.RelayTask)
 		klingV1Router.POST("/videos/image2video", controller.RelayTask)
+		klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTask)
+		klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTask)
 	}
 }

+ 34 - 0
web/.eslintrc.cjs

@@ -0,0 +1,34 @@
+module.exports = {
+  root: true,
+  env: { browser: true, es2021: true, node: true },
+  parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } },
+  plugins: ['header', 'react-hooks'],
+  overrides: [
+    {
+      files: ['**/*.{js,jsx}'],
+      rules: {
+        'header/header': [2, 'block', [
+          '',
+          'Copyright (C) 2025 QuantumNous',
+          '',
+          'This program is free software: you can redistribute it and/or modify',
+          'it under the terms of the GNU Affero General Public License as',
+          'published by the Free Software Foundation, either version 3 of the',
+          'License, or (at your option) any later version.',
+          '',
+          'This program is distributed in the hope that it will be useful,',
+          'but WITHOUT ANY WARRANTY; without even the implied warranty of',
+          'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
+          'GNU Affero General Public License for more details.',
+          '',
+          'You should have received a copy of the GNU Affero General Public License',
+          'along with this program. If not, see <https://www.gnu.org/licenses/>.',
+          '',
+          'For commercial licensing, please contact support@quantumnous.com',
+          ''
+        ]],
+        'no-multiple-empty-lines': ['error', { max: 1 }]
+      }
+    }
+  ]
+}; 

+ 146 - 17
web/bun.lock

@@ -46,6 +46,9 @@
         "@so1ve/prettier-config": "^3.1.0",
         "@vitejs/plugin-react": "^4.2.1",
         "autoprefixer": "^10.4.21",
+        "eslint": "8.57.0",
+        "eslint-plugin-header": "^3.1.1",
+        "eslint-plugin-react-hooks": "^5.2.0",
         "postcss": "^8.5.3",
         "prettier": "^3.0.0",
         "tailwindcss": "^3",
@@ -237,6 +240,14 @@
 
     "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
 
+    "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
+
+    "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
+
+    "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="],
+
+    "@eslint/js": ["@eslint/js@8.57.0", "", {}, "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g=="],
+
     "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="],
 
     "@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="],
@@ -249,6 +260,12 @@
 
     "@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="],
 
+    "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.11.14", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg=="],
+
+    "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+    "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
+
     "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
 
     "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="],
@@ -629,15 +646,17 @@
 
     "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="],
 
-    "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
+    "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
 
     "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
 
     "ahooks": ["ahooks@3.8.5", "", { "dependencies": { "@babel/runtime": "^7.21.0", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y+MLoJpBXVdjsnnBjE5rOSPkQ4DK+8i5aPDzLJdIOsCpo/fiAeXcBY1Y7oWgtOK0TpOz0gFa/XcyO1UGdoqLcw=="],
 
-    "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
+    "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
 
-    "ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
+    "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+    "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
 
     "antd": ["antd@5.25.2", "", { "dependencies": { "@ant-design/colors": "^7.2.0", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.0.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.2.6", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.2.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.8", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.50.5", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.9.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-7R2nUvlHhey7Trx64+hCtGXOiy+DTUs1Lv5bwbV1LzEIZIhWb0at1AM6V3K108a5lyoR9n7DX3ptlLF7uYV/DQ=="],
 
@@ -649,6 +668,8 @@
 
     "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
 
+    "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+
     "array-source": ["array-source@0.0.4", "", {}, "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw=="],
 
     "assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="],
@@ -699,6 +720,8 @@
 
     "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
 
+    "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
     "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
 
     "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
@@ -851,6 +874,8 @@
 
     "decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="],
 
+    "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
     "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
 
     "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
@@ -865,6 +890,8 @@
 
     "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
 
+    "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
+
     "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="],
 
     "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -887,7 +914,25 @@
 
     "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
 
-    "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+    "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+    "eslint": ["eslint@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.0", "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ=="],
+
+    "eslint-plugin-header": ["eslint-plugin-header@3.1.1", "", { "peerDependencies": { "eslint": ">=7.7.0" } }, "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg=="],
+
+    "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
+
+    "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="],
+
+    "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+    "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
+
+    "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
+
+    "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+    "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
 
     "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="],
 
@@ -903,6 +948,8 @@
 
     "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
 
+    "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
     "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
 
     "exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="],
@@ -917,8 +964,14 @@
 
     "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
 
+    "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+    "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
     "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
 
+    "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
+
     "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
 
     "file-source": ["file-source@0.6.1", "", { "dependencies": { "stream-source": "0.3" } }, "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA=="],
@@ -929,6 +982,12 @@
 
     "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
 
+    "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+    "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="],
+
+    "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
+
     "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
 
     "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
@@ -969,12 +1028,16 @@
 
     "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
 
-    "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
+    "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="],
 
     "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
 
+    "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
+
     "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
 
+    "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
     "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
 
     "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
@@ -1025,12 +1088,16 @@
 
     "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
 
+    "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
     "immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
 
     "immutable": ["immutable@5.1.2", "", {}, "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ=="],
 
     "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="],
 
+    "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
     "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
 
     "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -1065,6 +1132,8 @@
 
     "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
 
+    "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
+
     "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
 
     "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
@@ -1083,10 +1152,18 @@
 
     "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
 
+    "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
+
     "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
 
+    "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
     "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
 
+    "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+    "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
     "json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="],
 
     "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -1097,6 +1174,8 @@
 
     "katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="],
 
+    "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
     "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
 
     "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
@@ -1107,6 +1186,8 @@
 
     "leva": ["leva@0.10.0", "", { "dependencies": { "@radix-ui/react-portal": "1.0.2", "@radix-ui/react-tooltip": "1.0.5", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-RiNJWmeqQdKIeHuVXgshmxIHu144a2AMYtLxKf8Nm1j93pisDPexuQDHKNdQlbo37wdyDQibLjY9JKGIiD7gaw=="],
 
+    "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
     "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
 
     "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1119,12 +1200,16 @@
 
     "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="],
 
+    "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
     "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
 
     "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
 
     "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
 
+    "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+
     "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
 
     "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -1285,6 +1370,8 @@
 
     "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
 
+    "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
     "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
 
     "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
@@ -1307,6 +1394,12 @@
 
     "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
 
+    "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+    "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+    "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
     "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
 
     "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="],
@@ -1327,6 +1420,8 @@
 
     "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
 
+    "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
     "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
 
     "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -1375,6 +1470,8 @@
 
     "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
 
+    "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
     "prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="],
 
     "prettier-package-json": ["prettier-package-json@2.8.0", "", { "dependencies": { "@types/parse-author": "^2.0.0", "commander": "^4.0.1", "cosmiconfig": "^7.0.0", "fs-extra": "^10.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4", "parse-author": "^2.0.0", "sort-object-keys": "^1.1.3", "sort-order": "^1.0.1" }, "bin": { "prettier-package-json": "bin/prettier-package-json" } }, "sha512-WxtodH/wWavfw3MR7yK/GrS4pASEQ+iSTkdtSxPJWvqzG55ir5nvbLt9rw5AOiEcqqPCRM92WCtR1rk3TG3JSQ=="],
@@ -1393,6 +1490,8 @@
 
     "protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
 
+    "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
     "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="],
 
     "query-string": ["query-string@9.2.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ=="],
@@ -1577,6 +1676,8 @@
 
     "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
 
+    "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
+
     "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
 
     "rollup": ["rollup@4.30.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.30.0", "@rollup/rollup-android-arm64": "4.30.0", "@rollup/rollup-darwin-arm64": "4.30.0", "@rollup/rollup-darwin-x64": "4.30.0", "@rollup/rollup-freebsd-arm64": "4.30.0", "@rollup/rollup-freebsd-x64": "4.30.0", "@rollup/rollup-linux-arm-gnueabihf": "4.30.0", "@rollup/rollup-linux-arm-musleabihf": "4.30.0", "@rollup/rollup-linux-arm64-gnu": "4.30.0", "@rollup/rollup-linux-arm64-musl": "4.30.0", "@rollup/rollup-linux-loongarch64-gnu": "4.30.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.30.0", "@rollup/rollup-linux-riscv64-gnu": "4.30.0", "@rollup/rollup-linux-s390x-gnu": "4.30.0", "@rollup/rollup-linux-x64-gnu": "4.30.0", "@rollup/rollup-linux-x64-musl": "4.30.0", "@rollup/rollup-win32-arm64-msvc": "4.30.0", "@rollup/rollup-win32-ia32-msvc": "4.30.0", "@rollup/rollup-win32-x64-msvc": "4.30.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-sDnr1pcjTgUT69qBksNF1N1anwfbyYG6TBQ22b03bII8EdiUQ7J0TlozVaTMjT/eEJAO49e1ndV7t+UZfL1+vA=="],
@@ -1655,10 +1756,12 @@
 
     "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
 
-    "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
+    "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
 
     "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
 
+    "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+
     "style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="],
 
     "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
@@ -1667,6 +1770,8 @@
 
     "suf-log": ["suf-log@2.5.3", "", { "dependencies": { "s.color": "0.0.15" } }, "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow=="],
 
+    "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
     "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
 
     "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
@@ -1677,6 +1782,8 @@
 
     "text-encoding": ["text-encoding@0.6.4", "", {}, "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg=="],
 
+    "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
+
     "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
 
     "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -1705,6 +1812,10 @@
 
     "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
 
+    "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+    "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
+
     "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
 
     "typescript": ["typescript@4.4.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ=="],
@@ -1733,6 +1844,8 @@
 
     "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
 
+    "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
     "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
 
     "use-debounce": ["use-debounce@10.0.4", "", { "peerDependencies": { "react": "*" } }, "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw=="],
@@ -1777,6 +1890,8 @@
 
     "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
 
+    "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
     "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
 
     "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -1787,6 +1902,8 @@
 
     "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
 
+    "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
     "zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="],
 
     "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
@@ -1807,8 +1924,6 @@
 
     "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
 
-    "@emotion/babel-plugin/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
-
     "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
 
     "@emotion/babel-plugin/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
@@ -1819,6 +1934,10 @@
 
     "@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
 
+    "@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
+
+    "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
+
     "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
 
     "@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
@@ -1867,6 +1986,8 @@
 
     "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
 
+    "esast-util-from-js/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
+
     "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
 
     "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -1887,8 +2008,14 @@
 
     "leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="],
 
+    "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
     "mermaid/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
 
+    "micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
+
+    "mlly/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
+
     "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
 
     "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -1909,6 +2036,8 @@
 
     "react-toastify/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="],
 
+    "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+
     "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
 
     "set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
@@ -1921,11 +2050,9 @@
 
     "string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
 
-    "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
-
-    "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+    "string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
 
-    "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+    "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
 
     "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
 
@@ -1935,11 +2062,11 @@
 
     "vite/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
 
-    "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+    "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
 
-    "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+    "wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
 
-    "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+    "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
 
     "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001690", "", {}, "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w=="],
 
@@ -1951,6 +2078,8 @@
 
     "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="],
 
+    "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
+
     "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@0.5.4", "", { "dependencies": { "@floating-ui/core": "^0.7.3" } }, "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg=="],
 
     "@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
@@ -1981,11 +2110,11 @@
 
     "simplify-geojson/concat-stream/typedarray": ["typedarray@0.0.7", "", {}, "sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ=="],
 
-    "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+    "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
 
     "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
 
-    "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+    "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
 
     "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
 

+ 5 - 0
web/package.json

@@ -46,6 +46,8 @@
     "build": "vite build",
     "lint": "prettier . --check",
     "lint:fix": "prettier . --write",
+    "eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
+    "eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
     "preview": "vite preview"
   },
   "eslintConfig": {
@@ -71,6 +73,9 @@
     "@so1ve/prettier-config": "^3.1.0",
     "@vitejs/plugin-react": "^4.2.1",
     "autoprefixer": "^10.4.21",
+    "eslint": "8.57.0",
+    "eslint-plugin-header": "^3.1.1",
+    "eslint-plugin-react-hooks": "^5.2.0",
     "postcss": "^8.5.3",
     "prettier": "^3.0.0",
     "tailwindcss": "^3",

+ 19 - 0
web/postcss.config.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 export default {
   plugins: {
     tailwindcss: {},

+ 23 - 37
web/src/App.js

@@ -1,18 +1,36 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { lazy, Suspense } from 'react';
 import { Route, Routes, useLocation } from 'react-router-dom';
-import Loading from './components/common/Loading.js';
+import Loading from './components/common/ui/Loading.js';
 import User from './pages/User';
 import { AuthRedirect, PrivateRoute } from './helpers';
 import RegisterForm from './components/auth/RegisterForm.js';
 import LoginForm from './components/auth/LoginForm.js';
 import NotFound from './pages/NotFound';
 import Setting from './pages/Setting';
-import EditUser from './pages/User/EditUser';
+
 import PasswordResetForm from './components/auth/PasswordResetForm.js';
 import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
 import Channel from './pages/Channel';
 import Token from './pages/Token';
-import EditChannel from './pages/Channel/EditChannel';
 import Redemption from './pages/Redemption';
 import TopUp from './pages/TopUp';
 import Log from './pages/Log';
@@ -28,7 +46,7 @@ import Setup from './pages/Setup/index.js';
 import SetupCheck from './components/layout/SetupCheck.js';
 
 const Home = lazy(() => import('./pages/Home'));
-const Detail = lazy(() => import('./pages/Detail'));
+const Dashboard = lazy(() => import('./pages/Dashboard'));
 const About = lazy(() => import('./pages/About'));
 
 function App() {
@@ -61,22 +79,6 @@ function App() {
             </PrivateRoute>
           }
         />
-        <Route
-          path='/console/channel/edit/:id'
-          element={
-            <Suspense fallback={<Loading></Loading>} key={location.pathname}>
-              <EditChannel />
-            </Suspense>
-          }
-        />
-        <Route
-          path='/console/channel/add'
-          element={
-            <Suspense fallback={<Loading></Loading>} key={location.pathname}>
-              <EditChannel />
-            </Suspense>
-          }
-        />
         <Route
           path='/console/token'
           element={
@@ -109,22 +111,6 @@ function App() {
             </PrivateRoute>
           }
         />
-        <Route
-          path='/console/user/edit/:id'
-          element={
-            <Suspense fallback={<Loading></Loading>} key={location.pathname}>
-              <EditUser />
-            </Suspense>
-          }
-        />
-        <Route
-          path='/console/user/edit'
-          element={
-            <Suspense fallback={<Loading></Loading>} key={location.pathname}>
-              <EditUser />
-            </Suspense>
-          }
-        />
         <Route
           path='/user/reset'
           element={
@@ -228,7 +214,7 @@ function App() {
           element={
             <PrivateRoute>
               <Suspense fallback={<Loading></Loading>} key={location.pathname}>
-                <Detail />
+                <Dashboard />
               </Suspense>
             </PrivateRoute>
           }

+ 20 - 1
web/src/components/auth/LoginForm.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect, useState } from 'react';
 import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { UserContext } from '../../context/User/index.js';
@@ -523,7 +542,7 @@ const LoginForm = () => {
       {/* 背景模糊晕染球 */}
       <div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
       <div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
-      <div className="w-full max-w-sm mt-[64px]">
+      <div className="w-full max-w-sm mt-[60px]">
         {showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
           ? renderEmailLoginForm()
           : renderOAuthOptions()}

+ 20 - 1
web/src/components/auth/OAuth2Callback.js

@@ -1,9 +1,28 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
 import { UserContext } from '../../context/User';
-import Loading from '../common/Loading';
+import Loading from '../common/ui/Loading';
 
 const OAuth2Callback = (props) => {
   const { t } = useTranslation();

+ 20 - 1
web/src/components/auth/PasswordResetConfirm.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
 import { useSearchParams, Link } from 'react-router-dom';
@@ -82,7 +101,7 @@ const PasswordResetConfirm = () => {
       {/* 背景模糊晕染球 */}
       <div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
       <div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
-      <div className="w-full max-w-sm mt-[64px]">
+      <div className="w-full max-w-sm mt-[60px]">
         <div className="flex flex-col items-center">
           <div className="w-full max-w-md">
             <div className="flex items-center justify-center mb-6 gap-2">

+ 20 - 1
web/src/components/auth/PasswordResetForm.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
 import Turnstile from 'react-turnstile';
@@ -82,7 +101,7 @@ const PasswordResetForm = () => {
       {/* 背景模糊晕染球 */}
       <div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
       <div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
-      <div className="w-full max-w-sm mt-[64px]">
+      <div className="w-full max-w-sm mt-[60px]">
         <div className="flex flex-col items-center">
           <div className="w-full max-w-md">
             <div className="flex items-center justify-center mb-6 gap-2">

+ 20 - 1
web/src/components/auth/RegisterForm.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect, useState } from 'react';
 import { Link, useNavigate } from 'react-router-dom';
 import {
@@ -540,7 +559,7 @@ const RegisterForm = () => {
       {/* 背景模糊晕染球 */}
       <div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
       <div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
-      <div className="w-full max-w-sm mt-[64px]">
+      <div className="w-full max-w-sm mt-[60px]">
         {showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
           ? renderEmailRegisterForm()
           : renderOAuthOptions()}

+ 0 - 16
web/src/components/common/Loading.js

@@ -1,16 +0,0 @@
-import React from 'react';
-import { Spin } from '@douyinfe/semi-ui';
-
-const Loading = ({ size = 'small' }) => {
-
-  return (
-    <div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
-      <Spin
-        size={size}
-        spinning={true}
-      />
-    </div>
-  );
-};
-
-export default Loading;

+ 19 - 0
web/src/components/common/logo/LinuxDoIcon.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import { Icon } from '@douyinfe/semi-ui';
 

+ 19 - 0
web/src/components/common/logo/OIDCIcon.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import { Icon } from '@douyinfe/semi-ui';
 

+ 19 - 0
web/src/components/common/logo/WeChatIcon.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import { Icon } from '@douyinfe/semi-ui';
 

+ 19 - 0
web/src/components/common/markdown/MarkdownRenderer.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import ReactMarkdown from 'react-markdown';
 import 'katex/dist/katex.min.css';
 import 'highlight.js/styles/github.css';

+ 213 - 0
web/src/components/common/ui/CardPro.js

@@ -0,0 +1,213 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState } from 'react';
+import { Card, Divider, Typography, Button } from '@douyinfe/semi-ui';
+import PropTypes from 'prop-types';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+import { IconEyeOpened, IconEyeClosed } from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
+
+/**
+ * CardPro 高级卡片组件
+ * 
+ * 布局分为6个区域:
+ * 1. 统计信息区域 (statsArea)
+ * 2. 描述信息区域 (descriptionArea) 
+ * 3. 类型切换/标签区域 (tabsArea)
+ * 4. 操作按钮区域 (actionsArea)
+ * 5. 搜索表单区域 (searchArea)
+ * 6. 分页区域 (paginationArea) - 固定在卡片底部
+ * 
+ * 支持三种布局类型:
+ * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
+ * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
+ * - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单
+ */
+const CardPro = ({
+  type = 'type1',
+  className = '',
+  children,
+  // 各个区域的内容
+  statsArea,
+  descriptionArea,
+  tabsArea,
+  actionsArea,
+  searchArea,
+  paginationArea, // 新增分页区域
+  // 卡片属性
+  shadows = 'always',
+  bordered = false,
+  // 自定义样式
+  style,
+  // 国际化函数
+  t = (key) => key,
+  ...props
+}) => {
+  const isMobile = useIsMobile();
+  const [showMobileActions, setShowMobileActions] = useState(false);
+
+  const toggleMobileActions = () => {
+    setShowMobileActions(!showMobileActions);
+  };
+
+  const hasMobileHideableContent = actionsArea || searchArea;
+
+  const renderHeader = () => {
+    const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
+    if (!hasContent) return null;
+
+    return (
+      <div className="flex flex-col w-full">
+        {/* 统计信息区域 - 用于type2 */}
+        {type === 'type2' && statsArea && (
+          <>
+            {statsArea}
+          </>
+        )}
+
+        {/* 描述信息区域 - 用于type1和type3 */}
+        {(type === 'type1' || type === 'type3') && descriptionArea && (
+          <>
+            {descriptionArea}
+          </>
+        )}
+
+        {/* 第一个分隔线 - 在描述信息或统计信息后面 */}
+        {((type === 'type1' || type === 'type3') && descriptionArea) ||
+          (type === 'type2' && statsArea) ? (
+          <Divider margin="12px" />
+        ) : null}
+
+        {/* 类型切换/标签区域 - 主要用于type3 */}
+        {type === 'type3' && tabsArea && (
+          <>
+            {tabsArea}
+          </>
+        )}
+
+        {/* 移动端操作切换按钮 */}
+        {isMobile && hasMobileHideableContent && (
+          <>
+            <div className="w-full mb-2">
+              <Button
+                onClick={toggleMobileActions}
+                icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
+                type="tertiary"
+                size="small"
+                block
+              >
+                {showMobileActions ? t('隐藏操作项') : t('显示操作项')}
+              </Button>
+            </div>
+          </>
+        )}
+
+        {/* 操作按钮和搜索表单的容器 */}
+        <div
+          className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
+        >
+          {/* 操作按钮区域 - 用于type1和type3 */}
+          {(type === 'type1' || type === 'type3') && actionsArea && (
+            Array.isArray(actionsArea) ? (
+              actionsArea.map((area, idx) => (
+                <React.Fragment key={idx}>
+                  {idx !== 0 && <Divider />}
+                  <div className="w-full">
+                    {area}
+                  </div>
+                </React.Fragment>
+              ))
+            ) : (
+              <div className="w-full">
+                {actionsArea}
+              </div>
+            )
+          )}
+
+          {/* 当同时存在操作区和搜索区时,插入分隔线 */}
+          {(actionsArea && searchArea) && <Divider />}
+
+          {/* 搜索表单区域 - 所有类型都可能有 */}
+          {searchArea && (
+            <div className="w-full">
+              {searchArea}
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  };
+
+  const headerContent = renderHeader();
+
+  // 渲染分页区域
+  const renderFooter = () => {
+    if (!paginationArea) return null;
+
+    return (
+      <div className="flex justify-center w-full pt-4 border-t" style={{ borderColor: 'var(--semi-color-border)' }}>
+        {paginationArea}
+      </div>
+    );
+  };
+
+  const footerContent = renderFooter();
+
+  return (
+    <Card
+      className={`table-scroll-card !rounded-2xl ${className}`}
+      title={headerContent}
+      footer={footerContent}
+      shadows={shadows}
+      bordered={bordered}
+      style={style}
+      {...props}
+    >
+      {children}
+    </Card>
+  );
+};
+
+CardPro.propTypes = {
+  // 布局类型
+  type: PropTypes.oneOf(['type1', 'type2', 'type3']),
+  // 样式相关
+  className: PropTypes.string,
+  style: PropTypes.object,
+  shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+  bordered: PropTypes.bool,
+  // 内容区域
+  statsArea: PropTypes.node,
+  descriptionArea: PropTypes.node,
+  tabsArea: PropTypes.node,
+  actionsArea: PropTypes.oneOfType([
+    PropTypes.node,
+    PropTypes.arrayOf(PropTypes.node),
+  ]),
+  searchArea: PropTypes.node,
+  paginationArea: PropTypes.node,
+  // 表格内容
+  children: PropTypes.node,
+  // 国际化函数
+  t: PropTypes.func,
+};
+
+export default CardPro; 

+ 237 - 0
web/src/components/common/ui/CardTable.js

@@ -0,0 +1,237 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui';
+import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
+import PropTypes from 'prop-types';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+
+/**
+ * CardTable 响应式表格组件
+ * 
+ * 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
+ * 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
+ */
+const CardTable = ({
+  columns = [],
+  dataSource = [],
+  loading = false,
+  rowKey = 'key',
+  hidePagination = false,
+  ...tableProps
+}) => {
+  const isMobile = useIsMobile();
+  const { t } = useTranslation();
+
+  const [showSkeleton, setShowSkeleton] = useState(loading);
+  const loadingStartRef = useRef(Date.now());
+
+  useEffect(() => {
+    if (loading) {
+      loadingStartRef.current = Date.now();
+      setShowSkeleton(true);
+    } else {
+      const elapsed = Date.now() - loadingStartRef.current;
+      const remaining = Math.max(0, 500 - elapsed);
+      if (remaining === 0) {
+        setShowSkeleton(false);
+      } else {
+        const timer = setTimeout(() => setShowSkeleton(false), remaining);
+        return () => clearTimeout(timer);
+      }
+    }
+  }, [loading]);
+
+  const getRowKey = (record, index) => {
+    if (typeof rowKey === 'function') return rowKey(record);
+    return record[rowKey] !== undefined ? record[rowKey] : index;
+  };
+
+  if (!isMobile) {
+    const finalTableProps = hidePagination
+      ? { ...tableProps, pagination: false }
+      : tableProps;
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={dataSource}
+        loading={loading}
+        rowKey={rowKey}
+        {...finalTableProps}
+      />
+    );
+  }
+
+  if (showSkeleton) {
+    const visibleCols = columns.filter((col) => {
+      if (tableProps?.visibleColumns && col.key) {
+        return tableProps.visibleColumns[col.key];
+      }
+      return true;
+    });
+
+    const renderSkeletonCard = (key) => {
+      const placeholder = (
+        <div className="p-2">
+          {visibleCols.map((col, idx) => {
+            if (!col.title) {
+              return (
+                <div key={idx} className="mt-2 flex justify-end">
+                  <Skeleton.Title active style={{ width: 100, height: 24 }} />
+                </div>
+              );
+            }
+
+            return (
+              <div key={idx} className="flex justify-between items-center py-1 border-b last:border-b-0 border-dashed" style={{ borderColor: 'var(--semi-color-border)' }}>
+                <Skeleton.Title active style={{ width: 80, height: 14 }} />
+                <Skeleton.Title
+                  active
+                  style={{
+                    width: `${50 + (idx % 3) * 10}%`,
+                    maxWidth: 180,
+                    height: 14,
+                  }}
+                />
+              </div>
+            );
+          })}
+        </div>
+      );
+
+      return (
+        <Card key={key} className="!rounded-2xl shadow-sm">
+          <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
+        </Card>
+      );
+    };
+
+    return (
+      <div className="flex flex-col gap-2">
+        {[1, 2, 3].map((i) => renderSkeletonCard(i))}
+      </div>
+    );
+  }
+
+  const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0);
+
+  const MobileRowCard = ({ record, index }) => {
+    const [showDetails, setShowDetails] = useState(false);
+    const rowKeyVal = getRowKey(record, index);
+
+    const hasDetails =
+      tableProps.expandedRowRender &&
+      (!tableProps.rowExpandable || tableProps.rowExpandable(record));
+
+    return (
+      <Card key={rowKeyVal} className="!rounded-2xl shadow-sm">
+        {columns.map((col, colIdx) => {
+          if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) {
+            return null;
+          }
+
+          const title = col.title;
+          const cellContent = col.render
+            ? col.render(record[col.dataIndex], record, index)
+            : record[col.dataIndex];
+
+          if (!title) {
+            return (
+              <div key={col.key || colIdx} className="mt-2 flex justify-end">
+                {cellContent}
+              </div>
+            );
+          }
+
+          return (
+            <div
+              key={col.key || colIdx}
+              className="flex justify-between items-start py-1 border-b last:border-b-0 border-dashed"
+              style={{ borderColor: 'var(--semi-color-border)' }}
+            >
+              <span className="font-medium text-gray-600 mr-2 whitespace-nowrap select-none">
+                {title}
+              </span>
+              <div className="flex-1 break-all flex justify-end items-center gap-1">
+                {cellContent !== undefined && cellContent !== null ? cellContent : '-'}
+              </div>
+            </div>
+          );
+        })}
+
+        {hasDetails && (
+          <>
+            <Button
+              theme='borderless'
+              size='small'
+              className='w-full flex justify-center mt-2'
+              icon={showDetails ? <IconChevronUp /> : <IconChevronDown />}
+              onClick={(e) => {
+                e.stopPropagation();
+                setShowDetails(!showDetails);
+              }}
+            >
+              {showDetails ? t('收起') : t('详情')}
+            </Button>
+            <Collapsible isOpen={showDetails} keepDOM>
+              <div className="pt-2">
+                {tableProps.expandedRowRender(record, index)}
+              </div>
+            </Collapsible>
+          </>
+        )}
+      </Card>
+    );
+  };
+
+  if (isEmpty) {
+    if (tableProps.empty) return tableProps.empty;
+    return (
+      <div className="flex justify-center p-4">
+        <Empty description="No Data" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-col gap-2">
+      {dataSource.map((record, index) => (
+        <MobileRowCard key={getRowKey(record, index)} record={record} index={index} />
+      ))}
+      {!hidePagination && tableProps.pagination && dataSource.length > 0 && (
+        <div className="mt-2 flex justify-center">
+          <Pagination {...tableProps.pagination} />
+        </div>
+      )}
+    </div>
+  );
+};
+
+CardTable.propTypes = {
+  columns: PropTypes.array.isRequired,
+  dataSource: PropTypes.array,
+  loading: PropTypes.bool,
+  rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+  hidePagination: PropTypes.bool,
+};
+
+export default CardTable; 

+ 68 - 0
web/src/components/common/ui/CompactModeToggle.js

@@ -0,0 +1,68 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+import PropTypes from 'prop-types';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+
+/**
+ * 紧凑模式切换按钮组件
+ * 用于在自适应列表和紧凑列表之间切换
+ * 在移动端时自动隐藏,因为移动端使用"显示操作项"按钮来控制内容显示
+ */
+const CompactModeToggle = ({
+  compactMode,
+  setCompactMode,
+  t,
+  size = 'small',
+  type = 'tertiary',
+  className = '',
+  ...props
+}) => {
+  const isMobile = useIsMobile();
+
+  // 在移动端隐藏紧凑列表切换按钮
+  if (isMobile) {
+    return null;
+  }
+
+  return (
+    <Button
+      type={type}
+      size={size}
+      className={`w-full md:w-auto ${className}`}
+      onClick={() => setCompactMode(!compactMode)}
+      {...props}
+    >
+      {compactMode ? t('自适应列表') : t('紧凑列表')}
+    </Button>
+  );
+};
+
+CompactModeToggle.propTypes = {
+  compactMode: PropTypes.bool.isRequired,
+  setCompactMode: PropTypes.func.isRequired,
+  t: PropTypes.func.isRequired,
+  size: PropTypes.string,
+  type: PropTypes.string,
+  className: PropTypes.string,
+};
+
+export default CompactModeToggle; 

+ 35 - 0
web/src/components/common/ui/Loading.js

@@ -0,0 +1,35 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Spin } from '@douyinfe/semi-ui';
+
+const Loading = ({ size = 'small' }) => {
+
+  return (
+    <div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
+      <Spin
+        size={size}
+        spinning={true}
+      />
+    </div>
+  );
+};
+
+export default Loading;

+ 220 - 0
web/src/components/common/ui/ScrollableContainer.js

@@ -0,0 +1,220 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, {
+  useRef,
+  useState,
+  useEffect,
+  useCallback,
+  useMemo,
+  useImperativeHandle,
+  forwardRef
+} from 'react';
+
+/**
+ * ScrollableContainer 可滚动容器组件
+ * 
+ * 提供自动检测滚动状态和显示渐变指示器的功能
+ * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
+ * 
+ */
+const ScrollableContainer = forwardRef(({
+  children,
+  maxHeight = '24rem',
+  className = '',
+  contentClassName = 'p-2',
+  fadeIndicatorClassName = '',
+  checkInterval = 100,
+  scrollThreshold = 5,
+  debounceDelay = 16, // ~60fps
+  onScroll,
+  onScrollStateChange,
+  ...props
+}, ref) => {
+  const scrollRef = useRef(null);
+  const containerRef = useRef(null);
+  const debounceTimerRef = useRef(null);
+  const resizeObserverRef = useRef(null);
+  const onScrollStateChangeRef = useRef(onScrollStateChange);
+  const onScrollRef = useRef(onScroll);
+
+  const [showScrollHint, setShowScrollHint] = useState(false);
+
+  useEffect(() => {
+    onScrollStateChangeRef.current = onScrollStateChange;
+  }, [onScrollStateChange]);
+
+  useEffect(() => {
+    onScrollRef.current = onScroll;
+  }, [onScroll]);
+
+  const debounce = useCallback((func, delay) => {
+    return (...args) => {
+      if (debounceTimerRef.current) {
+        clearTimeout(debounceTimerRef.current);
+      }
+      debounceTimerRef.current = setTimeout(() => func(...args), delay);
+    };
+  }, []);
+
+  const checkScrollable = useCallback(() => {
+    if (!scrollRef.current) return;
+
+    const element = scrollRef.current;
+    const isScrollable = element.scrollHeight > element.clientHeight;
+    const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold;
+    const shouldShowHint = isScrollable && !isAtBottom;
+
+    setShowScrollHint(shouldShowHint);
+
+    if (onScrollStateChangeRef.current) {
+      onScrollStateChangeRef.current({
+        isScrollable,
+        isAtBottom,
+        showScrollHint: shouldShowHint,
+        scrollTop: element.scrollTop,
+        scrollHeight: element.scrollHeight,
+        clientHeight: element.clientHeight
+      });
+    }
+  }, [scrollThreshold]);
+
+  const debouncedCheckScrollable = useMemo(() =>
+    debounce(checkScrollable, debounceDelay),
+    [debounce, checkScrollable, debounceDelay]
+  );
+
+  const handleScroll = useCallback((e) => {
+    debouncedCheckScrollable();
+    if (onScrollRef.current) {
+      onScrollRef.current(e);
+    }
+  }, [debouncedCheckScrollable]);
+
+  useImperativeHandle(ref, () => ({
+    checkScrollable: () => {
+      checkScrollable();
+    },
+    scrollToTop: () => {
+      if (scrollRef.current) {
+        scrollRef.current.scrollTop = 0;
+      }
+    },
+    scrollToBottom: () => {
+      if (scrollRef.current) {
+        scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+      }
+    },
+    getScrollInfo: () => {
+      if (!scrollRef.current) return null;
+      const element = scrollRef.current;
+      return {
+        scrollTop: element.scrollTop,
+        scrollHeight: element.scrollHeight,
+        clientHeight: element.clientHeight,
+        isScrollable: element.scrollHeight > element.clientHeight,
+        isAtBottom: element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold
+      };
+    }
+  }), [checkScrollable, scrollThreshold]);
+
+  useEffect(() => {
+    const timer = setTimeout(() => {
+      checkScrollable();
+    }, checkInterval);
+    return () => clearTimeout(timer);
+  }, [checkScrollable, checkInterval]);
+
+  useEffect(() => {
+    if (!scrollRef.current) return;
+
+    if (typeof ResizeObserver === 'undefined') {
+      if (typeof MutationObserver !== 'undefined') {
+        const observer = new MutationObserver(() => {
+          debouncedCheckScrollable();
+        });
+
+        observer.observe(scrollRef.current, {
+          childList: true,
+          subtree: true,
+          attributes: true,
+          characterData: true
+        });
+
+        return () => observer.disconnect();
+      }
+      return;
+    }
+
+    resizeObserverRef.current = new ResizeObserver((entries) => {
+      for (const entry of entries) {
+        debouncedCheckScrollable();
+      }
+    });
+
+    resizeObserverRef.current.observe(scrollRef.current);
+
+    return () => {
+      if (resizeObserverRef.current) {
+        resizeObserverRef.current.disconnect();
+      }
+    };
+  }, [debouncedCheckScrollable]);
+
+  useEffect(() => {
+    return () => {
+      if (debounceTimerRef.current) {
+        clearTimeout(debounceTimerRef.current);
+      }
+    };
+  }, []);
+
+  const containerStyle = useMemo(() => ({
+    maxHeight
+  }), [maxHeight]);
+
+  const fadeIndicatorStyle = useMemo(() => ({
+    opacity: showScrollHint ? 1 : 0
+  }), [showScrollHint]);
+
+  return (
+    <div
+      ref={containerRef}
+      className={`card-content-container ${className}`}
+      {...props}
+    >
+      <div
+        ref={scrollRef}
+        className={`overflow-y-auto card-content-scroll ${contentClassName}`}
+        style={containerStyle}
+        onScroll={handleScroll}
+      >
+        {children}
+      </div>
+      <div
+        className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
+        style={fadeIndicatorStyle}
+      />
+    </div>
+  );
+});
+
+ScrollableContainer.displayName = 'ScrollableContainer';
+
+export default ScrollableContainer; 

+ 107 - 0
web/src/components/dashboard/AnnouncementsPanel.jsx

@@ -0,0 +1,107 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
+import { Bell } from 'lucide-react';
+import { marked } from 'marked';
+import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
+import ScrollableContainer from '../common/ui/ScrollableContainer';
+
+const AnnouncementsPanel = ({
+  announcementData,
+  announcementLegendData,
+  CARD_PROPS,
+  ILLUSTRATION_SIZE,
+  t
+}) => {
+  return (
+    <Card
+      {...CARD_PROPS}
+      className="shadow-sm !rounded-2xl lg:col-span-2"
+      title={
+        <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
+          <div className="flex items-center gap-2">
+            <Bell size={16} />
+            {t('系统公告')}
+            <Tag color="white" shape="circle">
+              {t('显示最新20条')}
+            </Tag>
+          </div>
+          {/* 图例 */}
+          <div className="flex flex-wrap gap-3 text-xs">
+            {announcementLegendData.map((legend, index) => (
+              <div key={index} className="flex items-center gap-1">
+                <div
+                  className="w-2 h-2 rounded-full"
+                  style={{
+                    backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
+                      legend.color === 'blue' ? '#3b82f6' :
+                        legend.color === 'green' ? '#10b981' :
+                          legend.color === 'orange' ? '#f59e0b' :
+                            legend.color === 'red' ? '#ef4444' : '#8b9aa7'
+                  }}
+                />
+                <span className="text-gray-600">{legend.label}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+      }
+      bodyStyle={{ padding: 0 }}
+    >
+      <ScrollableContainer maxHeight="24rem">
+        {announcementData.length > 0 ? (
+          <Timeline mode="alternate">
+            {announcementData.map((item, idx) => (
+              <Timeline.Item
+                key={idx}
+                type={item.type || 'default'}
+                time={item.time}
+              >
+                <div>
+                  <div
+                    dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
+                  />
+                  {item.extra && (
+                    <div
+                      className="text-xs text-gray-500"
+                      dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
+                    />
+                  )}
+                </div>
+              </Timeline.Item>
+            ))}
+          </Timeline>
+        ) : (
+          <div className="flex justify-center items-center py-8">
+            <Empty
+              image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
+              darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
+              title={t('暂无系统公告')}
+              description={t('请联系管理员在系统设置中配置公告信息')}
+            />
+          </div>
+        )}
+      </ScrollableContainer>
+    </Card>
+  );
+};
+
+export default AnnouncementsPanel; 

+ 117 - 0
web/src/components/dashboard/ApiInfoPanel.jsx

@@ -0,0 +1,117 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
+import { Server, Gauge, ExternalLink } from 'lucide-react';
+import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
+import ScrollableContainer from '../common/ui/ScrollableContainer';
+
+const ApiInfoPanel = ({
+  apiInfoData,
+  handleCopyUrl,
+  handleSpeedTest,
+  CARD_PROPS,
+  FLEX_CENTER_GAP2,
+  ILLUSTRATION_SIZE,
+  t
+}) => {
+  return (
+    <Card
+      {...CARD_PROPS}
+      className="bg-gray-50 border-0 !rounded-2xl"
+      title={
+        <div className={FLEX_CENTER_GAP2}>
+          <Server size={16} />
+          {t('API信息')}
+        </div>
+      }
+      bodyStyle={{ padding: 0 }}
+    >
+      <ScrollableContainer maxHeight="24rem">
+        {apiInfoData.length > 0 ? (
+          apiInfoData.map((api) => (
+            <React.Fragment key={api.id}>
+              <div className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
+                <div className="flex-shrink-0 mr-3">
+                  <Avatar
+                    size="extra-small"
+                    color={api.color}
+                  >
+                    {api.route.substring(0, 2)}
+                  </Avatar>
+                </div>
+                <div className="flex-1">
+                  <div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
+                    <span className="text-sm font-medium text-gray-900 !font-bold break-all">
+                      {api.route}
+                    </span>
+                    <div className="flex items-center gap-1 mt-1 lg:mt-0">
+                      <Tag
+                        prefixIcon={<Gauge size={12} />}
+                        size="small"
+                        color="white"
+                        shape='circle'
+                        onClick={() => handleSpeedTest(api.url)}
+                        className="cursor-pointer hover:opacity-80 text-xs"
+                      >
+                        {t('测速')}
+                      </Tag>
+                      <Tag
+                        prefixIcon={<ExternalLink size={12} />}
+                        size="small"
+                        color="white"
+                        shape='circle'
+                        onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
+                        className="cursor-pointer hover:opacity-80 text-xs"
+                      >
+                        {t('跳转')}
+                      </Tag>
+                    </div>
+                  </div>
+                  <div
+                    className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
+                    onClick={() => handleCopyUrl(api.url)}
+                  >
+                    {api.url}
+                  </div>
+                  <div className="text-gray-500">
+                    {api.description}
+                  </div>
+                </div>
+              </div>
+              <Divider />
+            </React.Fragment>
+          ))
+        ) : (
+          <div className="flex justify-center items-center py-8">
+            <Empty
+              image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
+              darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
+              title={t('暂无API信息')}
+              description={t('请联系管理员在系统设置中配置API信息')}
+            />
+          </div>
+        )}
+      </ScrollableContainer>
+    </Card>
+  );
+};
+
+export default ApiInfoPanel; 

+ 117 - 0
web/src/components/dashboard/ChartsPanel.jsx

@@ -0,0 +1,117 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
+import { PieChart } from 'lucide-react';
+import {
+  IconHistogram,
+  IconPulse,
+  IconPieChart2Stroked
+} from '@douyinfe/semi-icons';
+import { VChart } from '@visactor/react-vchart';
+
+const ChartsPanel = ({
+  activeChartTab,
+  setActiveChartTab,
+  spec_line,
+  spec_model_line,
+  spec_pie,
+  spec_rank_bar,
+  CARD_PROPS,
+  CHART_CONFIG,
+  FLEX_CENTER_GAP2,
+  hasApiInfoPanel,
+  t
+}) => {
+  return (
+    <Card
+      {...CARD_PROPS}
+      className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
+      title={
+        <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
+          <div className={FLEX_CENTER_GAP2}>
+            <PieChart size={16} />
+            {t('模型数据分析')}
+          </div>
+          <Tabs
+            type="button"
+            activeKey={activeChartTab}
+            onChange={setActiveChartTab}
+          >
+            <TabPane tab={
+              <span>
+                <IconHistogram />
+                {t('消耗分布')}
+              </span>
+            } itemKey="1" />
+            <TabPane tab={
+              <span>
+                <IconPulse />
+                {t('消耗趋势')}
+              </span>
+            } itemKey="2" />
+            <TabPane tab={
+              <span>
+                <IconPieChart2Stroked />
+                {t('调用次数分布')}
+              </span>
+            } itemKey="3" />
+            <TabPane tab={
+              <span>
+                <IconHistogram />
+                {t('调用次数排行')}
+              </span>
+            } itemKey="4" />
+          </Tabs>
+        </div>
+      }
+      bodyStyle={{ padding: 0 }}
+    >
+      <div className="h-96 p-2">
+        {activeChartTab === '1' && (
+          <VChart
+            spec={spec_line}
+            option={CHART_CONFIG}
+          />
+        )}
+        {activeChartTab === '2' && (
+          <VChart
+            spec={spec_model_line}
+            option={CHART_CONFIG}
+          />
+        )}
+        {activeChartTab === '3' && (
+          <VChart
+            spec={spec_pie}
+            option={CHART_CONFIG}
+          />
+        )}
+        {activeChartTab === '4' && (
+          <VChart
+            spec={spec_rank_bar}
+            option={CHART_CONFIG}
+          />
+        )}
+      </div>
+    </Card>
+  );
+};
+
+export default ChartsPanel; 

+ 61 - 0
web/src/components/dashboard/DashboardHeader.jsx

@@ -0,0 +1,61 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
+
+const DashboardHeader = ({
+  getGreeting,
+  greetingVisible,
+  showSearchModal,
+  refresh,
+  loading,
+  t
+}) => {
+  const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
+
+  return (
+    <div className="flex items-center justify-between mb-4">
+      <h2
+        className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
+        style={{ opacity: greetingVisible ? 1 : 0 }}
+      >
+        {getGreeting}
+      </h2>
+      <div className="flex gap-3">
+        <Button
+          type='tertiary'
+          icon={<IconSearch />}
+          onClick={showSearchModal}
+          className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
+        />
+        <Button
+          type='tertiary'
+          icon={<IconRefresh />}
+          onClick={refresh}
+          loading={loading}
+          className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default DashboardHeader; 

+ 81 - 0
web/src/components/dashboard/FaqPanel.jsx

@@ -0,0 +1,81 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
+import { HelpCircle } from 'lucide-react';
+import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
+import { marked } from 'marked';
+import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
+import ScrollableContainer from '../common/ui/ScrollableContainer';
+
+const FaqPanel = ({
+  faqData,
+  CARD_PROPS,
+  FLEX_CENTER_GAP2,
+  ILLUSTRATION_SIZE,
+  t
+}) => {
+  return (
+    <Card
+      {...CARD_PROPS}
+      className="shadow-sm !rounded-2xl lg:col-span-1"
+      title={
+        <div className={FLEX_CENTER_GAP2}>
+          <HelpCircle size={16} />
+          {t('常见问答')}
+        </div>
+      }
+      bodyStyle={{ padding: 0 }}
+    >
+      <ScrollableContainer maxHeight="24rem">
+        {faqData.length > 0 ? (
+          <Collapse
+            accordion
+            expandIcon={<IconPlus />}
+            collapseIcon={<IconMinus />}
+          >
+            {faqData.map((item, index) => (
+              <Collapse.Panel
+                key={index}
+                header={item.question}
+                itemKey={index.toString()}
+              >
+                <div
+                  dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
+                />
+              </Collapse.Panel>
+            ))}
+          </Collapse>
+        ) : (
+          <div className="flex justify-center items-center py-8">
+            <Empty
+              image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
+              darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
+              title={t('暂无常见问答')}
+              description={t('请联系管理员在系统设置中配置常见问答')}
+            />
+          </div>
+        )}
+      </ScrollableContainer>
+    </Card>
+  );
+};
+
+export default FaqPanel; 

+ 93 - 0
web/src/components/dashboard/StatsCards.jsx

@@ -0,0 +1,93 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Avatar, Skeleton } from '@douyinfe/semi-ui';
+import { VChart } from '@visactor/react-vchart';
+
+const StatsCards = ({
+  groupedStatsData,
+  loading,
+  getTrendSpec,
+  CARD_PROPS,
+  CHART_CONFIG
+}) => {
+  return (
+    <div className="mb-4">
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+        {groupedStatsData.map((group, idx) => (
+          <Card
+            key={idx}
+            {...CARD_PROPS}
+            className={`${group.color} border-0 !rounded-2xl w-full`}
+            title={group.title}
+          >
+            <div className="space-y-4">
+              {group.items.map((item, itemIdx) => (
+                <div
+                  key={itemIdx}
+                  className="flex items-center justify-between cursor-pointer"
+                  onClick={item.onClick}
+                >
+                  <div className="flex items-center">
+                    <Avatar
+                      className="mr-3"
+                      size="small"
+                      color={item.avatarColor}
+                    >
+                      {item.icon}
+                    </Avatar>
+                    <div>
+                      <div className="text-xs text-gray-500">{item.title}</div>
+                      <div className="text-lg font-semibold">
+                        <Skeleton
+                          loading={loading}
+                          active
+                          placeholder={
+                            <Skeleton.Paragraph
+                              active
+                              rows={1}
+                              style={{ width: '65px', height: '24px', marginTop: '4px' }}
+                            />
+                          }
+                        >
+                          {item.value}
+                        </Skeleton>
+                      </div>
+                    </div>
+                  </div>
+                  {(loading || (item.trendData && item.trendData.length > 0)) && (
+                    <div className="w-24 h-10">
+                      <VChart
+                        spec={getTrendSpec(item.trendData, item.trendColor)}
+                        option={CHART_CONFIG}
+                      />
+                    </div>
+                  )}
+                </div>
+              ))}
+            </div>
+          </Card>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+export default StatsCards; 

+ 136 - 0
web/src/components/dashboard/UptimePanel.jsx

@@ -0,0 +1,136 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Button, Spin, Tabs, TabPane, Tag, Empty } from '@douyinfe/semi-ui';
+import { Gauge } from 'lucide-react';
+import { IconRefresh } from '@douyinfe/semi-icons';
+import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
+import ScrollableContainer from '../common/ui/ScrollableContainer';
+
+const UptimePanel = ({
+  uptimeData,
+  uptimeLoading,
+  activeUptimeTab,
+  setActiveUptimeTab,
+  loadUptimeData,
+  uptimeLegendData,
+  renderMonitorList,
+  CARD_PROPS,
+  ILLUSTRATION_SIZE,
+  t
+}) => {
+  return (
+    <Card
+      {...CARD_PROPS}
+      className="shadow-sm !rounded-2xl lg:col-span-1"
+      title={
+        <div className="flex items-center justify-between w-full gap-2">
+          <div className="flex items-center gap-2">
+            <Gauge size={16} />
+            {t('服务可用性')}
+          </div>
+          <Button
+            icon={<IconRefresh />}
+            onClick={loadUptimeData}
+            loading={uptimeLoading}
+            size="small"
+            theme="borderless"
+            type='tertiary'
+            className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
+          />
+        </div>
+      }
+      bodyStyle={{ padding: 0 }}
+    >
+      {/* 内容区域 */}
+      <div className="relative">
+        <Spin spinning={uptimeLoading}>
+          {uptimeData.length > 0 ? (
+            uptimeData.length === 1 ? (
+              <ScrollableContainer maxHeight="24rem">
+                {renderMonitorList(uptimeData[0].monitors)}
+              </ScrollableContainer>
+            ) : (
+              <Tabs
+                type="card"
+                collapsible
+                activeKey={activeUptimeTab}
+                onChange={setActiveUptimeTab}
+                size="small"
+              >
+                {uptimeData.map((group, groupIdx) => (
+                  <TabPane
+                    tab={
+                      <span className="flex items-center gap-2">
+                        <Gauge size={14} />
+                        {group.categoryName}
+                        <Tag
+                          color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
+                          size='small'
+                          shape='circle'
+                        >
+                          {group.monitors ? group.monitors.length : 0}
+                        </Tag>
+                      </span>
+                    }
+                    itemKey={group.categoryName}
+                    key={groupIdx}
+                  >
+                    <ScrollableContainer maxHeight="21.5rem">
+                      {renderMonitorList(group.monitors)}
+                    </ScrollableContainer>
+                  </TabPane>
+                ))}
+              </Tabs>
+            )
+          ) : (
+            <div className="flex justify-center items-center py-8">
+              <Empty
+                image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
+                darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
+                title={t('暂无监控数据')}
+                description={t('请联系管理员在系统设置中配置Uptime')}
+              />
+            </div>
+          )}
+        </Spin>
+      </div>
+
+      {/* 图例 */}
+      {uptimeData.length > 0 && (
+        <div className="p-3 bg-gray-50 rounded-b-2xl">
+          <div className="flex flex-wrap gap-3 text-xs justify-center">
+            {uptimeLegendData.map((legend, index) => (
+              <div key={index} className="flex items-center gap-1">
+                <div
+                  className="w-2 h-2 rounded-full"
+                  style={{ backgroundColor: legend.color }}
+                />
+                <span className="text-gray-600">{legend.label}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </Card>
+  );
+};
+
+export default UptimePanel; 

+ 247 - 0
web/src/components/dashboard/index.jsx

@@ -0,0 +1,247 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useContext, useEffect } from 'react';
+import { getRelativeTime } from '../../helpers';
+import { UserContext } from '../../context/User/index.js';
+import { StatusContext } from '../../context/Status/index.js';
+
+import DashboardHeader from './DashboardHeader';
+import StatsCards from './StatsCards';
+import ChartsPanel from './ChartsPanel';
+import ApiInfoPanel from './ApiInfoPanel';
+import AnnouncementsPanel from './AnnouncementsPanel';
+import FaqPanel from './FaqPanel';
+import UptimePanel from './UptimePanel';
+import SearchModal from './modals/SearchModal';
+
+import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
+import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
+import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
+
+import {
+  CHART_CONFIG,
+  CARD_PROPS,
+  FLEX_CENTER_GAP2,
+  ILLUSTRATION_SIZE,
+  ANNOUNCEMENT_LEGEND_DATA,
+  UPTIME_STATUS_MAP
+} from '../../constants/dashboard.constants';
+import {
+  getTrendSpec,
+  handleCopyUrl,
+  handleSpeedTest,
+  getUptimeStatusColor,
+  getUptimeStatusText,
+  renderMonitorList
+} from '../../helpers/dashboard';
+
+const Dashboard = () => {
+  // ========== Context ==========
+  const [userState, userDispatch] = useContext(UserContext);
+  const [statusState, statusDispatch] = useContext(StatusContext);
+
+  // ========== 主要数据管理 ==========
+  const dashboardData = useDashboardData(userState, userDispatch, statusState);
+
+  // ========== 图表管理 ==========
+  const dashboardCharts = useDashboardCharts(
+    dashboardData.dataExportDefaultTime,
+    dashboardData.setTrendData,
+    dashboardData.setConsumeQuota,
+    dashboardData.setTimes,
+    dashboardData.setConsumeTokens,
+    dashboardData.setPieData,
+    dashboardData.setLineData,
+    dashboardData.setModelColors,
+    dashboardData.t
+  );
+
+  // ========== 统计数据 ==========
+  const { groupedStatsData } = useDashboardStats(
+    userState,
+    dashboardData.consumeQuota,
+    dashboardData.consumeTokens,
+    dashboardData.times,
+    dashboardData.trendData,
+    dashboardData.performanceMetrics,
+    dashboardData.navigate,
+    dashboardData.t
+  );
+
+  // ========== 数据处理 ==========
+  const initChart = async () => {
+    await dashboardData.loadQuotaData().then(data => {
+      if (data && data.length > 0) {
+        dashboardCharts.updateChartData(data);
+      }
+    });
+    await dashboardData.loadUptimeData();
+  };
+
+  const handleRefresh = async () => {
+    const data = await dashboardData.refresh();
+    if (data && data.length > 0) {
+      dashboardCharts.updateChartData(data);
+    }
+  };
+
+  const handleSearchConfirm = async () => {
+    await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
+  };
+
+  // ========== 数据准备 ==========
+  const apiInfoData = statusState?.status?.api_info || [];
+  const announcementData = (statusState?.status?.announcements || []).map(item => ({
+    ...item,
+    time: getRelativeTime(item.publishDate)
+  }));
+  const faqData = statusState?.status?.faq || [];
+
+  const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(([status, info]) => ({
+    status: Number(status),
+    color: info.color,
+    label: dashboardData.t(info.label)
+  }));
+
+  // ========== Effects ==========
+  useEffect(() => {
+    initChart();
+  }, []);
+
+  return (
+    <div className="h-full">
+      <DashboardHeader
+        getGreeting={dashboardData.getGreeting}
+        greetingVisible={dashboardData.greetingVisible}
+        showSearchModal={dashboardData.showSearchModal}
+        refresh={handleRefresh}
+        loading={dashboardData.loading}
+        t={dashboardData.t}
+      />
+
+      <SearchModal
+        searchModalVisible={dashboardData.searchModalVisible}
+        handleSearchConfirm={handleSearchConfirm}
+        handleCloseModal={dashboardData.handleCloseModal}
+        isMobile={dashboardData.isMobile}
+        isAdminUser={dashboardData.isAdminUser}
+        inputs={dashboardData.inputs}
+        dataExportDefaultTime={dashboardData.dataExportDefaultTime}
+        timeOptions={dashboardData.timeOptions}
+        handleInputChange={dashboardData.handleInputChange}
+        t={dashboardData.t}
+      />
+
+      <StatsCards
+        groupedStatsData={groupedStatsData}
+        loading={dashboardData.loading}
+        getTrendSpec={getTrendSpec}
+        CARD_PROPS={CARD_PROPS}
+        CHART_CONFIG={CHART_CONFIG}
+      />
+
+      {/* API信息和图表面板 */}
+      <div className="mb-4">
+        <div className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
+          <ChartsPanel
+            activeChartTab={dashboardData.activeChartTab}
+            setActiveChartTab={dashboardData.setActiveChartTab}
+            spec_line={dashboardCharts.spec_line}
+            spec_model_line={dashboardCharts.spec_model_line}
+            spec_pie={dashboardCharts.spec_pie}
+            spec_rank_bar={dashboardCharts.spec_rank_bar}
+            CARD_PROPS={CARD_PROPS}
+            CHART_CONFIG={CHART_CONFIG}
+            FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+            hasApiInfoPanel={dashboardData.hasApiInfoPanel}
+            t={dashboardData.t}
+          />
+
+          {dashboardData.hasApiInfoPanel && (
+            <ApiInfoPanel
+              apiInfoData={apiInfoData}
+              handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
+              handleSpeedTest={handleSpeedTest}
+              CARD_PROPS={CARD_PROPS}
+              FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+              ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
+              t={dashboardData.t}
+            />
+          )}
+        </div>
+      </div>
+
+      {/* 系统公告和常见问答卡片 */}
+      {dashboardData.hasInfoPanels && (
+        <div className="mb-4">
+          <div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
+            {/* 公告卡片 */}
+            {dashboardData.announcementsEnabled && (
+              <AnnouncementsPanel
+                announcementData={announcementData}
+                announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(item => ({
+                  ...item,
+                  label: dashboardData.t(item.label)
+                }))}
+                CARD_PROPS={CARD_PROPS}
+                ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
+                t={dashboardData.t}
+              />
+            )}
+
+            {/* 常见问答卡片 */}
+            {dashboardData.faqEnabled && (
+              <FaqPanel
+                faqData={faqData}
+                CARD_PROPS={CARD_PROPS}
+                FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+                ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
+                t={dashboardData.t}
+              />
+            )}
+
+            {/* 服务可用性卡片 */}
+            {dashboardData.uptimeEnabled && (
+              <UptimePanel
+                uptimeData={dashboardData.uptimeData}
+                uptimeLoading={dashboardData.uptimeLoading}
+                activeUptimeTab={dashboardData.activeUptimeTab}
+                setActiveUptimeTab={dashboardData.setActiveUptimeTab}
+                loadUptimeData={dashboardData.loadUptimeData}
+                uptimeLegendData={uptimeLegendData}
+                renderMonitorList={(monitors) => renderMonitorList(
+                  monitors,
+                  (status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
+                  (status) => getUptimeStatusText(status, UPTIME_STATUS_MAP, dashboardData.t),
+                  dashboardData.t
+                )}
+                CARD_PROPS={CARD_PROPS}
+                ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
+                t={dashboardData.t}
+              />
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default Dashboard; 

+ 101 - 0
web/src/components/dashboard/modals/SearchModal.jsx

@@ -0,0 +1,101 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useRef } from 'react';
+import { Modal, Form } from '@douyinfe/semi-ui';
+
+const SearchModal = ({
+  searchModalVisible,
+  handleSearchConfirm,
+  handleCloseModal,
+  isMobile,
+  isAdminUser,
+  inputs,
+  dataExportDefaultTime,
+  timeOptions,
+  handleInputChange,
+  t
+}) => {
+  const formRef = useRef();
+
+  const FORM_FIELD_PROPS = {
+    className: "w-full mb-2 !rounded-lg",
+  };
+
+  const createFormField = (Component, props) => (
+    <Component {...FORM_FIELD_PROPS} {...props} />
+  );
+
+  const { start_timestamp, end_timestamp, username } = inputs;
+
+  return (
+    <Modal
+      title={t('搜索条件')}
+      visible={searchModalVisible}
+      onOk={handleSearchConfirm}
+      onCancel={handleCloseModal}
+      closeOnEsc={true}
+      size={isMobile ? 'full-width' : 'small'}
+      centered
+    >
+      <Form ref={formRef} layout='vertical' className="w-full">
+        {createFormField(Form.DatePicker, {
+          field: 'start_timestamp',
+          label: t('起始时间'),
+          initValue: start_timestamp,
+          value: start_timestamp,
+          type: 'dateTime',
+          name: 'start_timestamp',
+          onChange: (value) => handleInputChange(value, 'start_timestamp')
+        })}
+
+        {createFormField(Form.DatePicker, {
+          field: 'end_timestamp',
+          label: t('结束时间'),
+          initValue: end_timestamp,
+          value: end_timestamp,
+          type: 'dateTime',
+          name: 'end_timestamp',
+          onChange: (value) => handleInputChange(value, 'end_timestamp')
+        })}
+
+        {createFormField(Form.Select, {
+          field: 'data_export_default_time',
+          label: t('时间粒度'),
+          initValue: dataExportDefaultTime,
+          placeholder: t('时间粒度'),
+          name: 'data_export_default_time',
+          optionList: timeOptions,
+          onChange: (value) => handleInputChange(value, 'data_export_default_time')
+        })}
+
+        {isAdminUser && createFormField(Form.Input, {
+          field: 'username',
+          label: t('用户名称'),
+          value: username,
+          placeholder: t('可选值'),
+          name: 'username',
+          onChange: (value) => handleInputChange(value, 'username')
+        })}
+      </Form>
+    </Modal>
+  );
+};
+
+export default SearchModal; 

+ 19 - 0
web/src/components/layout/Footer.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState, useMemo, useContext } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Typography } from '@douyinfe/semi-ui';

+ 42 - 13
web/src/components/layout/HeaderBar.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect, useState, useRef } from 'react';
 import { Link, useNavigate, useLocation } from 'react-router-dom';
 import { UserContext } from '../../context/User/index.js';
@@ -31,8 +50,8 @@ import {
   Badge,
 } from '@douyinfe/semi-ui';
 import { StatusContext } from '../../context/Status/index.js';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
-import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
+import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
 
 const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const { t, i18n } = useTranslation();
@@ -41,6 +60,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const isMobile = useIsMobile();
   const [collapsed, toggleCollapsed] = useSidebarCollapsed();
   const [isLoading, setIsLoading] = useState(true);
+  const [logoLoaded, setLogoLoaded] = useState(false);
   let navigate = useNavigate();
   const [currentLang, setCurrentLang] = useState(i18n.language);
   const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -207,6 +227,14 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
     }
   }, [statusState?.status]);
 
+  useEffect(() => {
+    setLogoLoaded(false);
+    if (!logo) return;
+    const img = new Image();
+    img.src = logo;
+    img.onload = () => setLogoLoaded(true);
+  }, [logo]);
+
   const handleLanguageChange = (lang) => {
     i18n.changeLanguage(lang);
     setMobileMenuOpen(false);
@@ -336,7 +364,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
               >
                 <div className="flex items-center gap-2">
                   <IconKey size="small" className="text-gray-500 dark:text-gray-400" />
-                  <span>{t('API令牌')}</span>
+                  <span>{t('令牌管理')}</span>
                 </div>
               </Dropdown.Item>
               <Dropdown.Item
@@ -477,19 +505,20 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
               />
             </div>
             <Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
-              <Skeleton
-                loading={isLoading}
-                active
-                placeholder={
+              <div className="relative w-8 h-8 md:w-8 md:h-8">
+                {(isLoading || !logoLoaded) && (
                   <Skeleton.Image
                     active
-                    className="h-7 md:h-8 !rounded-full"
-                    style={{ width: 32, height: 32 }}
+                    className="absolute inset-0 !rounded-full"
+                    style={{ width: '100%', height: '100%' }}
                   />
-                }
-              >
-                <img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
-              </Skeleton>
+                )}
+                <img
+                  src={logo}
+                  alt="logo"
+                  className={`absolute inset-0 w-full h-full transition-opacity duration-200 group-hover:scale-105 rounded-full ${(!isLoading && logoLoaded) ? 'opacity-100' : 'opacity-0'}`}
+                />
+              </div>
               <div className="hidden md:flex items-center gap-2">
                 <div className="flex items-center gap-2">
                   <Skeleton

+ 19 - 0
web/src/components/layout/NoticeModal.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState, useContext, useMemo } from 'react';
 import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';

+ 22 - 3
web/src/components/layout/PageLayout.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import HeaderBar from './HeaderBar.js';
 import { Layout } from '@douyinfe/semi-ui';
 import SiderBar from './SiderBar.js';
@@ -5,8 +24,8 @@ import App from '../../App.js';
 import FooterBar from './Footer.js';
 import { ToastContainer } from 'react-toastify';
 import React, { useContext, useEffect, useState } from 'react';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
-import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
+import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
 import { useTranslation } from 'react-i18next';
 import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
 import { UserContext } from '../../context/User/index.js';
@@ -23,7 +42,7 @@ const PageLayout = () => {
   const { i18n } = useTranslation();
   const location = useLocation();
 
-  const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat');
+  const shouldHideFooter = location.pathname.startsWith('/console');
 
   const shouldInnerPadding = location.pathname.includes('/console') &&
     !location.pathname.startsWith('/console/chat') &&

+ 19 - 0
web/src/components/layout/SetupCheck.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect } from 'react';
 import { Navigate, useLocation } from 'react-router-dom';
 import { StatusContext } from '../../context/Status';

+ 24 - 5
web/src/components/layout/SiderBar.js

@@ -1,9 +1,28 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useMemo, useState } from 'react';
 import { Link, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
 import { ChevronLeft } from 'lucide-react';
-import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
+import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
 import {
   isAdmin,
   isRoot,
@@ -56,7 +75,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
             : 'tableHiddle',
       },
       {
-        text: t('API令牌'),
+        text: t('令牌管理'),
         itemKey: 'token',
         to: '/token',
       },
@@ -109,13 +128,13 @@ const SiderBar = ({ onNavigate = () => { } }) => {
   const adminItems = useMemo(
     () => [
       {
-        text: t('渠道'),
+        text: t('渠道管理'),
         itemKey: 'channel',
         to: '/channel',
         className: isAdmin() ? '' : 'tableHiddle',
       },
       {
-        text: t('兑换码'),
+        text: t('兑换码管理'),
         itemKey: 'redemption',
         to: '/redemption',
         className: isAdmin() ? '' : 'tableHiddle',
@@ -421,7 +440,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
             />
           }
           onClick={toggleCollapsed}
-          iconOnly={collapsed}
+          icononly={collapsed}
           style={collapsed ? { padding: '4px', width: '100%' } : { padding: '4px 12px', width: '100%' }}
         >
           {!collapsed ? t('收起侧边栏') : null}

+ 19 - 0
web/src/components/playground/ChatArea.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import {
   Card,

+ 19 - 0
web/src/components/playground/CodeViewer.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useState, useMemo, useCallback } from 'react';
 import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
 import { Copy, ChevronDown, ChevronUp } from 'lucide-react';

+ 19 - 0
web/src/components/playground/ConfigManager.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useRef } from 'react';
 import {
   Button,

+ 19 - 0
web/src/components/playground/CustomInputRender.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 
 const CustomInputRender = (props) => {

+ 19 - 0
web/src/components/playground/CustomRequestEditor.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useState, useEffect } from 'react';
 import {
   TextArea,

+ 19 - 0
web/src/components/playground/DebugPanel.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useState, useEffect } from 'react';
 import {
   Card,

+ 20 - 1
web/src/components/playground/FloatingButtons.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import { Button } from '@douyinfe/semi-ui';
 import {
@@ -61,7 +80,7 @@ const FloatingButtons = ({
               ? 'linear-gradient(to right, #e11d48, #be123c)'
               : 'linear-gradient(to right, #4f46e5, #6366f1)',
           }}
-          className="lg:hidden !rounded-full !p-0"
+          className="lg:hidden"
         />
       )}
     </>

+ 19 - 0
web/src/components/playground/ImageUrlInput.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import {
   Input,

+ 19 - 0
web/src/components/playground/MessageActions.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import {
   Button,

+ 19 - 0
web/src/components/playground/MessageContent.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useRef, useEffect } from 'react';
 import {
   Typography,

+ 19 - 0
web/src/components/playground/OptimizedComponents.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import MessageContent from './MessageContent';
 import MessageActions from './MessageActions';

+ 19 - 0
web/src/components/playground/ParameterControl.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import {
   Input,

+ 22 - 3
web/src/components/playground/SettingsPanel.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React from 'react';
 import {
   Card,
@@ -14,7 +33,7 @@ import {
   Settings,
 } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
-import { renderGroupOption } from '../../helpers';
+import { renderGroupOption, modelSelectFilter } from '../../helpers';
 import ParameterControl from './ParameterControl';
 import ImageUrlInput from './ImageUrlInput';
 import ConfigManager from './ConfigManager';
@@ -154,8 +173,8 @@ const SettingsPanel = ({
             name='model'
             required
             selection
-            searchPosition='dropdown'
-            filter
+            filter={modelSelectFilter}
+            autoClearSearchValue={false}
             onChange={(value) => onInputChange('model', value)}
             value={inputs.model}
             autoComplete='new-password'

+ 19 - 0
web/src/components/playground/ThinkingContent.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useRef } from 'react';
 import { Typography } from '@douyinfe/semi-ui';
 import MarkdownRenderer from '../common/markdown/MarkdownRenderer';

+ 19 - 0
web/src/components/playground/configStorage.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
 
 const MESSAGES_STORAGE_KEY = 'playground_messages';

+ 19 - 0
web/src/components/playground/index.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 export { default as SettingsPanel } from './SettingsPanel';
 export { default as ChatArea } from './ChatArea';
 export { default as DebugPanel } from './DebugPanel';

+ 20 - 6
web/src/components/settings/ChannelSelectorModal.js

@@ -1,5 +1,24 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
+import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import {
   Modal,
   Table,
@@ -212,11 +231,6 @@ const ChannelSelectorModal = forwardRef(({
             showSizeChanger: true,
             showQuickJumper: true,
             pageSizeOptions: ['10', '20', '50', '100'],
-            formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-              start: page.currentStart,
-              end: page.currentEnd,
-              total: total,
-            }),
             onChange: (page, size) => {
               setCurrentPage(page);
               setPageSize(size);

+ 19 - 0
web/src/components/settings/ChatsSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { Card, Spin } from '@douyinfe/semi-ui';
 import SettingsChats from '../../pages/Setting/Chat/SettingsChats.js';

+ 19 - 0
web/src/components/settings/DashboardSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState, useMemo } from 'react';
 import { Card, Spin, Button, Modal } from '@douyinfe/semi-ui';
 import { API, showError, showSuccess, toBoolean } from '../../helpers';

+ 19 - 0
web/src/components/settings/DrawingSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { Card, Spin } from '@douyinfe/semi-ui';
 import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing.js';

+ 19 - 0
web/src/components/settings/ModelSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 

+ 19 - 0
web/src/components/settings/OperationSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { Card, Spin } from '@douyinfe/semi-ui';
 import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';

+ 19 - 0
web/src/components/settings/OtherSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect, useRef, useState } from 'react';
 import {
   Banner,

+ 19 - 0
web/src/components/settings/PaymentSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { Card, Spin } from '@douyinfe/semi-ui';
 import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';

+ 20 - 1
web/src/components/settings/PersonalSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import {
@@ -379,7 +398,7 @@ const PersonalSetting = () => {
   };
 
   return (
-    <div className="bg-gray-50 mt-[64px]">
+    <div className="bg-gray-50 mt-[60px]">
       <div className="flex justify-center">
         <div className="w-full">
           {/* 主卡片容器 */}

+ 19 - 0
web/src/components/settings/RateLimitSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { Card, Spin } from '@douyinfe/semi-ui';
 

+ 19 - 0
web/src/components/settings/RatioSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';

+ 19 - 0
web/src/components/settings/SystemSetting.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState, useRef } from 'react';
 import {
   Button,

+ 0 - 2212
web/src/components/table/ChannelsTable.js

@@ -1,2212 +0,0 @@
-import React, { useEffect, useState, useMemo, useRef } from 'react';
-import {
-  API,
-  showError,
-  showInfo,
-  showSuccess,
-  timestamp2string,
-  renderGroup,
-  renderQuota,
-  getChannelIcon,
-  renderQuotaWithAmount
-} from '../../helpers/index.js';
-import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
-import {
-  Button,
-  Divider,
-  Dropdown,
-  Empty,
-  Input,
-  InputNumber,
-  Modal,
-  Space,
-  SplitButtonGroup,
-  Switch,
-  Table,
-  Tag,
-  Tooltip,
-  Typography,
-  Checkbox,
-  Card,
-  Form,
-  Tabs,
-  TabPane,
-  Select
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import EditChannel from '../../pages/Channel/EditChannel.js';
-import {
-  IconTreeTriangleDown,
-  IconSearch,
-  IconMore,
-  IconDescend2
-} from '@douyinfe/semi-icons';
-import { loadChannelModels, copy } from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
-import EditTagModal from '../../pages/Channel/EditTagModal.js';
-import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-import { FaRandom } from 'react-icons/fa';
-
-const ChannelsTable = () => {
-  const { t } = useTranslation();
-  const isMobile = useIsMobile();
-
-  let type2label = undefined;
-
-  const renderType = (type, channelInfo = undefined) => {
-    if (!type2label) {
-      type2label = new Map();
-      for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
-        type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
-      }
-      type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
-    }
-
-    let icon = getChannelIcon(type);
-
-    if (channelInfo?.is_multi_key) {
-      icon = (
-        channelInfo?.multi_key_mode === 'random' ? (
-          <div className="flex items-center gap-1">
-            <FaRandom className="text-blue-500" />
-            {icon}
-          </div>
-        ) : (
-          <div className="flex items-center gap-1">
-            <IconDescend2 className="text-blue-500" />
-            {icon}
-          </div>
-        )
-      )
-    }
-
-    return (
-      <Tag
-        color={type2label[type]?.color}
-        shape='circle'
-        prefixIcon={icon}
-      >
-        {type2label[type]?.label}
-      </Tag>
-    );
-  };
-
-  const renderTagType = () => {
-    return (
-      <Tag
-        color='light-blue'
-        shape='circle'
-        type='light'
-      >
-        {t('标签聚合')}
-      </Tag>
-    );
-  };
-
-  const renderStatus = (status, channelInfo = undefined) => {
-    if (channelInfo) {
-      if (channelInfo.is_multi_key) {
-        let keySize = channelInfo.multi_key_size;
-        let enabledKeySize = keySize;
-        if (channelInfo.multi_key_status_list) {
-          // multi_key_status_list is a map, key is key, value is status
-          // get multi_key_status_list length
-          enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
-        }
-        return renderMultiKeyStatus(status, keySize, enabledKeySize);
-      }
-    }
-    switch (status) {
-      case 1:
-        return (
-          <Tag color='green' shape='circle'>
-            {t('已启用')}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='red' shape='circle'>
-            {t('已禁用')}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='yellow' shape='circle'>
-            {t('自动禁用')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='grey' shape='circle'>
-            {t('未知状态')}
-          </Tag>
-        );
-    }
-  };
-
-  const renderMultiKeyStatus = (status, keySize, enabledKeySize) => {
-    switch (status) {
-      case 1:
-        return (
-          <Tag color='green' shape='circle'>
-            {t('已启用')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='red' shape='circle'>
-            {t('已禁用')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='yellow' shape='circle'>
-            {t('自动禁用')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='grey' shape='circle'>
-            {t('未知状态')} {enabledKeySize}/{keySize}
-          </Tag>
-        );
-    }
-  }
-
-
-  const renderResponseTime = (responseTime) => {
-    let time = responseTime / 1000;
-    time = time.toFixed(2) + t(' 秒');
-    if (responseTime === 0) {
-      return (
-        <Tag color='grey' shape='circle'>
-          {t('未测试')}
-        </Tag>
-      );
-    } else if (responseTime <= 1000) {
-      return (
-        <Tag color='green' shape='circle'>
-          {time}
-        </Tag>
-      );
-    } else if (responseTime <= 3000) {
-      return (
-        <Tag color='lime' shape='circle'>
-          {time}
-        </Tag>
-      );
-    } else if (responseTime <= 5000) {
-      return (
-        <Tag color='yellow' shape='circle'>
-          {time}
-        </Tag>
-      );
-    } else {
-      return (
-        <Tag color='red' shape='circle'>
-          {time}
-        </Tag>
-      );
-    }
-  };
-
-  // Define column keys for selection
-  const COLUMN_KEYS = {
-    ID: 'id',
-    NAME: 'name',
-    GROUP: 'group',
-    TYPE: 'type',
-    STATUS: 'status',
-    RESPONSE_TIME: 'response_time',
-    BALANCE: 'balance',
-    PRIORITY: 'priority',
-    WEIGHT: 'weight',
-    OPERATE: 'operate',
-  };
-
-  // State for column visibility
-  const [visibleColumns, setVisibleColumns] = useState({});
-  const [showColumnSelector, setShowColumnSelector] = useState(false);
-
-  // 状态筛选 all / enabled / disabled
-  const [statusFilter, setStatusFilter] = useState(
-    localStorage.getItem('channel-status-filter') || 'all'
-  );
-
-  // Load saved column preferences from localStorage
-  useEffect(() => {
-    const savedColumns = localStorage.getItem('channels-table-columns');
-    if (savedColumns) {
-      try {
-        const parsed = JSON.parse(savedColumns);
-        // Make sure all columns are accounted for
-        const defaults = getDefaultColumnVisibility();
-        const merged = { ...defaults, ...parsed };
-        setVisibleColumns(merged);
-      } catch (e) {
-        console.error('Failed to parse saved column preferences', e);
-        initDefaultColumns();
-      }
-    } else {
-      initDefaultColumns();
-    }
-  }, []);
-
-  // Update table when column visibility changes
-  useEffect(() => {
-    if (Object.keys(visibleColumns).length > 0) {
-      // Save to localStorage
-      localStorage.setItem(
-        'channels-table-columns',
-        JSON.stringify(visibleColumns),
-      );
-    }
-  }, [visibleColumns]);
-
-  // Get default column visibility
-  const getDefaultColumnVisibility = () => {
-    return {
-      [COLUMN_KEYS.ID]: true,
-      [COLUMN_KEYS.NAME]: true,
-      [COLUMN_KEYS.GROUP]: true,
-      [COLUMN_KEYS.TYPE]: true,
-      [COLUMN_KEYS.STATUS]: true,
-      [COLUMN_KEYS.RESPONSE_TIME]: true,
-      [COLUMN_KEYS.BALANCE]: true,
-      [COLUMN_KEYS.PRIORITY]: true,
-      [COLUMN_KEYS.WEIGHT]: true,
-      [COLUMN_KEYS.OPERATE]: true,
-    };
-  };
-
-  // Initialize default column visibility
-  const initDefaultColumns = () => {
-    const defaults = getDefaultColumnVisibility();
-    setVisibleColumns(defaults);
-  };
-
-  // Handle column visibility change
-  const handleColumnVisibilityChange = (columnKey, checked) => {
-    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
-    setVisibleColumns(updatedColumns);
-  };
-
-  // Handle "Select All" checkbox
-  const handleSelectAll = (checked) => {
-    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
-    const updatedColumns = {};
-
-    allKeys.forEach((key) => {
-      updatedColumns[key] = checked;
-    });
-
-    setVisibleColumns(updatedColumns);
-  };
-
-  // Define all columns with keys
-  const allColumns = [
-    {
-      key: COLUMN_KEYS.ID,
-      title: t('ID'),
-      dataIndex: 'id',
-    },
-    {
-      key: COLUMN_KEYS.NAME,
-      title: t('名称'),
-      dataIndex: 'name',
-    },
-    {
-      key: COLUMN_KEYS.GROUP,
-      title: t('分组'),
-      dataIndex: 'group',
-      render: (text, record, index) => (
-        <div>
-          <Space spacing={2}>
-            {text
-              ?.split(',')
-              .sort((a, b) => {
-                if (a === 'default') return -1;
-                if (b === 'default') return 1;
-                return a.localeCompare(b);
-              })
-              .map((item, index) => renderGroup(item))}
-          </Space>
-        </div>
-      ),
-    },
-    {
-      key: COLUMN_KEYS.TYPE,
-      title: t('类型'),
-      dataIndex: 'type',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          if (record.channel_info) {
-            if (record.channel_info.is_multi_key) {
-              return <>{renderType(text, record.channel_info)}</>;
-            }
-          }
-          return <>{renderType(text)}</>;
-        } else {
-          return <>{renderTagType()}</>;
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.STATUS,
-      title: t('状态'),
-      dataIndex: 'status',
-      render: (text, record, index) => {
-        if (text === 3) {
-          if (record.other_info === '') {
-            record.other_info = '{}';
-          }
-          let otherInfo = JSON.parse(record.other_info);
-          let reason = otherInfo['status_reason'];
-          let time = otherInfo['status_time'];
-          return (
-            <div>
-              <Tooltip
-                content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
-              >
-                {renderStatus(text, record.channel_info)}
-              </Tooltip>
-            </div>
-          );
-        } else {
-          return renderStatus(text, record.channel_info);
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.RESPONSE_TIME,
-      title: t('响应时间'),
-      dataIndex: 'response_time',
-      render: (text, record, index) => (
-        <div>{renderResponseTime(text)}</div>
-      ),
-    },
-    {
-      key: COLUMN_KEYS.BALANCE,
-      title: t('已用/剩余'),
-      dataIndex: 'expired_time',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          return (
-            <div>
-              <Space spacing={1}>
-                <Tooltip content={t('已用额度')}>
-                  <Tag color='white' type='ghost' shape='circle'>
-                    {renderQuota(record.used_quota)}
-                  </Tag>
-                </Tooltip>
-                <Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
-                  <Tag
-                    color='white'
-                    type='ghost'
-                    shape='circle'
-                    onClick={() => updateChannelBalance(record)}
-                  >
-                    {renderQuotaWithAmount(record.balance)}
-                  </Tag>
-                </Tooltip>
-              </Space>
-            </div>
-          );
-        } else {
-          return (
-            <Tooltip content={t('已用额度')}>
-              <Tag color='white' type='ghost' shape='circle'>
-                {renderQuota(record.used_quota)}
-              </Tag>
-            </Tooltip>
-          );
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.PRIORITY,
-      title: t('优先级'),
-      dataIndex: 'priority',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          return (
-            <div>
-              <InputNumber
-                style={{ width: 70 }}
-                name='priority'
-                onBlur={(e) => {
-                  manageChannel(record.id, 'priority', record, e.target.value);
-                }}
-                keepFocus={true}
-                innerButtons
-                defaultValue={record.priority}
-                min={-999}
-                size="small"
-              />
-            </div>
-          );
-        } else {
-          return (
-            <InputNumber
-              style={{ width: 70 }}
-              name='priority'
-              keepFocus={true}
-              onBlur={(e) => {
-                Modal.warning({
-                  title: t('修改子渠道优先级'),
-                  content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
-                  onOk: () => {
-                    if (e.target.value === '') {
-                      return;
-                    }
-                    submitTagEdit('priority', {
-                      tag: record.key,
-                      priority: e.target.value,
-                    });
-                  },
-                });
-              }}
-              innerButtons
-              defaultValue={record.priority}
-              min={-999}
-              size="small"
-            />
-          );
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.WEIGHT,
-      title: t('权重'),
-      dataIndex: 'weight',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          return (
-            <div>
-              <InputNumber
-                style={{ width: 70 }}
-                name='weight'
-                onBlur={(e) => {
-                  manageChannel(record.id, 'weight', record, e.target.value);
-                }}
-                keepFocus={true}
-                innerButtons
-                defaultValue={record.weight}
-                min={0}
-                size="small"
-              />
-            </div>
-          );
-        } else {
-          return (
-            <InputNumber
-              style={{ width: 70 }}
-              name='weight'
-              keepFocus={true}
-              onBlur={(e) => {
-                Modal.warning({
-                  title: t('修改子渠道权重'),
-                  content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
-                  onOk: () => {
-                    if (e.target.value === '') {
-                      return;
-                    }
-                    submitTagEdit('weight', {
-                      tag: record.key,
-                      weight: e.target.value,
-                    });
-                  },
-                });
-              }}
-              innerButtons
-              defaultValue={record.weight}
-              min={-999}
-              size="small"
-            />
-          );
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.OPERATE,
-      title: '',
-      dataIndex: 'operate',
-      fixed: 'right',
-      render: (text, record, index) => {
-        if (record.children === undefined) {
-          // 创建更多操作的下拉菜单项
-          const moreMenuItems = [
-            {
-              node: 'item',
-              name: t('删除'),
-              type: 'danger',
-              onClick: () => {
-                Modal.confirm({
-                  title: t('确定是否要删除此渠道?'),
-                  content: t('此修改将不可逆'),
-                  onOk: () => {
-                    (async () => {
-                      await manageChannel(record.id, 'delete', record);
-                      await refresh();
-                      setTimeout(() => {
-                        if (channels.length === 0 && activePage > 1) {
-                          refresh(activePage - 1);
-                        }
-                      }, 100);
-                    })();
-                  },
-                });
-              },
-            },
-            {
-              node: 'item',
-              name: t('复制'),
-              type: 'tertiary',
-              onClick: () => {
-                Modal.confirm({
-                  title: t('确定是否要复制此渠道?'),
-                  content: t('复制渠道的所有信息'),
-                  onOk: () => copySelectedChannel(record),
-                });
-              },
-            },
-          ];
-
-          return (
-            <Space wrap>
-              <SplitButtonGroup
-                className="overflow-hidden"
-                aria-label={t('测试单个渠道操作项目组')}
-              >
-                <Button
-                  size="small"
-                  type='tertiary'
-                  onClick={() => testChannel(record, '')}
-                >
-                  {t('测试')}
-                </Button>
-                <Button
-                  size="small"
-                  type='tertiary'
-                  icon={<IconTreeTriangleDown />}
-                  onClick={() => {
-                    setCurrentTestChannel(record);
-                    setShowModelTestModal(true);
-                  }}
-                />
-              </SplitButtonGroup>
-
-              {record.channel_info?.is_multi_key ? (
-                <SplitButtonGroup
-                  aria-label={t('多密钥渠道操作项目组')}
-                >
-                  {
-                    record.status === 1 ? (
-                      <Button
-                        type='danger'
-                        size="small"
-                        onClick={() => manageChannel(record.id, 'disable', record)}
-                      >
-                        {t('禁用')}
-                      </Button>
-                    ) : (
-                      <Button
-                        size="small"
-                        onClick={() => manageChannel(record.id, 'enable', record)}
-                      >
-                        {t('启用')}
-                      </Button>
-                    )
-                  }
-                  <Dropdown
-                    trigger='click'
-                    position='bottomRight'
-                    menu={[
-                      {
-                        node: 'item',
-                        name: t('启用全部密钥'),
-                        onClick: () => manageChannel(record.id, 'enable_all', record),
-                      }
-                    ]}
-                  >
-                    <Button
-                      type='tertiary'
-                      size="small"
-                      icon={<IconTreeTriangleDown />}
-                    />
-                  </Dropdown>
-                </SplitButtonGroup>
-              ) : (
-                record.status === 1 ? (
-                  <Button
-                    type='danger'
-                    size="small"
-                    onClick={() => manageChannel(record.id, 'disable', record)}
-                  >
-                    {t('禁用')}
-                  </Button>
-                ) : (
-                  <Button
-                    size="small"
-                    onClick={() => manageChannel(record.id, 'enable', record)}
-                  >
-                    {t('启用')}
-                  </Button>
-                )
-              )}
-
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => {
-                  setEditingChannel(record);
-                  setShowEdit(true);
-                }}
-              >
-                {t('编辑')}
-              </Button>
-
-              <Dropdown
-                trigger='click'
-                position='bottomRight'
-                menu={moreMenuItems}
-              >
-                <Button
-                  icon={<IconMore />}
-                  type='tertiary'
-                  size="small"
-                />
-              </Dropdown>
-            </Space>
-          );
-        } else {
-          // 标签操作按钮
-          return (
-            <Space wrap>
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => manageTag(record.key, 'enable')}
-              >
-                {t('启用全部')}
-              </Button>
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => manageTag(record.key, 'disable')}
-              >
-                {t('禁用全部')}
-              </Button>
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => {
-                  setShowEditTag(true);
-                  setEditingTag(record.key);
-                }}
-              >
-                {t('编辑')}
-              </Button>
-            </Space>
-          );
-        }
-      },
-    },
-  ];
-
-  const [channels, setChannels] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [idSort, setIdSort] = useState(false);
-  const [searching, setSearching] = useState(false);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [channelCount, setChannelCount] = useState(pageSize);
-  const [groupOptions, setGroupOptions] = useState([]);
-  const [showEdit, setShowEdit] = useState(false);
-  const [enableBatchDelete, setEnableBatchDelete] = useState(false);
-  const [editingChannel, setEditingChannel] = useState({
-    id: undefined,
-  });
-  const [showEditTag, setShowEditTag] = useState(false);
-  const [editingTag, setEditingTag] = useState('');
-  const [selectedChannels, setSelectedChannels] = useState([]);
-  const [enableTagMode, setEnableTagMode] = useState(false);
-  const [showBatchSetTag, setShowBatchSetTag] = useState(false);
-  const [batchSetTagValue, setBatchSetTagValue] = useState('');
-  const [showModelTestModal, setShowModelTestModal] = useState(false);
-  const [currentTestChannel, setCurrentTestChannel] = useState(null);
-  const [modelSearchKeyword, setModelSearchKeyword] = useState('');
-  const [modelTestResults, setModelTestResults] = useState({});
-  const [testingModels, setTestingModels] = useState(new Set());
-  const [selectedModelKeys, setSelectedModelKeys] = useState([]);
-  const [isBatchTesting, setIsBatchTesting] = useState(false);
-  const [testQueue, setTestQueue] = useState([]);
-  const [isProcessingQueue, setIsProcessingQueue] = useState(false);
-  const [modelTablePage, setModelTablePage] = useState(1);
-  const [activeTypeKey, setActiveTypeKey] = useState('all');
-  const [typeCounts, setTypeCounts] = useState({});
-  const requestCounter = useRef(0);
-  const [formApi, setFormApi] = useState(null);
-  const [compactMode, setCompactMode] = useTableCompactMode('channels');
-  const formInitValues = {
-    searchKeyword: '',
-    searchGroup: '',
-    searchModel: '',
-  };
-  const allSelectingRef = useRef(false);
-
-  // Filter columns based on visibility settings
-  const getVisibleColumns = () => {
-    return allColumns.filter((column) => visibleColumns[column.key]);
-  };
-
-  // Column selector modal
-  const renderColumnSelector = () => {
-    return (
-      <Modal
-        title={t('列设置')}
-        visible={showColumnSelector}
-        onCancel={() => setShowColumnSelector(false)}
-        footer={
-          <div className="flex justify-end">
-            <Button onClick={() => initDefaultColumns()}>
-              {t('重置')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('取消')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('确定')}
-            </Button>
-          </div>
-        }
-      >
-        <div style={{ marginBottom: 20 }}>
-          <Checkbox
-            checked={Object.values(visibleColumns).every((v) => v === true)}
-            indeterminate={
-              Object.values(visibleColumns).some((v) => v === true) &&
-              !Object.values(visibleColumns).every((v) => v === true)
-            }
-            onChange={(e) => handleSelectAll(e.target.checked)}
-          >
-            {t('全选')}
-          </Checkbox>
-        </div>
-        <div
-          className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
-          style={{ border: '1px solid var(--semi-color-border)' }}
-        >
-          {allColumns.map((column) => {
-            // Skip columns without title
-            if (!column.title) {
-              return null;
-            }
-
-            return (
-              <div
-                key={column.key}
-                className="w-1/2 mb-4 pr-2"
-              >
-                <Checkbox
-                  checked={!!visibleColumns[column.key]}
-                  onChange={(e) =>
-                    handleColumnVisibilityChange(column.key, e.target.checked)
-                  }
-                >
-                  {column.title}
-                </Checkbox>
-              </div>
-            );
-          })}
-        </div>
-      </Modal>
-    );
-  };
-
-  const removeRecord = (record) => {
-    let newDataSource = [...channels];
-    if (record.id != null) {
-      let idx = newDataSource.findIndex((data) => {
-        if (data.children !== undefined) {
-          for (let i = 0; i < data.children.length; i++) {
-            if (data.children[i].id === record.id) {
-              data.children.splice(i, 1);
-              return false;
-            }
-          }
-        } else {
-          return data.id === record.id;
-        }
-      });
-
-      if (idx > -1) {
-        newDataSource.splice(idx, 1);
-        setChannels(newDataSource);
-      }
-    }
-  };
-
-  const setChannelFormat = (channels, enableTagMode) => {
-    let channelDates = [];
-    let channelTags = {};
-    for (let i = 0; i < channels.length; i++) {
-      channels[i].key = '' + channels[i].id;
-      if (!enableTagMode) {
-        channelDates.push(channels[i]);
-      } else {
-        let tag = channels[i].tag ? channels[i].tag : '';
-        // find from channelTags
-        let tagIndex = channelTags[tag];
-        let tagChannelDates = undefined;
-        if (tagIndex === undefined) {
-          // not found, create a new tag
-          channelTags[tag] = 1;
-          tagChannelDates = {
-            key: tag,
-            id: tag,
-            tag: tag,
-            name: '标签:' + tag,
-            group: '',
-            used_quota: 0,
-            response_time: 0,
-            priority: -1,
-            weight: -1,
-          };
-          tagChannelDates.children = [];
-          channelDates.push(tagChannelDates);
-        } else {
-          // found, add to the tag
-          tagChannelDates = channelDates.find((item) => item.key === tag);
-        }
-        if (tagChannelDates.priority === -1) {
-          tagChannelDates.priority = channels[i].priority;
-        } else {
-          if (tagChannelDates.priority !== channels[i].priority) {
-            tagChannelDates.priority = '';
-          }
-        }
-        if (tagChannelDates.weight === -1) {
-          tagChannelDates.weight = channels[i].weight;
-        } else {
-          if (tagChannelDates.weight !== channels[i].weight) {
-            tagChannelDates.weight = '';
-          }
-        }
-
-        if (tagChannelDates.group === '') {
-          tagChannelDates.group = channels[i].group;
-        } else {
-          let channelGroupsStr = channels[i].group;
-          channelGroupsStr.split(',').forEach((item, index) => {
-            if (tagChannelDates.group.indexOf(item) === -1) {
-              // join
-              tagChannelDates.group += ',' + item;
-            }
-          });
-        }
-
-        tagChannelDates.children.push(channels[i]);
-        if (channels[i].status === 1) {
-          tagChannelDates.status = 1;
-        }
-        tagChannelDates.used_quota += channels[i].used_quota;
-        tagChannelDates.response_time += channels[i].response_time;
-        tagChannelDates.response_time = tagChannelDates.response_time / 2;
-      }
-    }
-    setChannels(channelDates);
-  };
-
-  const loadChannels = async (
-    page,
-    pageSize,
-    idSort,
-    enableTagMode,
-    typeKey = activeTypeKey,
-    statusF,
-  ) => {
-    if (statusF === undefined) statusF = statusFilter;
-
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
-      setLoading(true);
-      await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
-      setLoading(false);
-      return;
-    }
-
-    const reqId = ++requestCounter.current; // 记录当前请求序号
-    setLoading(true);
-    const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
-    const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
-    const res = await API.get(
-      `/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
-    );
-    if (res === undefined || reqId !== requestCounter.current) {
-      return;
-    }
-    const { success, message, data } = res.data;
-    if (success) {
-      const { items, total, type_counts } = data;
-      if (type_counts) {
-        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
-        setTypeCounts({ ...type_counts, all: sumAll });
-      }
-      setChannelFormat(items, enableTagMode);
-      setChannelCount(total);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const copySelectedChannel = async (record) => {
-    try {
-      const res = await API.post(`/api/channel/copy/${record.id}`);
-      if (res?.data?.success) {
-        showSuccess(t('渠道复制成功'));
-        await refresh();
-      } else {
-        showError(res?.data?.message || t('渠道复制失败'));
-      }
-    } catch (error) {
-      showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
-    }
-  };
-
-  const refresh = async (page = activePage) => {
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-      await loadChannels(page, pageSize, idSort, enableTagMode);
-    } else {
-      await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
-    }
-  };
-
-  useEffect(() => {
-    const localIdSort = localStorage.getItem('id-sort') === 'true';
-    const localPageSize =
-      parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
-    const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
-    const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
-    setIdSort(localIdSort);
-    setPageSize(localPageSize);
-    setEnableTagMode(localEnableTagMode);
-    setEnableBatchDelete(localEnableBatchDelete);
-    loadChannels(1, localPageSize, localIdSort, localEnableTagMode)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-    fetchGroups().then();
-    loadChannelModels().then();
-  }, []);
-
-  const manageChannel = async (id, action, record, value) => {
-    let data = { id };
-    let res;
-    switch (action) {
-      case 'delete':
-        res = await API.delete(`/api/channel/${id}/`);
-        break;
-      case 'enable':
-        data.status = 1;
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'disable':
-        data.status = 2;
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'priority':
-        if (value === '') {
-          return;
-        }
-        data.priority = parseInt(value);
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'weight':
-        if (value === '') {
-          return;
-        }
-        data.weight = parseInt(value);
-        if (data.weight < 0) {
-          data.weight = 0;
-        }
-        res = await API.put('/api/channel/', data);
-        break;
-      case 'enable_all':
-        data.channel_info = record.channel_info;
-        data.channel_info.multi_key_status_list = {};
-        res = await API.put('/api/channel/', data);
-        break;
-    }
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(t('操作成功完成!'));
-      let channel = res.data.data;
-      let newChannels = [...channels];
-      if (action === 'delete') {
-      } else {
-        record.status = channel.status;
-      }
-      setChannels(newChannels);
-    } else {
-      showError(message);
-    }
-  };
-
-  const manageTag = async (tag, action) => {
-    console.log(tag, action);
-    let res;
-    switch (action) {
-      case 'enable':
-        res = await API.post('/api/channel/tag/enabled', {
-          tag: tag,
-        });
-        break;
-      case 'disable':
-        res = await API.post('/api/channel/tag/disabled', {
-          tag: tag,
-        });
-        break;
-    }
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('操作成功完成!');
-      let newChannels = [...channels];
-      for (let i = 0; i < newChannels.length; i++) {
-        if (newChannels[i].tag === tag) {
-          let status = action === 'enable' ? 1 : 2;
-          newChannels[i]?.children?.forEach((channel) => {
-            channel.status = status;
-          });
-          newChannels[i].status = status;
-        }
-      }
-      setChannels(newChannels);
-    } else {
-      showError(message);
-    }
-  };
-
-  // 获取表单值的辅助函数
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-    return {
-      searchKeyword: formValues.searchKeyword || '',
-      searchGroup: formValues.searchGroup || '',
-      searchModel: formValues.searchModel || '',
-    };
-  };
-
-  const searchChannels = async (
-    enableTagMode,
-    typeKey = activeTypeKey,
-    statusF = statusFilter,
-    page = 1,
-    pageSz = pageSize,
-    sortFlag = idSort,
-  ) => {
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    setSearching(true);
-    try {
-      if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-        await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
-        return;
-      }
-
-      const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
-      const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
-      const res = await API.get(
-        `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
-      );
-      const { success, message, data } = res.data;
-      if (success) {
-        const { items = [], total = 0, type_counts = {} } = data;
-        const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
-        setTypeCounts({ ...type_counts, all: sumAll });
-        setChannelFormat(items, enableTagMode);
-        setChannelCount(total);
-        setActivePage(page);
-      } else {
-        showError(message);
-      }
-    } finally {
-      setSearching(false);
-    }
-  };
-
-  const updateChannelProperty = (channelId, updateFn) => {
-    // Create a new copy of channels array
-    const newChannels = [...channels];
-    let updated = false;
-
-    // Find and update the correct channel
-    newChannels.forEach((channel) => {
-      if (channel.children !== undefined) {
-        // If this is a tag group, search in its children
-        channel.children.forEach((child) => {
-          if (child.id === channelId) {
-            updateFn(child);
-            updated = true;
-          }
-        });
-      } else if (channel.id === channelId) {
-        // Direct channel match
-        updateFn(channel);
-        updated = true;
-      }
-    });
-
-    // Only update state if we actually modified a channel
-    if (updated) {
-      setChannels(newChannels);
-    }
-  };
-
-  const processTestQueue = async () => {
-    if (!isProcessingQueue || testQueue.length === 0) return;
-
-    const { channel, model, indexInFiltered } = testQueue[0];
-
-    // 自动翻页到正在测试的模型所在页
-    if (currentTestChannel && currentTestChannel.id === channel.id) {
-      let pageNo;
-      if (indexInFiltered !== undefined) {
-        pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
-      } else {
-        const filteredModelsList = currentTestChannel.models
-          .split(',')
-          .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
-        const modelIdx = filteredModelsList.indexOf(model);
-        pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
-      }
-      setModelTablePage(pageNo);
-    }
-
-    try {
-      setTestingModels(prev => new Set([...prev, model]));
-      const res = await API.get(`/api/channel/test/${channel.id}?model=${model}`);
-      const { success, message, time } = res.data;
-
-      setModelTestResults(prev => ({
-        ...prev,
-        [`${channel.id}-${model}`]: { success, time }
-      }));
-
-      if (success) {
-        updateChannelProperty(channel.id, (ch) => {
-          ch.response_time = time * 1000;
-          ch.test_time = Date.now() / 1000;
-        });
-        if (!model) {
-          showInfo(
-            t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
-              .replace('${name}', channel.name)
-              .replace('${time.toFixed(2)}', time.toFixed(2)),
-          );
-        }
-      } else {
-        showError(message);
-      }
-    } catch (error) {
-      showError(error.message);
-    } finally {
-      setTestingModels(prev => {
-        const newSet = new Set(prev);
-        newSet.delete(model);
-        return newSet;
-      });
-    }
-
-    // 移除已处理的测试
-    setTestQueue(prev => prev.slice(1));
-  };
-
-  // 监听队列变化
-  useEffect(() => {
-    if (testQueue.length > 0 && isProcessingQueue) {
-      processTestQueue();
-    } else if (testQueue.length === 0 && isProcessingQueue) {
-      setIsProcessingQueue(false);
-      setIsBatchTesting(false);
-    }
-  }, [testQueue, isProcessingQueue]);
-
-  const testChannel = async (record, model) => {
-    setTestQueue(prev => [...prev, { channel: record, model }]);
-    if (!isProcessingQueue) {
-      setIsProcessingQueue(true);
-    }
-  };
-
-  const batchTestModels = async () => {
-    if (!currentTestChannel) return;
-
-    setIsBatchTesting(true);
-
-    // 重置分页到第一页
-    setModelTablePage(1);
-
-    const filteredModels = currentTestChannel.models
-      .split(',')
-      .filter((model) =>
-        model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
-      );
-
-    setTestQueue(
-      filteredModels.map((model, idx) => ({
-        channel: currentTestChannel,
-        model,
-        indexInFiltered: idx, // 记录在过滤列表中的顺序
-      })),
-    );
-    setIsProcessingQueue(true);
-  };
-
-  const handleCloseModal = () => {
-    if (isBatchTesting) {
-      // 清空测试队列来停止测试
-      setTestQueue([]);
-      setIsProcessingQueue(false);
-      setIsBatchTesting(false);
-      showSuccess(t('已停止测试'));
-    } else {
-      setShowModelTestModal(false);
-      setModelSearchKeyword('');
-      setSelectedModelKeys([]);
-      setModelTablePage(1);
-    }
-  };
-
-  const channelTypeCounts = useMemo(() => {
-    if (Object.keys(typeCounts).length > 0) return typeCounts;
-    // fallback 本地计算
-    const counts = { all: channels.length };
-    channels.forEach((channel) => {
-      const collect = (ch) => {
-        const type = ch.type;
-        counts[type] = (counts[type] || 0) + 1;
-      };
-      if (channel.children !== undefined) {
-        channel.children.forEach(collect);
-      } else {
-        collect(channel);
-      }
-    });
-    return counts;
-  }, [typeCounts, channels]);
-
-  const availableTypeKeys = useMemo(() => {
-    const keys = ['all'];
-    Object.entries(channelTypeCounts).forEach(([k, v]) => {
-      if (k !== 'all' && v > 0) keys.push(String(k));
-    });
-    return keys;
-  }, [channelTypeCounts]);
-
-  const renderTypeTabs = () => {
-    if (enableTagMode) return null;
-
-    return (
-      <Tabs
-        activeKey={activeTypeKey}
-        type="card"
-        collapsible
-        onChange={(key) => {
-          setActiveTypeKey(key);
-          setActivePage(1);
-          loadChannels(1, pageSize, idSort, enableTagMode, key);
-        }}
-        className="mb-4"
-      >
-        <TabPane
-          itemKey="all"
-          tab={
-            <span className="flex items-center gap-2">
-              {t('全部')}
-              <Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
-                {channelTypeCounts['all'] || 0}
-              </Tag>
-            </span>
-          }
-        />
-
-        {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
-          const key = String(option.value);
-          const count = channelTypeCounts[option.value] || 0;
-          return (
-            <TabPane
-              key={key}
-              itemKey={key}
-              tab={
-                <span className="flex items-center gap-2">
-                  {getChannelIcon(option.value)}
-                  {option.label}
-                  <Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
-                    {count}
-                  </Tag>
-                </span>
-              }
-            />
-          );
-        })}
-      </Tabs>
-    );
-  };
-
-  let pageData = channels;
-
-  const handlePageChange = (page) => {
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    setActivePage(page);
-    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-      loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
-    } else {
-      searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
-    }
-  };
-
-  const handlePageSizeChange = async (size) => {
-    localStorage.setItem('page-size', size + '');
-    setPageSize(size);
-    setActivePage(1);
-    const { searchKeyword, searchGroup, searchModel } = getFormValues();
-    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-      loadChannels(1, size, idSort, enableTagMode)
-        .then()
-        .catch((reason) => {
-          showError(reason);
-        });
-    } else {
-      searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
-    }
-  };
-
-  const fetchGroups = async () => {
-    try {
-      let res = await API.get(`/api/group/`);
-      if (res === undefined) {
-        return;
-      }
-      setGroupOptions(
-        res.data.data.map((group) => ({
-          label: group,
-          value: group,
-        })),
-      );
-    } catch (error) {
-      showError(error.message);
-    }
-  };
-
-  const submitTagEdit = async (type, data) => {
-    switch (type) {
-      case 'priority':
-        if (data.priority === undefined || data.priority === '') {
-          showInfo('优先级必须是整数!');
-          return;
-        }
-        data.priority = parseInt(data.priority);
-        break;
-      case 'weight':
-        if (
-          data.weight === undefined ||
-          data.weight < 0 ||
-          data.weight === ''
-        ) {
-          showInfo('权重必须是非负整数!');
-          return;
-        }
-        data.weight = parseInt(data.weight);
-        break;
-    }
-
-    try {
-      const res = await API.put('/api/channel/tag', data);
-      if (res?.data?.success) {
-        showSuccess('更新成功!');
-        await refresh();
-      }
-    } catch (error) {
-      showError(error);
-    }
-  };
-
-  const closeEdit = () => {
-    setShowEdit(false);
-  };
-
-  const handleRow = (record, index) => {
-    if (record.status !== 1) {
-      return {
-        style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
-      };
-    } else {
-      return {};
-    }
-  };
-
-  const batchSetChannelTag = async () => {
-    if (selectedChannels.length === 0) {
-      showError(t('请先选择要设置标签的渠道!'));
-      return;
-    }
-    if (batchSetTagValue === '') {
-      showError(t('标签不能为空!'));
-      return;
-    }
-    let ids = selectedChannels.map((channel) => channel.id);
-    const res = await API.post('/api/channel/batch/tag', {
-      ids: ids,
-      tag: batchSetTagValue === '' ? null : batchSetTagValue,
-    });
-    if (res.data.success) {
-      showSuccess(
-        t('已为 ${count} 个渠道设置标签!').replace('${count}', res.data.data),
-      );
-      await refresh();
-      setShowBatchSetTag(false);
-    } else {
-      showError(res.data.message);
-    }
-  };
-
-  const testAllChannels = async () => {
-    const res = await API.get(`/api/channel/test`);
-    const { success, message } = res.data;
-    if (success) {
-      showInfo(t('已成功开始测试所有已启用通道,请刷新页面查看结果。'));
-    } else {
-      showError(message);
-    }
-  };
-
-  const deleteAllDisabledChannels = async () => {
-    const res = await API.delete(`/api/channel/disabled`);
-    const { success, message, data } = res.data;
-    if (success) {
-      showSuccess(
-        t('已删除所有禁用渠道,共计 ${data} 个').replace('${data}', data),
-      );
-      await refresh();
-    } else {
-      showError(message);
-    }
-  };
-
-  const updateAllChannelsBalance = async () => {
-    const res = await API.get(`/api/channel/update_balance`);
-    const { success, message } = res.data;
-    if (success) {
-      showInfo(t('已更新完毕所有已启用通道余额!'));
-    } else {
-      showError(message);
-    }
-  };
-
-  const updateChannelBalance = async (record) => {
-    const res = await API.get(`/api/channel/update_balance/${record.id}/`);
-    const { success, message, balance } = res.data;
-    if (success) {
-      updateChannelProperty(record.id, (channel) => {
-        channel.balance = balance;
-        channel.balance_updated_time = Date.now() / 1000;
-      });
-      showInfo(
-        t('通道 ${name} 余额更新成功!').replace('${name}', record.name),
-      );
-    } else {
-      showError(message);
-    }
-  };
-
-  const batchDeleteChannels = async () => {
-    if (selectedChannels.length === 0) {
-      showError(t('请先选择要删除的通道!'));
-      return;
-    }
-    setLoading(true);
-    let ids = [];
-    selectedChannels.forEach((channel) => {
-      ids.push(channel.id);
-    });
-    const res = await API.post(`/api/channel/batch`, { ids: ids });
-    const { success, message, data } = res.data;
-    if (success) {
-      showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
-      await refresh();
-      setTimeout(() => {
-        if (channels.length === 0 && activePage > 1) {
-          refresh(activePage - 1);
-        }
-      }, 100);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const fixChannelsAbilities = async () => {
-    const res = await API.post(`/api/channel/fix`);
-    const { success, message, data } = res.data;
-    if (success) {
-      showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
-      await refresh();
-    } else {
-      showError(message);
-    }
-  };
-
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      {renderTypeTabs()}
-      <div className="flex flex-col md:flex-row justify-between gap-4">
-        <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
-          <Button
-            size='small'
-            disabled={!enableBatchDelete}
-            type='danger'
-            className="w-full md:w-auto"
-            onClick={() => {
-              Modal.confirm({
-                title: t('确定是否要删除所选通道?'),
-                content: t('此修改将不可逆'),
-                onOk: () => batchDeleteChannels(),
-              });
-            }}
-          >
-            {t('删除所选通道')}
-          </Button>
-
-          <Button
-            size='small'
-            disabled={!enableBatchDelete}
-            type='tertiary'
-            onClick={() => setShowBatchSetTag(true)}
-            className="w-full md:w-auto"
-          >
-            {t('批量设置标签')}
-          </Button>
-
-          <Dropdown
-            size='small'
-            trigger='click'
-            render={
-              <Dropdown.Menu>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    type='tertiary'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定?'),
-                        content: t('确定要测试所有通道吗?'),
-                        onOk: () => testAllChannels(),
-                        size: 'small',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('测试所有通道')}
-                  </Button>
-                </Dropdown.Item>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定是否要修复数据库一致性?'),
-                        content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
-                        onOk: () => fixChannelsAbilities(),
-                        size: 'sm',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('修复数据库一致性')}
-                  </Button>
-                </Dropdown.Item>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    type='secondary'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定?'),
-                        content: t('确定要更新所有已启用通道余额吗?'),
-                        onOk: () => updateAllChannelsBalance(),
-                        size: 'sm',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('更新所有已启用通道余额')}
-                  </Button>
-                </Dropdown.Item>
-                <Dropdown.Item>
-                  <Button
-                    size='small'
-                    type='danger'
-                    className="w-full"
-                    onClick={() => {
-                      Modal.confirm({
-                        title: t('确定是否要删除禁用通道?'),
-                        content: t('此修改将不可逆'),
-                        onOk: () => deleteAllDisabledChannels(),
-                        size: 'sm',
-                        centered: true,
-                      });
-                    }}
-                  >
-                    {t('删除禁用通道')}
-                  </Button>
-                </Dropdown.Item>
-              </Dropdown.Menu>
-            }
-          >
-            <Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
-              {t('批量操作')}
-            </Button>
-          </Dropdown>
-
-          <Button
-            size='small'
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={() => setCompactMode(!compactMode)}
-          >
-            {compactMode ? t('自适应列表') : t('紧凑列表')}
-          </Button>
-        </div>
-
-        <div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('使用ID排序')}
-            </Typography.Text>
-            <Switch
-              size='small'
-              checked={idSort}
-              onChange={(v) => {
-                localStorage.setItem('id-sort', v + '');
-                setIdSort(v);
-                const { searchKeyword, searchGroup, searchModel } = getFormValues();
-                if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-                  loadChannels(activePage, pageSize, v, enableTagMode);
-                } else {
-                  searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
-                }
-              }}
-            />
-          </div>
-
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('开启批量操作')}
-            </Typography.Text>
-            <Switch
-              size='small'
-              checked={enableBatchDelete}
-              onChange={(v) => {
-                localStorage.setItem('enable-batch-delete', v + '');
-                setEnableBatchDelete(v);
-              }}
-            />
-          </div>
-
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('标签聚合模式')}
-            </Typography.Text>
-            <Switch
-              size='small'
-              checked={enableTagMode}
-              onChange={(v) => {
-                localStorage.setItem('enable-tag-mode', v + '');
-                setEnableTagMode(v);
-                setActivePage(1);
-                loadChannels(1, pageSize, idSort, v);
-              }}
-            />
-          </div>
-
-          {/* 状态筛选器 */}
-          <div className="flex items-center justify-between w-full md:w-auto">
-            <Typography.Text strong className="mr-2">
-              {t('状态筛选')}
-            </Typography.Text>
-            <Select
-              size='small'
-              value={statusFilter}
-              onChange={(v) => {
-                localStorage.setItem('channel-status-filter', v);
-                setStatusFilter(v);
-                setActivePage(1);
-                loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
-              }}
-            >
-              <Select.Option value="all">{t('全部')}</Select.Option>
-              <Select.Option value="enabled">{t('已启用')}</Select.Option>
-              <Select.Option value="disabled">{t('已禁用')}</Select.Option>
-            </Select>
-          </div>
-        </div>
-      </div>
-
-      <Divider margin="12px" />
-
-      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
-          <Button
-            size='small'
-            theme='light'
-            type='primary'
-            className="w-full md:w-auto"
-            onClick={() => {
-              setEditingChannel({
-                id: undefined,
-              });
-              setShowEdit(true);
-            }}
-          >
-            {t('添加渠道')}
-          </Button>
-
-          <Button
-            size='small'
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={refresh}
-          >
-            {t('刷新')}
-          </Button>
-
-          <Button
-            size='small'
-            type='tertiary'
-            onClick={() => setShowColumnSelector(true)}
-            className="w-full md:w-auto"
-          >
-            {t('列设置')}
-          </Button>
-        </div>
-
-        <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
-          <Form
-            initValues={formInitValues}
-            getFormApi={(api) => setFormApi(api)}
-            onSubmit={() => searchChannels(enableTagMode)}
-            allowEmpty={true}
-            autoComplete="off"
-            layout="horizontal"
-            trigger="change"
-            stopValidateWithError={false}
-            className="flex flex-col md:flex-row items-center gap-4 w-full"
-          >
-            <div className="relative w-full md:w-64">
-              <Form.Input
-                size='small'
-                field="searchKeyword"
-                prefix={<IconSearch />}
-                placeholder={t('渠道ID,名称,密钥,API地址')}
-                showClear
-                pure
-              />
-            </div>
-            <div className="w-full md:w-48">
-              <Form.Input
-                size='small'
-                field="searchModel"
-                prefix={<IconSearch />}
-                placeholder={t('模型关键字')}
-                showClear
-                pure
-              />
-            </div>
-            <div className="w-full md:w-32">
-              <Form.Select
-                size='small'
-                field="searchGroup"
-                placeholder={t('选择分组')}
-                optionList={[
-                  { label: t('选择分组'), value: null },
-                  ...groupOptions,
-                ]}
-                className="w-full"
-                showClear
-                pure
-                onChange={() => {
-                  // 延迟执行搜索,让表单值先更新
-                  setTimeout(() => {
-                    searchChannels(enableTagMode);
-                  }, 0);
-                }}
-              />
-            </div>
-            <Button
-              size='small'
-              type="tertiary"
-              htmlType="submit"
-              loading={loading || searching}
-              className="w-full md:w-auto"
-            >
-              {t('查询')}
-            </Button>
-            <Button
-              size='small'
-              type='tertiary'
-              onClick={() => {
-                if (formApi) {
-                  formApi.reset();
-                  // 重置后立即查询,使用setTimeout确保表单重置完成
-                  setTimeout(() => {
-                    refresh();
-                  }, 100);
-                }
-              }}
-              className="w-full md:w-auto"
-            >
-              {t('重置')}
-            </Button>
-          </Form>
-        </div>
-      </div>
-    </div>
-  );
-
-  return (
-    <>
-      {renderColumnSelector()}
-      <EditTagModal
-        visible={showEditTag}
-        tag={editingTag}
-        handleClose={() => setShowEditTag(false)}
-        refresh={refresh}
-      />
-      <EditChannel
-        refresh={refresh}
-        visible={showEdit}
-        handleClose={closeEdit}
-        editingChannel={editingChannel}
-      />
-
-      <Card
-        className="!rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
-      >
-        <Table
-          columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
-          dataSource={pageData}
-          scroll={compactMode ? undefined : { x: 'max-content' }}
-          pagination={{
-            currentPage: activePage,
-            pageSize: pageSize,
-            total: channelCount,
-            pageSizeOpts: [10, 20, 50, 100],
-            showSizeChanger: true,
-            formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-              start: page.currentStart,
-              end: page.currentEnd,
-              total: channelCount,
-            }),
-            onPageSizeChange: (size) => {
-              handlePageSizeChange(size);
-            },
-            onPageChange: handlePageChange,
-          }}
-          expandAllRows={false}
-          onRow={handleRow}
-          rowSelection={
-            enableBatchDelete
-              ? {
-                onChange: (selectedRowKeys, selectedRows) => {
-                  setSelectedChannels(selectedRows);
-                },
-              }
-              : null
-          }
-          empty={
-            <Empty
-              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-              description={t('搜索无结果')}
-              style={{ padding: 30 }}
-            />
-          }
-          className="rounded-xl overflow-hidden"
-          size="middle"
-          loading={loading || searching}
-        />
-      </Card>
-
-      {/* 批量设置标签模态框 */}
-      <Modal
-        title={t('批量设置标签')}
-        visible={showBatchSetTag}
-        onOk={batchSetChannelTag}
-        onCancel={() => setShowBatchSetTag(false)}
-        maskClosable={false}
-        centered={true}
-        size="small"
-        className="!rounded-lg"
-      >
-        <div className="mb-5">
-          <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
-        </div>
-        <Input
-          placeholder={t('请输入标签名称')}
-          value={batchSetTagValue}
-          onChange={(v) => setBatchSetTagValue(v)}
-        />
-        <div className="mt-4">
-          <Typography.Text type='secondary'>
-            {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
-          </Typography.Text>
-        </div>
-      </Modal>
-
-      {/* 模型测试弹窗 */}
-      <Modal
-        title={
-          currentTestChannel && (
-            <div className="flex flex-col gap-2 w-full">
-              <div className="flex items-center gap-2">
-                <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
-                  {currentTestChannel.name} {t('渠道的模型测试')}
-                </Typography.Text>
-                <Typography.Text type="tertiary" className="!text-xs flex items-center">
-                  {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
-                </Typography.Text>
-              </div>
-            </div>
-          )
-        }
-        visible={showModelTestModal && currentTestChannel !== null}
-        onCancel={handleCloseModal}
-        footer={
-          <div className="flex justify-end">
-            {isBatchTesting ? (
-              <Button
-                type='danger'
-                onClick={handleCloseModal}
-              >
-                {t('停止测试')}
-              </Button>
-            ) : (
-              <Button
-                type='tertiary'
-                onClick={handleCloseModal}
-              >
-                {t('取消')}
-              </Button>
-            )}
-            <Button
-              onClick={batchTestModels}
-              loading={isBatchTesting}
-              disabled={isBatchTesting}
-            >
-              {isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
-                '${count}',
-                currentTestChannel
-                  ? currentTestChannel.models
-                    .split(',')
-                    .filter((model) =>
-                      model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
-                    ).length
-                  : 0
-              )}
-            </Button>
-          </div>
-        }
-        maskClosable={!isBatchTesting}
-        className="!rounded-lg"
-        size={isMobile ? 'full-width' : 'large'}
-      >
-        <div className="model-test-scroll">
-          {currentTestChannel && (
-            <div>
-              {/* 搜索与操作按钮 */}
-              <div className="flex items-center justify-end gap-2 w-full mb-2">
-                <Input
-                  placeholder={t('搜索模型...')}
-                  value={modelSearchKeyword}
-                  onChange={(v) => {
-                    setModelSearchKeyword(v);
-                    setModelTablePage(1);
-                  }}
-                  className="!w-full"
-                  prefix={<IconSearch />}
-                  showClear
-                />
-
-                <Button
-                  onClick={() => {
-                    if (selectedModelKeys.length === 0) {
-                      showError(t('请先选择模型!'));
-                      return;
-                    }
-                    copy(selectedModelKeys.join(',')).then((ok) => {
-                      if (ok) {
-                        showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
-                      } else {
-                        showError(t('复制失败,请手动复制'));
-                      }
-                    });
-                  }}
-                >
-                  {t('复制已选')}
-                </Button>
-
-                <Button
-                  type='tertiary'
-                  onClick={() => {
-                    if (!currentTestChannel) return;
-                    const successKeys = currentTestChannel.models
-                      .split(',')
-                      .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
-                      .filter((m) => {
-                        const result = modelTestResults[`${currentTestChannel.id}-${m}`];
-                        return result && result.success;
-                      });
-                    if (successKeys.length === 0) {
-                      showInfo(t('暂无成功模型'));
-                    }
-                    setSelectedModelKeys(successKeys);
-                  }}
-                >
-                  {t('选择成功')}
-                </Button>
-              </div>
-              <Table
-                columns={[
-                  {
-                    title: t('模型名称'),
-                    dataIndex: 'model',
-                    render: (text) => (
-                      <div className="flex items-center">
-                        <Typography.Text strong>{text}</Typography.Text>
-                      </div>
-                    )
-                  },
-                  {
-                    title: t('状态'),
-                    dataIndex: 'status',
-                    render: (text, record) => {
-                      const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
-                      const isTesting = testingModels.has(record.model);
-
-                      if (isTesting) {
-                        return (
-                          <Tag color='blue' shape='circle'>
-                            {t('测试中')}
-                          </Tag>
-                        );
-                      }
-
-                      if (!testResult) {
-                        return (
-                          <Tag color='grey' shape='circle'>
-                            {t('未开始')}
-                          </Tag>
-                        );
-                      }
-
-                      return (
-                        <div className="flex items-center gap-2">
-                          <Tag
-                            color={testResult.success ? 'green' : 'red'}
-                            shape='circle'
-                          >
-                            {testResult.success ? t('成功') : t('失败')}
-                          </Tag>
-                          {testResult.success && (
-                            <Typography.Text type="tertiary">
-                              {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
-                            </Typography.Text>
-                          )}
-                        </div>
-                      );
-                    }
-                  },
-                  {
-                    title: '',
-                    dataIndex: 'operate',
-                    render: (text, record) => {
-                      const isTesting = testingModels.has(record.model);
-                      return (
-                        <Button
-                          type='tertiary'
-                          onClick={() => testChannel(currentTestChannel, record.model)}
-                          loading={isTesting}
-                          size='small'
-                        >
-                          {t('测试')}
-                        </Button>
-                      );
-                    }
-                  }
-                ]}
-                dataSource={(() => {
-                  const filtered = currentTestChannel.models
-                    .split(',')
-                    .filter((model) =>
-                      model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
-                    );
-                  const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
-                  const end = start + MODEL_TABLE_PAGE_SIZE;
-                  return filtered.slice(start, end).map((model) => ({
-                    model,
-                    key: model,
-                  }));
-                })()}
-                rowSelection={{
-                  selectedRowKeys: selectedModelKeys,
-                  onChange: (keys) => {
-                    if (allSelectingRef.current) {
-                      allSelectingRef.current = false;
-                      return;
-                    }
-                    setSelectedModelKeys(keys);
-                  },
-                  onSelectAll: (checked) => {
-                    const filtered = currentTestChannel.models
-                      .split(',')
-                      .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
-                    allSelectingRef.current = true;
-                    setSelectedModelKeys(checked ? filtered : []);
-                  },
-                }}
-                pagination={{
-                  currentPage: modelTablePage,
-                  pageSize: MODEL_TABLE_PAGE_SIZE,
-                  total: currentTestChannel.models
-                    .split(',')
-                    .filter((model) =>
-                      model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
-                    ).length,
-                  showSizeChanger: false,
-                  onPageChange: (page) => setModelTablePage(page),
-                }}
-              />
-            </div>
-          )}
-        </div>
-      </Modal>
-    </>
-  );
-};
-
-export default ChannelsTable;

+ 0 - 1464
web/src/components/table/LogsTable.js

@@ -1,1464 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
-  API,
-  copy,
-  getTodayStartTimestamp,
-  isAdmin,
-  showError,
-  showSuccess,
-  timestamp2string,
-  renderAudioModelPrice,
-  renderClaudeLogContent,
-  renderClaudeModelPrice,
-  renderClaudeModelPriceSimple,
-  renderGroup,
-  renderLogContent,
-  renderModelPrice,
-  renderModelPriceSimple,
-  renderNumber,
-  renderQuota,
-  stringToColor,
-  getLogOther,
-  renderModelTag
-} from '../../helpers';
-
-import {
-  Avatar,
-  Button,
-  Descriptions,
-  Empty,
-  Modal,
-  Popover,
-  Space,
-  Spin,
-  Table,
-  Tag,
-  Tooltip,
-  Checkbox,
-  Card,
-  Typography,
-  Divider,
-  Form,
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark,
-} from '@douyinfe/semi-illustrations';
-import { ITEMS_PER_PAGE } from '../../constants';
-import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
-import { IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
-import { Route } from 'lucide-react';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-
-const { Text } = Typography;
-
-const colors = [
-  'amber',
-  'blue',
-  'cyan',
-  'green',
-  'grey',
-  'indigo',
-  'light-blue',
-  'lime',
-  'orange',
-  'pink',
-  'purple',
-  'red',
-  'teal',
-  'violet',
-  'yellow',
-];
-
-const LogsTable = () => {
-  const { t } = useTranslation();
-
-  function renderType(type) {
-    switch (type) {
-      case 1:
-        return (
-          <Tag color='cyan' shape='circle'>
-            {t('充值')}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='lime' shape='circle'>
-            {t('消费')}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='orange' shape='circle'>
-            {t('管理')}
-          </Tag>
-        );
-      case 4:
-        return (
-          <Tag color='purple' shape='circle'>
-            {t('系统')}
-          </Tag>
-        );
-      case 5:
-        return (
-          <Tag color='red' shape='circle'>
-            {t('错误')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='grey' shape='circle'>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  }
-
-  function renderIsStream(bool) {
-    if (bool) {
-      return (
-        <Tag color='blue' shape='circle'>
-          {t('流')}
-        </Tag>
-      );
-    } else {
-      return (
-        <Tag color='purple' shape='circle'>
-          {t('非流')}
-        </Tag>
-      );
-    }
-  }
-
-  function renderUseTime(type) {
-    const time = parseInt(type);
-    if (time < 101) {
-      return (
-        <Tag color='green' shape='circle'>
-          {' '}
-          {time} s{' '}
-        </Tag>
-      );
-    } else if (time < 300) {
-      return (
-        <Tag color='orange' shape='circle'>
-          {' '}
-          {time} s{' '}
-        </Tag>
-      );
-    } else {
-      return (
-        <Tag color='red' shape='circle'>
-          {' '}
-          {time} s{' '}
-        </Tag>
-      );
-    }
-  }
-
-  function renderFirstUseTime(type) {
-    let time = parseFloat(type) / 1000.0;
-    time = time.toFixed(1);
-    if (time < 3) {
-      return (
-        <Tag color='green' shape='circle'>
-          {' '}
-          {time} s{' '}
-        </Tag>
-      );
-    } else if (time < 10) {
-      return (
-        <Tag color='orange' shape='circle'>
-          {' '}
-          {time} s{' '}
-        </Tag>
-      );
-    } else {
-      return (
-        <Tag color='red' shape='circle'>
-          {' '}
-          {time} s{' '}
-        </Tag>
-      );
-    }
-  }
-
-  function renderModelName(record) {
-    let other = getLogOther(record.other);
-    let modelMapped =
-      other?.is_model_mapped &&
-      other?.upstream_model_name &&
-      other?.upstream_model_name !== '';
-    if (!modelMapped) {
-      return renderModelTag(record.model_name, {
-        onClick: (event) => {
-          copyText(event, record.model_name).then((r) => { });
-        },
-      });
-    } else {
-      return (
-        <>
-          <Space vertical align={'start'}>
-            <Popover
-              content={
-                <div style={{ padding: 10 }}>
-                  <Space vertical align={'start'}>
-                    <div className='flex items-center'>
-                      <Text strong style={{ marginRight: 8 }}>
-                        {t('请求并计费模型')}:
-                      </Text>
-                      {renderModelTag(record.model_name, {
-                        onClick: (event) => {
-                          copyText(event, record.model_name).then((r) => { });
-                        },
-                      })}
-                    </div>
-                    <div className='flex items-center'>
-                      <Text strong style={{ marginRight: 8 }}>
-                        {t('实际模型')}:
-                      </Text>
-                      {renderModelTag(other.upstream_model_name, {
-                        onClick: (event) => {
-                          copyText(event, other.upstream_model_name).then(
-                            (r) => { },
-                          );
-                        },
-                      })}
-                    </div>
-                  </Space>
-                </div>
-              }
-            >
-              {renderModelTag(record.model_name, {
-                onClick: (event) => {
-                  copyText(event, record.model_name).then((r) => { });
-                },
-                suffixIcon: (
-                  <Route
-                    style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
-                  />
-                ),
-              })}
-            </Popover>
-          </Space>
-        </>
-      );
-    }
-  }
-
-  // Define column keys for selection
-  const COLUMN_KEYS = {
-    TIME: 'time',
-    CHANNEL: 'channel',
-    USERNAME: 'username',
-    TOKEN: 'token',
-    GROUP: 'group',
-    TYPE: 'type',
-    MODEL: 'model',
-    USE_TIME: 'use_time',
-    PROMPT: 'prompt',
-    COMPLETION: 'completion',
-    COST: 'cost',
-    RETRY: 'retry',
-    IP: 'ip',
-    DETAILS: 'details',
-  };
-
-  // State for column visibility
-  const [visibleColumns, setVisibleColumns] = useState({});
-  const [showColumnSelector, setShowColumnSelector] = useState(false);
-
-  // Load saved column preferences from localStorage
-  useEffect(() => {
-    const savedColumns = localStorage.getItem('logs-table-columns');
-    if (savedColumns) {
-      try {
-        const parsed = JSON.parse(savedColumns);
-        // Make sure all columns are accounted for
-        const defaults = getDefaultColumnVisibility();
-        const merged = { ...defaults, ...parsed };
-        setVisibleColumns(merged);
-      } catch (e) {
-        console.error('Failed to parse saved column preferences', e);
-        initDefaultColumns();
-      }
-    } else {
-      initDefaultColumns();
-    }
-  }, []);
-
-  // Get default column visibility based on user role
-  const getDefaultColumnVisibility = () => {
-    return {
-      [COLUMN_KEYS.TIME]: true,
-      [COLUMN_KEYS.CHANNEL]: isAdminUser,
-      [COLUMN_KEYS.USERNAME]: isAdminUser,
-      [COLUMN_KEYS.TOKEN]: true,
-      [COLUMN_KEYS.GROUP]: true,
-      [COLUMN_KEYS.TYPE]: true,
-      [COLUMN_KEYS.MODEL]: true,
-      [COLUMN_KEYS.USE_TIME]: true,
-      [COLUMN_KEYS.PROMPT]: true,
-      [COLUMN_KEYS.COMPLETION]: true,
-      [COLUMN_KEYS.COST]: true,
-      [COLUMN_KEYS.RETRY]: isAdminUser,
-      [COLUMN_KEYS.IP]: true,
-      [COLUMN_KEYS.DETAILS]: true,
-    };
-  };
-
-  // Initialize default column visibility
-  const initDefaultColumns = () => {
-    const defaults = getDefaultColumnVisibility();
-    setVisibleColumns(defaults);
-    localStorage.setItem('logs-table-columns', JSON.stringify(defaults));
-  };
-
-  // Handle column visibility change
-  const handleColumnVisibilityChange = (columnKey, checked) => {
-    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
-    setVisibleColumns(updatedColumns);
-  };
-
-  // Handle "Select All" checkbox
-  const handleSelectAll = (checked) => {
-    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
-    const updatedColumns = {};
-
-    allKeys.forEach((key) => {
-      // For admin-only columns, only enable them if user is admin
-      if (
-        (key === COLUMN_KEYS.CHANNEL ||
-          key === COLUMN_KEYS.USERNAME ||
-          key === COLUMN_KEYS.RETRY) &&
-        !isAdminUser
-      ) {
-        updatedColumns[key] = false;
-      } else {
-        updatedColumns[key] = checked;
-      }
-    });
-
-    setVisibleColumns(updatedColumns);
-  };
-
-  // Define all columns
-  const allColumns = [
-    {
-      key: COLUMN_KEYS.TIME,
-      title: t('时间'),
-      dataIndex: 'timestamp2string',
-    },
-    {
-      key: COLUMN_KEYS.CHANNEL,
-      title: t('渠道'),
-      dataIndex: 'channel',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        let isMultiKey = false
-        let multiKeyIndex = -1;
-        let other = getLogOther(record.other);
-        if (other?.admin_info) {
-          let adminInfo = other.admin_info;
-          if (adminInfo?.is_multi_key) {
-            isMultiKey = true;
-            multiKeyIndex = adminInfo.multi_key_index;
-          }
-        }
-
-        return isAdminUser && (record.type === 0 || record.type === 2 || record.type === 5) ? (
-          <Space>
-            <Tooltip content={record.channel_name || t('未知渠道')}>
-              <Tag
-                color={colors[parseInt(text) % colors.length]}
-                shape='circle'
-              >
-                {text}
-              </Tag>
-            </Tooltip>
-            {isMultiKey && (
-              <Tag color='white' shape='circle'>
-                {multiKeyIndex}
-              </Tag>
-            )}
-          </Space>
-        ) : null;
-      },
-    },
-    {
-      key: COLUMN_KEYS.USERNAME,
-      title: t('用户'),
-      dataIndex: 'username',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return isAdminUser ? (
-          <div>
-            <Avatar
-              size='extra-small'
-              color={stringToColor(text)}
-              style={{ marginRight: 4 }}
-              onClick={(event) => {
-                event.stopPropagation();
-                showUserInfo(record.user_id);
-              }}
-            >
-              {typeof text === 'string' && text.slice(0, 1)}
-            </Avatar>
-            {text}
-          </div>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.TOKEN,
-      title: t('令牌'),
-      dataIndex: 'token_name',
-      render: (text, record, index) => {
-        return record.type === 0 || record.type === 2 || record.type === 5 ? (
-          <div>
-            <Tag
-              color='grey'
-              shape='circle'
-              onClick={(event) => {
-                //cancel the row click event
-                copyText(event, text);
-              }}
-            >
-              {' '}
-              {t(text)}{' '}
-            </Tag>
-          </div>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.GROUP,
-      title: t('分组'),
-      dataIndex: 'group',
-      render: (text, record, index) => {
-        if (record.type === 0 || record.type === 2 || record.type === 5) {
-          if (record.group) {
-            return <>{renderGroup(record.group)}</>;
-          } else {
-            let other = null;
-            try {
-              other = JSON.parse(record.other);
-            } catch (e) {
-              console.error(
-                `Failed to parse record.other: "${record.other}".`,
-                e,
-              );
-            }
-            if (other === null) {
-              return <></>;
-            }
-            if (other.group !== undefined) {
-              return <>{renderGroup(other.group)}</>;
-            } else {
-              return <></>;
-            }
-          }
-        } else {
-          return <></>;
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.TYPE,
-      title: t('类型'),
-      dataIndex: 'type',
-      render: (text, record, index) => {
-        return <>{renderType(text)}</>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.MODEL,
-      title: t('模型'),
-      dataIndex: 'model_name',
-      render: (text, record, index) => {
-        return record.type === 0 || record.type === 2 || record.type === 5 ? (
-          <>{renderModelName(record)}</>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.USE_TIME,
-      title: t('用时/首字'),
-      dataIndex: 'use_time',
-      render: (text, record, index) => {
-        if (!(record.type === 2 || record.type === 5)) {
-          return <></>;
-        }
-        if (record.is_stream) {
-          let other = getLogOther(record.other);
-          return (
-            <>
-              <Space>
-                {renderUseTime(text)}
-                {renderFirstUseTime(other?.frt)}
-                {renderIsStream(record.is_stream)}
-              </Space>
-            </>
-          );
-        } else {
-          return (
-            <>
-              <Space>
-                {renderUseTime(text)}
-                {renderIsStream(record.is_stream)}
-              </Space>
-            </>
-          );
-        }
-      },
-    },
-    {
-      key: COLUMN_KEYS.PROMPT,
-      title: t('提示'),
-      dataIndex: 'prompt_tokens',
-      render: (text, record, index) => {
-        return record.type === 0 || record.type === 2 || record.type === 5 ? (
-          <>{<span> {text} </span>}</>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.COMPLETION,
-      title: t('补全'),
-      dataIndex: 'completion_tokens',
-      render: (text, record, index) => {
-        return parseInt(text) > 0 &&
-          (record.type === 0 || record.type === 2 || record.type === 5) ? (
-          <>{<span> {text} </span>}</>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.COST,
-      title: t('花费'),
-      dataIndex: 'quota',
-      render: (text, record, index) => {
-        return record.type === 0 || record.type === 2 || record.type === 5 ? (
-          <>{renderQuota(text, 6)}</>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.IP,
-      title: (
-        <div className="flex items-center gap-1">
-          {t('IP')}
-          <Tooltip content={t('只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录')}>
-            <IconHelpCircle className="text-gray-400 cursor-help" />
-          </Tooltip>
-        </div>
-      ),
-      dataIndex: 'ip',
-      render: (text, record, index) => {
-        return (record.type === 2 || record.type === 5) && text ? (
-          <Tooltip content={text}>
-            <Tag
-              color='orange'
-              shape='circle'
-              onClick={(event) => {
-                copyText(event, text);
-              }}
-            >
-              {text}
-            </Tag>
-          </Tooltip>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.RETRY,
-      title: t('重试'),
-      dataIndex: 'retry',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        if (!(record.type === 2 || record.type === 5)) {
-          return <></>;
-        }
-        let content = t('渠道') + `:${record.channel}`;
-        if (record.other !== '') {
-          let other = JSON.parse(record.other);
-          if (other === null) {
-            return <></>;
-          }
-          if (other.admin_info !== undefined) {
-            if (
-              other.admin_info.use_channel !== null &&
-              other.admin_info.use_channel !== undefined &&
-              other.admin_info.use_channel !== ''
-            ) {
-              // channel id array
-              let useChannel = other.admin_info.use_channel;
-              let useChannelStr = useChannel.join('->');
-              content = t('渠道') + `:${useChannelStr}`;
-            }
-          }
-        }
-        return isAdminUser ? <div>{content}</div> : <></>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.DETAILS,
-      title: t('详情'),
-      dataIndex: 'content',
-      fixed: 'right',
-      render: (text, record, index) => {
-        let other = getLogOther(record.other);
-        if (other == null || record.type !== 2) {
-          return (
-            <Paragraph
-              ellipsis={{
-                rows: 2,
-                showTooltip: {
-                  type: 'popover',
-                  opts: { style: { width: 240 } },
-                },
-              }}
-              style={{ maxWidth: 240 }}
-            >
-              {text}
-            </Paragraph>
-          );
-        }
-        let content = other?.claude
-          ? renderClaudeModelPriceSimple(
-            other.model_ratio,
-            other.model_price,
-            other.group_ratio,
-            other?.user_group_ratio,
-            other.cache_tokens || 0,
-            other.cache_ratio || 1.0,
-            other.cache_creation_tokens || 0,
-            other.cache_creation_ratio || 1.0,
-          )
-          : renderModelPriceSimple(
-            other.model_ratio,
-            other.model_price,
-            other.group_ratio,
-            other?.user_group_ratio,
-            other.cache_tokens || 0,
-            other.cache_ratio || 1.0,
-          );
-        return (
-          <Paragraph
-            ellipsis={{
-              rows: 2,
-            }}
-            style={{ maxWidth: 240 }}
-          >
-            {content}
-          </Paragraph>
-        );
-      },
-    },
-  ];
-
-  // Update table when column visibility changes
-  useEffect(() => {
-    if (Object.keys(visibleColumns).length > 0) {
-      // Save to localStorage
-      localStorage.setItem(
-        'logs-table-columns',
-        JSON.stringify(visibleColumns),
-      );
-    }
-  }, [visibleColumns]);
-
-  // Filter columns based on visibility settings
-  const getVisibleColumns = () => {
-    return allColumns.filter((column) => visibleColumns[column.key]);
-  };
-
-  // Column selector modal
-  const renderColumnSelector = () => {
-    return (
-      <Modal
-        title={t('列设置')}
-        visible={showColumnSelector}
-        onCancel={() => setShowColumnSelector(false)}
-        footer={
-          <div className='flex justify-end'>
-            <Button onClick={() => initDefaultColumns()}>
-              {t('重置')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('取消')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('确定')}
-            </Button>
-          </div>
-        }
-      >
-        <div style={{ marginBottom: 20 }}>
-          <Checkbox
-            checked={Object.values(visibleColumns).every((v) => v === true)}
-            indeterminate={
-              Object.values(visibleColumns).some((v) => v === true) &&
-              !Object.values(visibleColumns).every((v) => v === true)
-            }
-            onChange={(e) => handleSelectAll(e.target.checked)}
-          >
-            {t('全选')}
-          </Checkbox>
-        </div>
-        <div
-          className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
-          style={{ border: '1px solid var(--semi-color-border)' }}
-        >
-          {allColumns.map((column) => {
-            // Skip admin-only columns for non-admin users
-            if (
-              !isAdminUser &&
-              (column.key === COLUMN_KEYS.CHANNEL ||
-                column.key === COLUMN_KEYS.USERNAME ||
-                column.key === COLUMN_KEYS.RETRY)
-            ) {
-              return null;
-            }
-
-            return (
-              <div key={column.key} className='w-1/2 mb-4 pr-2'>
-                <Checkbox
-                  checked={!!visibleColumns[column.key]}
-                  onChange={(e) =>
-                    handleColumnVisibilityChange(column.key, e.target.checked)
-                  }
-                >
-                  {column.title}
-                </Checkbox>
-              </div>
-            );
-          })}
-        </div>
-      </Modal>
-    );
-  };
-
-  const [logs, setLogs] = useState([]);
-  const [expandData, setExpandData] = useState({});
-  const [showStat, setShowStat] = useState(false);
-  const [loading, setLoading] = useState(false);
-  const [loadingStat, setLoadingStat] = useState(false);
-  const [activePage, setActivePage] = useState(1);
-  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [logType, setLogType] = useState(0);
-  const isAdminUser = isAdmin();
-  let now = new Date();
-
-  // Form 初始值
-  const formInitValues = {
-    username: '',
-    token_name: '',
-    model_name: '',
-    channel: '',
-    group: '',
-    dateRange: [
-      timestamp2string(getTodayStartTimestamp()),
-      timestamp2string(now.getTime() / 1000 + 3600),
-    ],
-    logType: '0',
-  };
-
-  const [stat, setStat] = useState({
-    quota: 0,
-    token: 0,
-  });
-
-  // Form API 引用
-  const [formApi, setFormApi] = useState(null);
-
-  // 获取表单值的辅助函数,确保所有值都是字符串
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-
-    // 处理时间范围
-    let start_timestamp = timestamp2string(getTodayStartTimestamp());
-    let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
-
-    if (
-      formValues.dateRange &&
-      Array.isArray(formValues.dateRange) &&
-      formValues.dateRange.length === 2
-    ) {
-      start_timestamp = formValues.dateRange[0];
-      end_timestamp = formValues.dateRange[1];
-    }
-
-    return {
-      username: formValues.username || '',
-      token_name: formValues.token_name || '',
-      model_name: formValues.model_name || '',
-      start_timestamp,
-      end_timestamp,
-      channel: formValues.channel || '',
-      group: formValues.group || '',
-      logType: formValues.logType ? parseInt(formValues.logType) : 0,
-    };
-  };
-
-  const getLogSelfStat = async () => {
-    const {
-      token_name,
-      model_name,
-      start_timestamp,
-      end_timestamp,
-      group,
-      logType: formLogType,
-    } = getFormValues();
-    const currentLogType = formLogType !== undefined ? formLogType : logType;
-    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
-    url = encodeURI(url);
-    let res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      setStat(data);
-    } else {
-      showError(message);
-    }
-  };
-
-  const getLogStat = async () => {
-    const {
-      username,
-      token_name,
-      model_name,
-      start_timestamp,
-      end_timestamp,
-      channel,
-      group,
-      logType: formLogType,
-    } = getFormValues();
-    const currentLogType = formLogType !== undefined ? formLogType : logType;
-    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
-    url = encodeURI(url);
-    let res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      setStat(data);
-    } else {
-      showError(message);
-    }
-  };
-
-  const handleEyeClick = async () => {
-    if (loadingStat) {
-      return;
-    }
-    setLoadingStat(true);
-    if (isAdminUser) {
-      await getLogStat();
-    } else {
-      await getLogSelfStat();
-    }
-    setShowStat(true);
-    setLoadingStat(false);
-  };
-
-  const showUserInfo = async (userId) => {
-    if (!isAdminUser) {
-      return;
-    }
-    const res = await API.get(`/api/user/${userId}`);
-    const { success, message, data } = res.data;
-    if (success) {
-      Modal.info({
-        title: t('用户信息'),
-        content: (
-          <div style={{ padding: 12 }}>
-            <p>
-              {t('用户名')}: {data.username}
-            </p>
-            <p>
-              {t('余额')}: {renderQuota(data.quota)}
-            </p>
-            <p>
-              {t('已用额度')}:{renderQuota(data.used_quota)}
-            </p>
-            <p>
-              {t('请求次数')}:{renderNumber(data.request_count)}
-            </p>
-          </div>
-        ),
-        centered: true,
-      });
-    } else {
-      showError(message);
-    }
-  };
-
-  const setLogsFormat = (logs) => {
-    let expandDatesLocal = {};
-    for (let i = 0; i < logs.length; i++) {
-      logs[i].timestamp2string = timestamp2string(logs[i].created_at);
-      logs[i].key = logs[i].id;
-      let other = getLogOther(logs[i].other);
-      let expandDataLocal = [];
-      if (isAdmin()) {
-        // let content = '渠道:' + logs[i].channel;
-        // if (other.admin_info !== undefined) {
-        //   if (
-        //     other.admin_info.use_channel !== null &&
-        //     other.admin_info.use_channel !== undefined &&
-        //     other.admin_info.use_channel !== ''
-        //   ) {
-        //     // channel id array
-        //     let useChannel = other.admin_info.use_channel;
-        //     let useChannelStr = useChannel.join('->');
-        //     content = `渠道:${useChannelStr}`;
-        //   }
-        // }
-        // expandDataLocal.push({
-        //   key: '渠道重试',
-        //   value: content,
-        // })
-      }
-      if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
-        expandDataLocal.push({
-          key: t('渠道信息'),
-          value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
-        });
-      }
-      if (other?.ws || other?.audio) {
-        expandDataLocal.push({
-          key: t('语音输入'),
-          value: other.audio_input,
-        });
-        expandDataLocal.push({
-          key: t('语音输出'),
-          value: other.audio_output,
-        });
-        expandDataLocal.push({
-          key: t('文字输入'),
-          value: other.text_input,
-        });
-        expandDataLocal.push({
-          key: t('文字输出'),
-          value: other.text_output,
-        });
-      }
-      if (other?.cache_tokens > 0) {
-        expandDataLocal.push({
-          key: t('缓存 Tokens'),
-          value: other.cache_tokens,
-        });
-      }
-      if (other?.cache_creation_tokens > 0) {
-        expandDataLocal.push({
-          key: t('缓存创建 Tokens'),
-          value: other.cache_creation_tokens,
-        });
-      }
-      if (logs[i].type === 2) {
-        expandDataLocal.push({
-          key: t('日志详情'),
-          value: other?.claude
-            ? renderClaudeLogContent(
-              other?.model_ratio,
-              other.completion_ratio,
-              other.model_price,
-              other.group_ratio,
-              other?.user_group_ratio,
-              other.cache_ratio || 1.0,
-              other.cache_creation_ratio || 1.0,
-            )
-            : renderLogContent(
-              other?.model_ratio,
-              other.completion_ratio,
-              other.model_price,
-              other.group_ratio,
-              other?.user_group_ratio,
-              false,
-              1.0,
-              other.web_search || false,
-              other.web_search_call_count || 0,
-              other.file_search || false,
-              other.file_search_call_count || 0,
-            ),
-        });
-      }
-      if (logs[i].type === 2) {
-        let modelMapped =
-          other?.is_model_mapped &&
-          other?.upstream_model_name &&
-          other?.upstream_model_name !== '';
-        if (modelMapped) {
-          expandDataLocal.push({
-            key: t('请求并计费模型'),
-            value: logs[i].model_name,
-          });
-          expandDataLocal.push({
-            key: t('实际模型'),
-            value: other.upstream_model_name,
-          });
-        }
-        let content = '';
-        if (other?.ws || other?.audio) {
-          content = renderAudioModelPrice(
-            other?.text_input,
-            other?.text_output,
-            other?.model_ratio,
-            other?.model_price,
-            other?.completion_ratio,
-            other?.audio_input,
-            other?.audio_output,
-            other?.audio_ratio,
-            other?.audio_completion_ratio,
-            other?.group_ratio,
-            other?.user_group_ratio,
-            other?.cache_tokens || 0,
-            other?.cache_ratio || 1.0,
-          );
-        } else if (other?.claude) {
-          content = renderClaudeModelPrice(
-            logs[i].prompt_tokens,
-            logs[i].completion_tokens,
-            other.model_ratio,
-            other.model_price,
-            other.completion_ratio,
-            other.group_ratio,
-            other?.user_group_ratio,
-            other.cache_tokens || 0,
-            other.cache_ratio || 1.0,
-            other.cache_creation_tokens || 0,
-            other.cache_creation_ratio || 1.0,
-          );
-        } else {
-          content = renderModelPrice(
-            logs[i].prompt_tokens,
-            logs[i].completion_tokens,
-            other?.model_ratio,
-            other?.model_price,
-            other?.completion_ratio,
-            other?.group_ratio,
-            other?.user_group_ratio,
-            other?.cache_tokens || 0,
-            other?.cache_ratio || 1.0,
-            other?.image || false,
-            other?.image_ratio || 0,
-            other?.image_output || 0,
-            other?.web_search || false,
-            other?.web_search_call_count || 0,
-            other?.web_search_price || 0,
-            other?.file_search || false,
-            other?.file_search_call_count || 0,
-            other?.file_search_price || 0,
-            other?.audio_input_seperate_price || false,
-            other?.audio_input_token_count || 0,
-            other?.audio_input_price || 0,
-          );
-        }
-        expandDataLocal.push({
-          key: t('计费过程'),
-          value: content,
-        });
-        if (other?.reasoning_effort) {
-          expandDataLocal.push({
-            key: t('Reasoning Effort'),
-            value: other.reasoning_effort,
-          });
-        }
-      }
-      expandDatesLocal[logs[i].key] = expandDataLocal;
-    }
-
-    setExpandData(expandDatesLocal);
-    setLogs(logs);
-  };
-
-  const loadLogs = async (startIdx, pageSize, customLogType = null) => {
-    setLoading(true);
-
-    let url = '';
-    const {
-      username,
-      token_name,
-      model_name,
-      start_timestamp,
-      end_timestamp,
-      channel,
-      group,
-      logType: formLogType,
-    } = getFormValues();
-
-    // 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
-    const currentLogType =
-      customLogType !== null
-        ? customLogType
-        : formLogType !== undefined
-          ? formLogType
-          : logType;
-
-    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    if (isAdminUser) {
-      url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
-    } else {
-      url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
-    }
-    url = encodeURI(url);
-    const res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setPageSize(data.page_size);
-      setLogCount(data.total);
-
-      setLogsFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const handlePageChange = (page) => {
-    setActivePage(page);
-    loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值
-  };
-
-  const handlePageSizeChange = async (size) => {
-    localStorage.setItem('page-size', size + '');
-    setPageSize(size);
-    setActivePage(1);
-    loadLogs(activePage, size)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-  };
-
-  const refresh = async () => {
-    setActivePage(1);
-    handleEyeClick();
-    await loadLogs(1, pageSize); // 不传入logType,让其从表单获取最新值
-  };
-
-  const copyText = async (e, text) => {
-    e.stopPropagation();
-    if (await copy(text)) {
-      showSuccess('已复制:' + text);
-    } else {
-      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
-    }
-  };
-
-  useEffect(() => {
-    const localPageSize =
-      parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
-    setPageSize(localPageSize);
-    loadLogs(activePage, localPageSize)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-  }, []);
-
-  // 当 formApi 可用时,初始化统计
-  useEffect(() => {
-    if (formApi) {
-      handleEyeClick();
-    }
-  }, [formApi]);
-
-  const expandRowRender = (record, index) => {
-    return <Descriptions data={expandData[record.key]} />;
-  };
-
-  // 检查是否有任何记录有展开内容
-  const hasExpandableRows = () => {
-    return logs.some(
-      (log) => expandData[log.key] && expandData[log.key].length > 0,
-    );
-  };
-
-  const [compactMode, setCompactMode] = useTableCompactMode('logs');
-
-  return (
-    <>
-      {renderColumnSelector()}
-      <Card
-        className='!rounded-2xl mb-4'
-        title={
-          <div className='flex flex-col w-full'>
-            <Spin spinning={loadingStat}>
-              <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-                <Space>
-                  <Tag
-                    color='blue'
-                    style={{
-                      fontWeight: 500,
-                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                      padding: 13,
-                    }}
-                    className='!rounded-lg'
-                  >
-                    {t('消耗额度')}: {renderQuota(stat.quota)}
-                  </Tag>
-                  <Tag
-                    color='pink'
-                    style={{
-                      fontWeight: 500,
-                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                      padding: 13,
-                    }}
-                    className='!rounded-lg'
-                  >
-                    RPM: {stat.rpm}
-                  </Tag>
-                  <Tag
-                    color='white'
-                    style={{
-                      border: 'none',
-                      boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                      fontWeight: 500,
-                      padding: 13,
-                    }}
-                    className='!rounded-lg'
-                  >
-                    TPM: {stat.tpm}
-                  </Tag>
-                </Space>
-
-                <Button
-                  type='tertiary'
-                  className="w-full md:w-auto"
-                  onClick={() => setCompactMode(!compactMode)}
-                  size="small"
-                >
-                  {compactMode ? t('自适应列表') : t('紧凑列表')}
-                </Button>
-              </div>
-            </Spin>
-
-            <Divider margin='12px' />
-
-            {/* 搜索表单区域 */}
-            <Form
-              initValues={formInitValues}
-              getFormApi={(api) => setFormApi(api)}
-              onSubmit={refresh}
-              allowEmpty={true}
-              autoComplete='off'
-              layout='vertical'
-              trigger='change'
-              stopValidateWithError={false}
-            >
-              <div className='flex flex-col gap-4'>
-                <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
-                  {/* 时间选择器 */}
-                  <div className='col-span-1 lg:col-span-2'>
-                    <Form.DatePicker
-                      field='dateRange'
-                      className='w-full'
-                      type='dateTimeRange'
-                      placeholder={[t('开始时间'), t('结束时间')]}
-                      showClear
-                      pure
-                      size="small"
-                    />
-                  </div>
-
-                  {/* 其他搜索字段 */}
-                  <Form.Input
-                    field='token_name'
-                    prefix={<IconSearch />}
-                    placeholder={t('令牌名称')}
-                    showClear
-                    pure
-                    size="small"
-                  />
-
-                  <Form.Input
-                    field='model_name'
-                    prefix={<IconSearch />}
-                    placeholder={t('模型名称')}
-                    showClear
-                    pure
-                    size="small"
-                  />
-
-                  <Form.Input
-                    field='group'
-                    prefix={<IconSearch />}
-                    placeholder={t('分组')}
-                    showClear
-                    pure
-                    size="small"
-                  />
-
-                  {isAdminUser && (
-                    <>
-                      <Form.Input
-                        field='channel'
-                        prefix={<IconSearch />}
-                        placeholder={t('渠道 ID')}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                      <Form.Input
-                        field='username'
-                        prefix={<IconSearch />}
-                        placeholder={t('用户名称')}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    </>
-                  )}
-                </div>
-
-                {/* 操作按钮区域 */}
-                <div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
-                  {/* 日志类型选择器 */}
-                  <div className='w-full sm:w-auto'>
-                    <Form.Select
-                      field='logType'
-                      placeholder={t('日志类型')}
-                      className='w-full sm:w-auto min-w-[120px]'
-                      showClear
-                      pure
-                      onChange={() => {
-                        // 延迟执行搜索,让表单值先更新
-                        setTimeout(() => {
-                          refresh();
-                        }, 0);
-                      }}
-                      size="small"
-                    >
-                      <Form.Select.Option value='0'>
-                        {t('全部')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='1'>
-                        {t('充值')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='2'>
-                        {t('消费')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='3'>
-                        {t('管理')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='4'>
-                        {t('系统')}
-                      </Form.Select.Option>
-                      <Form.Select.Option value='5'>
-                        {t('错误')}
-                      </Form.Select.Option>
-                    </Form.Select>
-                  </div>
-
-                  <div className='flex gap-2 w-full sm:w-auto justify-end'>
-                    <Button
-                      type='tertiary'
-                      htmlType='submit'
-                      loading={loading}
-                      size="small"
-                    >
-                      {t('查询')}
-                    </Button>
-                    <Button
-                      type='tertiary'
-                      onClick={() => {
-                        if (formApi) {
-                          formApi.reset();
-                          setLogType(0);
-                          setTimeout(() => {
-                            refresh();
-                          }, 100);
-                        }
-                      }}
-                      size="small"
-                    >
-                      {t('重置')}
-                    </Button>
-                    <Button
-                      type='tertiary'
-                      onClick={() => setShowColumnSelector(true)}
-                      size="small"
-                    >
-                      {t('列设置')}
-                    </Button>
-                  </div>
-                </div>
-              </div>
-            </Form>
-          </div>
-        }
-        shadows='always'
-        bordered={false}
-      >
-        <Table
-          columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
-          {...(hasExpandableRows() && {
-            expandedRowRender: expandRowRender,
-            expandRowByClick: true,
-            rowExpandable: (record) =>
-              expandData[record.key] && expandData[record.key].length > 0,
-          })}
-          dataSource={logs}
-          rowKey='key'
-          loading={loading}
-          scroll={compactMode ? undefined : { x: 'max-content' }}
-          className='rounded-xl overflow-hidden'
-          size='middle'
-          empty={
-            <Empty
-              image={
-                <IllustrationNoResult style={{ width: 150, height: 150 }} />
-              }
-              darkModeImage={
-                <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
-              }
-              description={t('搜索无结果')}
-              style={{ padding: 30 }}
-            />
-          }
-          pagination={{
-            formatPageText: (page) =>
-              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                start: page.currentStart,
-                end: page.currentEnd,
-                total: logCount,
-              }),
-            currentPage: activePage,
-            pageSize: pageSize,
-            total: logCount,
-            pageSizeOptions: [10, 20, 50, 100],
-            showSizeChanger: true,
-            onPageSizeChange: (size) => {
-              handlePageSizeChange(size);
-            },
-            onPageChange: handlePageChange,
-          }}
-        />
-      </Card>
-    </>
-  );
-};
-
-export default LogsTable;

+ 0 - 982
web/src/components/table/MjLogsTable.js

@@ -1,982 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
-  Palette,
-  ZoomIn,
-  Shuffle,
-  Move,
-  FileText,
-  Blend,
-  Upload,
-  Minimize2,
-  RotateCcw,
-  PaintBucket,
-  Focus,
-  Move3D,
-  Monitor,
-  UserCheck,
-  HelpCircle,
-  CheckCircle,
-  Clock,
-  Copy,
-  FileX,
-  Pause,
-  XCircle,
-  Loader,
-  AlertCircle,
-  Hash,
-} from 'lucide-react';
-import {
-  API,
-  copy,
-  isAdmin,
-  showError,
-  showSuccess,
-  timestamp2string
-} from '../../helpers';
-
-import {
-  Button,
-  Card,
-  Checkbox,
-  Divider,
-  Empty,
-  Form,
-  ImagePreview,
-  Layout,
-  Modal,
-  Progress,
-  Skeleton,
-  Table,
-  Tag,
-  Typography
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import { ITEMS_PER_PAGE } from '../../constants';
-import {
-  IconEyeOpened,
-  IconSearch,
-} from '@douyinfe/semi-icons';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-
-const { Text } = Typography;
-
-const colors = [
-  'amber',
-  'blue',
-  'cyan',
-  'green',
-  'grey',
-  'indigo',
-  'light-blue',
-  'lime',
-  'orange',
-  'pink',
-  'purple',
-  'red',
-  'teal',
-  'violet',
-  'yellow',
-];
-
-// 定义列键值常量
-const COLUMN_KEYS = {
-  SUBMIT_TIME: 'submit_time',
-  DURATION: 'duration',
-  CHANNEL: 'channel',
-  TYPE: 'type',
-  TASK_ID: 'task_id',
-  SUBMIT_RESULT: 'submit_result',
-  TASK_STATUS: 'task_status',
-  PROGRESS: 'progress',
-  IMAGE: 'image',
-  PROMPT: 'prompt',
-  PROMPT_EN: 'prompt_en',
-  FAIL_REASON: 'fail_reason',
-};
-
-const LogsTable = () => {
-  const { t } = useTranslation();
-  const [isModalOpen, setIsModalOpen] = useState(false);
-  const [modalContent, setModalContent] = useState('');
-
-  // 列可见性状态
-  const [visibleColumns, setVisibleColumns] = useState({});
-  const [showColumnSelector, setShowColumnSelector] = useState(false);
-  const isAdminUser = isAdmin();
-  const [compactMode, setCompactMode] = useTableCompactMode('mjLogs');
-
-  // 加载保存的列偏好设置
-  useEffect(() => {
-    const savedColumns = localStorage.getItem('mj-logs-table-columns');
-    if (savedColumns) {
-      try {
-        const parsed = JSON.parse(savedColumns);
-        const defaults = getDefaultColumnVisibility();
-        const merged = { ...defaults, ...parsed };
-        setVisibleColumns(merged);
-      } catch (e) {
-        console.error('Failed to parse saved column preferences', e);
-        initDefaultColumns();
-      }
-    } else {
-      initDefaultColumns();
-    }
-  }, []);
-
-  // 获取默认列可见性
-  const getDefaultColumnVisibility = () => {
-    return {
-      [COLUMN_KEYS.SUBMIT_TIME]: true,
-      [COLUMN_KEYS.DURATION]: true,
-      [COLUMN_KEYS.CHANNEL]: isAdminUser,
-      [COLUMN_KEYS.TYPE]: true,
-      [COLUMN_KEYS.TASK_ID]: true,
-      [COLUMN_KEYS.SUBMIT_RESULT]: isAdminUser,
-      [COLUMN_KEYS.TASK_STATUS]: true,
-      [COLUMN_KEYS.PROGRESS]: true,
-      [COLUMN_KEYS.IMAGE]: true,
-      [COLUMN_KEYS.PROMPT]: true,
-      [COLUMN_KEYS.PROMPT_EN]: true,
-      [COLUMN_KEYS.FAIL_REASON]: true,
-    };
-  };
-
-  // 初始化默认列可见性
-  const initDefaultColumns = () => {
-    const defaults = getDefaultColumnVisibility();
-    setVisibleColumns(defaults);
-    localStorage.setItem('mj-logs-table-columns', JSON.stringify(defaults));
-  };
-
-  // 处理列可见性变化
-  const handleColumnVisibilityChange = (columnKey, checked) => {
-    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
-    setVisibleColumns(updatedColumns);
-  };
-
-  // 处理全选
-  const handleSelectAll = (checked) => {
-    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
-    const updatedColumns = {};
-
-    allKeys.forEach((key) => {
-      if ((key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) && !isAdminUser) {
-        updatedColumns[key] = false;
-      } else {
-        updatedColumns[key] = checked;
-      }
-    });
-
-    setVisibleColumns(updatedColumns);
-  };
-
-  // 更新表格时保存列可见性
-  useEffect(() => {
-    if (Object.keys(visibleColumns).length > 0) {
-      localStorage.setItem('mj-logs-table-columns', JSON.stringify(visibleColumns));
-    }
-  }, [visibleColumns]);
-
-  function renderType(type) {
-    switch (type) {
-      case 'IMAGINE':
-        return (
-          <Tag color='blue' shape='circle' prefixIcon={<Palette size={14} />}>
-            {t('绘图')}
-          </Tag>
-        );
-      case 'UPSCALE':
-        return (
-          <Tag color='orange' shape='circle' prefixIcon={<ZoomIn size={14} />}>
-            {t('放大')}
-          </Tag>
-        );
-      case 'VIDEO':
-        return (
-          <Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
-            {t('视频')}
-          </Tag>
-        );
-      case 'EDITS':
-        return (
-          <Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
-            {t('编辑')}
-          </Tag>
-        );
-      case 'VARIATION':
-        return (
-          <Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
-            {t('变换')}
-          </Tag>
-        );
-      case 'HIGH_VARIATION':
-        return (
-          <Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
-            {t('强变换')}
-          </Tag>
-        );
-      case 'LOW_VARIATION':
-        return (
-          <Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
-            {t('弱变换')}
-          </Tag>
-        );
-      case 'PAN':
-        return (
-          <Tag color='cyan' shape='circle' prefixIcon={<Move size={14} />}>
-            {t('平移')}
-          </Tag>
-        );
-      case 'DESCRIBE':
-        return (
-          <Tag color='yellow' shape='circle' prefixIcon={<FileText size={14} />}>
-            {t('图生文')}
-          </Tag>
-        );
-      case 'BLEND':
-        return (
-          <Tag color='lime' shape='circle' prefixIcon={<Blend size={14} />}>
-            {t('图混合')}
-          </Tag>
-        );
-      case 'UPLOAD':
-        return (
-          <Tag color='blue' shape='circle' prefixIcon={<Upload size={14} />}>
-            上传文件
-          </Tag>
-        );
-      case 'SHORTEN':
-        return (
-          <Tag color='pink' shape='circle' prefixIcon={<Minimize2 size={14} />}>
-            {t('缩词')}
-          </Tag>
-        );
-      case 'REROLL':
-        return (
-          <Tag color='indigo' shape='circle' prefixIcon={<RotateCcw size={14} />}>
-            {t('重绘')}
-          </Tag>
-        );
-      case 'INPAINT':
-        return (
-          <Tag color='violet' shape='circle' prefixIcon={<PaintBucket size={14} />}>
-            {t('局部重绘-提交')}
-          </Tag>
-        );
-      case 'ZOOM':
-        return (
-          <Tag color='teal' shape='circle' prefixIcon={<Focus size={14} />}>
-            {t('变焦')}
-          </Tag>
-        );
-      case 'CUSTOM_ZOOM':
-        return (
-          <Tag color='teal' shape='circle' prefixIcon={<Move3D size={14} />}>
-            {t('自定义变焦-提交')}
-          </Tag>
-        );
-      case 'MODAL':
-        return (
-          <Tag color='green' shape='circle' prefixIcon={<Monitor size={14} />}>
-            {t('窗口处理')}
-          </Tag>
-        );
-      case 'SWAP_FACE':
-        return (
-          <Tag color='light-green' shape='circle' prefixIcon={<UserCheck size={14} />}>
-            {t('换脸')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  }
-
-  function renderCode(code) {
-    switch (code) {
-      case 1:
-        return (
-          <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
-            {t('已提交')}
-          </Tag>
-        );
-      case 21:
-        return (
-          <Tag color='lime' shape='circle' prefixIcon={<Clock size={14} />}>
-            {t('等待中')}
-          </Tag>
-        );
-      case 22:
-        return (
-          <Tag color='orange' shape='circle' prefixIcon={<Copy size={14} />}>
-            {t('重复提交')}
-          </Tag>
-        );
-      case 0:
-        return (
-          <Tag color='yellow' shape='circle' prefixIcon={<FileX size={14} />}>
-            {t('未提交')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  }
-
-  function renderStatus(type) {
-    switch (type) {
-      case 'SUCCESS':
-        return (
-          <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
-            {t('成功')}
-          </Tag>
-        );
-      case 'NOT_START':
-        return (
-          <Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
-            {t('未启动')}
-          </Tag>
-        );
-      case 'SUBMITTED':
-        return (
-          <Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
-            {t('队列中')}
-          </Tag>
-        );
-      case 'IN_PROGRESS':
-        return (
-          <Tag color='blue' shape='circle' prefixIcon={<Loader size={14} />}>
-            {t('执行中')}
-          </Tag>
-        );
-      case 'FAILURE':
-        return (
-          <Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
-            {t('失败')}
-          </Tag>
-        );
-      case 'MODAL':
-        return (
-          <Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
-            {t('窗口等待')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  }
-
-  const renderTimestamp = (timestampInSeconds) => {
-    const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
-
-    const year = date.getFullYear(); // 获取年份
-    const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
-    const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
-    const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
-    const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
-    const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
-
-    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
-  };
-  // 修改renderDuration函数以包含颜色逻辑
-  function renderDuration(submit_time, finishTime) {
-    if (!submit_time || !finishTime) return 'N/A';
-
-    const start = new Date(submit_time);
-    const finish = new Date(finishTime);
-    const durationMs = finish - start;
-    const durationSec = (durationMs / 1000).toFixed(1);
-    const color = durationSec > 60 ? 'red' : 'green';
-
-    return (
-      <Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
-        {durationSec} {t('秒')}
-      </Tag>
-    );
-  }
-
-  // 定义所有列
-  const allColumns = [
-    {
-      key: COLUMN_KEYS.SUBMIT_TIME,
-      title: t('提交时间'),
-      dataIndex: 'submit_time',
-      render: (text, record, index) => {
-        return <div>{renderTimestamp(text / 1000)}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.DURATION,
-      title: t('花费时间'),
-      dataIndex: 'finish_time',
-      render: (finish, record) => {
-        return renderDuration(record.submit_time, finish);
-      },
-    },
-    {
-      key: COLUMN_KEYS.CHANNEL,
-      title: t('渠道'),
-      dataIndex: 'channel_id',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return isAdminUser ? (
-          <div>
-            <Tag
-              color={colors[parseInt(text) % colors.length]}
-              shape='circle'
-              prefixIcon={<Hash size={14} />}
-              onClick={() => {
-                copyText(text);
-              }}
-            >
-              {' '}
-              {text}{' '}
-            </Tag>
-          </div>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.TYPE,
-      title: t('类型'),
-      dataIndex: 'action',
-      render: (text, record, index) => {
-        return <div>{renderType(text)}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.TASK_ID,
-      title: t('任务ID'),
-      dataIndex: 'mj_id',
-      render: (text, record, index) => {
-        return <div>{text}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.SUBMIT_RESULT,
-      title: t('提交结果'),
-      dataIndex: 'code',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return isAdminUser ? <div>{renderCode(text)}</div> : <></>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.TASK_STATUS,
-      title: t('任务状态'),
-      dataIndex: 'status',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return <div>{renderStatus(text)}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.PROGRESS,
-      title: t('进度'),
-      dataIndex: 'progress',
-      render: (text, record, index) => {
-        return (
-          <div>
-            {
-              <Progress
-                stroke={
-                  record.status === 'FAILURE'
-                    ? 'var(--semi-color-warning)'
-                    : null
-                }
-                percent={text ? parseInt(text.replace('%', '')) : 0}
-                showInfo={true}
-                aria-label='drawing progress'
-                style={{ minWidth: '160px' }}
-              />
-            }
-          </div>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.IMAGE,
-      title: t('结果图片'),
-      dataIndex: 'image_url',
-      render: (text, record, index) => {
-        if (!text) {
-          return t('无');
-        }
-        return (
-          <Button
-            size="small"
-            onClick={() => {
-              setModalImageUrl(text);
-              setIsModalOpenurl(true);
-            }}
-          >
-            {t('查看图片')}
-          </Button>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.PROMPT,
-      title: 'Prompt',
-      dataIndex: 'prompt',
-      render: (text, record, index) => {
-        if (!text) {
-          return t('无');
-        }
-
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.PROMPT_EN,
-      title: 'PromptEn',
-      dataIndex: 'prompt_en',
-      render: (text, record, index) => {
-        if (!text) {
-          return t('无');
-        }
-
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.FAIL_REASON,
-      title: t('失败原因'),
-      dataIndex: 'fail_reason',
-      fixed: 'right',
-      render: (text, record, index) => {
-        if (!text) {
-          return t('无');
-        }
-
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-  ];
-
-  // 根据可见性设置过滤列
-  const getVisibleColumns = () => {
-    return allColumns.filter((column) => visibleColumns[column.key]);
-  };
-
-  const [logs, setLogs] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [logCount, setLogCount] = useState(0);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
-  const [showBanner, setShowBanner] = useState(false);
-
-  // 定义模态框图片URL的状态和更新函数
-  const [modalImageUrl, setModalImageUrl] = useState('');
-  let now = new Date();
-
-  // Form 初始值
-  const formInitValues = {
-    channel_id: '',
-    mj_id: '',
-    dateRange: [
-      timestamp2string(now.getTime() / 1000 - 2592000),
-      timestamp2string(now.getTime() / 1000 + 3600)
-    ],
-  };
-
-  // Form API 引用
-  const [formApi, setFormApi] = useState(null);
-
-  const [stat, setStat] = useState({
-    quota: 0,
-    token: 0,
-  });
-
-  // 获取表单值的辅助函数
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-
-    // 处理时间范围
-    let start_timestamp = timestamp2string(now.getTime() / 1000 - 2592000);
-    let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
-
-    if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
-      start_timestamp = formValues.dateRange[0];
-      end_timestamp = formValues.dateRange[1];
-    }
-
-    return {
-      channel_id: formValues.channel_id || '',
-      mj_id: formValues.mj_id || '',
-      start_timestamp,
-      end_timestamp,
-    };
-  };
-
-  const enrichLogs = (items) => {
-    return items.map((log) => ({
-      ...log,
-      timestamp2string: timestamp2string(log.created_at),
-      key: '' + log.id,
-    }));
-  };
-
-  const syncPageData = (payload) => {
-    const items = enrichLogs(payload.items || []);
-    setLogs(items);
-    setLogCount(payload.total || 0);
-    setActivePage(payload.page || 1);
-    setPageSize(payload.page_size || pageSize);
-  };
-
-  const loadLogs = async (page = 1, size = pageSize) => {
-    setLoading(true);
-    const { channel_id, mj_id, start_timestamp, end_timestamp } = getFormValues();
-    let localStartTimestamp = Date.parse(start_timestamp);
-    let localEndTimestamp = Date.parse(end_timestamp);
-    const url = isAdminUser
-      ? `/api/mj/?p=${page}&page_size=${size}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
-      : `/api/mj/self/?p=${page}&page_size=${size}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-    const res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      syncPageData(data);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const pageData = logs;
-
-  const handlePageChange = (page) => {
-    loadLogs(page, pageSize).then();
-  };
-
-  const handlePageSizeChange = async (size) => {
-    localStorage.setItem('mj-page-size', size + '');
-    await loadLogs(1, size);
-  };
-
-  const refresh = async () => {
-    await loadLogs(1, pageSize);
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess(t('已复制:') + text);
-    } else {
-      // setSearchKeyword(text);
-      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
-    }
-  };
-
-  useEffect(() => {
-    const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
-    setPageSize(localPageSize);
-    loadLogs(1, localPageSize).then();
-  }, []);
-
-  useEffect(() => {
-    const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
-    if (mjNotifyEnabled !== 'true') {
-      setShowBanner(true);
-    }
-  }, []);
-
-  // 列选择器模态框
-  const renderColumnSelector = () => {
-    return (
-      <Modal
-        title={t('列设置')}
-        visible={showColumnSelector}
-        onCancel={() => setShowColumnSelector(false)}
-        footer={
-          <div className="flex justify-end">
-            <Button onClick={() => initDefaultColumns()}>
-              {t('重置')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('取消')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('确定')}
-            </Button>
-          </div>
-        }
-      >
-        <div style={{ marginBottom: 20 }}>
-          <Checkbox
-            checked={Object.values(visibleColumns).every((v) => v === true)}
-            indeterminate={
-              Object.values(visibleColumns).some((v) => v === true) &&
-              !Object.values(visibleColumns).every((v) => v === true)
-            }
-            onChange={(e) => handleSelectAll(e.target.checked)}
-          >
-            {t('全选')}
-          </Checkbox>
-        </div>
-        <div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" style={{ border: '1px solid var(--semi-color-border)' }}>
-          {allColumns.map((column) => {
-            // 为非管理员用户跳过管理员专用列
-            if (
-              !isAdminUser &&
-              (column.key === COLUMN_KEYS.CHANNEL ||
-                column.key === COLUMN_KEYS.SUBMIT_RESULT)
-            ) {
-              return null;
-            }
-
-            return (
-              <div key={column.key} className="w-1/2 mb-4 pr-2">
-                <Checkbox
-                  checked={!!visibleColumns[column.key]}
-                  onChange={(e) =>
-                    handleColumnVisibilityChange(column.key, e.target.checked)
-                  }
-                >
-                  {column.title}
-                </Checkbox>
-              </div>
-            );
-          })}
-        </div>
-      </Modal>
-    );
-  };
-
-  return (
-    <>
-      {renderColumnSelector()}
-      <Layout>
-        <Card
-          className="!rounded-2xl mb-4"
-          title={
-            <div className="flex flex-col w-full">
-              <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-                <div className="flex items-center text-orange-500 mb-2 md:mb-0">
-                  <IconEyeOpened className="mr-2" />
-                  {loading ? (
-                    <Skeleton.Title
-                      style={{
-                        width: 300,
-                        marginBottom: 0,
-                        marginTop: 0
-                      }}
-                    />
-                  ) : (
-                    <Text>
-                      {isAdminUser && showBanner
-                        ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
-                        : t('Midjourney 任务记录')}
-                    </Text>
-                  )}
-                </div>
-                <Button
-                  type='tertiary'
-                  className="w-full md:w-auto"
-                  onClick={() => setCompactMode(!compactMode)}
-                  size="small"
-                >
-                  {compactMode ? t('自适应列表') : t('紧凑列表')}
-                </Button>
-              </div>
-
-              <Divider margin="12px" />
-
-              {/* 搜索表单区域 */}
-              <Form
-                initValues={formInitValues}
-                getFormApi={(api) => setFormApi(api)}
-                onSubmit={refresh}
-                allowEmpty={true}
-                autoComplete="off"
-                layout="vertical"
-                trigger="change"
-                stopValidateWithError={false}
-              >
-                <div className="flex flex-col gap-4">
-                  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
-                    {/* 时间选择器 */}
-                    <div className="col-span-1 lg:col-span-2">
-                      <Form.DatePicker
-                        field='dateRange'
-                        className="w-full"
-                        type='dateTimeRange'
-                        placeholder={[t('开始时间'), t('结束时间')]}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    </div>
-
-                    {/* 任务 ID */}
-                    <Form.Input
-                      field='mj_id'
-                      prefix={<IconSearch />}
-                      placeholder={t('任务 ID')}
-                      showClear
-                      pure
-                      size="small"
-                    />
-
-                    {/* 渠道 ID - 仅管理员可见 */}
-                    {isAdminUser && (
-                      <Form.Input
-                        field='channel_id'
-                        prefix={<IconSearch />}
-                        placeholder={t('渠道 ID')}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    )}
-                  </div>
-
-                  {/* 操作按钮区域 */}
-                  <div className="flex justify-between items-center">
-                    <div></div>
-                    <div className="flex gap-2">
-                      <Button
-                        type='tertiary'
-                        htmlType='submit'
-                        loading={loading}
-                        size="small"
-                      >
-                        {t('查询')}
-                      </Button>
-                      <Button
-                        type='tertiary'
-                        onClick={() => {
-                          if (formApi) {
-                            formApi.reset();
-                            // 重置后立即查询,使用setTimeout确保表单重置完成
-                            setTimeout(() => {
-                              refresh();
-                            }, 100);
-                          }
-                        }}
-                        size="small"
-                      >
-                        {t('重置')}
-                      </Button>
-                      <Button
-                        type='tertiary'
-                        onClick={() => setShowColumnSelector(true)}
-                        size="small"
-                      >
-                        {t('列设置')}
-                      </Button>
-                    </div>
-                  </div>
-                </div>
-              </Form>
-            </div>
-          }
-          shadows='always'
-          bordered={false}
-        >
-          <Table
-            columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
-            dataSource={logs}
-            rowKey='key'
-            loading={loading}
-            scroll={compactMode ? undefined : { x: 'max-content' }}
-            className="rounded-xl overflow-hidden"
-            size="middle"
-            empty={
-              <Empty
-                image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-                darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-                description={t('搜索无结果')}
-                style={{ padding: 30 }}
-              />
-            }
-            pagination={{
-              formatPageText: (page) =>
-                t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                  start: page.currentStart,
-                  end: page.currentEnd,
-                  total: logCount,
-                }),
-              currentPage: activePage,
-              pageSize: pageSize,
-              total: logCount,
-              pageSizeOptions: [10, 20, 50, 100],
-              showSizeChanger: true,
-              onPageSizeChange: handlePageSizeChange,
-              onPageChange: handlePageChange,
-            }}
-          />
-        </Card>
-
-        <Modal
-          visible={isModalOpen}
-          onOk={() => setIsModalOpen(false)}
-          onCancel={() => setIsModalOpen(false)}
-          closable={null}
-          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
-          width={800} // 设置模态框宽度
-        >
-          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
-        </Modal>
-        <ImagePreview
-          src={modalImageUrl}
-          visible={isModalOpenurl}
-          onVisibleChange={(visible) => setIsModalOpenurl(visible)}
-        />
-      </Layout>
-    </>
-  );
-};
-
-export default LogsTable;

+ 19 - 6
web/src/components/table/ModelPricing.js

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
 import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag, stringToColor } from '../../helpers';
 import { useTranslation } from 'react-i18next';
@@ -535,12 +554,6 @@ const ModelPricing = () => {
           pageSize: pageSize,
           showSizeChanger: true,
           pageSizeOptions: [10, 20, 50, 100],
-          formatPageText: (page) =>
-            t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-              start: page.currentStart,
-              end: page.currentEnd,
-              total: filteredModels.length,
-            }),
           onPageSizeChange: (size) => setPageSize(size),
         }}
       />

+ 0 - 629
web/src/components/table/RedemptionsTable.js

@@ -1,629 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import {
-  API,
-  copy,
-  showError,
-  showSuccess,
-  timestamp2string,
-  renderQuota
-} from '../../helpers';
-
-import { Ticket } from 'lucide-react';
-
-import { ITEMS_PER_PAGE } from '../../constants';
-import {
-  Button,
-  Card,
-  Divider,
-  Dropdown,
-  Empty,
-  Form,
-  Modal,
-  Popover,
-  Space,
-  Table,
-  Tag,
-  Typography
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import {
-  IconSearch,
-  IconMore,
-} from '@douyinfe/semi-icons';
-import EditRedemption from '../../pages/Redemption/EditRedemption';
-import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-
-const { Text } = Typography;
-
-function renderTimestamp(timestamp) {
-  return <>{timestamp2string(timestamp)}</>;
-}
-
-const RedemptionsTable = () => {
-  const { t } = useTranslation();
-
-  const isExpired = (rec) => {
-    return rec.status === 1 && rec.expired_time !== 0 && rec.expired_time < Math.floor(Date.now() / 1000);
-  };
-
-  const renderStatus = (status, record) => {
-    if (isExpired(record)) {
-      return (
-        <Tag color='orange' shape='circle'>{t('已过期')}</Tag>
-      );
-    }
-    switch (status) {
-      case 1:
-        return (
-          <Tag color='green' shape='circle'>
-            {t('未使用')}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='red' shape='circle'>
-            {t('已禁用')}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='grey' shape='circle'>
-            {t('已使用')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='black' shape='circle'>
-            {t('未知状态')}
-          </Tag>
-        );
-    }
-  };
-
-  const columns = [
-    {
-      title: t('ID'),
-      dataIndex: 'id',
-    },
-    {
-      title: t('名称'),
-      dataIndex: 'name',
-    },
-    {
-      title: t('状态'),
-      dataIndex: 'status',
-      key: 'status',
-      render: (text, record, index) => {
-        return <div>{renderStatus(text, record)}</div>;
-      },
-    },
-    {
-      title: t('额度'),
-      dataIndex: 'quota',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Tag color='grey' shape='circle'>
-              {renderQuota(parseInt(text))}
-            </Tag>
-          </div>
-        );
-      },
-    },
-    {
-      title: t('创建时间'),
-      dataIndex: 'created_time',
-      render: (text, record, index) => {
-        return <div>{renderTimestamp(text)}</div>;
-      },
-    },
-    {
-      title: t('过期时间'),
-      dataIndex: 'expired_time',
-      render: (text) => {
-        return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
-      },
-    },
-    {
-      title: t('兑换人ID'),
-      dataIndex: 'used_user_id',
-      render: (text, record, index) => {
-        return <div>{text === 0 ? t('无') : text}</div>;
-      },
-    },
-    {
-      title: '',
-      dataIndex: 'operate',
-      fixed: 'right',
-      width: 205,
-      render: (text, record, index) => {
-        // 创建更多操作的下拉菜单项
-        const moreMenuItems = [
-          {
-            node: 'item',
-            name: t('删除'),
-            type: 'danger',
-            onClick: () => {
-              Modal.confirm({
-                title: t('确定是否要删除此兑换码?'),
-                content: t('此修改将不可逆'),
-                onOk: () => {
-                  (async () => {
-                    await manageRedemption(record.id, 'delete', record);
-                    await refresh();
-                    setTimeout(() => {
-                      if (redemptions.length === 0 && activePage > 1) {
-                        refresh(activePage - 1);
-                      }
-                    }, 100);
-                  })();
-                },
-              });
-            },
-          }
-        ];
-
-        if (record.status === 1 && !isExpired(record)) {
-          moreMenuItems.push({
-            node: 'item',
-            name: t('禁用'),
-            type: 'warning',
-            onClick: () => {
-              manageRedemption(record.id, 'disable', record);
-            },
-          });
-        } else if (!isExpired(record)) {
-          moreMenuItems.push({
-            node: 'item',
-            name: t('启用'),
-            type: 'secondary',
-            onClick: () => {
-              manageRedemption(record.id, 'enable', record);
-            },
-            disabled: record.status === 3,
-          });
-        }
-
-        return (
-          <Space>
-            <Popover content={record.key} style={{ padding: 20 }} position='top'>
-              <Button
-                type='tertiary'
-                size="small"
-              >
-                {t('查看')}
-              </Button>
-            </Popover>
-            <Button
-              size="small"
-              onClick={async () => {
-                await copyText(record.key);
-              }}
-            >
-              {t('复制')}
-            </Button>
-            <Button
-              type='tertiary'
-              size="small"
-              onClick={() => {
-                setEditingRedemption(record);
-                setShowEdit(true);
-              }}
-              disabled={record.status !== 1}
-            >
-              {t('编辑')}
-            </Button>
-            <Dropdown
-              trigger='click'
-              position='bottomRight'
-              menu={moreMenuItems}
-            >
-              <Button
-                type='tertiary'
-                size="small"
-                icon={<IconMore />}
-              />
-            </Dropdown>
-          </Space>
-        );
-      },
-    },
-  ];
-
-  const [redemptions, setRedemptions] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [searching, setSearching] = useState(false);
-  const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
-  const [selectedKeys, setSelectedKeys] = useState([]);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [editingRedemption, setEditingRedemption] = useState({
-    id: undefined,
-  });
-  const [showEdit, setShowEdit] = useState(false);
-  const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
-
-  const formInitValues = {
-    searchKeyword: '',
-  };
-
-  const [formApi, setFormApi] = useState(null);
-
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-    return {
-      searchKeyword: formValues.searchKeyword || '',
-    };
-  };
-
-  const closeEdit = () => {
-    setShowEdit(false);
-    setTimeout(() => {
-      setEditingRedemption({
-        id: undefined,
-      });
-    }, 500);
-  };
-
-  const setRedemptionFormat = (redeptions) => {
-    setRedemptions(redeptions);
-  };
-
-  const loadRedemptions = async (page = 1, pageSize) => {
-    setLoading(true);
-    const res = await API.get(
-      `/api/redemption/?p=${page}&page_size=${pageSize}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page <= 0 ? 1 : data.page);
-      setTokenCount(data.total);
-      setRedemptionFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const removeRecord = (key) => {
-    let newDataSource = [...redemptions];
-    if (key != null) {
-      let idx = newDataSource.findIndex((data) => data.key === key);
-
-      if (idx > -1) {
-        newDataSource.splice(idx, 1);
-        setRedemptions(newDataSource);
-      }
-    }
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess(t('已复制到剪贴板!'));
-    } else {
-      Modal.error({
-        title: t('无法复制到剪贴板,请手动复制'),
-        content: text,
-        size: 'large'
-      });
-    }
-  };
-
-  useEffect(() => {
-    loadRedemptions(1, pageSize)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-  }, [pageSize]);
-
-  const refresh = async (page = activePage) => {
-    const { searchKeyword } = getFormValues();
-    if (searchKeyword === '') {
-      await loadRedemptions(page, pageSize);
-    } else {
-      await searchRedemptions(searchKeyword, page, pageSize);
-    }
-  };
-
-  const manageRedemption = async (id, action, record) => {
-    setLoading(true);
-    let data = { id };
-    let res;
-    switch (action) {
-      case 'delete':
-        res = await API.delete(`/api/redemption/${id}/`);
-        break;
-      case 'enable':
-        data.status = 1;
-        res = await API.put('/api/redemption/?status_only=true', data);
-        break;
-      case 'disable':
-        data.status = 2;
-        res = await API.put('/api/redemption/?status_only=true', data);
-        break;
-    }
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(t('操作成功完成!'));
-      let redemption = res.data.data;
-      let newRedemptions = [...redemptions];
-      if (action === 'delete') {
-      } else {
-        record.status = redemption.status;
-      }
-      setRedemptions(newRedemptions);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const searchRedemptions = async (keyword = null, page, pageSize) => {
-    // 如果没有传递keyword参数,从表单获取值
-    if (keyword === null) {
-      const formValues = getFormValues();
-      keyword = formValues.searchKeyword;
-    }
-
-    if (keyword === '') {
-      await loadRedemptions(page, pageSize);
-      return;
-    }
-    setSearching(true);
-    const res = await API.get(
-      `/api/redemption/search?keyword=${keyword}&p=${page}&page_size=${pageSize}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setTokenCount(data.total);
-      setRedemptionFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setSearching(false);
-  };
-
-  const handlePageChange = (page) => {
-    setActivePage(page);
-    const { searchKeyword } = getFormValues();
-    if (searchKeyword === '') {
-      loadRedemptions(page, pageSize).then();
-    } else {
-      searchRedemptions(searchKeyword, page, pageSize).then();
-    }
-  };
-
-  let pageData = redemptions;
-  const rowSelection = {
-    onSelect: (record, selected) => { },
-    onSelectAll: (selected, selectedRows) => { },
-    onChange: (selectedRowKeys, selectedRows) => {
-      setSelectedKeys(selectedRows);
-    },
-  };
-
-  const handleRow = (record, index) => {
-    if (record.status !== 1 || isExpired(record)) {
-      return {
-        style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
-      };
-    } else {
-      return {};
-    }
-  };
-
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      <div className="mb-2">
-        <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-          <div className="flex items-center text-orange-500">
-            <Ticket size={16} className="mr-2" />
-            <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
-          </div>
-          <Button
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={() => setCompactMode(!compactMode)}
-            size="small"
-          >
-            {compactMode ? t('自适应列表') : t('紧凑列表')}
-          </Button>
-        </div>
-      </div>
-
-      <Divider margin="12px" />
-
-      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
-          <div className="flex gap-2 w-full sm:w-auto">
-            <Button
-              type='primary'
-              className="w-full sm:w-auto"
-              onClick={() => {
-                setEditingRedemption({
-                  id: undefined,
-                });
-                setShowEdit(true);
-              }}
-              size="small"
-            >
-              {t('添加兑换码')}
-            </Button>
-            <Button
-              type='tertiary'
-              className="w-full sm:w-auto"
-              onClick={async () => {
-                if (selectedKeys.length === 0) {
-                  showError(t('请至少选择一个兑换码!'));
-                  return;
-                }
-                let keys = '';
-                for (let i = 0; i < selectedKeys.length; i++) {
-                  keys +=
-                    selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
-                }
-                await copyText(keys);
-              }}
-              size="small"
-            >
-              {t('复制所选兑换码到剪贴板')}
-            </Button>
-          </div>
-          <Button
-            type='danger'
-            className="w-full sm:w-auto"
-            onClick={() => {
-              Modal.confirm({
-                title: t('确定清除所有失效兑换码?'),
-                content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
-                onOk: async () => {
-                  setLoading(true);
-                  const res = await API.delete('/api/redemption/invalid');
-                  const { success, message, data } = res.data;
-                  if (success) {
-                    showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
-                    await refresh();
-                  } else {
-                    showError(message);
-                  }
-                  setLoading(false);
-                },
-              });
-            }}
-            size="small"
-          >
-            {t('清除失效兑换码')}
-          </Button>
-        </div>
-
-        <Form
-          initValues={formInitValues}
-          getFormApi={(api) => setFormApi(api)}
-          onSubmit={() => {
-            setActivePage(1);
-            searchRedemptions(null, 1, pageSize);
-          }}
-          allowEmpty={true}
-          autoComplete="off"
-          layout="horizontal"
-          trigger="change"
-          stopValidateWithError={false}
-          className="w-full md:w-auto order-1 md:order-2"
-        >
-          <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
-            <div className="relative w-full md:w-64">
-              <Form.Input
-                field="searchKeyword"
-                prefix={<IconSearch />}
-                placeholder={t('关键字(id或者名称)')}
-                showClear
-                pure
-                size="small"
-              />
-            </div>
-            <div className="flex gap-2 w-full md:w-auto">
-              <Button
-                type="tertiary"
-                htmlType="submit"
-                loading={loading || searching}
-                className="flex-1 md:flex-initial md:w-auto"
-                size="small"
-              >
-                {t('查询')}
-              </Button>
-              <Button
-                type="tertiary"
-                onClick={() => {
-                  if (formApi) {
-                    formApi.reset();
-                    setTimeout(() => {
-                      setActivePage(1);
-                      loadRedemptions(1, pageSize);
-                    }, 100);
-                  }
-                }}
-                className="flex-1 md:flex-initial md:w-auto"
-                size="small"
-              >
-                {t('重置')}
-              </Button>
-            </div>
-          </div>
-        </Form>
-      </div>
-    </div>
-  );
-
-  return (
-    <>
-      <EditRedemption
-        refresh={refresh}
-        editingRedemption={editingRedemption}
-        visiable={showEdit}
-        handleClose={closeEdit}
-      ></EditRedemption>
-
-      <Card
-        className="!rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
-      >
-        <Table
-          columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
-          dataSource={pageData}
-          scroll={compactMode ? undefined : { x: 'max-content' }}
-          pagination={{
-            currentPage: activePage,
-            pageSize: pageSize,
-            total: tokenCount,
-            showSizeChanger: true,
-            pageSizeOptions: [10, 20, 50, 100],
-            formatPageText: (page) =>
-              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                start: page.currentStart,
-                end: page.currentEnd,
-                total: tokenCount,
-              }),
-            onPageSizeChange: (size) => {
-              setPageSize(size);
-              setActivePage(1);
-              const { searchKeyword } = getFormValues();
-              if (searchKeyword === '') {
-                loadRedemptions(1, size).then();
-              } else {
-                searchRedemptions(searchKeyword, 1, size).then();
-              }
-            },
-            onPageChange: handlePageChange,
-          }}
-          loading={loading}
-          rowSelection={rowSelection}
-          onRow={handleRow}
-          empty={
-            <Empty
-              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-              description={t('搜索无结果')}
-              style={{ padding: 30 }}
-            />
-          }
-          className="rounded-xl overflow-hidden"
-          size="middle"
-        ></Table>
-      </Card>
-    </>
-  );
-};
-
-export default RedemptionsTable;

+ 0 - 813
web/src/components/table/TaskLogsTable.js

@@ -1,813 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
-  Music,
-  FileText,
-  HelpCircle,
-  CheckCircle,
-  Pause,
-  Clock,
-  Play,
-  XCircle,
-  Loader,
-  List,
-  Hash,
-  Video,
-  Sparkles
-} from 'lucide-react';
-import {
-  API,
-  copy,
-  isAdmin,
-  showError,
-  showSuccess,
-  timestamp2string
-} from '../../helpers';
-
-import {
-  Button,
-  Card,
-  Checkbox,
-  Divider,
-  Empty,
-  Form,
-  Layout,
-  Modal,
-  Progress,
-  Table,
-  Tag,
-  Typography
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import { ITEMS_PER_PAGE } from '../../constants';
-import {
-  IconEyeOpened,
-  IconSearch,
-} from '@douyinfe/semi-icons';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant';
-
-const { Text } = Typography;
-
-const colors = [
-  'amber',
-  'blue',
-  'cyan',
-  'green',
-  'grey',
-  'indigo',
-  'light-blue',
-  'lime',
-  'orange',
-  'pink',
-  'purple',
-  'red',
-  'teal',
-  'violet',
-  'yellow',
-];
-
-// 定义列键值常量
-const COLUMN_KEYS = {
-  SUBMIT_TIME: 'submit_time',
-  FINISH_TIME: 'finish_time',
-  DURATION: 'duration',
-  CHANNEL: 'channel',
-  PLATFORM: 'platform',
-  TYPE: 'type',
-  TASK_ID: 'task_id',
-  TASK_STATUS: 'task_status',
-  PROGRESS: 'progress',
-  FAIL_REASON: 'fail_reason',
-  RESULT_URL: 'result_url',
-};
-
-const renderTimestamp = (timestampInSeconds) => {
-  const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
-
-  const year = date.getFullYear(); // 获取年份
-  const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
-  const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
-  const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
-  const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
-  const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
-
-  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
-};
-
-function renderDuration(submit_time, finishTime) {
-  if (!submit_time || !finishTime) return 'N/A';
-  const durationSec = finishTime - submit_time;
-  const color = durationSec > 60 ? 'red' : 'green';
-
-  // 返回带有样式的颜色标签
-  return (
-    <Tag color={color} prefixIcon={<Clock size={14} />}>
-      {durationSec} 秒
-    </Tag>
-  );
-}
-
-const LogsTable = () => {
-  const { t } = useTranslation();
-  const [isModalOpen, setIsModalOpen] = useState(false);
-  const [modalContent, setModalContent] = useState('');
-
-  // 列可见性状态
-  const [visibleColumns, setVisibleColumns] = useState({});
-  const [showColumnSelector, setShowColumnSelector] = useState(false);
-  const isAdminUser = isAdmin();
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-
-  // 加载保存的列偏好设置
-  useEffect(() => {
-    const savedColumns = localStorage.getItem('task-logs-table-columns');
-    if (savedColumns) {
-      try {
-        const parsed = JSON.parse(savedColumns);
-        const defaults = getDefaultColumnVisibility();
-        const merged = { ...defaults, ...parsed };
-        setVisibleColumns(merged);
-      } catch (e) {
-        console.error('Failed to parse saved column preferences', e);
-        initDefaultColumns();
-      }
-    } else {
-      initDefaultColumns();
-    }
-  }, []);
-
-  // 获取默认列可见性
-  const getDefaultColumnVisibility = () => {
-    return {
-      [COLUMN_KEYS.SUBMIT_TIME]: true,
-      [COLUMN_KEYS.FINISH_TIME]: true,
-      [COLUMN_KEYS.DURATION]: true,
-      [COLUMN_KEYS.CHANNEL]: isAdminUser,
-      [COLUMN_KEYS.PLATFORM]: true,
-      [COLUMN_KEYS.TYPE]: true,
-      [COLUMN_KEYS.TASK_ID]: true,
-      [COLUMN_KEYS.TASK_STATUS]: true,
-      [COLUMN_KEYS.PROGRESS]: true,
-      [COLUMN_KEYS.FAIL_REASON]: true,
-      [COLUMN_KEYS.RESULT_URL]: true,
-    };
-  };
-
-  // 初始化默认列可见性
-  const initDefaultColumns = () => {
-    const defaults = getDefaultColumnVisibility();
-    setVisibleColumns(defaults);
-    localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults));
-  };
-
-  // 处理列可见性变化
-  const handleColumnVisibilityChange = (columnKey, checked) => {
-    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
-    setVisibleColumns(updatedColumns);
-  };
-
-  // 处理全选
-  const handleSelectAll = (checked) => {
-    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
-    const updatedColumns = {};
-
-    allKeys.forEach((key) => {
-      if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) {
-        updatedColumns[key] = false;
-      } else {
-        updatedColumns[key] = checked;
-      }
-    });
-
-    setVisibleColumns(updatedColumns);
-  };
-
-  // 更新表格时保存列可见性
-  useEffect(() => {
-    if (Object.keys(visibleColumns).length > 0) {
-      localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns));
-    }
-  }, [visibleColumns]);
-
-  const renderType = (type) => {
-    switch (type) {
-      case 'MUSIC':
-        return (
-          <Tag color='grey' shape='circle' prefixIcon={<Music size={14} />}>
-            {t('生成音乐')}
-          </Tag>
-        );
-      case 'LYRICS':
-        return (
-          <Tag color='pink' shape='circle' prefixIcon={<FileText size={14} />}>
-            {t('生成歌词')}
-          </Tag>
-        );
-      case TASK_ACTION_GENERATE:
-        return (
-          <Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
-            {t('图生视频')}
-          </Tag>
-        );
-      case TASK_ACTION_TEXT_GENERATE:
-        return (
-          <Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
-            {t('文生视频')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  };
-
-  const renderPlatform = (platform) => {
-    switch (platform) {
-      case 'suno':
-        return (
-          <Tag color='green' shape='circle' prefixIcon={<Music size={14} />}>
-            Suno
-          </Tag>
-        );
-      case 'kling':
-        return (
-          <Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
-            Kling
-          </Tag>
-        );
-      case 'jimeng':
-        return (
-          <Tag color='purple' shape='circle' prefixIcon={<Video size={14} />}>
-            Jimeng
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  };
-
-  const renderStatus = (type) => {
-    switch (type) {
-      case 'SUCCESS':
-        return (
-          <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
-            {t('成功')}
-          </Tag>
-        );
-      case 'NOT_START':
-        return (
-          <Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
-            {t('未启动')}
-          </Tag>
-        );
-      case 'SUBMITTED':
-        return (
-          <Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
-            {t('队列中')}
-          </Tag>
-        );
-      case 'IN_PROGRESS':
-        return (
-          <Tag color='blue' shape='circle' prefixIcon={<Play size={14} />}>
-            {t('执行中')}
-          </Tag>
-        );
-      case 'FAILURE':
-        return (
-          <Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
-            {t('失败')}
-          </Tag>
-        );
-      case 'QUEUED':
-        return (
-          <Tag color='orange' shape='circle' prefixIcon={<List size={14} />}>
-            {t('排队中')}
-          </Tag>
-        );
-      case 'UNKNOWN':
-        return (
-          <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知')}
-          </Tag>
-        );
-      case '':
-        return (
-          <Tag color='grey' shape='circle' prefixIcon={<Loader size={14} />}>
-            {t('正在提交')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  };
-
-  // 定义所有列
-  const allColumns = [
-    {
-      key: COLUMN_KEYS.SUBMIT_TIME,
-      title: t('提交时间'),
-      dataIndex: 'submit_time',
-      render: (text, record, index) => {
-        return <div>{text ? renderTimestamp(text) : '-'}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.FINISH_TIME,
-      title: t('结束时间'),
-      dataIndex: 'finish_time',
-      render: (text, record, index) => {
-        return <div>{text ? renderTimestamp(text) : '-'}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.DURATION,
-      title: t('花费时间'),
-      dataIndex: 'finish_time',
-      render: (finish, record) => {
-        return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.CHANNEL,
-      title: t('渠道'),
-      dataIndex: 'channel_id',
-      className: isAdminUser ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return isAdminUser ? (
-          <div>
-            <Tag
-              color={colors[parseInt(text) % colors.length]}
-              size='large'
-              shape='circle'
-              prefixIcon={<Hash size={14} />}
-              onClick={() => {
-                copyText(text);
-              }}
-            >
-              {text}
-            </Tag>
-          </div>
-        ) : (
-          <></>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.PLATFORM,
-      title: t('平台'),
-      dataIndex: 'platform',
-      render: (text, record, index) => {
-        return <div>{renderPlatform(text)}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.TYPE,
-      title: t('类型'),
-      dataIndex: 'action',
-      render: (text, record, index) => {
-        return <div>{renderType(text)}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.TASK_ID,
-      title: t('任务ID'),
-      dataIndex: 'task_id',
-      render: (text, record, index) => {
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            onClick={() => {
-              setModalContent(JSON.stringify(record, null, 2));
-              setIsModalOpen(true);
-            }}
-          >
-            <div>{text}</div>
-          </Typography.Text>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.TASK_STATUS,
-      title: t('任务状态'),
-      dataIndex: 'status',
-      render: (text, record, index) => {
-        return <div>{renderStatus(text)}</div>;
-      },
-    },
-    {
-      key: COLUMN_KEYS.PROGRESS,
-      title: t('进度'),
-      dataIndex: 'progress',
-      render: (text, record, index) => {
-        return (
-          <div>
-            {
-              isNaN(text?.replace('%', '')) ? (
-                text || '-'
-              ) : (
-                <Progress
-                  stroke={
-                    record.status === 'FAILURE'
-                      ? 'var(--semi-color-warning)'
-                      : null
-                  }
-                  percent={text ? parseInt(text.replace('%', '')) : 0}
-                  showInfo={true}
-                  aria-label='task progress'
-                  style={{ minWidth: '160px' }}
-                />
-              )
-            }
-          </div>
-        );
-      },
-    },
-    {
-      key: COLUMN_KEYS.FAIL_REASON,
-      title: t('详情'),
-      dataIndex: 'fail_reason',
-      fixed: 'right',
-      render: (text, record, index) => {
-        // 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
-        const isVideoTask = record.action === TASK_ACTION_GENERATE || record.action === TASK_ACTION_TEXT_GENERATE;
-        const isSuccess = record.status === 'SUCCESS';
-        const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
-        if (isSuccess && isVideoTask && isUrl) {
-          return (
-            <a href={text} target="_blank" rel="noopener noreferrer">
-              {t('点击预览视频')}
-            </a>
-          );
-        }
-        if (!text) {
-          return t('无');
-        }
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-  ];
-
-  // 根据可见性设置过滤列
-  const getVisibleColumns = () => {
-    return allColumns.filter((column) => visibleColumns[column.key]);
-  };
-
-  const [activePage, setActivePage] = useState(1);
-  const [logCount, setLogCount] = useState(0);
-  const [logs, setLogs] = useState([]);
-  const [loading, setLoading] = useState(false);
-
-  const [compactMode, setCompactMode] = useTableCompactMode('taskLogs');
-
-  useEffect(() => {
-    const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
-    setPageSize(localPageSize);
-    loadLogs(1, localPageSize).then();
-  }, []);
-
-  let now = new Date();
-  // 初始化start_timestamp为前一天
-  let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
-
-  // Form 初始值
-  const formInitValues = {
-    channel_id: '',
-    task_id: '',
-    dateRange: [
-      timestamp2string(zeroNow.getTime() / 1000),
-      timestamp2string(now.getTime() / 1000 + 3600)
-    ],
-  };
-
-  // Form API 引用
-  const [formApi, setFormApi] = useState(null);
-
-  // 获取表单值的辅助函数
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-
-    // 处理时间范围
-    let start_timestamp = timestamp2string(zeroNow.getTime() / 1000);
-    let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
-
-    if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
-      start_timestamp = formValues.dateRange[0];
-      end_timestamp = formValues.dateRange[1];
-    }
-
-    return {
-      channel_id: formValues.channel_id || '',
-      task_id: formValues.task_id || '',
-      start_timestamp,
-      end_timestamp,
-    };
-  };
-
-  const enrichLogs = (items) => {
-    return items.map((log) => ({
-      ...log,
-      timestamp2string: timestamp2string(log.created_at),
-      key: '' + log.id,
-    }));
-  };
-
-  const syncPageData = (payload) => {
-    const items = enrichLogs(payload.items || []);
-    setLogs(items);
-    setLogCount(payload.total || 0);
-    setActivePage(payload.page || 1);
-    setPageSize(payload.page_size || pageSize);
-  };
-
-  const loadLogs = async (page = 1, size = pageSize) => {
-    setLoading(true);
-    const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues();
-    let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
-    let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
-    let url = isAdminUser
-      ? `/api/task/?p=${page}&page_size=${size}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
-      : `/api/task/self?p=${page}&page_size=${size}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-    const res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      syncPageData(data);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const pageData = logs;
-
-  const handlePageChange = (page) => {
-    loadLogs(page, pageSize).then();
-  };
-
-  const handlePageSizeChange = async (size) => {
-    localStorage.setItem('task-page-size', size + '');
-    await loadLogs(1, size);
-  };
-
-  const refresh = async () => {
-    await loadLogs(1, pageSize);
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess(t('已复制:') + text);
-    } else {
-      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
-    }
-  };
-
-  // 列选择器模态框
-  const renderColumnSelector = () => {
-    return (
-      <Modal
-        title={t('列设置')}
-        visible={showColumnSelector}
-        onCancel={() => setShowColumnSelector(false)}
-        footer={
-          <div className="flex justify-end">
-            <Button onClick={() => initDefaultColumns()}>
-              {t('重置')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('取消')}
-            </Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
-              {t('确定')}
-            </Button>
-          </div>
-        }
-      >
-        <div style={{ marginBottom: 20 }}>
-          <Checkbox
-            checked={Object.values(visibleColumns).every((v) => v === true)}
-            indeterminate={
-              Object.values(visibleColumns).some((v) => v === true) &&
-              !Object.values(visibleColumns).every((v) => v === true)
-            }
-            onChange={(e) => handleSelectAll(e.target.checked)}
-          >
-            {t('全选')}
-          </Checkbox>
-        </div>
-        <div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" style={{ border: '1px solid var(--semi-color-border)' }}>
-          {allColumns.map((column) => {
-            // 为非管理员用户跳过管理员专用列
-            if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) {
-              return null;
-            }
-
-            return (
-              <div key={column.key} className="w-1/2 mb-4 pr-2">
-                <Checkbox
-                  checked={!!visibleColumns[column.key]}
-                  onChange={(e) =>
-                    handleColumnVisibilityChange(column.key, e.target.checked)
-                  }
-                >
-                  {column.title}
-                </Checkbox>
-              </div>
-            );
-          })}
-        </div>
-      </Modal>
-    );
-  };
-
-  return (
-    <>
-      {renderColumnSelector()}
-      <Layout>
-        <Card
-          className="!rounded-2xl mb-4"
-          title={
-            <div className="flex flex-col w-full">
-              <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-                <div className="flex items-center text-orange-500 mb-2 md:mb-0">
-                  <IconEyeOpened className="mr-2" />
-                  <Text>{t('任务记录')}</Text>
-                </div>
-                <Button
-                  type='tertiary'
-                  className="w-full md:w-auto"
-                  onClick={() => setCompactMode(!compactMode)}
-                  size="small"
-                >
-                  {compactMode ? t('自适应列表') : t('紧凑列表')}
-                </Button>
-              </div>
-
-              <Divider margin="12px" />
-
-              {/* 搜索表单区域 */}
-              <Form
-                initValues={formInitValues}
-                getFormApi={(api) => setFormApi(api)}
-                onSubmit={refresh}
-                allowEmpty={true}
-                autoComplete="off"
-                layout="vertical"
-                trigger="change"
-                stopValidateWithError={false}
-              >
-                <div className="flex flex-col gap-4">
-                  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
-                    {/* 时间选择器 */}
-                    <div className="col-span-1 lg:col-span-2">
-                      <Form.DatePicker
-                        field='dateRange'
-                        className="w-full"
-                        type='dateTimeRange'
-                        placeholder={[t('开始时间'), t('结束时间')]}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    </div>
-
-                    {/* 任务 ID */}
-                    <Form.Input
-                      field='task_id'
-                      prefix={<IconSearch />}
-                      placeholder={t('任务 ID')}
-                      showClear
-                      pure
-                      size="small"
-                    />
-
-                    {/* 渠道 ID - 仅管理员可见 */}
-                    {isAdminUser && (
-                      <Form.Input
-                        field='channel_id'
-                        prefix={<IconSearch />}
-                        placeholder={t('渠道 ID')}
-                        showClear
-                        pure
-                        size="small"
-                      />
-                    )}
-                  </div>
-
-                  {/* 操作按钮区域 */}
-                  <div className="flex justify-between items-center">
-                    <div></div>
-                    <div className="flex gap-2">
-                      <Button
-                        type='tertiary'
-                        htmlType='submit'
-                        loading={loading}
-                        size="small"
-                      >
-                        {t('查询')}
-                      </Button>
-                      <Button
-                        type='tertiary'
-                        onClick={() => {
-                          if (formApi) {
-                            formApi.reset();
-                            // 重置后立即查询,使用setTimeout确保表单重置完成
-                            setTimeout(() => {
-                              refresh();
-                            }, 100);
-                          }
-                        }}
-                        size="small"
-                      >
-                        {t('重置')}
-                      </Button>
-                      <Button
-                        type='tertiary'
-                        onClick={() => setShowColumnSelector(true)}
-                        size="small"
-                      >
-                        {t('列设置')}
-                      </Button>
-                    </div>
-                  </div>
-                </div>
-              </Form>
-            </div>
-          }
-          shadows='always'
-          bordered={false}
-        >
-          <Table
-            columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
-            dataSource={logs}
-            rowKey='key'
-            loading={loading}
-            scroll={compactMode ? undefined : { x: 'max-content' }}
-            className="rounded-xl overflow-hidden"
-            size="middle"
-            empty={
-              <Empty
-                image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-                darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-                description={t('搜索无结果')}
-                style={{ padding: 30 }}
-              />
-            }
-            pagination={{
-              formatPageText: (page) =>
-                t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                  start: page.currentStart,
-                  end: page.currentEnd,
-                  total: logCount,
-                }),
-              currentPage: activePage,
-              pageSize: pageSize,
-              total: logCount,
-              pageSizeOptions: [10, 20, 50, 100],
-              showSizeChanger: true,
-              onPageSizeChange: handlePageSizeChange,
-              onPageChange: handlePageChange,
-            }}
-          />
-        </Card>
-
-        <Modal
-          visible={isModalOpen}
-          onOk={() => setIsModalOpen(false)}
-          onCancel={() => setIsModalOpen(false)}
-          closable={null}
-          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
-          width={800} // 设置模态框宽度
-        >
-          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
-        </Modal>
-      </Layout>
-    </>
-  );
-};
-
-export default LogsTable;

+ 0 - 924
web/src/components/table/TokensTable.js

@@ -1,924 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import {
-  API,
-  copy,
-  showError,
-  showSuccess,
-  timestamp2string,
-  renderGroup,
-  renderQuota,
-  getModelCategories
-} from '../../helpers';
-import { ITEMS_PER_PAGE } from '../../constants';
-import {
-  Button,
-  Card,
-  Divider,
-  Dropdown,
-  Empty,
-  Form,
-  Modal,
-  Space,
-  SplitButtonGroup,
-  Table,
-  Tag,
-  AvatarGroup,
-  Avatar,
-  Tooltip,
-  Progress,
-  Switch,
-  Input,
-  Typography
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import {
-  IconSearch,
-  IconTreeTriangleDown,
-  IconCopy,
-  IconEyeOpened,
-  IconEyeClosed,
-} from '@douyinfe/semi-icons';
-import { Key } from 'lucide-react';
-import EditToken from '../../pages/Token/EditToken';
-import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-
-const { Text } = Typography;
-
-function renderTimestamp(timestamp) {
-  return <>{timestamp2string(timestamp)}</>;
-}
-
-const TokensTable = () => {
-  const { t } = useTranslation();
-
-  const columns = [
-    {
-      title: t('名称'),
-      dataIndex: 'name',
-    },
-    {
-      title: t('状态'),
-      dataIndex: 'status',
-      key: 'status',
-      render: (text, record) => {
-        const enabled = text === 1;
-        const handleToggle = (checked) => {
-          if (checked) {
-            manageToken(record.id, 'enable', record);
-          } else {
-            manageToken(record.id, 'disable', record);
-          }
-        };
-
-        let tagColor = 'black';
-        let tagText = t('未知状态');
-        if (enabled) {
-          tagColor = 'green';
-          tagText = t('已启用');
-        } else if (text === 2) {
-          tagColor = 'red';
-          tagText = t('已禁用');
-        } else if (text === 3) {
-          tagColor = 'yellow';
-          tagText = t('已过期');
-        } else if (text === 4) {
-          tagColor = 'grey';
-          tagText = t('已耗尽');
-        }
-
-        const used = parseInt(record.used_quota) || 0;
-        const remain = parseInt(record.remain_quota) || 0;
-        const total = used + remain;
-        const percent = total > 0 ? (remain / total) * 100 : 0;
-
-        const getProgressColor = (pct) => {
-          if (pct === 100) return 'var(--semi-color-success)';
-          if (pct <= 10) return 'var(--semi-color-danger)';
-          if (pct <= 30) return 'var(--semi-color-warning)';
-          return undefined;
-        };
-
-        const quotaSuffix = record.unlimited_quota ? (
-          <div className='text-xs'>{t('无限额度')}</div>
-        ) : (
-          <div className='flex flex-col items-end'>
-            <span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
-            <Progress
-              percent={percent}
-              stroke={getProgressColor(percent)}
-              aria-label='quota usage'
-              format={() => `${percent.toFixed(0)}%`}
-              style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
-            />
-          </div>
-        );
-
-        const content = (
-          <Tag
-            color={tagColor}
-            shape='circle'
-            size='large'
-            prefixIcon={
-              <Switch
-                size='small'
-                checked={enabled}
-                onChange={handleToggle}
-                aria-label='token status switch'
-              />
-            }
-            suffixIcon={quotaSuffix}
-          >
-            {tagText}
-          </Tag>
-        );
-
-        if (record.unlimited_quota) {
-          return content;
-        }
-
-        return (
-          <Tooltip
-            content={
-              <div className='text-xs'>
-                <div>{t('已用额度')}: {renderQuota(used)}</div>
-                <div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
-                <div>{t('总额度')}: {renderQuota(total)}</div>
-              </div>
-            }
-          >
-            {content}
-          </Tooltip>
-        );
-      },
-    },
-    {
-      title: t('分组'),
-      dataIndex: 'group',
-      key: 'group',
-      render: (text) => {
-        if (text === 'auto') {
-          return (
-            <Tooltip
-              content={t('当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)')}
-              position='top'
-            >
-              <Tag color='white' shape='circle'> {t('智能熔断')} </Tag>
-            </Tooltip>
-          );
-        }
-        return renderGroup(text);
-      },
-    },
-    {
-      title: t('密钥'),
-      key: 'token_key',
-      render: (text, record) => {
-        const fullKey = 'sk-' + record.key;
-        const maskedKey = 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
-        const revealed = !!showKeys[record.id];
-
-        return (
-          <div className='w-[200px]'>
-            <Input
-              readOnly
-              value={revealed ? fullKey : maskedKey}
-              size='small'
-              suffix={
-                <div className='flex items-center'>
-                  <Button
-                    theme='borderless'
-                    size='small'
-                    type='tertiary'
-                    icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
-                    aria-label='toggle token visibility'
-                    onClick={(e) => {
-                      e.stopPropagation();
-                      setShowKeys(prev => ({ ...prev, [record.id]: !revealed }));
-                    }}
-                  />
-                  <Button
-                    theme='borderless'
-                    size='small'
-                    type='tertiary'
-                    icon={<IconCopy />}
-                    aria-label='copy token key'
-                    onClick={async (e) => {
-                      e.stopPropagation();
-                      await copyText(fullKey);
-                    }}
-                  />
-                </div>
-              }
-            />
-          </div>
-        );
-      },
-    },
-    {
-      title: t('可用模型'),
-      dataIndex: 'model_limits',
-      render: (text, record) => {
-        if (record.model_limits_enabled && text) {
-          const models = text.split(',').filter(Boolean);
-          const categories = getModelCategories(t);
-
-          const vendorAvatars = [];
-          const matchedModels = new Set();
-          Object.entries(categories).forEach(([key, category]) => {
-            if (key === 'all') return;
-            if (!category.icon || !category.filter) return;
-            const vendorModels = models.filter((m) => category.filter({ model_name: m }));
-            if (vendorModels.length > 0) {
-              vendorAvatars.push(
-                <Tooltip key={key} content={vendorModels.join(', ')} position='top' showArrow>
-                  <Avatar size='extra-extra-small' alt={category.label} color='transparent'>
-                    {category.icon}
-                  </Avatar>
-                </Tooltip>
-              );
-              vendorModels.forEach((m) => matchedModels.add(m));
-            }
-          });
-
-          const unmatchedModels = models.filter((m) => !matchedModels.has(m));
-          if (unmatchedModels.length > 0) {
-            vendorAvatars.push(
-              <Tooltip key='unknown' content={unmatchedModels.join(', ')} position='top' showArrow>
-                <Avatar size='extra-extra-small' alt='unknown'>
-                  {t('其他')}
-                </Avatar>
-              </Tooltip>
-            );
-          }
-
-          return (
-            <AvatarGroup size='extra-extra-small'>
-              {vendorAvatars}
-            </AvatarGroup>
-          );
-        } else {
-          return (
-            <Tag color='white' shape='circle'>
-              {t('无限制')}
-            </Tag>
-          );
-        }
-      },
-    },
-    {
-      title: t('IP限制'),
-      dataIndex: 'allow_ips',
-      render: (text) => {
-        if (!text || text.trim() === '') {
-          return (
-            <Tag color='white' shape='circle'>
-              {t('无限制')}
-            </Tag>
-          );
-        }
-
-        const ips = text
-          .split('\n')
-          .map((ip) => ip.trim())
-          .filter(Boolean);
-
-        const displayIps = ips.slice(0, 1);
-        const extraCount = ips.length - displayIps.length;
-
-        const ipTags = displayIps.map((ip, idx) => (
-          <Tag key={idx} shape='circle'>
-            {ip}
-          </Tag>
-        ));
-
-        if (extraCount > 0) {
-          ipTags.push(
-            <Tooltip
-              key='extra'
-              content={ips.slice(1).join(', ')}
-              position='top'
-              showArrow
-            >
-              <Tag shape='circle'>
-                {'+' + extraCount}
-              </Tag>
-            </Tooltip>
-          );
-        }
-
-        return <Space wrap>{ipTags}</Space>;
-      },
-    },
-    {
-      title: t('创建时间'),
-      dataIndex: 'created_time',
-      render: (text, record, index) => {
-        return <div>{renderTimestamp(text)}</div>;
-      },
-    },
-    {
-      title: t('过期时间'),
-      dataIndex: 'expired_time',
-      render: (text, record, index) => {
-        return (
-          <div>
-            {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)}
-          </div>
-        );
-      },
-    },
-    {
-      title: '',
-      dataIndex: 'operate',
-      fixed: 'right',
-      render: (text, record, index) => {
-        let chats = localStorage.getItem('chats');
-        let chatsArray = [];
-        let shouldUseCustom = true;
-
-        if (shouldUseCustom) {
-          try {
-            chats = JSON.parse(chats);
-            if (Array.isArray(chats)) {
-              for (let i = 0; i < chats.length; i++) {
-                let chat = {};
-                chat.node = 'item';
-                for (let key in chats[i]) {
-                  if (chats[i].hasOwnProperty(key)) {
-                    chat.key = i;
-                    chat.name = key;
-                    chat.onClick = () => {
-                      onOpenLink(key, chats[i][key], record);
-                    };
-                  }
-                }
-                chatsArray.push(chat);
-              }
-            }
-          } catch (e) {
-            console.log(e);
-            showError(t('聊天链接配置错误,请联系管理员'));
-          }
-        }
-
-        return (
-          <Space wrap>
-            <SplitButtonGroup
-              className="overflow-hidden"
-              aria-label={t('项目操作按钮组')}
-            >
-              <Button
-                size="small"
-                type='tertiary'
-                onClick={() => {
-                  if (chatsArray.length === 0) {
-                    showError(t('请联系管理员配置聊天链接'));
-                  } else {
-                    onOpenLink(
-                      'default',
-                      chats[0][Object.keys(chats[0])[0]],
-                      record,
-                    );
-                  }
-                }}
-              >
-                {t('聊天')}
-              </Button>
-              <Dropdown
-                trigger='click'
-                position='bottomRight'
-                menu={chatsArray}
-              >
-                <Button
-                  type='tertiary'
-                  icon={<IconTreeTriangleDown />}
-                  size="small"
-                ></Button>
-              </Dropdown>
-            </SplitButtonGroup>
-
-            <Button
-              type='tertiary'
-              size="small"
-              onClick={() => {
-                setEditingToken(record);
-                setShowEdit(true);
-              }}
-            >
-              {t('编辑')}
-            </Button>
-
-            <Button
-              type='danger'
-              size="small"
-              onClick={() => {
-                Modal.confirm({
-                  title: t('确定是否要删除此令牌?'),
-                  content: t('此修改将不可逆'),
-                  onOk: () => {
-                    (async () => {
-                      await manageToken(record.id, 'delete', record);
-                      await refresh();
-                    })();
-                  },
-                });
-              }}
-            >
-              {t('删除')}
-            </Button>
-          </Space>
-        );
-      },
-    },
-  ];
-
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [showEdit, setShowEdit] = useState(false);
-  const [tokens, setTokens] = useState([]);
-  const [selectedKeys, setSelectedKeys] = useState([]);
-  const [tokenCount, setTokenCount] = useState(pageSize);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [searching, setSearching] = useState(false);
-  const [editingToken, setEditingToken] = useState({
-    id: undefined,
-  });
-  const [compactMode, setCompactMode] = useTableCompactMode('tokens');
-  const [showKeys, setShowKeys] = useState({});
-
-  // Form 初始值
-  const formInitValues = {
-    searchKeyword: '',
-    searchToken: '',
-  };
-
-  // Form API 引用
-  const [formApi, setFormApi] = useState(null);
-
-  // 获取表单值的辅助函数
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-    return {
-      searchKeyword: formValues.searchKeyword || '',
-      searchToken: formValues.searchToken || '',
-    };
-  };
-
-  const closeEdit = () => {
-    setShowEdit(false);
-    setTimeout(() => {
-      setEditingToken({
-        id: undefined,
-      });
-    }, 500);
-  };
-
-  // 将后端返回的数据写入状态
-  const syncPageData = (payload) => {
-    setTokens(payload.items || []);
-    setTokenCount(payload.total || 0);
-    setActivePage(payload.page || 1);
-    setPageSize(payload.page_size || pageSize);
-  };
-
-  const loadTokens = async (page = 1, size = pageSize) => {
-    setLoading(true);
-    const res = await API.get(`/api/token/?p=${page}&size=${size}`);
-    const { success, message, data } = res.data;
-    if (success) {
-      syncPageData(data);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const refresh = async (page = activePage) => {
-    await loadTokens(page);
-    setSelectedKeys([]);
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess(t('已复制到剪贴板!'));
-    } else {
-      Modal.error({
-        title: t('无法复制到剪贴板,请手动复制'),
-        content: text,
-        size: 'large',
-      });
-    }
-  };
-
-  const onOpenLink = async (type, url, record) => {
-    let status = localStorage.getItem('status');
-    let serverAddress = '';
-    if (status) {
-      status = JSON.parse(status);
-      serverAddress = status.server_address;
-    }
-    if (serverAddress === '') {
-      serverAddress = window.location.origin;
-    }
-    if (url.includes('{cherryConfig}') === true) {
-      let cherryConfig = {
-        id: 'new-api',
-        baseUrl: serverAddress,
-        apiKey: 'sk-' + record.key,
-      }
-      // 替换 {cherryConfig} 为base64编码的JSON字符串
-      let encodedConfig = encodeURIComponent(
-        btoa(JSON.stringify(cherryConfig))
-      );
-      url = url.replaceAll('{cherryConfig}', encodedConfig);
-    } else {
-      let encodedServerAddress = encodeURIComponent(serverAddress);
-      url = url.replaceAll('{address}', encodedServerAddress);
-      url = url.replaceAll('{key}', 'sk-' + record.key);
-    }
-
-    window.open(url, '_blank');
-  };
-
-  useEffect(() => {
-    loadTokens(1)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-  }, [pageSize]);
-
-  const removeRecord = (key) => {
-    let newDataSource = [...tokens];
-    if (key != null) {
-      let idx = newDataSource.findIndex((data) => data.key === key);
-
-      if (idx > -1) {
-        newDataSource.splice(idx, 1);
-        setTokens(newDataSource);
-      }
-    }
-  };
-
-  const manageToken = async (id, action, record) => {
-    setLoading(true);
-    let data = { id };
-    let res;
-    switch (action) {
-      case 'delete':
-        res = await API.delete(`/api/token/${id}/`);
-        break;
-      case 'enable':
-        data.status = 1;
-        res = await API.put('/api/token/?status_only=true', data);
-        break;
-      case 'disable':
-        data.status = 2;
-        res = await API.put('/api/token/?status_only=true', data);
-        break;
-    }
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('操作成功完成!');
-      let token = res.data.data;
-      let newTokens = [...tokens];
-      if (action === 'delete') {
-      } else {
-        record.status = token.status;
-      }
-      setTokens(newTokens);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const searchTokens = async () => {
-    const { searchKeyword, searchToken } = getFormValues();
-    if (searchKeyword === '' && searchToken === '') {
-      await loadTokens(1);
-      return;
-    }
-    setSearching(true);
-    const res = await API.get(
-      `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      setTokens(data);
-      setTokenCount(data.length);
-      setActivePage(1);
-    } else {
-      showError(message);
-    }
-    setSearching(false);
-  };
-
-  const sortToken = (key) => {
-    if (tokens.length === 0) return;
-    setLoading(true);
-    let sortedTokens = [...tokens];
-    sortedTokens.sort((a, b) => {
-      return ('' + a[key]).localeCompare(b[key]);
-    });
-    if (sortedTokens[0].id === tokens[0].id) {
-      sortedTokens.reverse();
-    }
-    setTokens(sortedTokens);
-    setLoading(false);
-  };
-
-  const handlePageChange = (page) => {
-    loadTokens(page, pageSize).then();
-  };
-
-  const handlePageSizeChange = async (size) => {
-    setPageSize(size);
-    await loadTokens(1, size);
-  };
-
-  const rowSelection = {
-    onSelect: (record, selected) => { },
-    onSelectAll: (selected, selectedRows) => { },
-    onChange: (selectedRowKeys, selectedRows) => {
-      setSelectedKeys(selectedRows);
-    },
-  };
-
-  const handleRow = (record, index) => {
-    if (record.status !== 1) {
-      return {
-        style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
-      };
-    } else {
-      return {};
-    }
-  };
-
-  const batchDeleteTokens = async () => {
-    if (selectedKeys.length === 0) {
-      showError(t('请先选择要删除的令牌!'));
-      return;
-    }
-    setLoading(true);
-    try {
-      const ids = selectedKeys.map((token) => token.id);
-      const res = await API.post('/api/token/batch', { ids });
-      if (res?.data?.success) {
-        const count = res.data.data || 0;
-        showSuccess(t('已删除 {{count}} 个令牌!', { count }));
-        await refresh();
-        setTimeout(() => {
-          if (tokens.length === 0 && activePage > 1) {
-            refresh(activePage - 1);
-          }
-        }, 100);
-      } else {
-        showError(res?.data?.message || t('删除失败'));
-      }
-    } catch (error) {
-      showError(error.message);
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      <div className="mb-2">
-        <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-          <div className="flex items-center text-blue-500">
-            <Key size={16} className="mr-2" />
-            <Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
-          </div>
-          <Button
-            type="tertiary"
-            className="w-full md:w-auto"
-            onClick={() => setCompactMode(!compactMode)}
-            size="small"
-          >
-            {compactMode ? t('自适应列表') : t('紧凑列表')}
-          </Button>
-        </div>
-      </div>
-
-      <Divider margin="12px" />
-
-      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
-          <Button
-            type="primary"
-            className="flex-1 md:flex-initial"
-            onClick={() => {
-              setEditingToken({
-                id: undefined,
-              });
-              setShowEdit(true);
-            }}
-            size="small"
-          >
-            {t('添加令牌')}
-          </Button>
-          <Button
-            type='tertiary'
-            className="flex-1 md:flex-initial"
-            onClick={() => {
-              if (selectedKeys.length === 0) {
-                showError(t('请至少选择一个令牌!'));
-                return;
-              }
-              Modal.info({
-                title: t('复制令牌'),
-                icon: null,
-                content: t('请选择你的复制方式'),
-                footer: (
-                  <Space>
-                    <Button
-                      type='tertiary'
-                      onClick={async () => {
-                        let content = '';
-                        for (let i = 0; i < selectedKeys.length; i++) {
-                          content +=
-                            selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
-                        }
-                        await copyText(content);
-                        Modal.destroyAll();
-                      }}
-                    >
-                      {t('名称+密钥')}
-                    </Button>
-                    <Button
-                      onClick={async () => {
-                        let content = '';
-                        for (let i = 0; i < selectedKeys.length; i++) {
-                          content += 'sk-' + selectedKeys[i].key + '\n';
-                        }
-                        await copyText(content);
-                        Modal.destroyAll();
-                      }}
-                    >
-                      {t('仅密钥')}
-                    </Button>
-                  </Space>
-                ),
-              });
-            }}
-            size="small"
-          >
-            {t('复制所选令牌')}
-          </Button>
-          <Button
-            type='danger'
-            className="w-full md:w-auto"
-            onClick={() => {
-              if (selectedKeys.length === 0) {
-                showError(t('请至少选择一个令牌!'));
-                return;
-              }
-              Modal.confirm({
-                title: t('批量删除令牌'),
-                content: (
-                  <div>
-                    {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
-                  </div>
-                ),
-                onOk: () => batchDeleteTokens(),
-              });
-            }}
-            size="small"
-          >
-            {t('删除所选令牌')}
-          </Button>
-        </div>
-
-        <Form
-          initValues={formInitValues}
-          getFormApi={(api) => setFormApi(api)}
-          onSubmit={searchTokens}
-          allowEmpty={true}
-          autoComplete="off"
-          layout="horizontal"
-          trigger="change"
-          stopValidateWithError={false}
-          className="w-full md:w-auto order-1 md:order-2"
-        >
-          <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
-            <div className="relative w-full md:w-56">
-              <Form.Input
-                field="searchKeyword"
-                prefix={<IconSearch />}
-                placeholder={t('搜索关键字')}
-                showClear
-                pure
-                size="small"
-              />
-            </div>
-            <div className="relative w-full md:w-56">
-              <Form.Input
-                field="searchToken"
-                prefix={<IconSearch />}
-                placeholder={t('密钥')}
-                showClear
-                pure
-                size="small"
-              />
-            </div>
-            <div className="flex gap-2 w-full md:w-auto">
-              <Button
-                type="tertiary"
-                htmlType="submit"
-                loading={loading || searching}
-                className="flex-1 md:flex-initial md:w-auto"
-                size="small"
-              >
-                {t('查询')}
-              </Button>
-              <Button
-                type='tertiary'
-                onClick={() => {
-                  if (formApi) {
-                    formApi.reset();
-                    // 重置后立即查询,使用setTimeout确保表单重置完成
-                    setTimeout(() => {
-                      searchTokens();
-                    }, 100);
-                  }
-                }}
-                className="flex-1 md:flex-initial md:w-auto"
-                size="small"
-              >
-                {t('重置')}
-              </Button>
-            </div>
-          </div>
-        </Form>
-      </div>
-    </div>
-  );
-
-  return (
-    <>
-      <EditToken
-        refresh={refresh}
-        editingToken={editingToken}
-        visiable={showEdit}
-        handleClose={closeEdit}
-      ></EditToken>
-
-      <Card
-        className="!rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
-      >
-        <Table
-          columns={compactMode ? columns.map(col => {
-            if (col.dataIndex === 'operate') {
-              const { fixed, ...rest } = col;
-              return rest;
-            }
-            return col;
-          }) : columns}
-          dataSource={tokens}
-          scroll={compactMode ? undefined : { x: 'max-content' }}
-          pagination={{
-            currentPage: activePage,
-            pageSize: pageSize,
-            total: tokenCount,
-            showSizeChanger: true,
-            pageSizeOptions: [10, 20, 50, 100],
-            formatPageText: (page) =>
-              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                start: page.currentStart,
-                end: page.currentEnd,
-                total: tokenCount,
-              }),
-            onPageSizeChange: handlePageSizeChange,
-            onPageChange: handlePageChange,
-          }}
-          loading={loading}
-          rowSelection={rowSelection}
-          onRow={handleRow}
-          empty={
-            <Empty
-              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-              description={t('搜索无结果')}
-              style={{ padding: 30 }}
-            />
-          }
-          className="rounded-xl overflow-hidden"
-          size="middle"
-        ></Table>
-      </Card>
-    </>
-  );
-};
-
-export default TokensTable;

+ 0 - 686
web/src/components/table/UsersTable.js

@@ -1,686 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
-
-import {
-  User,
-  Shield,
-  Crown,
-  HelpCircle,
-  CheckCircle,
-  XCircle,
-  Minus,
-  Coins,
-  Activity,
-  Users,
-  DollarSign,
-  UserPlus,
-} from 'lucide-react';
-import {
-  Button,
-  Card,
-  Divider,
-  Dropdown,
-  Empty,
-  Form,
-  Modal,
-  Space,
-  Table,
-  Tag,
-  Tooltip,
-  Typography
-} from '@douyinfe/semi-ui';
-import {
-  IllustrationNoResult,
-  IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import {
-  IconSearch,
-  IconUserAdd,
-  IconMore,
-} from '@douyinfe/semi-icons';
-import { ITEMS_PER_PAGE } from '../../constants';
-import AddUser from '../../pages/User/AddUser';
-import EditUser from '../../pages/User/EditUser';
-import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/useTableCompactMode';
-
-const { Text } = Typography;
-
-const UsersTable = () => {
-  const { t } = useTranslation();
-  const [compactMode, setCompactMode] = useTableCompactMode('users');
-
-  function renderRole(role) {
-    switch (role) {
-      case 1:
-        return (
-          <Tag color='blue' shape='circle' prefixIcon={<User size={14} />}>
-            {t('普通用户')}
-          </Tag>
-        );
-      case 10:
-        return (
-          <Tag color='yellow' shape='circle' prefixIcon={<Shield size={14} />}>
-            {t('管理员')}
-          </Tag>
-        );
-      case 100:
-        return (
-          <Tag color='orange' shape='circle' prefixIcon={<Crown size={14} />}>
-            {t('超级管理员')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='red' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知身份')}
-          </Tag>
-        );
-    }
-  }
-
-  const renderStatus = (status) => {
-    switch (status) {
-      case 1:
-        return <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
-      case 2:
-        return (
-          <Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
-            {t('已封禁')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
-            {t('未知状态')}
-          </Tag>
-        );
-    }
-  };
-
-  const columns = [
-    {
-      title: 'ID',
-      dataIndex: 'id',
-    },
-    {
-      title: t('用户名'),
-      dataIndex: 'username',
-      render: (text, record) => {
-        const remark = record.remark;
-        if (!remark) {
-          return <span>{text}</span>;
-        }
-        const maxLen = 10;
-        const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark;
-        return (
-          <Space spacing={2}>
-            <span>{text}</span>
-            <Tooltip content={remark} position="top" showArrow>
-              <Tag color='white' shape='circle' className="!text-xs">
-                <div className="flex items-center gap-1">
-                  <div className="w-2 h-2 flex-shrink-0 rounded-full" style={{ backgroundColor: '#10b981' }} />
-                  {displayRemark}
-                </div>
-              </Tag>
-            </Tooltip>
-          </Space>
-        );
-      },
-    },
-    {
-      title: t('分组'),
-      dataIndex: 'group',
-      render: (text, record, index) => {
-        return <div>{renderGroup(text)}</div>;
-      },
-    },
-    {
-      title: t('统计信息'),
-      dataIndex: 'info',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Space spacing={1}>
-              <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
-                {t('剩余')}: {renderQuota(record.quota)}
-              </Tag>
-              <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
-                {t('已用')}: {renderQuota(record.used_quota)}
-              </Tag>
-              <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
-                {t('调用')}: {renderNumber(record.request_count)}
-              </Tag>
-            </Space>
-          </div>
-        );
-      },
-    },
-    {
-      title: t('邀请信息'),
-      dataIndex: 'invite',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Space spacing={1}>
-              <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
-                {t('邀请')}: {renderNumber(record.aff_count)}
-              </Tag>
-              <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<DollarSign size={14} />}>
-                {t('收益')}: {renderQuota(record.aff_history_quota)}
-              </Tag>
-              <Tag color='white' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
-                {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
-              </Tag>
-            </Space>
-          </div>
-        );
-      },
-    },
-    {
-      title: t('角色'),
-      dataIndex: 'role',
-      render: (text, record, index) => {
-        return <div>{renderRole(text)}</div>;
-      },
-    },
-    {
-      title: t('状态'),
-      dataIndex: 'status',
-      render: (text, record, index) => {
-        return (
-          <div>
-            {record.DeletedAt !== null ? (
-              <Tag color='red' shape='circle' prefixIcon={<Minus size={14} />}>{t('已注销')}</Tag>
-            ) : (
-              renderStatus(text)
-            )}
-          </div>
-        );
-      },
-    },
-    {
-      title: '',
-      dataIndex: 'operate',
-      fixed: 'right',
-      render: (text, record, index) => {
-        if (record.DeletedAt !== null) {
-          return <></>;
-        }
-
-        // 创建更多操作的下拉菜单项
-        const moreMenuItems = [
-          {
-            node: 'item',
-            name: t('提升'),
-            type: 'warning',
-            onClick: () => {
-              Modal.confirm({
-                title: t('确定要提升此用户吗?'),
-                content: t('此操作将提升用户的权限级别'),
-                onOk: () => {
-                  manageUser(record.id, 'promote', record);
-                },
-              });
-            },
-          },
-          {
-            node: 'item',
-            name: t('降级'),
-            type: 'secondary',
-            onClick: () => {
-              Modal.confirm({
-                title: t('确定要降级此用户吗?'),
-                content: t('此操作将降低用户的权限级别'),
-                onOk: () => {
-                  manageUser(record.id, 'demote', record);
-                },
-              });
-            },
-          },
-          {
-            node: 'item',
-            name: t('注销'),
-            type: 'danger',
-            onClick: () => {
-              Modal.confirm({
-                title: t('确定是否要注销此用户?'),
-                content: t('相当于删除用户,此修改将不可逆'),
-                onOk: () => {
-                  (async () => {
-                    await manageUser(record.id, 'delete', record);
-                    await refresh();
-                    setTimeout(() => {
-                      if (users.length === 0 && activePage > 1) {
-                        refresh(activePage - 1);
-                      }
-                    }, 100);
-                  })();
-                },
-              });
-            },
-          }
-        ];
-
-        // 动态添加启用/禁用按钮
-        if (record.status === 1) {
-          moreMenuItems.splice(-1, 0, {
-            node: 'item',
-            name: t('禁用'),
-            type: 'warning',
-            onClick: () => {
-              manageUser(record.id, 'disable', record);
-            },
-          });
-        } else {
-          moreMenuItems.splice(-1, 0, {
-            node: 'item',
-            name: t('启用'),
-            type: 'secondary',
-            onClick: () => {
-              manageUser(record.id, 'enable', record);
-            },
-            disabled: record.status === 3,
-          });
-        }
-
-        return (
-          <Space>
-            <Button
-              type='tertiary'
-              size="small"
-              onClick={() => {
-                setEditingUser(record);
-                setShowEditUser(true);
-              }}
-            >
-              {t('编辑')}
-            </Button>
-            <Dropdown
-              trigger='click'
-              position='bottomRight'
-              menu={moreMenuItems}
-            >
-              <Button
-                type='tertiary'
-                size="small"
-                icon={<IconMore />}
-              />
-            </Dropdown>
-          </Space>
-        );
-      },
-    },
-  ];
-
-  const [users, setUsers] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [searching, setSearching] = useState(false);
-  const [groupOptions, setGroupOptions] = useState([]);
-  const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
-  const [showAddUser, setShowAddUser] = useState(false);
-  const [showEditUser, setShowEditUser] = useState(false);
-  const [editingUser, setEditingUser] = useState({
-    id: undefined,
-  });
-
-  // Form 初始值
-  const formInitValues = {
-    searchKeyword: '',
-    searchGroup: '',
-  };
-
-  // Form API 引用
-  const [formApi, setFormApi] = useState(null);
-
-  // 获取表单值的辅助函数
-  const getFormValues = () => {
-    const formValues = formApi ? formApi.getValues() : {};
-    return {
-      searchKeyword: formValues.searchKeyword || '',
-      searchGroup: formValues.searchGroup || '',
-    };
-  };
-
-  const removeRecord = (key) => {
-    let newDataSource = [...users];
-    if (key != null) {
-      let idx = newDataSource.findIndex((data) => data.id === key);
-
-      if (idx > -1) {
-        // update deletedAt
-        newDataSource[idx].DeletedAt = new Date();
-        setUsers(newDataSource);
-      }
-    }
-  };
-
-  const setUserFormat = (users) => {
-    for (let i = 0; i < users.length; i++) {
-      users[i].key = users[i].id;
-    }
-    setUsers(users);
-  };
-
-  const loadUsers = async (startIdx, pageSize) => {
-    const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setUserCount(data.total);
-      setUserFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  useEffect(() => {
-    loadUsers(0, pageSize)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-    fetchGroups().then();
-  }, []);
-
-  const manageUser = async (userId, action, record) => {
-    const res = await API.post('/api/user/manage', {
-      id: userId,
-      action,
-    });
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('操作成功完成!');
-      let user = res.data.data;
-      let newUsers = [...users];
-      if (action === 'delete') {
-      } else {
-        record.status = user.status;
-        record.role = user.role;
-      }
-      setUsers(newUsers);
-    } else {
-      showError(message);
-    }
-  };
-
-  const searchUsers = async (
-    startIdx,
-    pageSize,
-    searchKeyword = null,
-    searchGroup = null,
-  ) => {
-    // 如果没有传递参数,从表单获取值
-    if (searchKeyword === null || searchGroup === null) {
-      const formValues = getFormValues();
-      searchKeyword = formValues.searchKeyword;
-      searchGroup = formValues.searchGroup;
-    }
-
-    if (searchKeyword === '' && searchGroup === '') {
-      // if keyword is blank, load files instead.
-      await loadUsers(startIdx, pageSize);
-      return;
-    }
-    setSearching(true);
-    const res = await API.get(
-      `/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setUserCount(data.total);
-      setUserFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setSearching(false);
-  };
-
-  const handlePageChange = (page) => {
-    setActivePage(page);
-    const { searchKeyword, searchGroup } = getFormValues();
-    if (searchKeyword === '' && searchGroup === '') {
-      loadUsers(page, pageSize).then();
-    } else {
-      searchUsers(page, pageSize, searchKeyword, searchGroup).then();
-    }
-  };
-
-  const closeAddUser = () => {
-    setShowAddUser(false);
-  };
-
-  const closeEditUser = () => {
-    setShowEditUser(false);
-    setEditingUser({
-      id: undefined,
-    });
-  };
-
-  const refresh = async (page = activePage) => {
-    const { searchKeyword, searchGroup } = getFormValues();
-    if (searchKeyword === '' && searchGroup === '') {
-      await loadUsers(page, pageSize);
-    } else {
-      await searchUsers(page, pageSize, searchKeyword, searchGroup);
-    }
-  };
-
-  const fetchGroups = async () => {
-    try {
-      let res = await API.get(`/api/group/`);
-      // add 'all' option
-      // res.data.data.unshift('all');
-      if (res === undefined) {
-        return;
-      }
-      setGroupOptions(
-        res.data.data.map((group) => ({
-          label: group,
-          value: group,
-        })),
-      );
-    } catch (error) {
-      showError(error.message);
-    }
-  };
-
-  const handlePageSizeChange = async (size) => {
-    localStorage.setItem('page-size', size + '');
-    setPageSize(size);
-    setActivePage(1);
-    loadUsers(activePage, size)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-  };
-
-  const handleRow = (record, index) => {
-    if (record.DeletedAt !== null || record.status !== 1) {
-      return {
-        style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
-      };
-    } else {
-      return {};
-    }
-  };
-
-  const renderHeader = () => (
-    <div className="flex flex-col w-full">
-      <div className="mb-2">
-        <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-          <div className="flex items-center text-blue-500">
-            <IconUserAdd className="mr-2" />
-            <Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
-          </div>
-          <Button
-            type='tertiary'
-            className="w-full md:w-auto"
-            onClick={() => setCompactMode(!compactMode)}
-            size="small"
-          >
-            {compactMode ? t('自适应列表') : t('紧凑列表')}
-          </Button>
-        </div>
-      </div>
-
-      <Divider margin="12px" />
-
-      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
-          <Button
-            className="w-full md:w-auto"
-            onClick={() => {
-              setShowAddUser(true);
-            }}
-            size="small"
-          >
-            {t('添加用户')}
-          </Button>
-        </div>
-
-        <Form
-          initValues={formInitValues}
-          getFormApi={(api) => setFormApi(api)}
-          onSubmit={() => {
-            setActivePage(1);
-            searchUsers(1, pageSize);
-          }}
-          allowEmpty={true}
-          autoComplete="off"
-          layout="horizontal"
-          trigger="change"
-          stopValidateWithError={false}
-          className="w-full md:w-auto order-1 md:order-2"
-        >
-          <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
-            <div className="relative w-full md:w-64">
-              <Form.Input
-                field="searchKeyword"
-                prefix={<IconSearch />}
-                placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
-                showClear
-                pure
-                size="small"
-              />
-            </div>
-            <div className="w-full md:w-48">
-              <Form.Select
-                field="searchGroup"
-                placeholder={t('选择分组')}
-                optionList={groupOptions}
-                onChange={(value) => {
-                  // 分组变化时自动搜索
-                  setTimeout(() => {
-                    setActivePage(1);
-                    searchUsers(1, pageSize);
-                  }, 100);
-                }}
-                className="w-full"
-                showClear
-                pure
-                size="small"
-              />
-            </div>
-            <div className="flex gap-2 w-full md:w-auto">
-              <Button
-                type="tertiary"
-                htmlType="submit"
-                loading={loading || searching}
-                className="flex-1 md:flex-initial md:w-auto"
-                size="small"
-              >
-                {t('查询')}
-              </Button>
-              <Button
-                type='tertiary'
-                onClick={() => {
-                  if (formApi) {
-                    formApi.reset();
-                    setTimeout(() => {
-                      setActivePage(1);
-                      loadUsers(1, pageSize);
-                    }, 100);
-                  }
-                }}
-                className="flex-1 md:flex-initial md:w-auto"
-                size="small"
-              >
-                {t('重置')}
-              </Button>
-            </div>
-          </div>
-        </Form>
-      </div>
-    </div>
-  );
-
-  return (
-    <>
-      <AddUser
-        refresh={refresh}
-        visible={showAddUser}
-        handleClose={closeAddUser}
-      ></AddUser>
-      <EditUser
-        refresh={refresh}
-        visible={showEditUser}
-        handleClose={closeEditUser}
-        editingUser={editingUser}
-      ></EditUser>
-
-      <Card
-        className="!rounded-2xl"
-        title={renderHeader()}
-        shadows='always'
-        bordered={false}
-      >
-        <Table
-          columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
-          dataSource={users}
-          scroll={compactMode ? undefined : { x: 'max-content' }}
-          pagination={{
-            formatPageText: (page) =>
-              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                start: page.currentStart,
-                end: page.currentEnd,
-                total: userCount,
-              }),
-            currentPage: activePage,
-            pageSize: pageSize,
-            total: userCount,
-            pageSizeOpts: [10, 20, 50, 100],
-            showSizeChanger: true,
-            onPageSizeChange: (size) => {
-              handlePageSizeChange(size);
-            },
-            onPageChange: handlePageChange,
-          }}
-          loading={loading}
-          onRow={handleRow}
-          empty={
-            <Empty
-              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-              description={t('搜索无结果')}
-              style={{ padding: 30 }}
-            />
-          }
-          className="overflow-hidden"
-          size="middle"
-        />
-      </Card>
-    </>
-  );
-};
-
-export default UsersTable;

+ 257 - 0
web/src/components/table/channels/ChannelsActions.jsx

@@ -0,0 +1,257 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import {
+  Button,
+  Dropdown,
+  Modal,
+  Switch,
+  Typography,
+  Select
+} from '@douyinfe/semi-ui';
+import CompactModeToggle from '../../common/ui/CompactModeToggle';
+
+const ChannelsActions = ({
+  enableBatchDelete,
+  batchDeleteChannels,
+  setShowBatchSetTag,
+  testAllChannels,
+  fixChannelsAbilities,
+  updateAllChannelsBalance,
+  deleteAllDisabledChannels,
+  compactMode,
+  setCompactMode,
+  idSort,
+  setIdSort,
+  setEnableBatchDelete,
+  enableTagMode,
+  setEnableTagMode,
+  statusFilter,
+  setStatusFilter,
+  getFormValues,
+  loadChannels,
+  searchChannels,
+  activeTypeKey,
+  activePage,
+  pageSize,
+  setActivePage,
+  t
+}) => {
+  return (
+    <div className="flex flex-col gap-2">
+      {/* 第一行:批量操作按钮 + 设置开关 */}
+      <div className="flex flex-col md:flex-row justify-between gap-2">
+        {/* 左侧:批量操作按钮 */}
+        <div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
+          <Button
+            size='small'
+            disabled={!enableBatchDelete}
+            type='danger'
+            className="w-full md:w-auto"
+            onClick={() => {
+              Modal.confirm({
+                title: t('确定是否要删除所选通道?'),
+                content: t('此修改将不可逆'),
+                onOk: () => batchDeleteChannels(),
+              });
+            }}
+          >
+            {t('删除所选通道')}
+          </Button>
+
+          <Button
+            size='small'
+            disabled={!enableBatchDelete}
+            type='tertiary'
+            onClick={() => setShowBatchSetTag(true)}
+            className="w-full md:w-auto"
+          >
+            {t('批量设置标签')}
+          </Button>
+
+          <Dropdown
+            size='small'
+            trigger='click'
+            render={
+              <Dropdown.Menu>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='tertiary'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定?'),
+                        content: t('确定要测试所有通道吗?'),
+                        onOk: () => testAllChannels(),
+                        size: 'small',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('测试所有通道')}
+                  </Button>
+                </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定是否要修复数据库一致性?'),
+                        content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
+                        onOk: () => fixChannelsAbilities(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('修复数据库一致性')}
+                  </Button>
+                </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='secondary'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定?'),
+                        content: t('确定要更新所有已启用通道余额吗?'),
+                        onOk: () => updateAllChannelsBalance(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('更新所有已启用通道余额')}
+                  </Button>
+                </Dropdown.Item>
+                <Dropdown.Item>
+                  <Button
+                    size='small'
+                    type='danger'
+                    className="w-full"
+                    onClick={() => {
+                      Modal.confirm({
+                        title: t('确定是否要删除禁用通道?'),
+                        content: t('此修改将不可逆'),
+                        onOk: () => deleteAllDisabledChannels(),
+                        size: 'sm',
+                        centered: true,
+                      });
+                    }}
+                  >
+                    {t('删除禁用通道')}
+                  </Button>
+                </Dropdown.Item>
+              </Dropdown.Menu>
+            }
+          >
+            <Button size='small' theme='light' type='tertiary' className="w-full md:w-auto">
+              {t('批量操作')}
+            </Button>
+          </Dropdown>
+
+          <CompactModeToggle
+            compactMode={compactMode}
+            setCompactMode={setCompactMode}
+            t={t}
+          />
+        </div>
+
+        {/* 右侧:设置开关区域 */}
+        <div className="flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2">
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('使用ID排序')}
+            </Typography.Text>
+            <Switch
+              size='small'
+              checked={idSort}
+              onChange={(v) => {
+                localStorage.setItem('id-sort', v + '');
+                setIdSort(v);
+                const { searchKeyword, searchGroup, searchModel } = getFormValues();
+                if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
+                  loadChannels(activePage, pageSize, v, enableTagMode);
+                } else {
+                  searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
+                }
+              }}
+            />
+          </div>
+
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('开启批量操作')}
+            </Typography.Text>
+            <Switch
+              size='small'
+              checked={enableBatchDelete}
+              onChange={(v) => {
+                localStorage.setItem('enable-batch-delete', v + '');
+                setEnableBatchDelete(v);
+              }}
+            />
+          </div>
+
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('标签聚合模式')}
+            </Typography.Text>
+            <Switch
+              size='small'
+              checked={enableTagMode}
+              onChange={(v) => {
+                localStorage.setItem('enable-tag-mode', v + '');
+                setEnableTagMode(v);
+                setActivePage(1);
+                loadChannels(1, pageSize, idSort, v);
+              }}
+            />
+          </div>
+
+          <div className="flex items-center justify-between w-full md:w-auto">
+            <Typography.Text strong className="mr-2">
+              {t('状态筛选')}
+            </Typography.Text>
+            <Select
+              size='small'
+              value={statusFilter}
+              onChange={(v) => {
+                localStorage.setItem('channel-status-filter', v);
+                setStatusFilter(v);
+                setActivePage(1);
+                loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
+              }}
+            >
+              <Select.Option value="all">{t('全部')}</Select.Option>
+              <Select.Option value="enabled">{t('已启用')}</Select.Option>
+              <Select.Option value="disabled">{t('已禁用')}</Select.Option>
+            </Select>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default ChannelsActions; 

+ 623 - 0
web/src/components/table/channels/ChannelsColumnDefs.js

@@ -0,0 +1,623 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import {
+  Button,
+  Dropdown,
+  InputNumber,
+  Modal,
+  Space,
+  SplitButtonGroup,
+  Tag,
+  Tooltip,
+  Typography
+} from '@douyinfe/semi-ui';
+import {
+  timestamp2string,
+  renderGroup,
+  renderQuota,
+  getChannelIcon,
+  renderQuotaWithAmount
+} from '../../../helpers/index.js';
+import { CHANNEL_OPTIONS } from '../../../constants/index.js';
+import { IconTreeTriangleDown, IconMore } from '@douyinfe/semi-icons';
+import { FaRandom } from 'react-icons/fa';
+
+// Render functions
+const renderType = (type, channelInfo = undefined, t) => {
+  let type2label = new Map();
+  for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
+    type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
+  }
+  type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
+
+  let icon = getChannelIcon(type);
+
+  if (channelInfo?.is_multi_key) {
+    icon = (
+      channelInfo?.multi_key_mode === 'random' ? (
+        <div className="flex items-center gap-1">
+          <FaRandom className="text-blue-500" />
+          {icon}
+        </div>
+      ) : (
+        <div className="flex items-center gap-1">
+          <IconTreeTriangleDown className="text-blue-500" />
+          {icon}
+        </div>
+      )
+    )
+  }
+
+  return (
+    <Tag
+      color={type2label[type]?.color}
+      shape='circle'
+      prefixIcon={icon}
+    >
+      {type2label[type]?.label}
+    </Tag>
+  );
+};
+
+const renderTagType = (t) => {
+  return (
+    <Tag
+      color='light-blue'
+      shape='circle'
+      type='light'
+    >
+      {t('标签聚合')}
+    </Tag>
+  );
+};
+
+const renderStatus = (status, channelInfo = undefined, t) => {
+  if (channelInfo) {
+    if (channelInfo.is_multi_key) {
+      let keySize = channelInfo.multi_key_size;
+      let enabledKeySize = keySize;
+      if (channelInfo.multi_key_status_list) {
+        enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
+      }
+      return renderMultiKeyStatus(status, keySize, enabledKeySize, t);
+    }
+  }
+  switch (status) {
+    case 1:
+      return (
+        <Tag color='green' shape='circle'>
+          {t('已启用')}
+        </Tag>
+      );
+    case 2:
+      return (
+        <Tag color='red' shape='circle'>
+          {t('已禁用')}
+        </Tag>
+      );
+    case 3:
+      return (
+        <Tag color='yellow' shape='circle'>
+          {t('自动禁用')}
+        </Tag>
+      );
+    default:
+      return (
+        <Tag color='grey' shape='circle'>
+          {t('未知状态')}
+        </Tag>
+      );
+  }
+};
+
+const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => {
+  switch (status) {
+    case 1:
+      return (
+        <Tag color='green' shape='circle'>
+          {t('已启用')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+    case 2:
+      return (
+        <Tag color='red' shape='circle'>
+          {t('已禁用')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+    case 3:
+      return (
+        <Tag color='yellow' shape='circle'>
+          {t('自动禁用')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+    default:
+      return (
+        <Tag color='grey' shape='circle'>
+          {t('未知状态')} {enabledKeySize}/{keySize}
+        </Tag>
+      );
+  }
+}
+
+const renderResponseTime = (responseTime, t) => {
+  let time = responseTime / 1000;
+  time = time.toFixed(2) + t(' 秒');
+  if (responseTime === 0) {
+    return (
+      <Tag color='grey' shape='circle'>
+        {t('未测试')}
+      </Tag>
+    );
+  } else if (responseTime <= 1000) {
+    return (
+      <Tag color='green' shape='circle'>
+        {time}
+      </Tag>
+    );
+  } else if (responseTime <= 3000) {
+    return (
+      <Tag color='lime' shape='circle'>
+        {time}
+      </Tag>
+    );
+  } else if (responseTime <= 5000) {
+    return (
+      <Tag color='yellow' shape='circle'>
+        {time}
+      </Tag>
+    );
+  } else {
+    return (
+      <Tag color='red' shape='circle'>
+        {time}
+      </Tag>
+    );
+  }
+};
+
+export const getChannelsColumns = ({
+  t,
+  COLUMN_KEYS,
+  updateChannelBalance,
+  manageChannel,
+  manageTag,
+  submitTagEdit,
+  testChannel,
+  setCurrentTestChannel,
+  setShowModelTestModal,
+  setEditingChannel,
+  setShowEdit,
+  setShowEditTag,
+  setEditingTag,
+  copySelectedChannel,
+  refresh,
+  activePage,
+  channels
+}) => {
+  return [
+    {
+      key: COLUMN_KEYS.ID,
+      title: t('ID'),
+      dataIndex: 'id',
+    },
+    {
+      key: COLUMN_KEYS.NAME,
+      title: t('名称'),
+      dataIndex: 'name',
+    },
+    {
+      key: COLUMN_KEYS.GROUP,
+      title: t('分组'),
+      dataIndex: 'group',
+      render: (text, record, index) => (
+        <div>
+          <Space spacing={2}>
+            {text
+              ?.split(',')
+              .sort((a, b) => {
+                if (a === 'default') return -1;
+                if (b === 'default') return 1;
+                return a.localeCompare(b);
+              })
+              .map((item, index) => renderGroup(item))}
+          </Space>
+        </div>
+      ),
+    },
+    {
+      key: COLUMN_KEYS.TYPE,
+      title: t('类型'),
+      dataIndex: 'type',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          if (record.channel_info) {
+            if (record.channel_info.is_multi_key) {
+              return <>{renderType(text, record.channel_info, t)}</>;
+            }
+          }
+          return <>{renderType(text, undefined, t)}</>;
+        } else {
+          return <>{renderTagType(t)}</>;
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.STATUS,
+      title: t('状态'),
+      dataIndex: 'status',
+      render: (text, record, index) => {
+        if (text === 3) {
+          if (record.other_info === '') {
+            record.other_info = '{}';
+          }
+          let otherInfo = JSON.parse(record.other_info);
+          let reason = otherInfo['status_reason'];
+          let time = otherInfo['status_time'];
+          return (
+            <div>
+              <Tooltip
+                content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
+              >
+                {renderStatus(text, record.channel_info, t)}
+              </Tooltip>
+            </div>
+          );
+        } else {
+          return renderStatus(text, record.channel_info, t);
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.RESPONSE_TIME,
+      title: t('响应时间'),
+      dataIndex: 'response_time',
+      render: (text, record, index) => (
+        <div>{renderResponseTime(text, t)}</div>
+      ),
+    },
+    {
+      key: COLUMN_KEYS.BALANCE,
+      title: t('已用/剩余'),
+      dataIndex: 'expired_time',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <Space spacing={1}>
+                <Tooltip content={t('已用额度')}>
+                  <Tag color='white' type='ghost' shape='circle'>
+                    {renderQuota(record.used_quota)}
+                  </Tag>
+                </Tooltip>
+                <Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
+                  <Tag
+                    color='white'
+                    type='ghost'
+                    shape='circle'
+                    onClick={() => updateChannelBalance(record)}
+                  >
+                    {renderQuotaWithAmount(record.balance)}
+                  </Tag>
+                </Tooltip>
+              </Space>
+            </div>
+          );
+        } else {
+          return (
+            <Tooltip content={t('已用额度')}>
+              <Tag color='white' type='ghost' shape='circle'>
+                {renderQuota(record.used_quota)}
+              </Tag>
+            </Tooltip>
+          );
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.PRIORITY,
+      title: t('优先级'),
+      dataIndex: 'priority',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name='priority'
+                onBlur={(e) => {
+                  manageChannel(record.id, 'priority', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.priority}
+                min={-999}
+                size="small"
+              />
+            </div>
+          );
+        } else {
+          return (
+            <InputNumber
+              style={{ width: 70 }}
+              name='priority'
+              keepFocus={true}
+              onBlur={(e) => {
+                Modal.warning({
+                  title: t('修改子渠道优先级'),
+                  content: t('确定要修改所有子渠道优先级为 ') + e.target.value + t(' 吗?'),
+                  onOk: () => {
+                    if (e.target.value === '') {
+                      return;
+                    }
+                    submitTagEdit('priority', {
+                      tag: record.key,
+                      priority: e.target.value,
+                    });
+                  },
+                });
+              }}
+              innerButtons
+              defaultValue={record.priority}
+              min={-999}
+              size="small"
+            />
+          );
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.WEIGHT,
+      title: t('权重'),
+      dataIndex: 'weight',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name='weight'
+                onBlur={(e) => {
+                  manageChannel(record.id, 'weight', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.weight}
+                min={0}
+                size="small"
+              />
+            </div>
+          );
+        } else {
+          return (
+            <InputNumber
+              style={{ width: 70 }}
+              name='weight'
+              keepFocus={true}
+              onBlur={(e) => {
+                Modal.warning({
+                  title: t('修改子渠道权重'),
+                  content: t('确定要修改所有子渠道权重为 ') + e.target.value + t(' 吗?'),
+                  onOk: () => {
+                    if (e.target.value === '') {
+                      return;
+                    }
+                    submitTagEdit('weight', {
+                      tag: record.key,
+                      weight: e.target.value,
+                    });
+                  },
+                });
+              }}
+              innerButtons
+              defaultValue={record.weight}
+              min={-999}
+              size="small"
+            />
+          );
+        }
+      },
+    },
+    {
+      key: COLUMN_KEYS.OPERATE,
+      title: '',
+      dataIndex: 'operate',
+      fixed: 'right',
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          const moreMenuItems = [
+            {
+              node: 'item',
+              name: t('删除'),
+              type: 'danger',
+              onClick: () => {
+                Modal.confirm({
+                  title: t('确定是否要删除此渠道?'),
+                  content: t('此修改将不可逆'),
+                  onOk: () => {
+                    (async () => {
+                      await manageChannel(record.id, 'delete', record);
+                      await refresh();
+                      setTimeout(() => {
+                        if (channels.length === 0 && activePage > 1) {
+                          refresh(activePage - 1);
+                        }
+                      }, 100);
+                    })();
+                  },
+                });
+              },
+            },
+            {
+              node: 'item',
+              name: t('复制'),
+              type: 'tertiary',
+              onClick: () => {
+                Modal.confirm({
+                  title: t('确定是否要复制此渠道?'),
+                  content: t('复制渠道的所有信息'),
+                  onOk: () => copySelectedChannel(record),
+                });
+              },
+            },
+          ];
+
+          return (
+            <Space wrap>
+              <SplitButtonGroup
+                className="overflow-hidden"
+                aria-label={t('测试单个渠道操作项目组')}
+              >
+                <Button
+                  size="small"
+                  type='tertiary'
+                  onClick={() => testChannel(record, '')}
+                >
+                  {t('测试')}
+                </Button>
+                <Button
+                  size="small"
+                  type='tertiary'
+                  icon={<IconTreeTriangleDown />}
+                  onClick={() => {
+                    setCurrentTestChannel(record);
+                    setShowModelTestModal(true);
+                  }}
+                />
+              </SplitButtonGroup>
+
+              {record.channel_info?.is_multi_key ? (
+                <SplitButtonGroup
+                  aria-label={t('多密钥渠道操作项目组')}
+                >
+                  {
+                    record.status === 1 ? (
+                      <Button
+                        type='danger'
+                        size="small"
+                        onClick={() => manageChannel(record.id, 'disable', record)}
+                      >
+                        {t('禁用')}
+                      </Button>
+                    ) : (
+                      <Button
+                        size="small"
+                        onClick={() => manageChannel(record.id, 'enable', record)}
+                      >
+                        {t('启用')}
+                      </Button>
+                    )
+                  }
+                  <Dropdown
+                    trigger='click'
+                    position='bottomRight'
+                    menu={[
+                      {
+                        node: 'item',
+                        name: t('启用全部密钥'),
+                        onClick: () => manageChannel(record.id, 'enable_all', record),
+                      }
+                    ]}
+                  >
+                    <Button
+                      type='tertiary'
+                      size="small"
+                      icon={<IconTreeTriangleDown />}
+                    />
+                  </Dropdown>
+                </SplitButtonGroup>
+              ) : (
+                record.status === 1 ? (
+                  <Button
+                    type='danger'
+                    size="small"
+                    onClick={() => manageChannel(record.id, 'disable', record)}
+                  >
+                    {t('禁用')}
+                  </Button>
+                ) : (
+                  <Button
+                    size="small"
+                    onClick={() => manageChannel(record.id, 'enable', record)}
+                  >
+                    {t('启用')}
+                  </Button>
+                )
+              )}
+
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => {
+                  setEditingChannel(record);
+                  setShowEdit(true);
+                }}
+              >
+                {t('编辑')}
+              </Button>
+
+              <Dropdown
+                trigger='click'
+                position='bottomRight'
+                menu={moreMenuItems}
+              >
+                <Button
+                  icon={<IconMore />}
+                  type='tertiary'
+                  size="small"
+                />
+              </Dropdown>
+            </Space>
+          );
+        } else {
+          // 标签操作按钮
+          return (
+            <Space wrap>
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => manageTag(record.key, 'enable')}
+              >
+                {t('启用全部')}
+              </Button>
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => manageTag(record.key, 'disable')}
+              >
+                {t('禁用全部')}
+              </Button>
+              <Button
+                type='tertiary'
+                size="small"
+                onClick={() => {
+                  setShowEditTag(true);
+                  setEditingTag(record.key);
+                }}
+              >
+                {t('编辑')}
+              </Button>
+            </Space>
+          );
+        }
+      },
+    },
+  ];
+}; 

+ 159 - 0
web/src/components/table/channels/ChannelsFilters.jsx

@@ -0,0 +1,159 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button, Form } from '@douyinfe/semi-ui';
+import { IconSearch } from '@douyinfe/semi-icons';
+
+const ChannelsFilters = ({
+  setEditingChannel,
+  setShowEdit,
+  refresh,
+  setShowColumnSelector,
+  formInitValues,
+  setFormApi,
+  searchChannels,
+  enableTagMode,
+  formApi,
+  groupOptions,
+  loading,
+  searching,
+  t
+}) => {
+  return (
+    <div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
+      <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
+        <Button
+          size='small'
+          theme='light'
+          type='primary'
+          className="w-full md:w-auto"
+          onClick={() => {
+            setEditingChannel({
+              id: undefined,
+            });
+            setShowEdit(true);
+          }}
+        >
+          {t('添加渠道')}
+        </Button>
+
+        <Button
+          size='small'
+          type='tertiary'
+          className="w-full md:w-auto"
+          onClick={refresh}
+        >
+          {t('刷新')}
+        </Button>
+
+        <Button
+          size='small'
+          type='tertiary'
+          onClick={() => setShowColumnSelector(true)}
+          className="w-full md:w-auto"
+        >
+          {t('列设置')}
+        </Button>
+      </div>
+
+      <div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto order-1 md:order-2">
+        <Form
+          initValues={formInitValues}
+          getFormApi={(api) => setFormApi(api)}
+          onSubmit={() => searchChannels(enableTagMode)}
+          allowEmpty={true}
+          autoComplete="off"
+          layout="horizontal"
+          trigger="change"
+          stopValidateWithError={false}
+          className="flex flex-col md:flex-row items-center gap-2 w-full"
+        >
+          <div className="relative w-full md:w-64">
+            <Form.Input
+              size='small'
+              field="searchKeyword"
+              prefix={<IconSearch />}
+              placeholder={t('渠道ID,名称,密钥,API地址')}
+              showClear
+              pure
+            />
+          </div>
+          <div className="w-full md:w-48">
+            <Form.Input
+              size='small'
+              field="searchModel"
+              prefix={<IconSearch />}
+              placeholder={t('模型关键字')}
+              showClear
+              pure
+            />
+          </div>
+          <div className="w-full md:w-32">
+            <Form.Select
+              size='small'
+              field="searchGroup"
+              placeholder={t('选择分组')}
+              optionList={[
+                { label: t('选择分组'), value: null },
+                ...groupOptions,
+              ]}
+              className="w-full"
+              showClear
+              pure
+              onChange={() => {
+                // 延迟执行搜索,让表单值先更新
+                setTimeout(() => {
+                  searchChannels(enableTagMode);
+                }, 0);
+              }}
+            />
+          </div>
+          <Button
+            size='small'
+            type="tertiary"
+            htmlType="submit"
+            loading={loading || searching}
+            className="w-full md:w-auto"
+          >
+            {t('查询')}
+          </Button>
+          <Button
+            size='small'
+            type='tertiary'
+            onClick={() => {
+              if (formApi) {
+                formApi.reset();
+                // 重置后立即查询,使用setTimeout确保表单重置完成
+                setTimeout(() => {
+                  refresh();
+                }, 100);
+              }
+            }}
+            className="w-full md:w-auto"
+          >
+            {t('重置')}
+          </Button>
+        </Form>
+      </div>
+    </div>
+  );
+};
+
+export default ChannelsFilters; 

+ 159 - 0
web/src/components/table/channels/ChannelsTable.jsx

@@ -0,0 +1,159 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useMemo } from 'react';
+import { Empty } from '@douyinfe/semi-ui';
+import CardTable from '../../common/ui/CardTable.js';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark
+} from '@douyinfe/semi-illustrations';
+import { getChannelsColumns } from './ChannelsColumnDefs.js';
+
+const ChannelsTable = (channelsData) => {
+  const {
+    channels,
+    loading,
+    searching,
+    activePage,
+    pageSize,
+    channelCount,
+    enableBatchDelete,
+    compactMode,
+    visibleColumns,
+    setSelectedChannels,
+    handlePageChange,
+    handlePageSizeChange,
+    handleRow,
+    t,
+    COLUMN_KEYS,
+    // Column functions and data
+    updateChannelBalance,
+    manageChannel,
+    manageTag,
+    submitTagEdit,
+    testChannel,
+    setCurrentTestChannel,
+    setShowModelTestModal,
+    setEditingChannel,
+    setShowEdit,
+    setShowEditTag,
+    setEditingTag,
+    copySelectedChannel,
+    refresh,
+  } = channelsData;
+
+  // Get all columns
+  const allColumns = useMemo(() => {
+    return getChannelsColumns({
+      t,
+      COLUMN_KEYS,
+      updateChannelBalance,
+      manageChannel,
+      manageTag,
+      submitTagEdit,
+      testChannel,
+      setCurrentTestChannel,
+      setShowModelTestModal,
+      setEditingChannel,
+      setShowEdit,
+      setShowEditTag,
+      setEditingTag,
+      copySelectedChannel,
+      refresh,
+      activePage,
+      channels,
+    });
+  }, [
+    t,
+    COLUMN_KEYS,
+    updateChannelBalance,
+    manageChannel,
+    manageTag,
+    submitTagEdit,
+    testChannel,
+    setCurrentTestChannel,
+    setShowModelTestModal,
+    setEditingChannel,
+    setShowEdit,
+    setShowEditTag,
+    setEditingTag,
+    copySelectedChannel,
+    refresh,
+    activePage,
+    channels,
+  ]);
+
+  // Filter columns based on visibility settings
+  const getVisibleColumns = () => {
+    return allColumns.filter((column) => visibleColumns[column.key]);
+  };
+
+  const visibleColumnsList = useMemo(() => {
+    return getVisibleColumns();
+  }, [visibleColumns, allColumns]);
+
+  const tableColumns = useMemo(() => {
+    return compactMode
+      ? visibleColumnsList.map(({ fixed, ...rest }) => rest)
+      : visibleColumnsList;
+  }, [compactMode, visibleColumnsList]);
+
+  return (
+    <CardTable
+      columns={tableColumns}
+      dataSource={channels}
+      scroll={compactMode ? undefined : { x: 'max-content' }}
+      pagination={{
+        currentPage: activePage,
+        pageSize: pageSize,
+        total: channelCount,
+        pageSizeOpts: [10, 20, 50, 100],
+        showSizeChanger: true,
+        onPageSizeChange: handlePageSizeChange,
+        onPageChange: handlePageChange,
+      }}
+      hidePagination={true}
+      expandAllRows={false}
+      onRow={handleRow}
+      rowSelection={
+        enableBatchDelete
+          ? {
+            onChange: (selectedRowKeys, selectedRows) => {
+              setSelectedChannels(selectedRows);
+            },
+          }
+          : null
+      }
+      empty={
+        <Empty
+          image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+          darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+          description={t('搜索无结果')}
+          style={{ padding: 30 }}
+        />
+      }
+      className="rounded-xl overflow-hidden"
+      size="middle"
+      loading={loading || searching}
+    />
+  );
+};
+
+export default ChannelsTable; 

+ 89 - 0
web/src/components/table/channels/ChannelsTabs.jsx

@@ -0,0 +1,89 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
+import { CHANNEL_OPTIONS } from '../../../constants/index.js';
+import { getChannelIcon } from '../../../helpers/index.js';
+
+const ChannelsTabs = ({
+  enableTagMode,
+  activeTypeKey,
+  setActiveTypeKey,
+  channelTypeCounts,
+  availableTypeKeys,
+  loadChannels,
+  activePage,
+  pageSize,
+  idSort,
+  setActivePage,
+  t
+}) => {
+  if (enableTagMode) return null;
+
+  const handleTabChange = (key) => {
+    setActiveTypeKey(key);
+    setActivePage(1);
+    loadChannels(1, pageSize, idSort, enableTagMode, key);
+  };
+
+  return (
+    <Tabs
+      activeKey={activeTypeKey}
+      type="card"
+      collapsible
+      onChange={handleTabChange}
+      className="mb-2"
+    >
+      <TabPane
+        itemKey="all"
+        tab={
+          <span className="flex items-center gap-2">
+            {t('全部')}
+            <Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
+              {channelTypeCounts['all'] || 0}
+            </Tag>
+          </span>
+        }
+      />
+
+      {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => {
+        const key = String(option.value);
+        const count = channelTypeCounts[option.value] || 0;
+        return (
+          <TabPane
+            key={key}
+            itemKey={key}
+            tab={
+              <span className="flex items-center gap-2">
+                {getChannelIcon(option.value)}
+                {option.label}
+                <Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
+                  {count}
+                </Tag>
+              </span>
+            }
+          />
+        );
+      })}
+    </Tabs>
+  );
+};
+
+export default ChannelsTabs; 

+ 80 - 0
web/src/components/table/channels/index.jsx

@@ -0,0 +1,80 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import CardPro from '../../common/ui/CardPro.js';
+import ChannelsTable from './ChannelsTable.jsx';
+import ChannelsActions from './ChannelsActions.jsx';
+import ChannelsFilters from './ChannelsFilters.jsx';
+import ChannelsTabs from './ChannelsTabs.jsx';
+import { useChannelsData } from '../../../hooks/channels/useChannelsData.js';
+import { useIsMobile } from '../../../hooks/common/useIsMobile.js';
+import BatchTagModal from './modals/BatchTagModal.jsx';
+import ModelTestModal from './modals/ModelTestModal.jsx';
+import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx';
+import EditChannelModal from './modals/EditChannelModal.jsx';
+import EditTagModal from './modals/EditTagModal.jsx';
+import { createCardProPagination } from '../../../helpers/utils';
+
+const ChannelsPage = () => {
+  const channelsData = useChannelsData();
+  const isMobile = useIsMobile();
+
+  return (
+    <>
+      {/* Modals */}
+      <ColumnSelectorModal {...channelsData} />
+      <EditTagModal
+        visible={channelsData.showEditTag}
+        tag={channelsData.editingTag}
+        handleClose={() => channelsData.setShowEditTag(false)}
+        refresh={channelsData.refresh}
+      />
+      <EditChannelModal
+        refresh={channelsData.refresh}
+        visible={channelsData.showEdit}
+        handleClose={channelsData.closeEdit}
+        editingChannel={channelsData.editingChannel}
+      />
+      <BatchTagModal {...channelsData} />
+      <ModelTestModal {...channelsData} />
+
+      {/* Main Content */}
+      <CardPro
+        type="type3"
+        tabsArea={<ChannelsTabs {...channelsData} />}
+        actionsArea={<ChannelsActions {...channelsData} />}
+        searchArea={<ChannelsFilters {...channelsData} />}
+        paginationArea={createCardProPagination({
+          currentPage: channelsData.activePage,
+          pageSize: channelsData.pageSize,
+          total: channelsData.channelCount,
+          onPageChange: channelsData.handlePageChange,
+          onPageSizeChange: channelsData.handlePageSizeChange,
+          isMobile: isMobile,
+        })}
+        t={channelsData.t}
+      >
+        <ChannelsTable {...channelsData} />
+      </CardPro>
+    </>
+  );
+};
+
+export default ChannelsPage; 

+ 60 - 0
web/src/components/table/channels/modals/BatchTagModal.jsx

@@ -0,0 +1,60 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Modal, Input, Typography } from '@douyinfe/semi-ui';
+
+const BatchTagModal = ({
+  showBatchSetTag,
+  setShowBatchSetTag,
+  batchSetChannelTag,
+  batchSetTagValue,
+  setBatchSetTagValue,
+  selectedChannels,
+  t
+}) => {
+  return (
+    <Modal
+      title={t('批量设置标签')}
+      visible={showBatchSetTag}
+      onOk={batchSetChannelTag}
+      onCancel={() => setShowBatchSetTag(false)}
+      maskClosable={false}
+      centered={true}
+      size="small"
+      className="!rounded-lg"
+    >
+      <div className="mb-5">
+        <Typography.Text>{t('请输入要设置的标签名称')}</Typography.Text>
+      </div>
+      <Input
+        placeholder={t('请输入标签名称')}
+        value={batchSetTagValue}
+        onChange={(v) => setBatchSetTagValue(v)}
+      />
+      <div className="mt-4">
+        <Typography.Text type='secondary'>
+          {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)}
+        </Typography.Text>
+      </div>
+    </Modal>
+  );
+};
+
+export default BatchTagModal; 

+ 133 - 0
web/src/components/table/channels/modals/ColumnSelectorModal.jsx

@@ -0,0 +1,133 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
+import { getChannelsColumns } from '../ChannelsColumnDefs.js';
+
+const ColumnSelectorModal = ({
+  showColumnSelector,
+  setShowColumnSelector,
+  visibleColumns,
+  handleColumnVisibilityChange,
+  handleSelectAll,
+  initDefaultColumns,
+  COLUMN_KEYS,
+  t,
+  // Props needed for getChannelsColumns
+  updateChannelBalance,
+  manageChannel,
+  manageTag,
+  submitTagEdit,
+  testChannel,
+  setCurrentTestChannel,
+  setShowModelTestModal,
+  setEditingChannel,
+  setShowEdit,
+  setShowEditTag,
+  setEditingTag,
+  copySelectedChannel,
+  refresh,
+  activePage,
+  channels,
+}) => {
+  // Get all columns for display in selector
+  const allColumns = getChannelsColumns({
+    t,
+    COLUMN_KEYS,
+    updateChannelBalance,
+    manageChannel,
+    manageTag,
+    submitTagEdit,
+    testChannel,
+    setCurrentTestChannel,
+    setShowModelTestModal,
+    setEditingChannel,
+    setShowEdit,
+    setShowEditTag,
+    setEditingTag,
+    copySelectedChannel,
+    refresh,
+    activePage,
+    channels,
+  });
+
+  return (
+    <Modal
+      title={t('列设置')}
+      visible={showColumnSelector}
+      onCancel={() => setShowColumnSelector(false)}
+      footer={
+        <div className="flex justify-end">
+          <Button onClick={() => initDefaultColumns()}>
+            {t('重置')}
+          </Button>
+          <Button onClick={() => setShowColumnSelector(false)}>
+            {t('取消')}
+          </Button>
+          <Button onClick={() => setShowColumnSelector(false)}>
+            {t('确定')}
+          </Button>
+        </div>
+      }
+    >
+      <div style={{ marginBottom: 20 }}>
+        <Checkbox
+          checked={Object.values(visibleColumns).every((v) => v === true)}
+          indeterminate={
+            Object.values(visibleColumns).some((v) => v === true) &&
+            !Object.values(visibleColumns).every((v) => v === true)
+          }
+          onChange={(e) => handleSelectAll(e.target.checked)}
+        >
+          {t('全选')}
+        </Checkbox>
+      </div>
+      <div
+        className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
+        style={{ border: '1px solid var(--semi-color-border)' }}
+      >
+        {allColumns.map((column) => {
+          // Skip columns without title
+          if (!column.title) {
+            return null;
+          }
+
+          return (
+            <div
+              key={column.key}
+              className="w-1/2 mb-4 pr-2"
+            >
+              <Checkbox
+                checked={!!visibleColumns[column.key]}
+                onChange={(e) =>
+                  handleColumnVisibilityChange(column.key, e.target.checked)
+                }
+              >
+                {column.title}
+              </Checkbox>
+            </div>
+          );
+        })}
+      </div>
+    </Modal>
+  );
+};
+
+export default ColumnSelectorModal; 

+ 42 - 23
web/src/pages/Channel/EditChannel.js → web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -1,5 +1,23 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useEffect, useState, useRef, useMemo } from 'react';
-import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import {
   API,
@@ -7,9 +25,9 @@ import {
   showInfo,
   showSuccess,
   verifyJSON,
-} from '../../helpers';
-import { useIsMobile } from '../../hooks/useIsMobile.js';
-import { CHANNEL_OPTIONS } from '../../constants';
+} from '../../../../helpers';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
+import { CHANNEL_OPTIONS } from '../../../../constants';
 import {
   SideSheet,
   Space,
@@ -28,7 +46,7 @@ import {
   Col,
   Highlight,
 } from '@douyinfe/semi-ui';
-import { getChannelModels, copy, getChannelIcon, getModelCategories } from '../../helpers';
+import { getChannelModels, copy, getChannelIcon, getModelCategories, modelSelectFilter } from '../../../../helpers';
 import {
   IconSave,
   IconClose,
@@ -68,7 +86,7 @@ function type2secretPrompt(type) {
     case 33:
       return '按照如下格式输入:Ak|Sk|Region';
     case 50:
-      return '按照如下格式输入: AccessKey|SecretKey';
+      return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey';
     case 51:
       return '按照如下格式输入: Access Key ID|Secret Access Key';
     default:
@@ -76,9 +94,8 @@ function type2secretPrompt(type) {
   }
 }
 
-const EditChannel = (props) => {
+const EditChannelModal = (props) => {
   const { t } = useTranslation();
-  const navigate = useNavigate();
   const channelId = props.editingChannel.id;
   const isEdit = channelId !== undefined;
   const [loading, setLoading] = useState(isEdit);
@@ -193,7 +210,7 @@ const EditChannel = (props) => {
         setInputs((inputs) => ({ ...inputs, models: localModels }));
       }
       setBasicModels(localModels);
-      
+
       // 重置手动输入模式状态
       setUseManualInput(false);
     }
@@ -726,9 +743,9 @@ const EditChannel = (props) => {
       onClick,
       ...rest
     } = renderProps;
-    
+
     const searchWords = channelSearchValue ? [channelSearchValue] : [];
-    
+
     // 构建样式类名
     const optionClassName = [
       'flex items-center gap-3 px-3 py-2 transition-all duration-200 rounded-lg mx-2 my-1',
@@ -738,12 +755,12 @@ const EditChannel = (props) => {
       !disabled && 'hover:bg-gray-50 hover:shadow-md cursor-pointer',
       className
     ].filter(Boolean).join(' ');
-    
+
     return (
-      <div 
-        style={style} 
+      <div
+        style={style}
         className={optionClassName}
-        onClick={() => !disabled && onClick()} 
+        onClick={() => !disabled && onClick()}
         onMouseEnter={e => onMouseEnter()}
       >
         <div className="flex items-center gap-3 w-full">
@@ -751,8 +768,8 @@ const EditChannel = (props) => {
             {getChannelIcon(value)}
           </div>
           <div className="flex-1 min-w-0">
-            <Highlight 
-              sourceString={label} 
+            <Highlight
+              sourceString={label}
               searchWords={searchWords}
               className="text-sm font-medium truncate"
             />
@@ -760,7 +777,7 @@ const EditChannel = (props) => {
           {selected && (
             <div className="flex-shrink-0 text-blue-600">
               <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
-                <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
+                <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
               </svg>
             </div>
           )}
@@ -836,7 +853,8 @@ const EditChannel = (props) => {
                     rules={[{ required: true, message: t('请选择渠道类型') }]}
                     optionList={channelOptionList}
                     style={{ width: '100%' }}
-                    filter
+                    filter={modelSelectFilter}
+                    autoClearSearchValue={false}
                     searchPosition='dropdown'
                     onSearch={(value) => setChannelSearchValue(value)}
                     renderOptionItem={renderChannelOption}
@@ -926,7 +944,7 @@ const EditChannel = (props) => {
                               </Space>
                             </div>
                           )}
-                          
+
                           {batch && (
                             <Banner
                               type='info'
@@ -934,7 +952,7 @@ const EditChannel = (props) => {
                               className='!rounded-lg mb-3'
                             />
                           )}
-                          
+
                           {useManualInput && !batch ? (
                             <Form.TextArea
                               field='key'
@@ -1234,7 +1252,8 @@ const EditChannel = (props) => {
                     placeholder={t('请选择该渠道所支持的模型')}
                     rules={[{ required: true, message: t('请选择模型') }]}
                     multiple
-                    filter
+                    filter={modelSelectFilter}
+                    autoClearSearchValue={false}
                     searchPosition='dropdown'
                     optionList={modelOptions}
                     style={{ width: '100%' }}
@@ -1466,4 +1485,4 @@ const EditChannel = (props) => {
   );
 };
 
-export default EditChannel;
+export default EditChannelModal; 

+ 25 - 4
web/src/pages/Channel/EditTagModal.js → web/src/components/table/channels/modals/EditTagModal.jsx

@@ -1,3 +1,22 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
 import React, { useState, useEffect, useRef } from 'react';
 import {
   API,
@@ -6,7 +25,8 @@ import {
   showSuccess,
   showWarning,
   verifyJSON,
-} from '../../helpers';
+  modelSelectFilter,
+} from '../../../../helpers';
 import {
   SideSheet,
   Space,
@@ -26,7 +46,7 @@ import {
   IconUser,
   IconCode,
 } from '@douyinfe/semi-icons';
-import { getChannelModels } from '../../helpers';
+import { getChannelModels } from '../../../../helpers';
 import { useTranslation } from 'react-i18next';
 
 const { Text, Title } = Typography;
@@ -375,7 +395,8 @@ const EditTagModal = (props) => {
                     label={t('模型')}
                     placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
                     multiple
-                    filter
+                    filter={modelSelectFilter}
+                    autoClearSearchValue={false}
                     searchPosition='dropdown'
                     optionList={modelOptions}
                     style={{ width: '100%' }}
@@ -441,4 +462,4 @@ const EditTagModal = (props) => {
   );
 };
 
-export default EditTagModal;
+export default EditTagModal; 

+ 276 - 0
web/src/components/table/channels/modals/ModelTestModal.jsx

@@ -0,0 +1,276 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import {
+  Modal,
+  Button,
+  Input,
+  Table,
+  Tag,
+  Typography
+} from '@douyinfe/semi-ui';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { copy, showError, showInfo, showSuccess } from '../../../../helpers/index.js';
+import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants/index.js';
+
+const ModelTestModal = ({
+  showModelTestModal,
+  currentTestChannel,
+  handleCloseModal,
+  isBatchTesting,
+  batchTestModels,
+  modelSearchKeyword,
+  setModelSearchKeyword,
+  selectedModelKeys,
+  setSelectedModelKeys,
+  modelTestResults,
+  testingModels,
+  testChannel,
+  modelTablePage,
+  setModelTablePage,
+  allSelectingRef,
+  isMobile,
+  t
+}) => {
+  const hasChannel = Boolean(currentTestChannel);
+
+  const filteredModels = hasChannel
+    ? currentTestChannel.models
+      .split(',')
+      .filter((model) =>
+        model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
+      )
+    : [];
+
+  const handleCopySelected = () => {
+    if (selectedModelKeys.length === 0) {
+      showError(t('请先选择模型!'));
+      return;
+    }
+    copy(selectedModelKeys.join(',')).then((ok) => {
+      if (ok) {
+        showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
+      } else {
+        showError(t('复制失败,请手动复制'));
+      }
+    });
+  };
+
+  const handleSelectSuccess = () => {
+    if (!currentTestChannel) return;
+    const successKeys = currentTestChannel.models
+      .split(',')
+      .filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
+      .filter((m) => {
+        const result = modelTestResults[`${currentTestChannel.id}-${m}`];
+        return result && result.success;
+      });
+    if (successKeys.length === 0) {
+      showInfo(t('暂无成功模型'));
+    }
+    setSelectedModelKeys(successKeys);
+  };
+
+  const columns = [
+    {
+      title: t('模型名称'),
+      dataIndex: 'model',
+      render: (text) => (
+        <div className="flex items-center">
+          <Typography.Text strong>{text}</Typography.Text>
+        </div>
+      )
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'status',
+      render: (text, record) => {
+        const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`];
+        const isTesting = testingModels.has(record.model);
+
+        if (isTesting) {
+          return (
+            <Tag color='blue' shape='circle'>
+              {t('测试中')}
+            </Tag>
+          );
+        }
+
+        if (!testResult) {
+          return (
+            <Tag color='grey' shape='circle'>
+              {t('未开始')}
+            </Tag>
+          );
+        }
+
+        return (
+          <div className="flex items-center gap-2">
+            <Tag
+              color={testResult.success ? 'green' : 'red'}
+              shape='circle'
+            >
+              {testResult.success ? t('成功') : t('失败')}
+            </Tag>
+            {testResult.success && (
+              <Typography.Text type="tertiary">
+                {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))}
+              </Typography.Text>
+            )}
+          </div>
+        );
+      }
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      render: (text, record) => {
+        const isTesting = testingModels.has(record.model);
+        return (
+          <Button
+            type='tertiary'
+            onClick={() => testChannel(currentTestChannel, record.model)}
+            loading={isTesting}
+            size='small'
+          >
+            {t('测试')}
+          </Button>
+        );
+      }
+    }
+  ];
+
+  const dataSource = (() => {
+    if (!hasChannel) return [];
+    const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
+    const end = start + MODEL_TABLE_PAGE_SIZE;
+    return filteredModels.slice(start, end).map((model) => ({
+      model,
+      key: model,
+    }));
+  })();
+
+  return (
+    <Modal
+      title={hasChannel ? (
+        <div className="flex flex-col gap-2 w-full">
+          <div className="flex items-center gap-2">
+            <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
+              {currentTestChannel.name} {t('渠道的模型测试')}
+            </Typography.Text>
+            <Typography.Text type="tertiary" className="!text-xs flex items-center">
+              {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
+            </Typography.Text>
+          </div>
+        </div>
+      ) : null}
+      visible={showModelTestModal}
+      onCancel={handleCloseModal}
+      footer={hasChannel ? (
+        <div className="flex justify-end">
+          {isBatchTesting ? (
+            <Button
+              type='danger'
+              onClick={handleCloseModal}
+            >
+              {t('停止测试')}
+            </Button>
+          ) : (
+            <Button
+              type='tertiary'
+              onClick={handleCloseModal}
+            >
+              {t('取消')}
+            </Button>
+          )}
+          <Button
+            onClick={batchTestModels}
+            loading={isBatchTesting}
+            disabled={isBatchTesting}
+          >
+            {isBatchTesting ? t('测试中...') : t('批量测试${count}个模型').replace(
+              '${count}',
+              filteredModels.length
+            )}
+          </Button>
+        </div>
+      ) : null}
+      maskClosable={!isBatchTesting}
+      className="!rounded-lg"
+      size={isMobile ? 'full-width' : 'large'}
+    >
+      {hasChannel && (<div className="model-test-scroll">
+        {/* 搜索与操作按钮 */}
+        <div className="flex items-center justify-end gap-2 w-full mb-2">
+          <Input
+            placeholder={t('搜索模型...')}
+            value={modelSearchKeyword}
+            onChange={(v) => {
+              setModelSearchKeyword(v);
+              setModelTablePage(1);
+            }}
+            className="!w-full"
+            prefix={<IconSearch />}
+            showClear
+          />
+
+          <Button onClick={handleCopySelected}>
+            {t('复制已选')}
+          </Button>
+
+          <Button
+            type='tertiary'
+            onClick={handleSelectSuccess}
+          >
+            {t('选择成功')}
+          </Button>
+        </div>
+
+        <Table
+          columns={columns}
+          dataSource={dataSource}
+          rowSelection={{
+            selectedRowKeys: selectedModelKeys,
+            onChange: (keys) => {
+              if (allSelectingRef.current) {
+                allSelectingRef.current = false;
+                return;
+              }
+              setSelectedModelKeys(keys);
+            },
+            onSelectAll: (checked) => {
+              allSelectingRef.current = true;
+              setSelectedModelKeys(checked ? filteredModels : []);
+            },
+          }}
+          pagination={{
+            currentPage: modelTablePage,
+            pageSize: MODEL_TABLE_PAGE_SIZE,
+            total: filteredModels.length,
+            showSizeChanger: false,
+            onPageChange: (page) => setModelTablePage(page),
+          }}
+        />
+      </div>)}
+    </Modal>
+  );
+};
+
+export default ModelTestModal; 

+ 64 - 0
web/src/components/table/mj-logs/MjLogsActions.jsx

@@ -0,0 +1,64 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Skeleton, Typography } from '@douyinfe/semi-ui';
+import { IconEyeOpened } from '@douyinfe/semi-icons';
+import CompactModeToggle from '../../common/ui/CompactModeToggle';
+
+const { Text } = Typography;
+
+const MjLogsActions = ({
+  loading,
+  showBanner,
+  isAdminUser,
+  compactMode,
+  setCompactMode,
+  t,
+}) => {
+  return (
+    <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+      <div className="flex items-center text-orange-500 mb-2 md:mb-0">
+        <IconEyeOpened className="mr-2" />
+        {loading ? (
+          <Skeleton.Title
+            style={{
+              width: 300,
+              marginBottom: 0,
+              marginTop: 0
+            }}
+          />
+        ) : (
+          <Text>
+            {isAdminUser && showBanner
+              ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
+              : t('Midjourney 任务记录')}
+          </Text>
+        )}
+      </div>
+      <CompactModeToggle
+        compactMode={compactMode}
+        setCompactMode={setCompactMode}
+        t={t}
+      />
+    </div>
+  );
+};
+
+export default MjLogsActions; 

+ 496 - 0
web/src/components/table/mj-logs/MjLogsColumnDefs.js

@@ -0,0 +1,496 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import {
+  Button,
+  Progress,
+  Tag,
+  Typography
+} from '@douyinfe/semi-ui';
+import {
+  Palette,
+  ZoomIn,
+  Shuffle,
+  Move,
+  FileText,
+  Blend,
+  Upload,
+  Minimize2,
+  RotateCcw,
+  PaintBucket,
+  Focus,
+  Move3D,
+  Monitor,
+  UserCheck,
+  HelpCircle,
+  CheckCircle,
+  Clock,
+  Copy,
+  FileX,
+  Pause,
+  XCircle,
+  Loader,
+  AlertCircle,
+  Hash,
+  Video
+} from 'lucide-react';
+
+const colors = [
+  'amber',
+  'blue',
+  'cyan',
+  'green',
+  'grey',
+  'indigo',
+  'light-blue',
+  'lime',
+  'orange',
+  'pink',
+  'purple',
+  'red',
+  'teal',
+  'violet',
+  'yellow',
+];
+
+// Render functions
+function renderType(type, t) {
+  switch (type) {
+    case 'IMAGINE':
+      return (
+        <Tag color='blue' shape='circle' prefixIcon={<Palette size={14} />}>
+          {t('绘图')}
+        </Tag>
+      );
+    case 'UPSCALE':
+      return (
+        <Tag color='orange' shape='circle' prefixIcon={<ZoomIn size={14} />}>
+          {t('放大')}
+        </Tag>
+      );
+    case 'VIDEO':
+      return (
+        <Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
+          {t('视频')}
+        </Tag>
+      );
+    case 'EDITS':
+      return (
+        <Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
+          {t('编辑')}
+        </Tag>
+      );
+    case 'VARIATION':
+      return (
+        <Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
+          {t('变换')}
+        </Tag>
+      );
+    case 'HIGH_VARIATION':
+      return (
+        <Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
+          {t('强变换')}
+        </Tag>
+      );
+    case 'LOW_VARIATION':
+      return (
+        <Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
+          {t('弱变换')}
+        </Tag>
+      );
+    case 'PAN':
+      return (
+        <Tag color='cyan' shape='circle' prefixIcon={<Move size={14} />}>
+          {t('平移')}
+        </Tag>
+      );
+    case 'DESCRIBE':
+      return (
+        <Tag color='yellow' shape='circle' prefixIcon={<FileText size={14} />}>
+          {t('图生文')}
+        </Tag>
+      );
+    case 'BLEND':
+      return (
+        <Tag color='lime' shape='circle' prefixIcon={<Blend size={14} />}>
+          {t('图混合')}
+        </Tag>
+      );
+    case 'UPLOAD':
+      return (
+        <Tag color='blue' shape='circle' prefixIcon={<Upload size={14} />}>
+          上传文件
+        </Tag>
+      );
+    case 'SHORTEN':
+      return (
+        <Tag color='pink' shape='circle' prefixIcon={<Minimize2 size={14} />}>
+          {t('缩词')}
+        </Tag>
+      );
+    case 'REROLL':
+      return (
+        <Tag color='indigo' shape='circle' prefixIcon={<RotateCcw size={14} />}>
+          {t('重绘')}
+        </Tag>
+      );
+    case 'INPAINT':
+      return (
+        <Tag color='violet' shape='circle' prefixIcon={<PaintBucket size={14} />}>
+          {t('局部重绘-提交')}
+        </Tag>
+      );
+    case 'ZOOM':
+      return (
+        <Tag color='teal' shape='circle' prefixIcon={<Focus size={14} />}>
+          {t('变焦')}
+        </Tag>
+      );
+    case 'CUSTOM_ZOOM':
+      return (
+        <Tag color='teal' shape='circle' prefixIcon={<Move3D size={14} />}>
+          {t('自定义变焦-提交')}
+        </Tag>
+      );
+    case 'MODAL':
+      return (
+        <Tag color='green' shape='circle' prefixIcon={<Monitor size={14} />}>
+          {t('窗口处理')}
+        </Tag>
+      );
+    case 'SWAP_FACE':
+      return (
+        <Tag color='light-green' shape='circle' prefixIcon={<UserCheck size={14} />}>
+          {t('换脸')}
+        </Tag>
+      );
+    default:
+      return (
+        <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
+          {t('未知')}
+        </Tag>
+      );
+  }
+}
+
+function renderCode(code, t) {
+  switch (code) {
+    case 1:
+      return (
+        <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
+          {t('已提交')}
+        </Tag>
+      );
+    case 21:
+      return (
+        <Tag color='lime' shape='circle' prefixIcon={<Clock size={14} />}>
+          {t('等待中')}
+        </Tag>
+      );
+    case 22:
+      return (
+        <Tag color='orange' shape='circle' prefixIcon={<Copy size={14} />}>
+          {t('重复提交')}
+        </Tag>
+      );
+    case 0:
+      return (
+        <Tag color='yellow' shape='circle' prefixIcon={<FileX size={14} />}>
+          {t('未提交')}
+        </Tag>
+      );
+    default:
+      return (
+        <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
+          {t('未知')}
+        </Tag>
+      );
+  }
+}
+
+function renderStatus(type, t) {
+  switch (type) {
+    case 'SUCCESS':
+      return (
+        <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
+          {t('成功')}
+        </Tag>
+      );
+    case 'NOT_START':
+      return (
+        <Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
+          {t('未启动')}
+        </Tag>
+      );
+    case 'SUBMITTED':
+      return (
+        <Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
+          {t('队列中')}
+        </Tag>
+      );
+    case 'IN_PROGRESS':
+      return (
+        <Tag color='blue' shape='circle' prefixIcon={<Loader size={14} />}>
+          {t('执行中')}
+        </Tag>
+      );
+    case 'FAILURE':
+      return (
+        <Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
+          {t('失败')}
+        </Tag>
+      );
+    case 'MODAL':
+      return (
+        <Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
+          {t('窗口等待')}
+        </Tag>
+      );
+    default:
+      return (
+        <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
+          {t('未知')}
+        </Tag>
+      );
+  }
+}
+
+const renderTimestamp = (timestampInSeconds) => {
+  const date = new Date(timestampInSeconds * 1000);
+  const year = date.getFullYear();
+  const month = ('0' + (date.getMonth() + 1)).slice(-2);
+  const day = ('0' + date.getDate()).slice(-2);
+  const hours = ('0' + date.getHours()).slice(-2);
+  const minutes = ('0' + date.getMinutes()).slice(-2);
+  const seconds = ('0' + date.getSeconds()).slice(-2);
+
+  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+};
+
+function renderDuration(submit_time, finishTime, t) {
+  if (!submit_time || !finishTime) return 'N/A';
+
+  const start = new Date(submit_time);
+  const finish = new Date(finishTime);
+  const durationMs = finish - start;
+  const durationSec = (durationMs / 1000).toFixed(1);
+  const color = durationSec > 60 ? 'red' : 'green';
+
+  return (
+    <Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
+      {durationSec} {t('秒')}
+    </Tag>
+  );
+}
+
+export const getMjLogsColumns = ({
+  t,
+  COLUMN_KEYS,
+  copyText,
+  openContentModal,
+  openImageModal,
+  isAdminUser,
+}) => {
+  return [
+    {
+      key: COLUMN_KEYS.SUBMIT_TIME,
+      title: t('提交时间'),
+      dataIndex: 'submit_time',
+      render: (text, record, index) => {
+        return <div>{renderTimestamp(text / 1000)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.DURATION,
+      title: t('花费时间'),
+      dataIndex: 'finish_time',
+      render: (finish, record) => {
+        return renderDuration(record.submit_time, finish, t);
+      },
+    },
+    {
+      key: COLUMN_KEYS.CHANNEL,
+      title: t('渠道'),
+      dataIndex: 'channel_id',
+      render: (text, record, index) => {
+        return isAdminUser ? (
+          <div>
+            <Tag
+              color={colors[parseInt(text) % colors.length]}
+              shape='circle'
+              prefixIcon={<Hash size={14} />}
+              onClick={() => {
+                copyText(text);
+              }}
+            >
+              {' '}
+              {text}{' '}
+            </Tag>
+          </div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.TYPE,
+      title: t('类型'),
+      dataIndex: 'action',
+      render: (text, record, index) => {
+        return <div>{renderType(text, t)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.TASK_ID,
+      title: t('任务ID'),
+      dataIndex: 'mj_id',
+      render: (text, record, index) => {
+        return <div>{text}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.SUBMIT_RESULT,
+      title: t('提交结果'),
+      dataIndex: 'code',
+      render: (text, record, index) => {
+        return isAdminUser ? <div>{renderCode(text, t)}</div> : <></>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.TASK_STATUS,
+      title: t('任务状态'),
+      dataIndex: 'status',
+      render: (text, record, index) => {
+        return <div>{renderStatus(text, t)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.PROGRESS,
+      title: t('进度'),
+      dataIndex: 'progress',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {
+              <Progress
+                stroke={
+                  record.status === 'FAILURE'
+                    ? 'var(--semi-color-warning)'
+                    : null
+                }
+                percent={text ? parseInt(text.replace('%', '')) : 0}
+                showInfo={true}
+                aria-label='drawing progress'
+                style={{ minWidth: '160px' }}
+              />
+            }
+          </div>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.IMAGE,
+      title: t('结果图片'),
+      dataIndex: 'image_url',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+        return (
+          <Button
+            size="small"
+            onClick={() => {
+              openImageModal(text);
+            }}
+          >
+            {t('查看图片')}
+          </Button>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.PROMPT,
+      title: 'Prompt',
+      dataIndex: 'prompt',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              openContentModal(text);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.PROMPT_EN,
+      title: 'PromptEn',
+      dataIndex: 'prompt_en',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              openContentModal(text);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.FAIL_REASON,
+      title: t('失败原因'),
+      dataIndex: 'fail_reason',
+      fixed: 'right',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              openContentModal(text);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      },
+    },
+  ];
+}; 

+ 123 - 0
web/src/components/table/mj-logs/MjLogsFilters.jsx

@@ -0,0 +1,123 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button, Form } from '@douyinfe/semi-ui';
+import { IconSearch } from '@douyinfe/semi-icons';
+
+const MjLogsFilters = ({
+  formInitValues,
+  setFormApi,
+  refresh,
+  setShowColumnSelector,
+  formApi,
+  loading,
+  isAdminUser,
+  t,
+}) => {
+  return (
+    <Form
+      initValues={formInitValues}
+      getFormApi={(api) => setFormApi(api)}
+      onSubmit={refresh}
+      allowEmpty={true}
+      autoComplete="off"
+      layout="vertical"
+      trigger="change"
+      stopValidateWithError={false}
+    >
+      <div className="flex flex-col gap-2">
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
+          {/* 时间选择器 */}
+          <div className="col-span-1 lg:col-span-2">
+            <Form.DatePicker
+              field='dateRange'
+              className="w-full"
+              type='dateTimeRange'
+              placeholder={[t('开始时间'), t('结束时间')]}
+              showClear
+              pure
+              size="small"
+            />
+          </div>
+
+          {/* 任务 ID */}
+          <Form.Input
+            field='mj_id'
+            prefix={<IconSearch />}
+            placeholder={t('任务 ID')}
+            showClear
+            pure
+            size="small"
+          />
+
+          {/* 渠道 ID - 仅管理员可见 */}
+          {isAdminUser && (
+            <Form.Input
+              field='channel_id'
+              prefix={<IconSearch />}
+              placeholder={t('渠道 ID')}
+              showClear
+              pure
+              size="small"
+            />
+          )}
+        </div>
+
+        {/* 操作按钮区域 */}
+        <div className="flex justify-between items-center">
+          <div></div>
+          <div className="flex gap-2">
+            <Button
+              type='tertiary'
+              htmlType='submit'
+              loading={loading}
+              size="small"
+            >
+              {t('查询')}
+            </Button>
+            <Button
+              type='tertiary'
+              onClick={() => {
+                if (formApi) {
+                  formApi.reset();
+                  setTimeout(() => {
+                    refresh();
+                  }, 100);
+                }
+              }}
+              size="small"
+            >
+              {t('重置')}
+            </Button>
+            <Button
+              type='tertiary'
+              onClick={() => setShowColumnSelector(true)}
+              size="small"
+            >
+              {t('列设置')}
+            </Button>
+          </div>
+        </div>
+      </div>
+    </Form>
+  );
+};
+
+export default MjLogsFilters; 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů