diff --git a/.dockerignore b/.dockerignore index e4e8e72e..0670cd7d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ .vscode .gitignore Makefile -docs \ No newline at end of file +docs +.eslintcache \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6a23f89e..1382829f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ web/dist .env one-api .DS_Store -tiktoken_cache \ No newline at end of file +tiktoken_cache +.eslintcache \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9e..71284f6d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,103 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +# **New API 许可协议 (Licensing)** - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。 - 1. Definitions. +**核心原则:** - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. +- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。 +- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。 - "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. +## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用** - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. +- 在遵守 **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 的所有条款及上述附加限制。 - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. +## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求** - "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. +在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API: - "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). +- **场景一:移除品牌和版权信息** + 您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。 - "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. +- **场景二:规避 AGPLv3 开源义务** + 您基于 New API 进行了修改,并希望: + - 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。 + - 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。 - "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." +- **场景三:企业政策与集成需求** + - 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。 + - 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。 - "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. +- **场景四:需要商业支持与保障** + 您需要 AGPLv3 未提供的商业保障,如官方技术支持等。 - 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. +**获取商业许可:** +请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。 - 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. +## **3. 贡献 (Contributions)** - 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: +- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。 +- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。 +- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。 - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and +## **4. 其他条款 (Other Terms)** - (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. +# **New API Licensing** - 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. +This project uses a **Usage-Based Dual Licensing** model. - 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. +**Core Principles:** - 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. +- **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**. - 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. +## **1. Open Source License: AGPLv3 – For Basic Usage** - 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. +- 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. - END OF TERMS AND CONDITIONS +## **2. Commercial License – For Advanced Scenarios & Closed Source Needs** - APPENDIX: How to apply the Apache License to your work. +You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API: - 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. +- **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. - Copyright [yyyy] [name of copyright owner] +- **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. - 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 +- **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. - http://www.apache.org/licenses/LICENSE-2.0 +- **Scenario 4: Commercial Support and Assurances** + You require commercial assurances not provided by AGPLv3, such as official technical support. - 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. +**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). diff --git a/README.en.md b/README.en.md index b4ae921a..df7f1cbc 100644 --- a/README.en.md +++ b/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 + +

+ Cherry Studio +      + Peking University +      + UCloud +

+ +

No particular order

+ ## 🌟 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) diff --git a/README.md b/README.md index 05423548..4060715c 100644 --- a/README.md +++ b/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) +## 🤝 我们信任的合作伙伴 + +

+ Cherry Studio +      + 北京大学 +      + UCloud 优刻得 +

+ +

排名不分先后

+ ## 🌟 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) diff --git a/controller/swag_video.go b/controller/swag_video.go index 185fd515..68dd6345 100644 --- a/controller/swag_video.go +++ b/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) {} diff --git a/controller/task_video.go b/controller/task_video.go index b62978a7..684f30fa 100644 --- a/controller/task_video.go +++ b/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()) } diff --git a/docs/images/cherry-studio.svg b/docs/images/cherry-studio.svg new file mode 100644 index 00000000..4dad25f2 --- /dev/null +++ b/docs/images/cherry-studio.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/images/pku.png b/docs/images/pku.png new file mode 100644 index 00000000..b62e37cc Binary files /dev/null and b/docs/images/pku.png differ diff --git a/docs/images/ucloud.svg b/docs/images/ucloud.svg new file mode 100644 index 00000000..a8529a1f --- /dev/null +++ b/docs/images/ucloud.svg @@ -0,0 +1 @@ +logo-浅色底-中英-by \ No newline at end of file diff --git a/i18n/zh-cn.json b/i18n/zh-cn.json index 7b57b51a..160fc0a4 100644 --- a/i18n/zh-cn.json +++ b/i18n/zh-cn.json @@ -70,7 +70,7 @@ "关于": "关于", "注销成功!": "注销成功!", "个人设置": "个人设置", - "API令牌": "API令牌", + "令牌管理": "令牌管理", "退出": "退出", "关闭侧边栏": "关闭侧边栏", "打开侧边栏": "打开侧边栏", diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go index 3d4943d2..20973c9f 100644 --- a/middleware/kling_adapter.go +++ b/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{}{ diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index afa39201..b7b9a5ff 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/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 diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index b5195752..394fc0e9 100644 --- a/relay/constant/relay_mode.go +++ b/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 diff --git a/router/video-router.go b/router/video-router.go index 9e605d54..0bd8cd83 100644 --- a/router/video-router.go +++ b/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) } } diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 00000000..5e88871d --- /dev/null +++ b/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 .', + '', + 'For commercial licensing, please contact support@quantumnous.com', + '' + ]], + 'no-multiple-empty-lines': ['error', { max: 1 }] + } + } + ] +}; \ No newline at end of file diff --git a/web/bun.lock b/web/bun.lock index b78c149b..ca4e337c 100644 --- a/web/bun.lock +++ b/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,12 +2050,10 @@ "string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "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=="], - - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -1935,12 +2062,12 @@ "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/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "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-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001690", "", {}, "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w=="], "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.76", "", {}, "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ=="], @@ -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=="], diff --git a/web/package.json b/web/package.json index a313e0f5..ba0df966 100644 --- a/web/package.json +++ b/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", diff --git a/web/postcss.config.js b/web/postcss.config.js index 2e7af2b7..590e21a4 100644 --- a/web/postcss.config.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export default { plugins: { tailwindcss: {}, diff --git a/web/src/App.js b/web/src/App.js index 2d715767..47304b16 100644 --- a/web/src/App.js +++ b/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 . + +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() { } /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> } /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> } key={location.pathname}> - + } diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js index ae7fc0fc..f81dfd81 100644 --- a/web/src/components/auth/LoginForm.js +++ b/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 . + +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 = () => { {/* 背景模糊晕染球 */}
-
+
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) ? renderEmailLoginForm() : renderOAuthOptions()} diff --git a/web/src/components/auth/OAuth2Callback.js b/web/src/components/auth/OAuth2Callback.js index 7d435574..4fb3a512 100644 --- a/web/src/components/auth/OAuth2Callback.js +++ b/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 . + +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(); diff --git a/web/src/components/auth/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js index 5fbd1fc5..6c729c03 100644 --- a/web/src/components/auth/PasswordResetConfirm.js +++ b/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 . + +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 = () => { {/* 背景模糊晕染球 */}
-
+
diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js index 033989e0..3602f317 100644 --- a/web/src/components/auth/PasswordResetForm.js +++ b/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 . + +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 = () => { {/* 背景模糊晕染球 */}
-
+
diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js index 9d213a60..897881ad 100644 --- a/web/src/components/auth/RegisterForm.js +++ b/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 . + +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 = () => { {/* 背景模糊晕染球 */}
-
+
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) ? renderEmailRegisterForm() : renderOAuthOptions()} diff --git a/web/src/components/common/Loading.js b/web/src/components/common/Loading.js deleted file mode 100644 index 73822755..00000000 --- a/web/src/components/common/Loading.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { Spin } from '@douyinfe/semi-ui'; - -const Loading = ({ size = 'small' }) => { - - return ( -
- -
- ); -}; - -export default Loading; diff --git a/web/src/components/common/logo/LinuxDoIcon.js b/web/src/components/common/logo/LinuxDoIcon.js index f6ee9b31..861f19d4 100644 --- a/web/src/components/common/logo/LinuxDoIcon.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Icon } from '@douyinfe/semi-ui'; diff --git a/web/src/components/common/logo/OIDCIcon.js b/web/src/components/common/logo/OIDCIcon.js index bd98c8fb..28d538eb 100644 --- a/web/src/components/common/logo/OIDCIcon.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Icon } from '@douyinfe/semi-ui'; diff --git a/web/src/components/common/logo/WeChatIcon.js b/web/src/components/common/logo/WeChatIcon.js index 723c7ecb..f9f7057c 100644 --- a/web/src/components/common/logo/WeChatIcon.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Icon } from '@douyinfe/semi-ui'; diff --git a/web/src/components/common/markdown/MarkdownRenderer.js b/web/src/components/common/markdown/MarkdownRenderer.js index a48d34d1..820f2bbf 100644 --- a/web/src/components/common/markdown/MarkdownRenderer.js +++ b/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 . + +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'; diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js new file mode 100644 index 00000000..e72cc42b --- /dev/null +++ b/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 . + +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 ( +
+ {/* 统计信息区域 - 用于type2 */} + {type === 'type2' && statsArea && ( + <> + {statsArea} + + )} + + {/* 描述信息区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && descriptionArea && ( + <> + {descriptionArea} + + )} + + {/* 第一个分隔线 - 在描述信息或统计信息后面 */} + {((type === 'type1' || type === 'type3') && descriptionArea) || + (type === 'type2' && statsArea) ? ( + + ) : null} + + {/* 类型切换/标签区域 - 主要用于type3 */} + {type === 'type3' && tabsArea && ( + <> + {tabsArea} + + )} + + {/* 移动端操作切换按钮 */} + {isMobile && hasMobileHideableContent && ( + <> +
+ +
+ + )} + + {/* 操作按钮和搜索表单的容器 */} +
+ {/* 操作按钮区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && actionsArea && ( + Array.isArray(actionsArea) ? ( + actionsArea.map((area, idx) => ( + + {idx !== 0 && } +
+ {area} +
+
+ )) + ) : ( +
+ {actionsArea} +
+ ) + )} + + {/* 当同时存在操作区和搜索区时,插入分隔线 */} + {(actionsArea && searchArea) && } + + {/* 搜索表单区域 - 所有类型都可能有 */} + {searchArea && ( +
+ {searchArea} +
+ )} +
+
+ ); + }; + + const headerContent = renderHeader(); + + // 渲染分页区域 + const renderFooter = () => { + if (!paginationArea) return null; + + return ( +
+ {paginationArea} +
+ ); + }; + + const footerContent = renderFooter(); + + return ( + + {children} + + ); +}; + +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; \ No newline at end of file diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js new file mode 100644 index 00000000..75b6df00 --- /dev/null +++ b/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 . + +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 ( + + ); + } + + if (showSkeleton) { + const visibleCols = columns.filter((col) => { + if (tableProps?.visibleColumns && col.key) { + return tableProps.visibleColumns[col.key]; + } + return true; + }); + + const renderSkeletonCard = (key) => { + const placeholder = ( +
+ {visibleCols.map((col, idx) => { + if (!col.title) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); + })} +
+ ); + + return ( + + + + ); + }; + + return ( +
+ {[1, 2, 3].map((i) => renderSkeletonCard(i))} +
+ ); + } + + 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 ( + + {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 ( +
+ {cellContent} +
+ ); + } + + return ( +
+ + {title} + +
+ {cellContent !== undefined && cellContent !== null ? cellContent : '-'} +
+
+ ); + })} + + {hasDetails && ( + <> + + +
+ {tableProps.expandedRowRender(record, index)} +
+
+ + )} +
+ ); + }; + + if (isEmpty) { + if (tableProps.empty) return tableProps.empty; + return ( +
+ +
+ ); + } + + return ( +
+ {dataSource.map((record, index) => ( + + ))} + {!hidePagination && tableProps.pagination && dataSource.length > 0 && ( +
+ +
+ )} +
+ ); +}; + +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; \ No newline at end of file diff --git a/web/src/components/common/ui/CompactModeToggle.js b/web/src/components/common/ui/CompactModeToggle.js new file mode 100644 index 00000000..631156ee --- /dev/null +++ b/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 . + +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 ( + + ); +}; + +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; \ No newline at end of file diff --git a/web/src/components/common/ui/Loading.js b/web/src/components/common/ui/Loading.js new file mode 100644 index 00000000..60f94748 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Spin } from '@douyinfe/semi-ui'; + +const Loading = ({ size = 'small' }) => { + + return ( +
+ +
+ ); +}; + +export default Loading; diff --git a/web/src/components/common/ui/ScrollableContainer.js b/web/src/components/common/ui/ScrollableContainer.js new file mode 100644 index 00000000..0137c64b --- /dev/null +++ b/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 . + +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 ( +
+
+ {children} +
+
+
+ ); +}); + +ScrollableContainer.displayName = 'ScrollableContainer'; + +export default ScrollableContainer; \ No newline at end of file diff --git a/web/src/components/dashboard/AnnouncementsPanel.jsx b/web/src/components/dashboard/AnnouncementsPanel.jsx new file mode 100644 index 00000000..89d5f335 --- /dev/null +++ b/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 . + +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 ( + +
+ + {t('系统公告')} + + {t('显示最新20条')} + +
+ {/* 图例 */} +
+ {announcementLegendData.map((legend, index) => ( +
+
+ {legend.label} +
+ ))} +
+
+ } + bodyStyle={{ padding: 0 }} + > + + {announcementData.length > 0 ? ( + + {announcementData.map((item, idx) => ( + +
+
+ {item.extra && ( +
+ )} +
+ + ))} + + ) : ( +
+ } + darkModeImage={} + title={t('暂无系统公告')} + description={t('请联系管理员在系统设置中配置公告信息')} + /> +
+ )} + + + ); +}; + +export default AnnouncementsPanel; \ No newline at end of file diff --git a/web/src/components/dashboard/ApiInfoPanel.jsx b/web/src/components/dashboard/ApiInfoPanel.jsx new file mode 100644 index 00000000..5da250e6 --- /dev/null +++ b/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 . + +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 ( + + + {t('API信息')} +
+ } + bodyStyle={{ padding: 0 }} + > + + {apiInfoData.length > 0 ? ( + apiInfoData.map((api) => ( + +
+
+ + {api.route.substring(0, 2)} + +
+
+
+ + {api.route} + +
+ } + size="small" + color="white" + shape='circle' + onClick={() => handleSpeedTest(api.url)} + className="cursor-pointer hover:opacity-80 text-xs" + > + {t('测速')} + + } + size="small" + color="white" + shape='circle' + onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')} + className="cursor-pointer hover:opacity-80 text-xs" + > + {t('跳转')} + +
+
+
handleCopyUrl(api.url)} + > + {api.url} +
+
+ {api.description} +
+
+
+ +
+ )) + ) : ( +
+ } + darkModeImage={} + title={t('暂无API信息')} + description={t('请联系管理员在系统设置中配置API信息')} + /> +
+ )} +
+ + ); +}; + +export default ApiInfoPanel; \ No newline at end of file diff --git a/web/src/components/dashboard/ChartsPanel.jsx b/web/src/components/dashboard/ChartsPanel.jsx new file mode 100644 index 00000000..86726e53 --- /dev/null +++ b/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 . + +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 ( + +
+ + {t('模型数据分析')} +
+ + + + {t('消耗分布')} + + } itemKey="1" /> + + + {t('消耗趋势')} + + } itemKey="2" /> + + + {t('调用次数分布')} + + } itemKey="3" /> + + + {t('调用次数排行')} + + } itemKey="4" /> + +
+ } + bodyStyle={{ padding: 0 }} + > +
+ {activeChartTab === '1' && ( + + )} + {activeChartTab === '2' && ( + + )} + {activeChartTab === '3' && ( + + )} + {activeChartTab === '4' && ( + + )} +
+
+ ); +}; + +export default ChartsPanel; \ No newline at end of file diff --git a/web/src/components/dashboard/DashboardHeader.jsx b/web/src/components/dashboard/DashboardHeader.jsx new file mode 100644 index 00000000..f59aa0b8 --- /dev/null +++ b/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 . + +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 ( +
+

+ {getGreeting} +

+
+
+
+ ); +}; + +export default DashboardHeader; \ No newline at end of file diff --git a/web/src/components/dashboard/FaqPanel.jsx b/web/src/components/dashboard/FaqPanel.jsx new file mode 100644 index 00000000..bf09392c --- /dev/null +++ b/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 . + +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 ( + + + {t('常见问答')} +
+ } + bodyStyle={{ padding: 0 }} + > + + {faqData.length > 0 ? ( + } + collapseIcon={} + > + {faqData.map((item, index) => ( + +
+ + ))} + + ) : ( +
+ } + darkModeImage={} + title={t('暂无常见问答')} + description={t('请联系管理员在系统设置中配置常见问答')} + /> +
+ )} + + + ); +}; + +export default FaqPanel; \ No newline at end of file diff --git a/web/src/components/dashboard/StatsCards.jsx b/web/src/components/dashboard/StatsCards.jsx new file mode 100644 index 00000000..ae614eb5 --- /dev/null +++ b/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 . + +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 ( +
+
+ {groupedStatsData.map((group, idx) => ( + +
+ {group.items.map((item, itemIdx) => ( +
+
+ + {item.icon} + +
+
{item.title}
+
+ + } + > + {item.value} + +
+
+
+ {(loading || (item.trendData && item.trendData.length > 0)) && ( +
+ +
+ )} +
+ ))} +
+
+ ))} +
+
+ ); +}; + +export default StatsCards; \ No newline at end of file diff --git a/web/src/components/dashboard/UptimePanel.jsx b/web/src/components/dashboard/UptimePanel.jsx new file mode 100644 index 00000000..9c5049b8 --- /dev/null +++ b/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 . + +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 ( + +
+ + {t('服务可用性')} +
+
+ } + bodyStyle={{ padding: 0 }} + > + {/* 内容区域 */} +
+ + {uptimeData.length > 0 ? ( + uptimeData.length === 1 ? ( + + {renderMonitorList(uptimeData[0].monitors)} + + ) : ( + + {uptimeData.map((group, groupIdx) => ( + + + {group.categoryName} + + {group.monitors ? group.monitors.length : 0} + + + } + itemKey={group.categoryName} + key={groupIdx} + > + + {renderMonitorList(group.monitors)} + + + ))} + + ) + ) : ( +
+ } + darkModeImage={} + title={t('暂无监控数据')} + description={t('请联系管理员在系统设置中配置Uptime')} + /> +
+ )} +
+
+ + {/* 图例 */} + {uptimeData.length > 0 && ( +
+
+ {uptimeLegendData.map((legend, index) => ( +
+
+ {legend.label} +
+ ))} +
+
+ )} + + ); +}; + +export default UptimePanel; \ No newline at end of file diff --git a/web/src/components/dashboard/index.jsx b/web/src/components/dashboard/index.jsx new file mode 100644 index 00000000..b9588e8e --- /dev/null +++ b/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 . + +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 ( +
+ + + + + + + {/* API信息和图表面板 */} +
+
+ + + {dashboardData.hasApiInfoPanel && ( + handleCopyUrl(url, dashboardData.t)} + handleSpeedTest={handleSpeedTest} + CARD_PROPS={CARD_PROPS} + FLEX_CENTER_GAP2={FLEX_CENTER_GAP2} + ILLUSTRATION_SIZE={ILLUSTRATION_SIZE} + t={dashboardData.t} + /> + )} +
+
+ + {/* 系统公告和常见问答卡片 */} + {dashboardData.hasInfoPanels && ( +
+
+ {/* 公告卡片 */} + {dashboardData.announcementsEnabled && ( + ({ + ...item, + label: dashboardData.t(item.label) + }))} + CARD_PROPS={CARD_PROPS} + ILLUSTRATION_SIZE={ILLUSTRATION_SIZE} + t={dashboardData.t} + /> + )} + + {/* 常见问答卡片 */} + {dashboardData.faqEnabled && ( + + )} + + {/* 服务可用性卡片 */} + {dashboardData.uptimeEnabled && ( + 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} + /> + )} +
+
+ )} +
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/web/src/components/dashboard/modals/SearchModal.jsx b/web/src/components/dashboard/modals/SearchModal.jsx new file mode 100644 index 00000000..251f040c --- /dev/null +++ b/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 . + +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) => ( + + ); + + const { start_timestamp, end_timestamp, username } = inputs; + + return ( + +
+ {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') + })} + +
+ ); +}; + +export default SearchModal; \ No newline at end of file diff --git a/web/src/components/layout/Footer.js b/web/src/components/layout/Footer.js index d380e574..560c4ac3 100644 --- a/web/src/components/layout/Footer.js +++ b/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 . + +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'; diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index 4d83d48b..a2e3986c 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/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 . + +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 }) => { >
- {t('API令牌')} + {t('令牌管理')}
{ />
handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2"> - + {(isLoading || !logoLoaded) && ( - } - > - logo - + )} + logo +
. + +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'; diff --git a/web/src/components/layout/PageLayout.js b/web/src/components/layout/PageLayout.js index 7ef42eb7..f8462ff7 100644 --- a/web/src/components/layout/PageLayout.js +++ b/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 . + +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') && diff --git a/web/src/components/layout/SetupCheck.js b/web/src/components/layout/SetupCheck.js index 3fbd9012..b81cfa97 100644 --- a/web/src/components/layout/SetupCheck.js +++ b/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 . + +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'; diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js index b18dad6c..e8703113 100644 --- a/web/src/components/layout/SiderBar.js +++ b/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 . + +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} diff --git a/web/src/components/playground/ChatArea.js b/web/src/components/playground/ChatArea.js index 81e2df90..b6303112 100644 --- a/web/src/components/playground/ChatArea.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Card, diff --git a/web/src/components/playground/CodeViewer.js b/web/src/components/playground/CodeViewer.js index 1ce723ce..0e0d0bf5 100644 --- a/web/src/components/playground/CodeViewer.js +++ b/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 . + +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'; diff --git a/web/src/components/playground/ConfigManager.js b/web/src/components/playground/ConfigManager.js index ddff8785..753d1138 100644 --- a/web/src/components/playground/ConfigManager.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useRef } from 'react'; import { Button, diff --git a/web/src/components/playground/CustomInputRender.js b/web/src/components/playground/CustomInputRender.js index ff62c104..2191cb16 100644 --- a/web/src/components/playground/CustomInputRender.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; const CustomInputRender = (props) => { diff --git a/web/src/components/playground/CustomRequestEditor.js b/web/src/components/playground/CustomRequestEditor.js index 9b11b4f4..cd21398a 100644 --- a/web/src/components/playground/CustomRequestEditor.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useState, useEffect } from 'react'; import { TextArea, diff --git a/web/src/components/playground/DebugPanel.js b/web/src/components/playground/DebugPanel.js index 8c717a4a..24158c2b 100644 --- a/web/src/components/playground/DebugPanel.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useState, useEffect } from 'react'; import { Card, diff --git a/web/src/components/playground/FloatingButtons.js b/web/src/components/playground/FloatingButtons.js index 4b629770..87a3b0b5 100644 --- a/web/src/components/playground/FloatingButtons.js +++ b/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 . + +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" /> )} diff --git a/web/src/components/playground/ImageUrlInput.js b/web/src/components/playground/ImageUrlInput.js index 2b8fb854..43c65b62 100644 --- a/web/src/components/playground/ImageUrlInput.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Input, diff --git a/web/src/components/playground/MessageActions.js b/web/src/components/playground/MessageActions.js index 9f42aeb7..64775ae5 100644 --- a/web/src/components/playground/MessageActions.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Button, diff --git a/web/src/components/playground/MessageContent.js b/web/src/components/playground/MessageContent.js index 5988c844..fdeb3813 100644 --- a/web/src/components/playground/MessageContent.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useRef, useEffect } from 'react'; import { Typography, diff --git a/web/src/components/playground/OptimizedComponents.js b/web/src/components/playground/OptimizedComponents.js index 9ba2a7c7..2f2c4a87 100644 --- a/web/src/components/playground/OptimizedComponents.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import MessageContent from './MessageContent'; import MessageActions from './MessageActions'; diff --git a/web/src/components/playground/ParameterControl.js b/web/src/components/playground/ParameterControl.js index e499dcfe..3f4cead9 100644 --- a/web/src/components/playground/ParameterControl.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Input, diff --git a/web/src/components/playground/SettingsPanel.js b/web/src/components/playground/SettingsPanel.js index b2e8310a..fa65b363 100644 --- a/web/src/components/playground/SettingsPanel.js +++ b/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 . + +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' diff --git a/web/src/components/playground/ThinkingContent.js b/web/src/components/playground/ThinkingContent.js index d5210507..f7eaead2 100644 --- a/web/src/components/playground/ThinkingContent.js +++ b/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 . + +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'; diff --git a/web/src/components/playground/configStorage.js b/web/src/components/playground/configStorage.js index 91fda88a..b42b57ce 100644 --- a/web/src/components/playground/configStorage.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants'; const MESSAGES_STORAGE_KEY = 'playground_messages'; diff --git a/web/src/components/playground/index.js b/web/src/components/playground/index.js index 57826256..7011eda8 100644 --- a/web/src/components/playground/index.js +++ b/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 . + +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'; diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js index 998c2bf3..2e3e5c20 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/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 . + +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); diff --git a/web/src/components/settings/ChatsSetting.js b/web/src/components/settings/ChatsSetting.js index cc345594..f1b649d6 100644 --- a/web/src/components/settings/ChatsSetting.js +++ b/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 . + +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'; diff --git a/web/src/components/settings/DashboardSetting.js b/web/src/components/settings/DashboardSetting.js index ac1a73ed..764148cc 100644 --- a/web/src/components/settings/DashboardSetting.js +++ b/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 . + +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'; diff --git a/web/src/components/settings/DrawingSetting.js b/web/src/components/settings/DrawingSetting.js index 7b35ea64..789c3321 100644 --- a/web/src/components/settings/DrawingSetting.js +++ b/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 . + +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'; diff --git a/web/src/components/settings/ModelSetting.js b/web/src/components/settings/ModelSetting.js index 5f81ecb6..e63905b5 100644 --- a/web/src/components/settings/ModelSetting.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useEffect, useState } from 'react'; import { Card, Spin, Tabs } from '@douyinfe/semi-ui'; diff --git a/web/src/components/settings/OperationSetting.js b/web/src/components/settings/OperationSetting.js index 899fa30a..93322181 100644 --- a/web/src/components/settings/OperationSetting.js +++ b/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 . + +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'; diff --git a/web/src/components/settings/OtherSetting.js b/web/src/components/settings/OtherSetting.js index a054e0da..bc4164a2 100644 --- a/web/src/components/settings/OtherSetting.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useContext, useEffect, useRef, useState } from 'react'; import { Banner, diff --git a/web/src/components/settings/PaymentSetting.js b/web/src/components/settings/PaymentSetting.js index ed175a20..5f909cf0 100644 --- a/web/src/components/settings/PaymentSetting.js +++ b/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 . + +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'; diff --git a/web/src/components/settings/PersonalSetting.js b/web/src/components/settings/PersonalSetting.js index 7e2b85fd..1e0132cf 100644 --- a/web/src/components/settings/PersonalSetting.js +++ b/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 . + +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 ( -
+
{/* 主卡片容器 */} diff --git a/web/src/components/settings/RateLimitSetting.js b/web/src/components/settings/RateLimitSetting.js index e7f105ec..eafbfc59 100644 --- a/web/src/components/settings/RateLimitSetting.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useEffect, useState } from 'react'; import { Card, Spin } from '@douyinfe/semi-ui'; diff --git a/web/src/components/settings/RatioSetting.js b/web/src/components/settings/RatioSetting.js index 01c2637c..baa24f9c 100644 --- a/web/src/components/settings/RatioSetting.js +++ b/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 . + +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'; diff --git a/web/src/components/settings/SystemSetting.js b/web/src/components/settings/SystemSetting.js index aec8ea69..ce8ac7a7 100644 --- a/web/src/components/settings/SystemSetting.js +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useEffect, useState, useRef } from 'react'; import { Button, diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js deleted file mode 100644 index fba5db79..00000000 --- a/web/src/components/table/ChannelsTable.js +++ /dev/null @@ -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' ? ( -
- - {icon} -
- ) : ( -
- - {icon} -
- ) - ) - } - - return ( - - {type2label[type]?.label} - - ); - }; - - const renderTagType = () => { - return ( - - {t('标签聚合')} - - ); - }; - - 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 ( - - {t('已启用')} - - ); - case 2: - return ( - - {t('已禁用')} - - ); - case 3: - return ( - - {t('自动禁用')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const renderMultiKeyStatus = (status, keySize, enabledKeySize) => { - switch (status) { - case 1: - return ( - - {t('已启用')} {enabledKeySize}/{keySize} - - ); - case 2: - return ( - - {t('已禁用')} {enabledKeySize}/{keySize} - - ); - case 3: - return ( - - {t('自动禁用')} {enabledKeySize}/{keySize} - - ); - default: - return ( - - {t('未知状态')} {enabledKeySize}/{keySize} - - ); - } - } - - - const renderResponseTime = (responseTime) => { - let time = responseTime / 1000; - time = time.toFixed(2) + t(' 秒'); - if (responseTime === 0) { - return ( - - {t('未测试')} - - ); - } else if (responseTime <= 1000) { - return ( - - {time} - - ); - } else if (responseTime <= 3000) { - return ( - - {time} - - ); - } else if (responseTime <= 5000) { - return ( - - {time} - - ); - } else { - return ( - - {time} - - ); - } - }; - - // 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) => ( -
- - {text - ?.split(',') - .sort((a, b) => { - if (a === 'default') return -1; - if (b === 'default') return 1; - return a.localeCompare(b); - }) - .map((item, index) => renderGroup(item))} - -
- ), - }, - { - 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 ( -
- - {renderStatus(text, record.channel_info)} - -
- ); - } else { - return renderStatus(text, record.channel_info); - } - }, - }, - { - key: COLUMN_KEYS.RESPONSE_TIME, - title: t('响应时间'), - dataIndex: 'response_time', - render: (text, record, index) => ( -
{renderResponseTime(text)}
- ), - }, - { - key: COLUMN_KEYS.BALANCE, - title: t('已用/剩余'), - dataIndex: 'expired_time', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- - - - {renderQuota(record.used_quota)} - - - - updateChannelBalance(record)} - > - {renderQuotaWithAmount(record.balance)} - - - -
- ); - } else { - return ( - - - {renderQuota(record.used_quota)} - - - ); - } - }, - }, - { - key: COLUMN_KEYS.PRIORITY, - title: t('优先级'), - dataIndex: 'priority', - render: (text, record, index) => { - if (record.children === undefined) { - return ( -
- { - manageChannel(record.id, 'priority', record, e.target.value); - }} - keepFocus={true} - innerButtons - defaultValue={record.priority} - min={-999} - size="small" - /> -
- ); - } else { - return ( - { - 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 ( -
- { - manageChannel(record.id, 'weight', record, e.target.value); - }} - keepFocus={true} - innerButtons - defaultValue={record.weight} - min={0} - size="small" - /> -
- ); - } else { - return ( - { - 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 ( - - - - - ) : ( - - ) - } - manageChannel(record.id, 'enable_all', record), - } - ]} - > - - ) : ( - - ) - )} - - - - - - - - - ); - } - }, - }, - ]; - - 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 ( - setShowColumnSelector(false)} - footer={ -
- - - -
- } - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {allColumns.map((column) => { - // Skip columns without title - if (!column.title) { - return null; - } - - return ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - - 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 ( - { - setActiveTypeKey(key); - setActivePage(1); - loadChannels(1, pageSize, idSort, enableTagMode, key); - }} - className="mb-4" - > - - {t('全部')} - - {channelTypeCounts['all'] || 0} - - - } - /> - - {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => { - const key = String(option.value); - const count = channelTypeCounts[option.value] || 0; - return ( - - {getChannelIcon(option.value)} - {option.label} - - {count} - - - } - /> - ); - })} - - ); - }; - - 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 = () => ( -
- {renderTypeTabs()} -
-
- - - - - - - - - - - - - - - - - - - } - > - - - - -
- -
-
- - {t('使用ID排序')} - - { - 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); - } - }} - /> -
- -
- - {t('开启批量操作')} - - { - localStorage.setItem('enable-batch-delete', v + ''); - setEnableBatchDelete(v); - }} - /> -
- -
- - {t('标签聚合模式')} - - { - localStorage.setItem('enable-tag-mode', v + ''); - setEnableTagMode(v); - setActivePage(1); - loadChannels(1, pageSize, idSort, v); - }} - /> -
- - {/* 状态筛选器 */} -
- - {t('状态筛选')} - - -
-
-
- - - -
-
- - - - - -
- -
-
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" - > -
- } - placeholder={t('渠道ID,名称,密钥,API地址')} - showClear - pure - /> -
-
- } - placeholder={t('模型关键字')} - showClear - pure - /> -
-
- { - // 延迟执行搜索,让表单值先更新 - setTimeout(() => { - searchChannels(enableTagMode); - }, 0); - }} - /> -
- - - -
-
-
- ); - - return ( - <> - {renderColumnSelector()} - setShowEditTag(false)} - refresh={refresh} - /> - - - -
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={ - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - className="rounded-xl overflow-hidden" - size="middle" - loading={loading || searching} - /> - - - {/* 批量设置标签模态框 */} - setShowBatchSetTag(false)} - maskClosable={false} - centered={true} - size="small" - className="!rounded-lg" - > -
- {t('请输入要设置的标签名称')} -
- setBatchSetTagValue(v)} - /> -
- - {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)} - -
-
- - {/* 模型测试弹窗 */} - -
- - {currentTestChannel.name} {t('渠道的模型测试')} - - - {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} - -
- - ) - } - visible={showModelTestModal && currentTestChannel !== null} - onCancel={handleCloseModal} - footer={ -
- {isBatchTesting ? ( - - ) : ( - - )} - -
- } - maskClosable={!isBatchTesting} - className="!rounded-lg" - size={isMobile ? 'full-width' : 'large'} - > -
- {currentTestChannel && ( -
- {/* 搜索与操作按钮 */} -
- { - setModelSearchKeyword(v); - setModelTablePage(1); - }} - className="!w-full" - prefix={} - showClear - /> - - - - -
-
( -
- {text} -
- ) - }, - { - title: t('状态'), - dataIndex: 'status', - render: (text, record) => { - const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`]; - const isTesting = testingModels.has(record.model); - - if (isTesting) { - return ( - - {t('测试中')} - - ); - } - - if (!testResult) { - return ( - - {t('未开始')} - - ); - } - - return ( -
- - {testResult.success ? t('成功') : t('失败')} - - {testResult.success && ( - - {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))} - - )} -
- ); - } - }, - { - title: '', - dataIndex: 'operate', - render: (text, record) => { - const isTesting = testingModels.has(record.model); - return ( - - ); - } - } - ]} - 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), - }} - /> - - )} - - - - ); -}; - -export default ChannelsTable; diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js deleted file mode 100644 index bff87675..00000000 --- a/web/src/components/table/LogsTable.js +++ /dev/null @@ -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 ( - - {t('充值')} - - ); - case 2: - return ( - - {t('消费')} - - ); - case 3: - return ( - - {t('管理')} - - ); - case 4: - return ( - - {t('系统')} - - ); - case 5: - return ( - - {t('错误')} - - ); - default: - return ( - - {t('未知')} - - ); - } - } - - function renderIsStream(bool) { - if (bool) { - return ( - - {t('流')} - - ); - } else { - return ( - - {t('非流')} - - ); - } - } - - function renderUseTime(type) { - const time = parseInt(type); - if (time < 101) { - return ( - - {' '} - {time} s{' '} - - ); - } else if (time < 300) { - return ( - - {' '} - {time} s{' '} - - ); - } else { - return ( - - {' '} - {time} s{' '} - - ); - } - } - - function renderFirstUseTime(type) { - let time = parseFloat(type) / 1000.0; - time = time.toFixed(1); - if (time < 3) { - return ( - - {' '} - {time} s{' '} - - ); - } else if (time < 10) { - return ( - - {' '} - {time} s{' '} - - ); - } else { - return ( - - {' '} - {time} s{' '} - - ); - } - } - - 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 ( - <> - - - -
- - {t('请求并计费模型')}: - - {renderModelTag(record.model_name, { - onClick: (event) => { - copyText(event, record.model_name).then((r) => { }); - }, - })} -
-
- - {t('实际模型')}: - - {renderModelTag(other.upstream_model_name, { - onClick: (event) => { - copyText(event, other.upstream_model_name).then( - (r) => { }, - ); - }, - })} -
-
- - } - > - {renderModelTag(record.model_name, { - onClick: (event) => { - copyText(event, record.model_name).then((r) => { }); - }, - suffixIcon: ( - - ), - })} -
-
- - ); - } - } - - // 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) ? ( - - - - {text} - - - {isMultiKey && ( - - {multiKeyIndex} - - )} - - ) : null; - }, - }, - { - key: COLUMN_KEYS.USERNAME, - title: t('用户'), - dataIndex: 'username', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return isAdminUser ? ( -
- { - event.stopPropagation(); - showUserInfo(record.user_id); - }} - > - {typeof text === 'string' && text.slice(0, 1)} - - {text} -
- ) : ( - <> - ); - }, - }, - { - key: COLUMN_KEYS.TOKEN, - title: t('令牌'), - dataIndex: 'token_name', - render: (text, record, index) => { - return record.type === 0 || record.type === 2 || record.type === 5 ? ( -
- { - //cancel the row click event - copyText(event, text); - }} - > - {' '} - {t(text)}{' '} - -
- ) : ( - <> - ); - }, - }, - { - 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 ( - <> - - {renderUseTime(text)} - {renderFirstUseTime(other?.frt)} - {renderIsStream(record.is_stream)} - - - ); - } else { - return ( - <> - - {renderUseTime(text)} - {renderIsStream(record.is_stream)} - - - ); - } - }, - }, - { - key: COLUMN_KEYS.PROMPT, - title: t('提示'), - dataIndex: 'prompt_tokens', - render: (text, record, index) => { - return record.type === 0 || record.type === 2 || record.type === 5 ? ( - <>{ {text} } - ) : ( - <> - ); - }, - }, - { - 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) ? ( - <>{ {text} } - ) : ( - <> - ); - }, - }, - { - 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: ( -
- {t('IP')} - - - -
- ), - dataIndex: 'ip', - render: (text, record, index) => { - return (record.type === 2 || record.type === 5) && text ? ( - - { - copyText(event, text); - }} - > - {text} - - - ) : ( - <> - ); - }, - }, - { - 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 ?
{content}
: <>; - }, - }, - { - 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 ( - - {text} - - ); - } - 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 ( - - {content} - - ); - }, - }, - ]; - - // 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 ( - setShowColumnSelector(false)} - footer={ -
- - - -
- } - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {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 ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - - 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: ( -
-

- {t('用户名')}: {data.username} -

-

- {t('余额')}: {renderQuota(data.quota)} -

-

- {t('已用额度')}:{renderQuota(data.used_quota)} -

-

- {t('请求次数')}:{renderNumber(data.request_count)} -

-
- ), - 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 ; - }; - - // 检查是否有任何记录有展开内容 - const hasExpandableRows = () => { - return logs.some( - (log) => expandData[log.key] && expandData[log.key].length > 0, - ); - }; - - const [compactMode, setCompactMode] = useTableCompactMode('logs'); - - return ( - <> - {renderColumnSelector()} - - -
- - - {t('消耗额度')}: {renderQuota(stat.quota)} - - - RPM: {stat.rpm} - - - TPM: {stat.tpm} - - - - -
-
- - - - {/* 搜索表单区域 */} -
setFormApi(api)} - onSubmit={refresh} - allowEmpty={true} - autoComplete='off' - layout='vertical' - trigger='change' - stopValidateWithError={false} - > -
-
- {/* 时间选择器 */} -
- -
- - {/* 其他搜索字段 */} - } - placeholder={t('令牌名称')} - showClear - pure - size="small" - /> - - } - placeholder={t('模型名称')} - showClear - pure - size="small" - /> - - } - placeholder={t('分组')} - showClear - pure - size="small" - /> - - {isAdminUser && ( - <> - } - placeholder={t('渠道 ID')} - showClear - pure - size="small" - /> - } - placeholder={t('用户名称')} - showClear - pure - size="small" - /> - - )} -
- - {/* 操作按钮区域 */} -
- {/* 日志类型选择器 */} -
- { - // 延迟执行搜索,让表单值先更新 - setTimeout(() => { - refresh(); - }, 0); - }} - size="small" - > - - {t('全部')} - - - {t('充值')} - - - {t('消费')} - - - {t('管理')} - - - {t('系统')} - - - {t('错误')} - - -
- -
- - - -
-
-
- - - } - shadows='always' - bordered={false} - > -
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={ - - } - darkModeImage={ - - } - 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, - }} - /> - - - ); -}; - -export default LogsTable; diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js deleted file mode 100644 index 57e221d9..00000000 --- a/web/src/components/table/MjLogsTable.js +++ /dev/null @@ -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 ( - }> - {t('绘图')} - - ); - case 'UPSCALE': - return ( - }> - {t('放大')} - - ); - case 'VIDEO': - return ( - }> - {t('视频')} - - ); - case 'EDITS': - return ( - }> - {t('编辑')} - - ); - case 'VARIATION': - return ( - }> - {t('变换')} - - ); - case 'HIGH_VARIATION': - return ( - }> - {t('强变换')} - - ); - case 'LOW_VARIATION': - return ( - }> - {t('弱变换')} - - ); - case 'PAN': - return ( - }> - {t('平移')} - - ); - case 'DESCRIBE': - return ( - }> - {t('图生文')} - - ); - case 'BLEND': - return ( - }> - {t('图混合')} - - ); - case 'UPLOAD': - return ( - }> - 上传文件 - - ); - case 'SHORTEN': - return ( - }> - {t('缩词')} - - ); - case 'REROLL': - return ( - }> - {t('重绘')} - - ); - case 'INPAINT': - return ( - }> - {t('局部重绘-提交')} - - ); - case 'ZOOM': - return ( - }> - {t('变焦')} - - ); - case 'CUSTOM_ZOOM': - return ( - }> - {t('自定义变焦-提交')} - - ); - case 'MODAL': - return ( - }> - {t('窗口处理')} - - ); - case 'SWAP_FACE': - return ( - }> - {t('换脸')} - - ); - default: - return ( - }> - {t('未知')} - - ); - } - } - - function renderCode(code) { - switch (code) { - case 1: - return ( - }> - {t('已提交')} - - ); - case 21: - return ( - }> - {t('等待中')} - - ); - case 22: - return ( - }> - {t('重复提交')} - - ); - case 0: - return ( - }> - {t('未提交')} - - ); - default: - return ( - }> - {t('未知')} - - ); - } - } - - function renderStatus(type) { - switch (type) { - case 'SUCCESS': - return ( - }> - {t('成功')} - - ); - case 'NOT_START': - return ( - }> - {t('未启动')} - - ); - case 'SUBMITTED': - return ( - }> - {t('队列中')} - - ); - case 'IN_PROGRESS': - return ( - }> - {t('执行中')} - - ); - case 'FAILURE': - return ( - }> - {t('失败')} - - ); - case 'MODAL': - return ( - }> - {t('窗口等待')} - - ); - default: - return ( - }> - {t('未知')} - - ); - } - } - - 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 ( - }> - {durationSec} {t('秒')} - - ); - } - - // 定义所有列 - const allColumns = [ - { - key: COLUMN_KEYS.SUBMIT_TIME, - title: t('提交时间'), - dataIndex: 'submit_time', - render: (text, record, index) => { - return
{renderTimestamp(text / 1000)}
; - }, - }, - { - 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 ? ( -
- } - onClick={() => { - copyText(text); - }} - > - {' '} - {text}{' '} - -
- ) : ( - <> - ); - }, - }, - { - key: COLUMN_KEYS.TYPE, - title: t('类型'), - dataIndex: 'action', - render: (text, record, index) => { - return
{renderType(text)}
; - }, - }, - { - key: COLUMN_KEYS.TASK_ID, - title: t('任务ID'), - dataIndex: 'mj_id', - render: (text, record, index) => { - return
{text}
; - }, - }, - { - key: COLUMN_KEYS.SUBMIT_RESULT, - title: t('提交结果'), - dataIndex: 'code', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return isAdminUser ?
{renderCode(text)}
: <>; - }, - }, - { - key: COLUMN_KEYS.TASK_STATUS, - title: t('任务状态'), - dataIndex: 'status', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - { - key: COLUMN_KEYS.PROGRESS, - title: t('进度'), - dataIndex: 'progress', - render: (text, record, index) => { - return ( -
- { - - } -
- ); - }, - }, - { - key: COLUMN_KEYS.IMAGE, - title: t('结果图片'), - dataIndex: 'image_url', - render: (text, record, index) => { - if (!text) { - return t('无'); - } - return ( - - ); - }, - }, - { - key: COLUMN_KEYS.PROMPT, - title: 'Prompt', - dataIndex: 'prompt', - render: (text, record, index) => { - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - { - key: COLUMN_KEYS.PROMPT_EN, - title: 'PromptEn', - dataIndex: 'prompt_en', - render: (text, record, index) => { - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - { - key: COLUMN_KEYS.FAIL_REASON, - title: t('失败原因'), - dataIndex: 'fail_reason', - fixed: 'right', - render: (text, record, index) => { - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {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 ( - setShowColumnSelector(false)} - footer={ -
- - - -
- } - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {allColumns.map((column) => { - // 为非管理员用户跳过管理员专用列 - if ( - !isAdminUser && - (column.key === COLUMN_KEYS.CHANNEL || - column.key === COLUMN_KEYS.SUBMIT_RESULT) - ) { - return null; - } - - return ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - - return ( - <> - {renderColumnSelector()} - - -
-
- - {loading ? ( - - ) : ( - - {isAdminUser && showBanner - ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') - : t('Midjourney 任务记录')} - - )} -
- -
- - - - {/* 搜索表单区域 */} -
setFormApi(api)} - onSubmit={refresh} - allowEmpty={true} - autoComplete="off" - layout="vertical" - trigger="change" - stopValidateWithError={false} - > -
-
- {/* 时间选择器 */} -
- -
- - {/* 任务 ID */} - } - placeholder={t('任务 ID')} - showClear - pure - size="small" - /> - - {/* 渠道 ID - 仅管理员可见 */} - {isAdminUser && ( - } - placeholder={t('渠道 ID')} - showClear - pure - size="small" - /> - )} -
- - {/* 操作按钮区域 */} -
-
-
- - - -
-
-
- - - } - shadows='always' - bordered={false} - > -
rest) : getVisibleColumns()} - dataSource={logs} - rowKey='key' - loading={loading} - scroll={compactMode ? undefined : { x: 'max-content' }} - className="rounded-xl overflow-hidden" - size="middle" - empty={ - } - darkModeImage={} - 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, - }} - /> - - - setIsModalOpen(false)} - onCancel={() => setIsModalOpen(false)} - closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式 - width={800} // 设置模态框宽度 - > -

{modalContent}

-
- setIsModalOpenurl(visible)} - /> - - - ); -}; - -export default LogsTable; diff --git a/web/src/components/table/ModelPricing.js b/web/src/components/table/ModelPricing.js index e3f68a76..07acba1c 100644 --- a/web/src/components/table/ModelPricing.js +++ b/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 . + +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), }} /> diff --git a/web/src/components/table/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js deleted file mode 100644 index b463294e..00000000 --- a/web/src/components/table/RedemptionsTable.js +++ /dev/null @@ -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 ( - {t('已过期')} - ); - } - switch (status) { - case 1: - return ( - - {t('未使用')} - - ); - case 2: - return ( - - {t('已禁用')} - - ); - case 3: - return ( - - {t('已使用')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const columns = [ - { - title: t('ID'), - dataIndex: 'id', - }, - { - title: t('名称'), - dataIndex: 'name', - }, - { - title: t('状态'), - dataIndex: 'status', - key: 'status', - render: (text, record, index) => { - return
{renderStatus(text, record)}
; - }, - }, - { - title: t('额度'), - dataIndex: 'quota', - render: (text, record, index) => { - return ( -
- - {renderQuota(parseInt(text))} - -
- ); - }, - }, - { - title: t('创建时间'), - dataIndex: 'created_time', - render: (text, record, index) => { - return
{renderTimestamp(text)}
; - }, - }, - { - title: t('过期时间'), - dataIndex: 'expired_time', - render: (text) => { - return
{text === 0 ? t('永不过期') : renderTimestamp(text)}
; - }, - }, - { - title: t('兑换人ID'), - dataIndex: 'used_user_id', - render: (text, record, index) => { - return
{text === 0 ? t('无') : text}
; - }, - }, - { - 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 ( - - - - - - - - - - - - - -
-
-
- - -
- -
- -
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" - > -
-
- } - placeholder={t('关键字(id或者名称)')} - showClear - pure - size="small" - /> -
-
- - -
-
- -
- - ); - - return ( - <> - - - -
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={ - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - className="rounded-xl overflow-hidden" - size="middle" - >
- - - ); -}; - -export default RedemptionsTable; diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js deleted file mode 100644 index ba4dcd50..00000000 --- a/web/src/components/table/TaskLogsTable.js +++ /dev/null @@ -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 ( - }> - {durationSec} 秒 - - ); -} - -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 ( - }> - {t('生成音乐')} - - ); - case 'LYRICS': - return ( - }> - {t('生成歌词')} - - ); - case TASK_ACTION_GENERATE: - return ( - }> - {t('图生视频')} - - ); - case TASK_ACTION_TEXT_GENERATE: - return ( - }> - {t('文生视频')} - - ); - default: - return ( - }> - {t('未知')} - - ); - } - }; - - const renderPlatform = (platform) => { - switch (platform) { - case 'suno': - return ( - }> - Suno - - ); - case 'kling': - return ( - }> - Kling - - ); - case 'jimeng': - return ( - }> - Jimeng - - ); - default: - return ( - }> - {t('未知')} - - ); - } - }; - - const renderStatus = (type) => { - switch (type) { - case 'SUCCESS': - return ( - }> - {t('成功')} - - ); - case 'NOT_START': - return ( - }> - {t('未启动')} - - ); - case 'SUBMITTED': - return ( - }> - {t('队列中')} - - ); - case 'IN_PROGRESS': - return ( - }> - {t('执行中')} - - ); - case 'FAILURE': - return ( - }> - {t('失败')} - - ); - case 'QUEUED': - return ( - }> - {t('排队中')} - - ); - case 'UNKNOWN': - return ( - }> - {t('未知')} - - ); - case '': - return ( - }> - {t('正在提交')} - - ); - default: - return ( - }> - {t('未知')} - - ); - } - }; - - // 定义所有列 - const allColumns = [ - { - key: COLUMN_KEYS.SUBMIT_TIME, - title: t('提交时间'), - dataIndex: 'submit_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - key: COLUMN_KEYS.FINISH_TIME, - title: t('结束时间'), - dataIndex: 'finish_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - 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 ? ( -
- } - onClick={() => { - copyText(text); - }} - > - {text} - -
- ) : ( - <> - ); - }, - }, - { - key: COLUMN_KEYS.PLATFORM, - title: t('平台'), - dataIndex: 'platform', - render: (text, record, index) => { - return
{renderPlatform(text)}
; - }, - }, - { - key: COLUMN_KEYS.TYPE, - title: t('类型'), - dataIndex: 'action', - render: (text, record, index) => { - return
{renderType(text)}
; - }, - }, - { - key: COLUMN_KEYS.TASK_ID, - title: t('任务ID'), - dataIndex: 'task_id', - render: (text, record, index) => { - return ( - { - setModalContent(JSON.stringify(record, null, 2)); - setIsModalOpen(true); - }} - > -
{text}
-
- ); - }, - }, - { - key: COLUMN_KEYS.TASK_STATUS, - title: t('任务状态'), - dataIndex: 'status', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - { - key: COLUMN_KEYS.PROGRESS, - title: t('进度'), - dataIndex: 'progress', - render: (text, record, index) => { - return ( -
- { - isNaN(text?.replace('%', '')) ? ( - text || '-' - ) : ( - - ) - } -
- ); - }, - }, - { - 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 ( - - {t('点击预览视频')} - - ); - } - if (!text) { - return t('无'); - } - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {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 ( - setShowColumnSelector(false)} - footer={ -
- - - -
- } - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {allColumns.map((column) => { - // 为非管理员用户跳过管理员专用列 - if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) { - return null; - } - - return ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - - return ( - <> - {renderColumnSelector()} - - -
-
- - {t('任务记录')} -
- -
- - - - {/* 搜索表单区域 */} -
setFormApi(api)} - onSubmit={refresh} - allowEmpty={true} - autoComplete="off" - layout="vertical" - trigger="change" - stopValidateWithError={false} - > -
-
- {/* 时间选择器 */} -
- -
- - {/* 任务 ID */} - } - placeholder={t('任务 ID')} - showClear - pure - size="small" - /> - - {/* 渠道 ID - 仅管理员可见 */} - {isAdminUser && ( - } - placeholder={t('渠道 ID')} - showClear - pure - size="small" - /> - )} -
- - {/* 操作按钮区域 */} -
-
-
- - - -
-
-
-
-
- } - shadows='always' - bordered={false} - > - rest) : getVisibleColumns()} - dataSource={logs} - rowKey='key' - loading={loading} - scroll={compactMode ? undefined : { x: 'max-content' }} - className="rounded-xl overflow-hidden" - size="middle" - empty={ - } - darkModeImage={} - 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, - }} - /> - - - setIsModalOpen(false)} - onCancel={() => setIsModalOpen(false)} - closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式 - width={800} // 设置模态框宽度 - > -

{modalContent}

-
- - - ); -}; - -export default LogsTable; diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js deleted file mode 100644 index ac7fca92..00000000 --- a/web/src/components/table/TokensTable.js +++ /dev/null @@ -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 ? ( -
{t('无限额度')}
- ) : ( -
- {`${renderQuota(remain)} / ${renderQuota(total)}`} - `${percent.toFixed(0)}%`} - style={{ width: '100%', marginTop: '1px', marginBottom: 0 }} - /> -
- ); - - const content = ( - - } - suffixIcon={quotaSuffix} - > - {tagText} - - ); - - if (record.unlimited_quota) { - return content; - } - - return ( - -
{t('已用额度')}: {renderQuota(used)}
-
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
-
{t('总额度')}: {renderQuota(total)}
- - } - > - {content} -
- ); - }, - }, - { - title: t('分组'), - dataIndex: 'group', - key: 'group', - render: (text) => { - if (text === 'auto') { - return ( - - {t('智能熔断')} - - ); - } - 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 ( -
- -
- } - /> - - ); - }, - }, - { - 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( - - - {category.icon} - - - ); - vendorModels.forEach((m) => matchedModels.add(m)); - } - }); - - const unmatchedModels = models.filter((m) => !matchedModels.has(m)); - if (unmatchedModels.length > 0) { - vendorAvatars.push( - - - {t('其他')} - - - ); - } - - return ( - - {vendorAvatars} - - ); - } else { - return ( - - {t('无限制')} - - ); - } - }, - }, - { - title: t('IP限制'), - dataIndex: 'allow_ips', - render: (text) => { - if (!text || text.trim() === '') { - return ( - - {t('无限制')} - - ); - } - - 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) => ( - - {ip} - - )); - - if (extraCount > 0) { - ipTags.push( - - - {'+' + extraCount} - - - ); - } - - return {ipTags}; - }, - }, - { - title: t('创建时间'), - dataIndex: 'created_time', - render: (text, record, index) => { - return
{renderTimestamp(text)}
; - }, - }, - { - title: t('过期时间'), - dataIndex: 'expired_time', - render: (text, record, index) => { - return ( -
- {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)} -
- ); - }, - }, - { - 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 ( - - - - - - - - - - - - - ); - }, - }, - ]; - - 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 = () => ( -
-
-
-
- - {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} -
- -
-
- - - -
-
- - - - - ), - }); - }} - size="small" - > - {t('复制所选令牌')} - - -
- -
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" - > -
-
- } - placeholder={t('搜索关键字')} - showClear - pure - size="small" - /> -
-
- } - placeholder={t('密钥')} - showClear - pure - size="small" - /> -
-
- - -
-
- -
-
- ); - - return ( - <> - - - -
{ - 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={ - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - className="rounded-xl overflow-hidden" - size="middle" - >
- - - ); -}; - -export default TokensTable; diff --git a/web/src/components/table/UsersTable.js b/web/src/components/table/UsersTable.js deleted file mode 100644 index 193e1d65..00000000 --- a/web/src/components/table/UsersTable.js +++ /dev/null @@ -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 ( - }> - {t('普通用户')} - - ); - case 10: - return ( - }> - {t('管理员')} - - ); - case 100: - return ( - }> - {t('超级管理员')} - - ); - default: - return ( - }> - {t('未知身份')} - - ); - } - } - - const renderStatus = (status) => { - switch (status) { - case 1: - return }>{t('已激活')}; - case 2: - return ( - }> - {t('已封禁')} - - ); - default: - return ( - }> - {t('未知状态')} - - ); - } - }; - - const columns = [ - { - title: 'ID', - dataIndex: 'id', - }, - { - title: t('用户名'), - dataIndex: 'username', - render: (text, record) => { - const remark = record.remark; - if (!remark) { - return {text}; - } - const maxLen = 10; - const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark; - return ( - - {text} - - -
-
- {displayRemark} -
- - - - ); - }, - }, - { - title: t('分组'), - dataIndex: 'group', - render: (text, record, index) => { - return
{renderGroup(text)}
; - }, - }, - { - title: t('统计信息'), - dataIndex: 'info', - render: (text, record, index) => { - return ( -
- - }> - {t('剩余')}: {renderQuota(record.quota)} - - }> - {t('已用')}: {renderQuota(record.used_quota)} - - }> - {t('调用')}: {renderNumber(record.request_count)} - - -
- ); - }, - }, - { - title: t('邀请信息'), - dataIndex: 'invite', - render: (text, record, index) => { - return ( -
- - }> - {t('邀请')}: {renderNumber(record.aff_count)} - - }> - {t('收益')}: {renderQuota(record.aff_history_quota)} - - }> - {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`} - - -
- ); - }, - }, - { - title: t('角色'), - dataIndex: 'role', - render: (text, record, index) => { - return
{renderRole(text)}
; - }, - }, - { - title: t('状态'), - dataIndex: 'status', - render: (text, record, index) => { - return ( -
- {record.DeletedAt !== null ? ( - }>{t('已注销')} - ) : ( - renderStatus(text) - )} -
- ); - }, - }, - { - 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 ( - - - - -
-
- - - -
-
- -
- -
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" - > -
-
- } - placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')} - showClear - pure - size="small" - /> -
-
- { - // 分组变化时自动搜索 - setTimeout(() => { - setActivePage(1); - searchUsers(1, pageSize); - }, 100); - }} - className="w-full" - showClear - pure - size="small" - /> -
-
- - -
-
-
-
-
- ); - - return ( - <> - - - - - 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={ - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - className="overflow-hidden" - size="middle" - /> - - - ); -}; - -export default UsersTable; diff --git a/web/src/components/table/channels/ChannelsActions.jsx b/web/src/components/table/channels/ChannelsActions.jsx new file mode 100644 index 00000000..d88b66ed --- /dev/null +++ b/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 . + +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 ( +
+ {/* 第一行:批量操作按钮 + 设置开关 */} +
+ {/* 左侧:批量操作按钮 */} +
+ + + + + + + + + + + + + + + + + + + } + > + + + + +
+ + {/* 右侧:设置开关区域 */} +
+
+ + {t('使用ID排序')} + + { + 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); + } + }} + /> +
+ +
+ + {t('开启批量操作')} + + { + localStorage.setItem('enable-batch-delete', v + ''); + setEnableBatchDelete(v); + }} + /> +
+ +
+ + {t('标签聚合模式')} + + { + localStorage.setItem('enable-tag-mode', v + ''); + setEnableTagMode(v); + setActivePage(1); + loadChannels(1, pageSize, idSort, v); + }} + /> +
+ +
+ + {t('状态筛选')} + + +
+
+
+
+ ); +}; + +export default ChannelsActions; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsColumnDefs.js b/web/src/components/table/channels/ChannelsColumnDefs.js new file mode 100644 index 00000000..beb5fe55 --- /dev/null +++ b/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 . + +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' ? ( +
+ + {icon} +
+ ) : ( +
+ + {icon} +
+ ) + ) + } + + return ( + + {type2label[type]?.label} + + ); +}; + +const renderTagType = (t) => { + return ( + + {t('标签聚合')} + + ); +}; + +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 ( + + {t('已启用')} + + ); + case 2: + return ( + + {t('已禁用')} + + ); + case 3: + return ( + + {t('自动禁用')} + + ); + default: + return ( + + {t('未知状态')} + + ); + } +}; + +const renderMultiKeyStatus = (status, keySize, enabledKeySize, t) => { + switch (status) { + case 1: + return ( + + {t('已启用')} {enabledKeySize}/{keySize} + + ); + case 2: + return ( + + {t('已禁用')} {enabledKeySize}/{keySize} + + ); + case 3: + return ( + + {t('自动禁用')} {enabledKeySize}/{keySize} + + ); + default: + return ( + + {t('未知状态')} {enabledKeySize}/{keySize} + + ); + } +} + +const renderResponseTime = (responseTime, t) => { + let time = responseTime / 1000; + time = time.toFixed(2) + t(' 秒'); + if (responseTime === 0) { + return ( + + {t('未测试')} + + ); + } else if (responseTime <= 1000) { + return ( + + {time} + + ); + } else if (responseTime <= 3000) { + return ( + + {time} + + ); + } else if (responseTime <= 5000) { + return ( + + {time} + + ); + } else { + return ( + + {time} + + ); + } +}; + +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) => ( +
+ + {text + ?.split(',') + .sort((a, b) => { + if (a === 'default') return -1; + if (b === 'default') return 1; + return a.localeCompare(b); + }) + .map((item, index) => renderGroup(item))} + +
+ ), + }, + { + 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 ( +
+ + {renderStatus(text, record.channel_info, t)} + +
+ ); + } else { + return renderStatus(text, record.channel_info, t); + } + }, + }, + { + key: COLUMN_KEYS.RESPONSE_TIME, + title: t('响应时间'), + dataIndex: 'response_time', + render: (text, record, index) => ( +
{renderResponseTime(text, t)}
+ ), + }, + { + key: COLUMN_KEYS.BALANCE, + title: t('已用/剩余'), + dataIndex: 'expired_time', + render: (text, record, index) => { + if (record.children === undefined) { + return ( +
+ + + + {renderQuota(record.used_quota)} + + + + updateChannelBalance(record)} + > + {renderQuotaWithAmount(record.balance)} + + + +
+ ); + } else { + return ( + + + {renderQuota(record.used_quota)} + + + ); + } + }, + }, + { + key: COLUMN_KEYS.PRIORITY, + title: t('优先级'), + dataIndex: 'priority', + render: (text, record, index) => { + if (record.children === undefined) { + return ( +
+ { + manageChannel(record.id, 'priority', record, e.target.value); + }} + keepFocus={true} + innerButtons + defaultValue={record.priority} + min={-999} + size="small" + /> +
+ ); + } else { + return ( + { + 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 ( +
+ { + manageChannel(record.id, 'weight', record, e.target.value); + }} + keepFocus={true} + innerButtons + defaultValue={record.weight} + min={0} + size="small" + /> +
+ ); + } else { + return ( + { + 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 ( + + + + + ) : ( + + ) + } + manageChannel(record.id, 'enable_all', record), + } + ]} + > + + ) : ( + + ) + )} + + + + + + + + + ); + } + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsFilters.jsx b/web/src/components/table/channels/ChannelsFilters.jsx new file mode 100644 index 00000000..0d607f5f --- /dev/null +++ b/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 . + +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 ( +
+
+ + + + + +
+ +
+
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" + > +
+ } + placeholder={t('渠道ID,名称,密钥,API地址')} + showClear + pure + /> +
+
+ } + placeholder={t('模型关键字')} + showClear + pure + /> +
+
+ { + // 延迟执行搜索,让表单值先更新 + setTimeout(() => { + searchChannels(enableTagMode); + }, 0); + }} + /> +
+ + + +
+
+ ); +}; + +export default ChannelsFilters; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsTable.jsx b/web/src/components/table/channels/ChannelsTable.jsx new file mode 100644 index 00000000..bf4d24de --- /dev/null +++ b/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 . + +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 ( + { + setSelectedChannels(selectedRows); + }, + } + : null + } + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + size="middle" + loading={loading || searching} + /> + ); +}; + +export default ChannelsTable; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsTabs.jsx b/web/src/components/table/channels/ChannelsTabs.jsx new file mode 100644 index 00000000..f0448efc --- /dev/null +++ b/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 . + +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 ( + + + {t('全部')} + + {channelTypeCounts['all'] || 0} + + + } + /> + + {CHANNEL_OPTIONS.filter((opt) => availableTypeKeys.includes(String(opt.value))).map((option) => { + const key = String(option.value); + const count = channelTypeCounts[option.value] || 0; + return ( + + {getChannelIcon(option.value)} + {option.label} + + {count} + + + } + /> + ); + })} + + ); +}; + +export default ChannelsTabs; \ No newline at end of file diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx new file mode 100644 index 00000000..f9370150 --- /dev/null +++ b/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 . + +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 */} + + channelsData.setShowEditTag(false)} + refresh={channelsData.refresh} + /> + + + + + {/* Main Content */} + } + actionsArea={} + searchArea={} + paginationArea={createCardProPagination({ + currentPage: channelsData.activePage, + pageSize: channelsData.pageSize, + total: channelsData.channelCount, + onPageChange: channelsData.handlePageChange, + onPageSizeChange: channelsData.handlePageSizeChange, + isMobile: isMobile, + })} + t={channelsData.t} + > + + + + ); +}; + +export default ChannelsPage; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/BatchTagModal.jsx b/web/src/components/table/channels/modals/BatchTagModal.jsx new file mode 100644 index 00000000..121ba87f --- /dev/null +++ b/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 . + +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 ( + setShowBatchSetTag(false)} + maskClosable={false} + centered={true} + size="small" + className="!rounded-lg" + > +
+ {t('请输入要设置的标签名称')} +
+ setBatchSetTagValue(v)} + /> +
+ + {t('已选择 ${count} 个渠道').replace('${count}', selectedChannels.length)} + +
+
+ ); +}; + +export default BatchTagModal; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/ColumnSelectorModal.jsx b/web/src/components/table/channels/modals/ColumnSelectorModal.jsx new file mode 100644 index 00000000..291992ce --- /dev/null +++ b/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 . + +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 ( + setShowColumnSelector(false)} + footer={ +
+ + + +
+ } + > +
+ v === true)} + indeterminate={ + Object.values(visibleColumns).some((v) => v === true) && + !Object.values(visibleColumns).every((v) => v === true) + } + onChange={(e) => handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {allColumns.map((column) => { + // Skip columns without title + if (!column.title) { + return null; + } + + return ( +
+ + handleColumnVisibilityChange(column.key, e.target.checked) + } + > + {column.title} + +
+ ); + })} +
+
+ ); +}; + +export default ColumnSelectorModal; \ No newline at end of file diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/components/table/channels/modals/EditChannelModal.jsx similarity index 97% rename from web/src/pages/Channel/EditChannel.js rename to web/src/components/table/channels/modals/EditChannelModal.jsx index 0934d891..92c26540 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/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 . + +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 ( -
!disabled && onClick()} + onClick={() => !disabled && onClick()} onMouseEnter={e => onMouseEnter()} >
@@ -751,8 +768,8 @@ const EditChannel = (props) => { {getChannelIcon(value)}
- @@ -760,7 +777,7 @@ const EditChannel = (props) => { {selected && (
- +
)} @@ -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) => {
)} - + {batch && ( { className='!rounded-lg mb-3' /> )} - + {useManualInput && !batch ? ( { 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; \ No newline at end of file diff --git a/web/src/pages/Channel/EditTagModal.js b/web/src/components/table/channels/modals/EditTagModal.jsx similarity index 93% rename from web/src/pages/Channel/EditTagModal.js rename to web/src/components/table/channels/modals/EditTagModal.jsx index 433d4f09..5aadb95e 100644 --- a/web/src/pages/Channel/EditTagModal.js +++ b/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 . + +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; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx new file mode 100644 index 00000000..1d159473 --- /dev/null +++ b/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 . + +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) => ( +
+ {text} +
+ ) + }, + { + title: t('状态'), + dataIndex: 'status', + render: (text, record) => { + const testResult = modelTestResults[`${currentTestChannel.id}-${record.model}`]; + const isTesting = testingModels.has(record.model); + + if (isTesting) { + return ( + + {t('测试中')} + + ); + } + + if (!testResult) { + return ( + + {t('未开始')} + + ); + } + + return ( +
+ + {testResult.success ? t('成功') : t('失败')} + + {testResult.success && ( + + {t('请求时长: ${time}s').replace('${time}', testResult.time.toFixed(2))} + + )} +
+ ); + } + }, + { + title: '', + dataIndex: 'operate', + render: (text, record) => { + const isTesting = testingModels.has(record.model); + return ( + + ); + } + } + ]; + + 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 ( + +
+ + {currentTestChannel.name} {t('渠道的模型测试')} + + + {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} + +
+
+ ) : null} + visible={showModelTestModal} + onCancel={handleCloseModal} + footer={hasChannel ? ( +
+ {isBatchTesting ? ( + + ) : ( + + )} + +
+ ) : null} + maskClosable={!isBatchTesting} + className="!rounded-lg" + size={isMobile ? 'full-width' : 'large'} + > + {hasChannel && (
+ {/* 搜索与操作按钮 */} +
+ { + setModelSearchKeyword(v); + setModelTablePage(1); + }} + className="!w-full" + prefix={} + showClear + /> + + + + +
+ +
{ + 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), + }} + /> + )} + + ); +}; + +export default ModelTestModal; \ No newline at end of file diff --git a/web/src/components/table/mj-logs/MjLogsActions.jsx b/web/src/components/table/mj-logs/MjLogsActions.jsx new file mode 100644 index 00000000..b924c36a --- /dev/null +++ b/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 . + +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 ( +
+
+ + {loading ? ( + + ) : ( + + {isAdminUser && showBanner + ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') + : t('Midjourney 任务记录')} + + )} +
+ +
+ ); +}; + +export default MjLogsActions; \ No newline at end of file diff --git a/web/src/components/table/mj-logs/MjLogsColumnDefs.js b/web/src/components/table/mj-logs/MjLogsColumnDefs.js new file mode 100644 index 00000000..5d9db7d7 --- /dev/null +++ b/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 . + +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 ( + }> + {t('绘图')} + + ); + case 'UPSCALE': + return ( + }> + {t('放大')} + + ); + case 'VIDEO': + return ( + }> + {t('视频')} + + ); + case 'EDITS': + return ( + }> + {t('编辑')} + + ); + case 'VARIATION': + return ( + }> + {t('变换')} + + ); + case 'HIGH_VARIATION': + return ( + }> + {t('强变换')} + + ); + case 'LOW_VARIATION': + return ( + }> + {t('弱变换')} + + ); + case 'PAN': + return ( + }> + {t('平移')} + + ); + case 'DESCRIBE': + return ( + }> + {t('图生文')} + + ); + case 'BLEND': + return ( + }> + {t('图混合')} + + ); + case 'UPLOAD': + return ( + }> + 上传文件 + + ); + case 'SHORTEN': + return ( + }> + {t('缩词')} + + ); + case 'REROLL': + return ( + }> + {t('重绘')} + + ); + case 'INPAINT': + return ( + }> + {t('局部重绘-提交')} + + ); + case 'ZOOM': + return ( + }> + {t('变焦')} + + ); + case 'CUSTOM_ZOOM': + return ( + }> + {t('自定义变焦-提交')} + + ); + case 'MODAL': + return ( + }> + {t('窗口处理')} + + ); + case 'SWAP_FACE': + return ( + }> + {t('换脸')} + + ); + default: + return ( + }> + {t('未知')} + + ); + } +} + +function renderCode(code, t) { + switch (code) { + case 1: + return ( + }> + {t('已提交')} + + ); + case 21: + return ( + }> + {t('等待中')} + + ); + case 22: + return ( + }> + {t('重复提交')} + + ); + case 0: + return ( + }> + {t('未提交')} + + ); + default: + return ( + }> + {t('未知')} + + ); + } +} + +function renderStatus(type, t) { + switch (type) { + case 'SUCCESS': + return ( + }> + {t('成功')} + + ); + case 'NOT_START': + return ( + }> + {t('未启动')} + + ); + case 'SUBMITTED': + return ( + }> + {t('队列中')} + + ); + case 'IN_PROGRESS': + return ( + }> + {t('执行中')} + + ); + case 'FAILURE': + return ( + }> + {t('失败')} + + ); + case 'MODAL': + return ( + }> + {t('窗口等待')} + + ); + default: + return ( + }> + {t('未知')} + + ); + } +} + +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 ( + }> + {durationSec} {t('秒')} + + ); +} + +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
{renderTimestamp(text / 1000)}
; + }, + }, + { + 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 ? ( +
+ } + onClick={() => { + copyText(text); + }} + > + {' '} + {text}{' '} + +
+ ) : ( + <> + ); + }, + }, + { + key: COLUMN_KEYS.TYPE, + title: t('类型'), + dataIndex: 'action', + render: (text, record, index) => { + return
{renderType(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.TASK_ID, + title: t('任务ID'), + dataIndex: 'mj_id', + render: (text, record, index) => { + return
{text}
; + }, + }, + { + key: COLUMN_KEYS.SUBMIT_RESULT, + title: t('提交结果'), + dataIndex: 'code', + render: (text, record, index) => { + return isAdminUser ?
{renderCode(text, t)}
: <>; + }, + }, + { + key: COLUMN_KEYS.TASK_STATUS, + title: t('任务状态'), + dataIndex: 'status', + render: (text, record, index) => { + return
{renderStatus(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.PROGRESS, + title: t('进度'), + dataIndex: 'progress', + render: (text, record, index) => { + return ( +
+ { + + } +
+ ); + }, + }, + { + key: COLUMN_KEYS.IMAGE, + title: t('结果图片'), + dataIndex: 'image_url', + render: (text, record, index) => { + if (!text) { + return t('无'); + } + return ( + + ); + }, + }, + { + key: COLUMN_KEYS.PROMPT, + title: 'Prompt', + dataIndex: 'prompt', + render: (text, record, index) => { + if (!text) { + return t('无'); + } + + return ( + { + openContentModal(text); + }} + > + {text} + + ); + }, + }, + { + key: COLUMN_KEYS.PROMPT_EN, + title: 'PromptEn', + dataIndex: 'prompt_en', + render: (text, record, index) => { + if (!text) { + return t('无'); + } + + return ( + { + openContentModal(text); + }} + > + {text} + + ); + }, + }, + { + key: COLUMN_KEYS.FAIL_REASON, + title: t('失败原因'), + dataIndex: 'fail_reason', + fixed: 'right', + render: (text, record, index) => { + if (!text) { + return t('无'); + } + + return ( + { + openContentModal(text); + }} + > + {text} + + ); + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx new file mode 100644 index 00000000..4aced0f2 --- /dev/null +++ b/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 . + +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 ( +
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete="off" + layout="vertical" + trigger="change" + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
+ + {/* 任务 ID */} + } + placeholder={t('任务 ID')} + showClear + pure + size="small" + /> + + {/* 渠道 ID - 仅管理员可见 */} + {isAdminUser && ( + } + placeholder={t('渠道 ID')} + showClear + pure + size="small" + /> + )} +
+ + {/* 操作按钮区域 */} +
+
+
+ + + +
+
+
+ + ); +}; + +export default MjLogsFilters; \ No newline at end of file diff --git a/web/src/components/table/mj-logs/MjLogsTable.jsx b/web/src/components/table/mj-logs/MjLogsTable.jsx new file mode 100644 index 00000000..31a2d10e --- /dev/null +++ b/web/src/components/table/mj-logs/MjLogsTable.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 . + +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 { getMjLogsColumns } from './MjLogsColumnDefs.js'; + +const MjLogsTable = (mjLogsData) => { + const { + logs, + loading, + activePage, + pageSize, + logCount, + compactMode, + visibleColumns, + handlePageChange, + handlePageSizeChange, + copyText, + openContentModal, + openImageModal, + isAdminUser, + t, + COLUMN_KEYS, + } = mjLogsData; + + // Get all columns + const allColumns = useMemo(() => { + return getMjLogsColumns({ + t, + COLUMN_KEYS, + copyText, + openContentModal, + openImageModal, + isAdminUser, + }); + }, [ + t, + COLUMN_KEYS, + copyText, + openContentModal, + openImageModal, + isAdminUser, + ]); + + // 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 ( + + } + darkModeImage={ + + } + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + pagination={{ + currentPage: activePage, + pageSize: pageSize, + total: logCount, + pageSizeOptions: [10, 20, 50, 100], + showSizeChanger: true, + onPageSizeChange: handlePageSizeChange, + onPageChange: handlePageChange, + }} + hidePagination={true} + /> + ); +}; + +export default MjLogsTable; \ No newline at end of file diff --git a/web/src/components/table/mj-logs/index.jsx b/web/src/components/table/mj-logs/index.jsx new file mode 100644 index 00000000..86f96713 --- /dev/null +++ b/web/src/components/table/mj-logs/index.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Layout } from '@douyinfe/semi-ui'; +import CardPro from '../../common/ui/CardPro.js'; +import MjLogsTable from './MjLogsTable.jsx'; +import MjLogsActions from './MjLogsActions.jsx'; +import MjLogsFilters from './MjLogsFilters.jsx'; +import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx'; +import ContentModal from './modals/ContentModal.jsx'; +import { useMjLogsData } from '../../../hooks/mj-logs/useMjLogsData.js'; +import { useIsMobile } from '../../../hooks/common/useIsMobile.js'; +import { createCardProPagination } from '../../../helpers/utils'; + +const MjLogsPage = () => { + const mjLogsData = useMjLogsData(); + const isMobile = useIsMobile(); + + return ( + <> + {/* Modals */} + + + + + } + searchArea={} + paginationArea={createCardProPagination({ + currentPage: mjLogsData.activePage, + pageSize: mjLogsData.pageSize, + total: mjLogsData.logCount, + onPageChange: mjLogsData.handlePageChange, + onPageSizeChange: mjLogsData.handlePageSizeChange, + isMobile: isMobile, + })} + t={mjLogsData.t} + > + + + + + ); +}; + +export default MjLogsPage; \ No newline at end of file diff --git a/web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx b/web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx new file mode 100644 index 00000000..d05f9cf0 --- /dev/null +++ b/web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx @@ -0,0 +1,111 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; +import { getMjLogsColumns } from '../MjLogsColumnDefs.js'; + +const ColumnSelectorModal = ({ + showColumnSelector, + setShowColumnSelector, + visibleColumns, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + isAdminUser, + copyText, + openContentModal, + openImageModal, + t, +}) => { + // Get all columns for display in selector + const allColumns = getMjLogsColumns({ + t, + COLUMN_KEYS, + copyText, + openContentModal, + openImageModal, + isAdminUser, + }); + + return ( + setShowColumnSelector(false)} + footer={ +
+ + + +
+ } + > +
+ v === true)} + indeterminate={ + Object.values(visibleColumns).some((v) => v === true) && + !Object.values(visibleColumns).every((v) => v === true) + } + onChange={(e) => handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {allColumns.map((column) => { + // Skip admin-only columns for non-admin users + if ( + !isAdminUser && + (column.key === COLUMN_KEYS.CHANNEL || + column.key === COLUMN_KEYS.SUBMIT_RESULT) + ) { + return null; + } + + return ( +
+ + handleColumnVisibilityChange(column.key, e.target.checked) + } + > + {column.title} + +
+ ); + })} +
+
+ ); +}; + +export default ColumnSelectorModal; \ No newline at end of file diff --git a/web/src/components/table/mj-logs/modals/ContentModal.jsx b/web/src/components/table/mj-logs/modals/ContentModal.jsx new file mode 100644 index 00000000..f73cda24 --- /dev/null +++ b/web/src/components/table/mj-logs/modals/ContentModal.jsx @@ -0,0 +1,55 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, ImagePreview } from '@douyinfe/semi-ui'; + +const ContentModal = ({ + isModalOpen, + setIsModalOpen, + modalContent, + isModalOpenurl, + setIsModalOpenurl, + modalImageUrl, +}) => { + return ( + <> + {/* Text Content Modal */} + setIsModalOpen(false)} + onCancel={() => setIsModalOpen(false)} + closable={null} + bodyStyle={{ height: '400px', overflow: 'auto' }} + width={800} + > +

{modalContent}

+
+ + {/* Image Preview Modal */} + setIsModalOpenurl(visible)} + /> + + ); +}; + +export default ContentModal; \ No newline at end of file diff --git a/web/src/components/table/redemptions/RedemptionsActions.jsx b/web/src/components/table/redemptions/RedemptionsActions.jsx new file mode 100644 index 00000000..5b10fb00 --- /dev/null +++ b/web/src/components/table/redemptions/RedemptionsActions.jsx @@ -0,0 +1,72 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Button } from '@douyinfe/semi-ui'; + +const RedemptionsActions = ({ + selectedKeys, + setEditingRedemption, + setShowEdit, + batchCopyRedemptions, + batchDeleteRedemptions, + t +}) => { + + // Add new redemption code + const handleAddRedemption = () => { + setEditingRedemption({ + id: undefined, + }); + setShowEdit(true); + }; + + return ( +
+ + + + + +
+ ); +}; + +export default RedemptionsActions; \ No newline at end of file diff --git a/web/src/components/table/redemptions/RedemptionsColumnDefs.js b/web/src/components/table/redemptions/RedemptionsColumnDefs.js new file mode 100644 index 00000000..fc1601c1 --- /dev/null +++ b/web/src/components/table/redemptions/RedemptionsColumnDefs.js @@ -0,0 +1,217 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Tag, Button, Space, Popover, Dropdown } from '@douyinfe/semi-ui'; +import { IconMore } from '@douyinfe/semi-icons'; +import { renderQuota, timestamp2string } from '../../../helpers'; +import { REDEMPTION_STATUS, REDEMPTION_STATUS_MAP, REDEMPTION_ACTIONS } from '../../../constants/redemption.constants'; + +/** + * Check if redemption code is expired + */ +export const isExpired = (record) => { + return record.status === REDEMPTION_STATUS.UNUSED && + record.expired_time !== 0 && + record.expired_time < Math.floor(Date.now() / 1000); +}; + +/** + * Render timestamp + */ +const renderTimestamp = (timestamp) => { + return <>{timestamp2string(timestamp)}; +}; + +/** + * Render redemption code status + */ +const renderStatus = (status, record, t) => { + if (isExpired(record)) { + return ( + {t('已过期')} + ); + } + + const statusConfig = REDEMPTION_STATUS_MAP[status]; + if (statusConfig) { + return ( + + {t(statusConfig.text)} + + ); + } + + return ( + + {t('未知状态')} + + ); +}; + +/** + * Get redemption code table column definitions + */ +export const getRedemptionsColumns = ({ + t, + manageRedemption, + copyText, + setEditingRedemption, + setShowEdit, + refresh, + redemptions, + activePage, + showDeleteRedemptionModal +}) => { + return [ + { + title: t('ID'), + dataIndex: 'id', + }, + { + title: t('名称'), + dataIndex: 'name', + }, + { + title: t('状态'), + dataIndex: 'status', + key: 'status', + render: (text, record) => { + return
{renderStatus(text, record, t)}
; + }, + }, + { + title: t('额度'), + dataIndex: 'quota', + render: (text) => { + return ( +
+ + {renderQuota(parseInt(text))} + +
+ ); + }, + }, + { + title: t('创建时间'), + dataIndex: 'created_time', + render: (text) => { + return
{renderTimestamp(text)}
; + }, + }, + { + title: t('过期时间'), + dataIndex: 'expired_time', + render: (text) => { + return
{text === 0 ? t('永不过期') : renderTimestamp(text)}
; + }, + }, + { + title: t('兑换人ID'), + dataIndex: 'used_user_id', + render: (text) => { + return
{text === 0 ? t('无') : text}
; + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + width: 205, + render: (text, record) => { + // Create dropdown menu items for more operations + const moreMenuItems = [ + { + node: 'item', + name: t('删除'), + type: 'danger', + onClick: () => { + showDeleteRedemptionModal(record); + }, + } + ]; + + if (record.status === REDEMPTION_STATUS.UNUSED && !isExpired(record)) { + moreMenuItems.push({ + node: 'item', + name: t('禁用'), + type: 'warning', + onClick: () => { + manageRedemption(record.id, REDEMPTION_ACTIONS.DISABLE, record); + }, + }); + } else if (!isExpired(record)) { + moreMenuItems.push({ + node: 'item', + name: t('启用'), + type: 'secondary', + onClick: () => { + manageRedemption(record.id, REDEMPTION_ACTIONS.ENABLE, record); + }, + disabled: record.status === REDEMPTION_STATUS.USED, + }); + } + + return ( + + + + + + + + + + + + + ); +}; + +export default RedemptionsFilters; \ No newline at end of file diff --git a/web/src/components/table/redemptions/RedemptionsTable.jsx b/web/src/components/table/redemptions/RedemptionsTable.jsx new file mode 100644 index 00000000..76e50532 --- /dev/null +++ b/web/src/components/table/redemptions/RedemptionsTable.jsx @@ -0,0 +1,140 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo, useState } from 'react'; +import { Empty } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable.js'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import { getRedemptionsColumns, isExpired } from './RedemptionsColumnDefs'; +import DeleteRedemptionModal from './modals/DeleteRedemptionModal'; + +const RedemptionsTable = (redemptionsData) => { + const { + redemptions, + loading, + activePage, + pageSize, + tokenCount, + compactMode, + handlePageChange, + rowSelection, + handleRow, + manageRedemption, + copyText, + setEditingRedemption, + setShowEdit, + refresh, + t, + } = redemptionsData; + + // Modal states + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletingRecord, setDeletingRecord] = useState(null); + + // Handle show delete modal + const showDeleteRedemptionModal = (record) => { + setDeletingRecord(record); + setShowDeleteModal(true); + }; + + // Get all columns + const columns = useMemo(() => { + return getRedemptionsColumns({ + t, + manageRedemption, + copyText, + setEditingRedemption, + setShowEdit, + refresh, + redemptions, + activePage, + showDeleteRedemptionModal + }); + }, [ + t, + manageRedemption, + copyText, + setEditingRedemption, + setShowEdit, + refresh, + redemptions, + activePage, + showDeleteRedemptionModal, + ]); + + // Handle compact mode by removing fixed positioning + const tableColumns = useMemo(() => { + return compactMode ? columns.map(col => { + if (col.dataIndex === 'operate') { + const { fixed, ...rest } = col; + return rest; + } + return col; + }) : columns; + }, [compactMode, columns]); + + return ( + <> + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + size="middle" + /> + + setShowDeleteModal(false)} + record={deletingRecord} + manageRedemption={manageRedemption} + refresh={refresh} + redemptions={redemptions} + activePage={activePage} + t={t} + /> + + ); +}; + +export default RedemptionsTable; \ No newline at end of file diff --git a/web/src/components/table/redemptions/index.jsx b/web/src/components/table/redemptions/index.jsx new file mode 100644 index 00000000..5abb64aa --- /dev/null +++ b/web/src/components/table/redemptions/index.jsx @@ -0,0 +1,121 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import CardPro from '../../common/ui/CardPro'; +import RedemptionsTable from './RedemptionsTable.jsx'; +import RedemptionsActions from './RedemptionsActions.jsx'; +import RedemptionsFilters from './RedemptionsFilters.jsx'; +import RedemptionsDescription from './RedemptionsDescription.jsx'; +import EditRedemptionModal from './modals/EditRedemptionModal'; +import { useRedemptionsData } from '../../../hooks/redemptions/useRedemptionsData'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { createCardProPagination } from '../../../helpers/utils'; + +const RedemptionsPage = () => { + const redemptionsData = useRedemptionsData(); + const isMobile = useIsMobile(); + + const { + // Edit state + showEdit, + editingRedemption, + closeEdit, + refresh, + + // Actions state + selectedKeys, + setEditingRedemption, + setShowEdit, + batchCopyRedemptions, + batchDeleteRedemptions, + + // Filters state + formInitValues, + setFormApi, + searchRedemptions, + loading, + searching, + + // UI state + compactMode, + setCompactMode, + + // Translation + t, + } = redemptionsData; + + return ( + <> + + + + } + actionsArea={ +
+ + +
+ +
+
+ } + paginationArea={createCardProPagination({ + currentPage: redemptionsData.activePage, + pageSize: redemptionsData.pageSize, + total: redemptionsData.tokenCount, + onPageChange: redemptionsData.handlePageChange, + onPageSizeChange: redemptionsData.handlePageSizeChange, + isMobile: isMobile, + })} + t={t} + > + +
+ + ); +}; + +export default RedemptionsPage; \ No newline at end of file diff --git a/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx b/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx new file mode 100644 index 00000000..d99968e7 --- /dev/null +++ b/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx @@ -0,0 +1,58 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; +import { REDEMPTION_ACTIONS } from '../../../../constants/redemption.constants'; + +const DeleteRedemptionModal = ({ + visible, + onCancel, + record, + manageRedemption, + refresh, + redemptions, + activePage, + t +}) => { + const handleConfirm = async () => { + await manageRedemption(record.id, REDEMPTION_ACTIONS.DELETE, record); + await refresh(); + setTimeout(() => { + if (redemptions.length === 0 && activePage > 1) { + refresh(activePage - 1); + } + }, 100); + onCancel(); // Close modal after success + }; + + return ( + + {t('此修改将不可逆')} + + ); +}; + +export default DeleteRedemptionModal; \ No newline at end of file diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx similarity index 91% rename from web/src/pages/Redemption/EditRedemption.js rename to web/src/components/table/redemptions/modals/EditRedemptionModal.jsx index 44d17e62..79b834a3 100644 --- a/web/src/pages/Redemption/EditRedemption.js +++ b/web/src/components/table/redemptions/modals/EditRedemptionModal.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useEffect, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -7,8 +26,8 @@ import { showSuccess, renderQuota, renderQuotaWithPrompt, -} from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +} from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, Modal, @@ -32,7 +51,7 @@ import { const { Text, Title } = Typography; -const EditRedemption = (props) => { +const EditRedemptionModal = (props) => { const { t } = useTranslation(); const isEdit = props.editingRedemption.id !== undefined; const [loading, setLoading] = useState(isEdit); @@ -302,4 +321,4 @@ const EditRedemption = (props) => { ); }; -export default EditRedemption; +export default EditRedemptionModal; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsActions.jsx b/web/src/components/table/task-logs/TaskLogsActions.jsx new file mode 100644 index 00000000..5df27e69 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsActions.jsx @@ -0,0 +1,47 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Typography } from '@douyinfe/semi-ui'; +import { IconEyeOpened } from '@douyinfe/semi-icons'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; + +const { Text } = Typography; + +const TaskLogsActions = ({ + compactMode, + setCompactMode, + t, +}) => { + return ( +
+
+ + {t('任务记录')} +
+ +
+ ); +}; + +export default TaskLogsActions; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.js b/web/src/components/table/task-logs/TaskLogsColumnDefs.js new file mode 100644 index 00000000..8b066758 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.js @@ -0,0 +1,370 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { + Progress, + Tag, + Typography +} from '@douyinfe/semi-ui'; +import { + Music, + FileText, + HelpCircle, + CheckCircle, + Pause, + Clock, + Play, + XCircle, + Loader, + List, + Hash, + Video, + Sparkles +} from 'lucide-react'; +import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../../constants/common.constant'; + +const colors = [ + 'amber', + 'blue', + 'cyan', + 'green', + 'grey', + 'indigo', + 'light-blue', + 'lime', + 'orange', + 'pink', + 'purple', + 'red', + 'teal', + 'violet', + 'yellow', +]; + +// Render functions +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 ( + }> + {durationSec} 秒 + + ); +} + +const renderType = (type, t) => { + switch (type) { + case 'MUSIC': + return ( + }> + {t('生成音乐')} + + ); + case 'LYRICS': + return ( + }> + {t('生成歌词')} + + ); + case TASK_ACTION_GENERATE: + return ( + }> + {t('图生视频')} + + ); + case TASK_ACTION_TEXT_GENERATE: + return ( + }> + {t('文生视频')} + + ); + default: + return ( + }> + {t('未知')} + + ); + } +}; + +const renderPlatform = (platform, t) => { + switch (platform) { + case 'suno': + return ( + }> + Suno + + ); + case 'kling': + return ( + }> + Kling + + ); + case 'jimeng': + return ( + }> + Jimeng + + ); + default: + return ( + }> + {t('未知')} + + ); + } +}; + +const renderStatus = (type, t) => { + switch (type) { + case 'SUCCESS': + return ( + }> + {t('成功')} + + ); + case 'NOT_START': + return ( + }> + {t('未启动')} + + ); + case 'SUBMITTED': + return ( + }> + {t('队列中')} + + ); + case 'IN_PROGRESS': + return ( + }> + {t('执行中')} + + ); + case 'FAILURE': + return ( + }> + {t('失败')} + + ); + case 'QUEUED': + return ( + }> + {t('排队中')} + + ); + case 'UNKNOWN': + return ( + }> + {t('未知')} + + ); + case '': + return ( + }> + {t('正在提交')} + + ); + default: + return ( + }> + {t('未知')} + + ); + } +}; + +export const getTaskLogsColumns = ({ + t, + COLUMN_KEYS, + copyText, + openContentModal, + isAdminUser, +}) => { + return [ + { + key: COLUMN_KEYS.SUBMIT_TIME, + title: t('提交时间'), + dataIndex: 'submit_time', + render: (text, record, index) => { + return
{text ? renderTimestamp(text) : '-'}
; + }, + }, + { + key: COLUMN_KEYS.FINISH_TIME, + title: t('结束时间'), + dataIndex: 'finish_time', + render: (text, record, index) => { + return
{text ? renderTimestamp(text) : '-'}
; + }, + }, + { + 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', + render: (text, record, index) => { + return isAdminUser ? ( +
+ } + onClick={() => { + copyText(text); + }} + > + {text} + +
+ ) : ( + <> + ); + }, + }, + { + key: COLUMN_KEYS.PLATFORM, + title: t('平台'), + dataIndex: 'platform', + render: (text, record, index) => { + return
{renderPlatform(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.TYPE, + title: t('类型'), + dataIndex: 'action', + render: (text, record, index) => { + return
{renderType(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.TASK_ID, + title: t('任务ID'), + dataIndex: 'task_id', + render: (text, record, index) => { + return ( + { + openContentModal(JSON.stringify(record, null, 2)); + }} + > +
{text}
+
+ ); + }, + }, + { + key: COLUMN_KEYS.TASK_STATUS, + title: t('任务状态'), + dataIndex: 'status', + render: (text, record, index) => { + return
{renderStatus(text, t)}
; + }, + }, + { + key: COLUMN_KEYS.PROGRESS, + title: t('进度'), + dataIndex: 'progress', + render: (text, record, index) => { + return ( +
+ { + isNaN(text?.replace('%', '')) ? ( + text || '-' + ) : ( + + ) + } +
+ ); + }, + }, + { + 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 ( + + {t('点击预览视频')} + + ); + } + if (!text) { + return t('无'); + } + return ( + { + openContentModal(text); + }} + > + {text} + + ); + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx new file mode 100644 index 00000000..c3e26eea --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx @@ -0,0 +1,124 @@ +/* +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 . + +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 TaskLogsFilters = ({ + formInitValues, + setFormApi, + refresh, + setShowColumnSelector, + formApi, + loading, + isAdminUser, + t, +}) => { + return ( +
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete="off" + layout="vertical" + trigger="change" + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
+ + {/* 任务 ID */} + } + placeholder={t('任务 ID')} + showClear + pure + size="small" + /> + + {/* 渠道 ID - 仅管理员可见 */} + {isAdminUser && ( + } + placeholder={t('渠道 ID')} + showClear + pure + size="small" + /> + )} +
+ + {/* 操作按钮区域 */} +
+
+
+ + + +
+
+
+ + ); +}; + +export default TaskLogsFilters; \ No newline at end of file diff --git a/web/src/components/table/task-logs/TaskLogsTable.jsx b/web/src/components/table/task-logs/TaskLogsTable.jsx new file mode 100644 index 00000000..cacb12dd --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsTable.jsx @@ -0,0 +1,114 @@ +/* +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 . + +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 { getTaskLogsColumns } from './TaskLogsColumnDefs.js'; + +const TaskLogsTable = (taskLogsData) => { + const { + logs, + loading, + activePage, + pageSize, + logCount, + compactMode, + visibleColumns, + handlePageChange, + handlePageSizeChange, + copyText, + openContentModal, + isAdminUser, + t, + COLUMN_KEYS, + } = taskLogsData; + + // Get all columns + const allColumns = useMemo(() => { + return getTaskLogsColumns({ + t, + COLUMN_KEYS, + copyText, + openContentModal, + isAdminUser, + }); + }, [ + t, + COLUMN_KEYS, + copyText, + openContentModal, + isAdminUser, + ]); + + // 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 ( + + } + darkModeImage={ + + } + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + pagination={{ + currentPage: activePage, + pageSize: pageSize, + total: logCount, + pageSizeOptions: [10, 20, 50, 100], + showSizeChanger: true, + onPageSizeChange: handlePageSizeChange, + onPageChange: handlePageChange, + }} + hidePagination={true} + /> + ); +}; + +export default TaskLogsTable; \ No newline at end of file diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx new file mode 100644 index 00000000..c9a02541 --- /dev/null +++ b/web/src/components/table/task-logs/index.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Layout } from '@douyinfe/semi-ui'; +import CardPro from '../../common/ui/CardPro.js'; +import TaskLogsTable from './TaskLogsTable.jsx'; +import TaskLogsActions from './TaskLogsActions.jsx'; +import TaskLogsFilters from './TaskLogsFilters.jsx'; +import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx'; +import ContentModal from './modals/ContentModal.jsx'; +import { useTaskLogsData } from '../../../hooks/task-logs/useTaskLogsData.js'; +import { useIsMobile } from '../../../hooks/common/useIsMobile.js'; +import { createCardProPagination } from '../../../helpers/utils'; + +const TaskLogsPage = () => { + const taskLogsData = useTaskLogsData(); + const isMobile = useIsMobile(); + + return ( + <> + {/* Modals */} + + + + + } + searchArea={} + paginationArea={createCardProPagination({ + currentPage: taskLogsData.activePage, + pageSize: taskLogsData.pageSize, + total: taskLogsData.logCount, + onPageChange: taskLogsData.handlePageChange, + onPageSizeChange: taskLogsData.handlePageSizeChange, + isMobile: isMobile, + })} + t={taskLogsData.t} + > + + + + + ); +}; + +export default TaskLogsPage; \ No newline at end of file diff --git a/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx b/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx new file mode 100644 index 00000000..6a66304b --- /dev/null +++ b/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx @@ -0,0 +1,105 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; +import { getTaskLogsColumns } from '../TaskLogsColumnDefs.js'; + +const ColumnSelectorModal = ({ + showColumnSelector, + setShowColumnSelector, + visibleColumns, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + isAdminUser, + copyText, + openContentModal, + t, +}) => { + // Get all columns for display in selector + const allColumns = getTaskLogsColumns({ + t, + COLUMN_KEYS, + copyText, + openContentModal, + isAdminUser, + }); + + return ( + setShowColumnSelector(false)} + footer={ +
+ + + +
+ } + > +
+ v === true)} + indeterminate={ + Object.values(visibleColumns).some((v) => v === true) && + !Object.values(visibleColumns).every((v) => v === true) + } + onChange={(e) => handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {allColumns.map((column) => { + // Skip admin-only columns for non-admin users + if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) { + return null; + } + + return ( +
+ + handleColumnVisibilityChange(column.key, e.target.checked) + } + > + {column.title} + +
+ ); + })} +
+
+ ); +}; + +export default ColumnSelectorModal; \ No newline at end of file diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx new file mode 100644 index 00000000..11869614 --- /dev/null +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -0,0 +1,42 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; + +const ContentModal = ({ + isModalOpen, + setIsModalOpen, + modalContent, +}) => { + return ( + setIsModalOpen(false)} + onCancel={() => setIsModalOpen(false)} + closable={null} + bodyStyle={{ height: '400px', overflow: 'auto' }} + width={800} + > +

{modalContent}

+
+ ); +}; + +export default ContentModal; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensActions.jsx b/web/src/components/table/tokens/TokensActions.jsx new file mode 100644 index 00000000..765069e1 --- /dev/null +++ b/web/src/components/table/tokens/TokensActions.jsx @@ -0,0 +1,118 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { Button, Space } from '@douyinfe/semi-ui'; +import { showError } from '../../../helpers'; +import CopyTokensModal from './modals/CopyTokensModal'; +import DeleteTokensModal from './modals/DeleteTokensModal'; + +const TokensActions = ({ + selectedKeys, + setEditingToken, + setShowEdit, + batchCopyTokens, + batchDeleteTokens, + copyText, + t, +}) => { + // Modal states + const [showCopyModal, setShowCopyModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // Handle copy selected tokens with options + const handleCopySelectedTokens = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + setShowCopyModal(true); + }; + + // Handle delete selected tokens with confirmation + const handleDeleteSelectedTokens = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + setShowDeleteModal(true); + }; + + // Handle delete confirmation + const handleConfirmDelete = () => { + batchDeleteTokens(); + setShowDeleteModal(false); + }; + + return ( + <> +
+ + + + + +
+ + setShowCopyModal(false)} + selectedKeys={selectedKeys} + copyText={copyText} + t={t} + /> + + setShowDeleteModal(false)} + onConfirm={handleConfirmDelete} + selectedKeys={selectedKeys} + t={t} + /> + + ); +}; + +export default TokensActions; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensColumnDefs.js b/web/src/components/table/tokens/TokensColumnDefs.js new file mode 100644 index 00000000..ffa5ff79 --- /dev/null +++ b/web/src/components/table/tokens/TokensColumnDefs.js @@ -0,0 +1,472 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { + Button, + Dropdown, + Space, + SplitButtonGroup, + Tag, + AvatarGroup, + Avatar, + Tooltip, + Progress, + Switch, + Input, + Modal +} from '@douyinfe/semi-ui'; +import { + timestamp2string, + renderGroup, + renderQuota, + getModelCategories, + showError +} from '../../../helpers'; +import { + IconTreeTriangleDown, + IconCopy, + IconEyeOpened, + IconEyeClosed, +} from '@douyinfe/semi-icons'; + +// Render functions +function renderTimestamp(timestamp) { + return <>{timestamp2string(timestamp)}; +} + +// Render status column with switch and progress bar +const renderStatus = (text, record, manageToken, t) => { + 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 ? ( +
{t('无限额度')}
+ ) : ( +
+ {`${renderQuota(remain)} / ${renderQuota(total)}`} + `${percent.toFixed(0)}%`} + style={{ width: '100%', marginTop: '1px', marginBottom: 0 }} + /> +
+ ); + + const content = ( + + } + suffixIcon={quotaSuffix} + > + {tagText} + + ); + + const tooltipContent = record.unlimited_quota ? ( +
+
{t('已用额度')}: {renderQuota(used)}
+
+ ) : ( +
+
{t('已用额度')}: {renderQuota(used)}
+
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+
{t('总额度')}: {renderQuota(total)}
+
+ ); + + return ( + + {content} + + ); +}; + +// Render group column +const renderGroupColumn = (text, t) => { + if (text === 'auto') { + return ( + + {t('智能熔断')} + + ); + } + return renderGroup(text); +}; + +// Render token key column with show/hide and copy functionality +const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => { + const fullKey = 'sk-' + record.key; + const maskedKey = 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4); + const revealed = !!showKeys[record.id]; + + return ( +
+ +
+ } + /> + + ); +}; + +// Render model limits column +const renderModelLimits = (text, record, t) => { + 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( + + + {category.icon} + + + ); + vendorModels.forEach((m) => matchedModels.add(m)); + } + }); + + const unmatchedModels = models.filter((m) => !matchedModels.has(m)); + if (unmatchedModels.length > 0) { + vendorAvatars.push( + + + {t('其他')} + + + ); + } + + return ( + + {vendorAvatars} + + ); + } else { + return ( + + {t('无限制')} + + ); + } +}; + +// Render IP restrictions column +const renderAllowIps = (text, t) => { + if (!text || text.trim() === '') { + return ( + + {t('无限制')} + + ); + } + + 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) => ( + + {ip} + + )); + + if (extraCount > 0) { + ipTags.push( + + + {'+' + extraCount} + + + ); + } + + return {ipTags}; +}; + +// Render operations column +const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => { + 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 ( + + + + + + + + + + + + + ); +}; + +export const getTokensColumns = ({ + t, + showKeys, + setShowKeys, + copyText, + manageToken, + onOpenLink, + setEditingToken, + setShowEdit, + refresh, +}) => { + return [ + { + title: t('名称'), + dataIndex: 'name', + }, + { + title: t('状态'), + dataIndex: 'status', + key: 'status', + render: (text, record) => renderStatus(text, record, manageToken, t), + }, + { + title: t('分组'), + dataIndex: 'group', + key: 'group', + render: (text) => renderGroupColumn(text, t), + }, + { + title: t('密钥'), + key: 'token_key', + render: (text, record) => renderTokenKey(text, record, showKeys, setShowKeys, copyText), + }, + { + title: t('可用模型'), + dataIndex: 'model_limits', + render: (text, record) => renderModelLimits(text, record, t), + }, + { + title: t('IP限制'), + dataIndex: 'allow_ips', + render: (text) => renderAllowIps(text, t), + }, + { + title: t('创建时间'), + dataIndex: 'created_time', + render: (text, record, index) => { + return
{renderTimestamp(text)}
; + }, + }, + { + title: t('过期时间'), + dataIndex: 'expired_time', + render: (text, record, index) => { + return ( +
+ {record.expired_time === -1 ? t('永不过期') : renderTimestamp(text)} +
+ ); + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + render: (text, record, index) => renderOperations( + text, + record, + onOpenLink, + setEditingToken, + setShowEdit, + manageToken, + refresh, + t + ), + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensDescription.jsx b/web/src/components/table/tokens/TokensDescription.jsx new file mode 100644 index 00000000..3dcfebac --- /dev/null +++ b/web/src/components/table/tokens/TokensDescription.jsx @@ -0,0 +1,44 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Typography } from '@douyinfe/semi-ui'; +import { Key } from 'lucide-react'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; + +const { Text } = Typography; + +const TokensDescription = ({ compactMode, setCompactMode, t }) => { + return ( +
+
+ + {t('令牌管理')} +
+ + +
+ ); +}; + +export default TokensDescription; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensFilters.jsx b/web/src/components/table/tokens/TokensFilters.jsx new file mode 100644 index 00000000..e8241a68 --- /dev/null +++ b/web/src/components/table/tokens/TokensFilters.jsx @@ -0,0 +1,106 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useRef } from 'react'; +import { Form, Button } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; + +const TokensFilters = ({ + formInitValues, + setFormApi, + searchTokens, + loading, + searching, + t, +}) => { + // Handle form reset and immediate search + const formApiRef = useRef(null); + + const handleReset = () => { + if (!formApiRef.current) return; + formApiRef.current.reset(); + setTimeout(() => { + searchTokens(); + }, 100); + }; + + return ( +
{ + setFormApi(api); + formApiRef.current = api; + }} + onSubmit={searchTokens} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('搜索关键字')} + showClear + pure + size="small" + /> +
+ +
+ } + placeholder={t('密钥')} + showClear + pure + size="small" + /> +
+ +
+ + + +
+
+ + ); +}; + +export default TokensFilters; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensTable.jsx b/web/src/components/table/tokens/TokensTable.jsx new file mode 100644 index 00000000..15be1c63 --- /dev/null +++ b/web/src/components/table/tokens/TokensTable.jsx @@ -0,0 +1,120 @@ +/* +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 . + +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 { getTokensColumns } from './TokensColumnDefs.js'; + +const TokensTable = (tokensData) => { + const { + tokens, + loading, + activePage, + pageSize, + tokenCount, + compactMode, + handlePageChange, + handlePageSizeChange, + rowSelection, + handleRow, + showKeys, + setShowKeys, + copyText, + manageToken, + onOpenLink, + setEditingToken, + setShowEdit, + refresh, + t, + } = tokensData; + + // Get all columns + const columns = useMemo(() => { + return getTokensColumns({ + t, + showKeys, + setShowKeys, + copyText, + manageToken, + onOpenLink, + setEditingToken, + setShowEdit, + refresh, + }); + }, [ + t, + showKeys, + setShowKeys, + copyText, + manageToken, + onOpenLink, + setEditingToken, + setShowEdit, + refresh, + ]); + + // Handle compact mode by removing fixed positioning + const tableColumns = useMemo(() => { + return compactMode ? columns.map(col => { + if (col.dataIndex === 'operate') { + const { fixed, ...rest } = col; + return rest; + } + return col; + }) : columns; + }, [compactMode, columns]); + + return ( + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + size="middle" + /> + ); +}; + +export default TokensTable; \ No newline at end of file diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx new file mode 100644 index 00000000..a955f13c --- /dev/null +++ b/web/src/components/table/tokens/index.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import CardPro from '../../common/ui/CardPro'; +import TokensTable from './TokensTable.jsx'; +import TokensActions from './TokensActions.jsx'; +import TokensFilters from './TokensFilters.jsx'; +import TokensDescription from './TokensDescription.jsx'; +import EditTokenModal from './modals/EditTokenModal'; +import { useTokensData } from '../../../hooks/tokens/useTokensData'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { createCardProPagination } from '../../../helpers/utils'; + +const TokensPage = () => { + const tokensData = useTokensData(); + const isMobile = useIsMobile(); + + const { + // Edit state + showEdit, + editingToken, + closeEdit, + refresh, + + // Actions state + selectedKeys, + setEditingToken, + setShowEdit, + batchCopyTokens, + batchDeleteTokens, + copyText, + + // Filters state + formInitValues, + setFormApi, + searchTokens, + loading, + searching, + + // Description state + compactMode, + setCompactMode, + + // Translation + t, + } = tokensData; + + return ( + <> + + + + } + actionsArea={ +
+ + +
+ +
+
+ } + paginationArea={createCardProPagination({ + currentPage: tokensData.activePage, + pageSize: tokensData.pageSize, + total: tokensData.tokenCount, + onPageChange: tokensData.handlePageChange, + onPageSizeChange: tokensData.handlePageSizeChange, + isMobile: isMobile, + })} + t={t} + > + +
+ + ); +}; + +export default TokensPage; \ No newline at end of file diff --git a/web/src/components/table/tokens/modals/CopyTokensModal.jsx b/web/src/components/table/tokens/modals/CopyTokensModal.jsx new file mode 100644 index 00000000..93ea3cfa --- /dev/null +++ b/web/src/components/table/tokens/modals/CopyTokensModal.jsx @@ -0,0 +1,71 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Button, Space } from '@douyinfe/semi-ui'; + +const CopyTokensModal = ({ visible, onCancel, selectedKeys, copyText, t }) => { + // Handle copy with name and key format + const handleCopyWithName = async () => { + let content = ''; + for (let i = 0; i < selectedKeys.length; i++) { + content += selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n'; + } + await copyText(content); + onCancel(); + }; + + // Handle copy with key only format + const handleCopyKeyOnly = async () => { + let content = ''; + for (let i = 0; i < selectedKeys.length; i++) { + content += 'sk-' + selectedKeys[i].key + '\n'; + } + await copyText(content); + onCancel(); + }; + + return ( + + + +
+ } + > + {t('请选择你的复制方式')} + + ); +}; + +export default CopyTokensModal; \ No newline at end of file diff --git a/web/src/components/table/tokens/modals/DeleteTokensModal.jsx b/web/src/components/table/tokens/modals/DeleteTokensModal.jsx new file mode 100644 index 00000000..4f339ec3 --- /dev/null +++ b/web/src/components/table/tokens/modals/DeleteTokensModal.jsx @@ -0,0 +1,39 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; + +const DeleteTokensModal = ({ visible, onCancel, onConfirm, selectedKeys, t }) => { + return ( + +
+ {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })} +
+
+ ); +}; + +export default DeleteTokensModal; \ No newline at end of file diff --git a/web/src/pages/Token/EditToken.js b/web/src/components/table/tokens/modals/EditTokenModal.jsx similarity index 94% rename from web/src/pages/Token/EditToken.js rename to web/src/components/table/tokens/modals/EditTokenModal.jsx index 4eb9bcf4..d26701f4 100644 --- a/web/src/pages/Token/EditToken.js +++ b/web/src/components/table/tokens/modals/EditTokenModal.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useEffect, useState, useContext, useRef } from 'react'; import { API, @@ -7,8 +26,9 @@ import { renderGroupOption, renderQuotaWithPrompt, getModelCategories, -} from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; + modelSelectFilter, +} from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, SideSheet, @@ -30,11 +50,11 @@ import { IconKey, } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; -import { StatusContext } from '../../context/Status'; +import { StatusContext } from '../../../../context/Status'; const { Text, Title } = Typography; -const EditToken = (props) => { +const EditTokenModal = (props) => { const { t } = useTranslation(); const [statusState, statusDispatch] = useContext(StatusContext); const [loading, setLoading] = useState(false); @@ -494,7 +514,8 @@ const EditToken = (props) => { multiple optionList={models} extraText={t('非必要,不建议启用模型限制')} - filter + filter={modelSelectFilter} + autoClearSearchValue={false} searchPosition='dropdown' showClear style={{ width: '100%' }} @@ -522,4 +543,4 @@ const EditToken = (props) => { ); }; -export default EditToken; +export default EditTokenModal; \ No newline at end of file diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx new file mode 100644 index 00000000..72db01e4 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx @@ -0,0 +1,112 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useRef } from 'react'; +import { Tag, Space, Skeleton } from '@douyinfe/semi-ui'; +import { renderQuota } from '../../../helpers'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; + +const LogsActions = ({ + stat, + loadingStat, + showStat, + compactMode, + setCompactMode, + t, +}) => { + const [showSkeleton, setShowSkeleton] = useState(loadingStat); + const needSkeleton = !showStat || showSkeleton; + const loadingStartRef = useRef(Date.now()); + + useEffect(() => { + if (loadingStat) { + 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); + } + } + }, [loadingStat]); + + // Skeleton placeholder layout (three tag-size blocks) + const placeholder = ( + + + + + + ); + + return ( +
+ + + + {t('消耗额度')}: {renderQuota(stat.quota)} + + + RPM: {stat.rpm} + + + TPM: {stat.tpm} + + + + + +
+ ); +}; + +export default LogsActions; \ No newline at end of file diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.js b/web/src/components/table/usage-logs/UsageLogsColumnDefs.js new file mode 100644 index 00000000..d4ff1713 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.js @@ -0,0 +1,572 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { + Avatar, + Space, + Tag, + Tooltip, + Popover, + Typography +} from '@douyinfe/semi-ui'; +import { + timestamp2string, + renderGroup, + renderQuota, + stringToColor, + getLogOther, + renderModelTag, + renderClaudeLogContent, + renderClaudeModelPriceSimple, + renderLogContent, + renderModelPriceSimple, + renderAudioModelPrice, + renderClaudeModelPrice, + renderModelPrice +} from '../../../helpers'; +import { IconHelpCircle } from '@douyinfe/semi-icons'; +import { Route } 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 1: + return ( + + {t('充值')} + + ); + case 2: + return ( + + {t('消费')} + + ); + case 3: + return ( + + {t('管理')} + + ); + case 4: + return ( + + {t('系统')} + + ); + case 5: + return ( + + {t('错误')} + + ); + default: + return ( + + {t('未知')} + + ); + } +} + +function renderIsStream(bool, t) { + if (bool) { + return ( + + {t('流')} + + ); + } else { + return ( + + {t('非流')} + + ); + } +} + +function renderUseTime(type, t) { + const time = parseInt(type); + if (time < 101) { + return ( + + {' '} + {time} s{' '} + + ); + } else if (time < 300) { + return ( + + {' '} + {time} s{' '} + + ); + } else { + return ( + + {' '} + {time} s{' '} + + ); + } +} + +function renderFirstUseTime(type, t) { + let time = parseFloat(type) / 1000.0; + time = time.toFixed(1); + if (time < 3) { + return ( + + {' '} + {time} s{' '} + + ); + } else if (time < 10) { + return ( + + {' '} + {time} s{' '} + + ); + } else { + return ( + + {' '} + {time} s{' '} + + ); + } +} + +function renderModelName(record, copyText, t) { + 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 ( + <> + + + +
+ + {t('请求并计费模型')}: + + {renderModelTag(record.model_name, { + onClick: (event) => { + copyText(event, record.model_name).then((r) => { }); + }, + })} +
+
+ + {t('实际模型')}: + + {renderModelTag(other.upstream_model_name, { + onClick: (event) => { + copyText(event, other.upstream_model_name).then( + (r) => { }, + ); + }, + })} +
+
+ + } + > + {renderModelTag(record.model_name, { + onClick: (event) => { + copyText(event, record.model_name).then((r) => { }); + }, + suffixIcon: ( + + ), + })} +
+
+ + ); + } +} + +export const getLogsColumns = ({ + t, + COLUMN_KEYS, + copyText, + showUserInfoFunc, + isAdminUser, +}) => { + return [ + { + key: COLUMN_KEYS.TIME, + title: t('时间'), + dataIndex: 'timestamp2string', + }, + { + key: COLUMN_KEYS.CHANNEL, + title: t('渠道'), + dataIndex: 'channel', + 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) ? ( + + + + + {text} + + + + {isMultiKey && ( + + {multiKeyIndex} + + )} + + ) : null; + }, + }, + { + key: COLUMN_KEYS.USERNAME, + title: t('用户'), + dataIndex: 'username', + render: (text, record, index) => { + return isAdminUser ? ( +
+ { + event.stopPropagation(); + showUserInfoFunc(record.user_id); + }} + > + {typeof text === 'string' && text.slice(0, 1)} + + {text} +
+ ) : ( + <> + ); + }, + }, + { + key: COLUMN_KEYS.TOKEN, + title: t('令牌'), + dataIndex: 'token_name', + render: (text, record, index) => { + return record.type === 0 || record.type === 2 || record.type === 5 ? ( +
+ { + copyText(event, text); + }} + > + {' '} + {t(text)}{' '} + +
+ ) : ( + <> + ); + }, + }, + { + 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, t)}; + }, + }, + { + 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, copyText, t)} + ) : ( + <> + ); + }, + }, + { + 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 ( + <> + + {renderUseTime(text, t)} + {renderFirstUseTime(other?.frt, t)} + {renderIsStream(record.is_stream, t)} + + + ); + } else { + return ( + <> + + {renderUseTime(text, t)} + {renderIsStream(record.is_stream, t)} + + + ); + } + }, + }, + { + key: COLUMN_KEYS.PROMPT, + title: t('提示'), + dataIndex: 'prompt_tokens', + render: (text, record, index) => { + return record.type === 0 || record.type === 2 || record.type === 5 ? ( + <>{ {text} } + ) : ( + <> + ); + }, + }, + { + 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) ? ( + <>{ {text} } + ) : ( + <> + ); + }, + }, + { + 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: ( +
+ {t('IP')} + + + +
+ ), + dataIndex: 'ip', + render: (text, record, index) => { + return (record.type === 2 || record.type === 5) && text ? ( + + + { + copyText(event, text); + }} + > + {text} + + + + ) : ( + <> + ); + }, + }, + { + key: COLUMN_KEYS.RETRY, + title: t('重试'), + dataIndex: 'retry', + 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 !== '' + ) { + let useChannel = other.admin_info.use_channel; + let useChannelStr = useChannel.join('->'); + content = t('渠道') + `:${useChannelStr}`; + } + } + } + return isAdminUser ?
{content}
: <>; + }, + }, + { + 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 ( + + {text} + + ); + } + 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 ( + + {content} + + ); + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/usage-logs/UsageLogsFilters.jsx b/web/src/components/table/usage-logs/UsageLogsFilters.jsx new file mode 100644 index 00000000..4ff33628 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogsFilters.jsx @@ -0,0 +1,188 @@ +/* +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 . + +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 LogsFilters = ({ + formInitValues, + setFormApi, + refresh, + setShowColumnSelector, + formApi, + setLogType, + loading, + isAdminUser, + t, +}) => { + return ( +
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete='off' + layout='vertical' + trigger='change' + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
+ + {/* 其他搜索字段 */} + } + placeholder={t('令牌名称')} + showClear + pure + size="small" + /> + + } + placeholder={t('模型名称')} + showClear + pure + size="small" + /> + + } + placeholder={t('分组')} + showClear + pure + size="small" + /> + + {isAdminUser && ( + <> + } + placeholder={t('渠道 ID')} + showClear + pure + size="small" + /> + } + placeholder={t('用户名称')} + showClear + pure + size="small" + /> + + )} +
+ + {/* 操作按钮区域 */} +
+ {/* 日志类型选择器 */} +
+ { + // 延迟执行搜索,让表单值先更新 + setTimeout(() => { + refresh(); + }, 0); + }} + size="small" + > + + {t('全部')} + + + {t('充值')} + + + {t('消费')} + + + {t('管理')} + + + {t('系统')} + + + {t('错误')} + + +
+ +
+ + + +
+
+
+ + ); +}; + +export default LogsFilters; \ No newline at end of file diff --git a/web/src/components/table/usage-logs/UsageLogsTable.jsx b/web/src/components/table/usage-logs/UsageLogsTable.jsx new file mode 100644 index 00000000..2739d3c4 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogsTable.jsx @@ -0,0 +1,128 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo } from 'react'; +import { Empty, Descriptions } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable.js'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { getLogsColumns } from './UsageLogsColumnDefs.js'; + +const LogsTable = (logsData) => { + const { + logs, + expandData, + loading, + activePage, + pageSize, + logCount, + compactMode, + visibleColumns, + handlePageChange, + handlePageSizeChange, + copyText, + showUserInfoFunc, + hasExpandableRows, + isAdminUser, + t, + COLUMN_KEYS, + } = logsData; + + // Get all columns + const allColumns = useMemo(() => { + return getLogsColumns({ + t, + COLUMN_KEYS, + copyText, + showUserInfoFunc, + isAdminUser, + }); + }, [ + t, + COLUMN_KEYS, + copyText, + showUserInfoFunc, + isAdminUser, + ]); + + // 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]); + + const expandRowRender = (record, index) => { + return ; + }; + + return ( + + 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={ + + } + darkModeImage={ + + } + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + pagination={{ + currentPage: activePage, + pageSize: pageSize, + total: logCount, + pageSizeOptions: [10, 20, 50, 100], + showSizeChanger: true, + onPageSizeChange: (size) => { + handlePageSizeChange(size); + }, + onPageChange: handlePageChange, + }} + hidePagination={true} + /> + ); +}; + +export default LogsTable; \ No newline at end of file diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx new file mode 100644 index 00000000..6f7aeafd --- /dev/null +++ b/web/src/components/table/usage-logs/index.jsx @@ -0,0 +1,62 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import CardPro from '../../common/ui/CardPro.js'; +import LogsTable from './UsageLogsTable.jsx'; +import LogsActions from './UsageLogsActions.jsx'; +import LogsFilters from './UsageLogsFilters.jsx'; +import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx'; +import UserInfoModal from './modals/UserInfoModal.jsx'; +import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData.js'; +import { useIsMobile } from '../../../hooks/common/useIsMobile.js'; +import { createCardProPagination } from '../../../helpers/utils'; + +const LogsPage = () => { + const logsData = useLogsData(); + const isMobile = useIsMobile(); + + return ( + <> + {/* Modals */} + + + + {/* Main Content */} + } + searchArea={} + paginationArea={createCardProPagination({ + currentPage: logsData.activePage, + pageSize: logsData.pageSize, + total: logsData.logCount, + onPageChange: logsData.handlePageChange, + onPageSizeChange: logsData.handlePageSizeChange, + isMobile: isMobile, + })} + t={logsData.t} + > + + + + ); +}; + +export default LogsPage; \ No newline at end of file diff --git a/web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx b/web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx new file mode 100644 index 00000000..262041fe --- /dev/null +++ b/web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx @@ -0,0 +1,110 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; +import { getLogsColumns } from '../UsageLogsColumnDefs.js'; + +const ColumnSelectorModal = ({ + showColumnSelector, + setShowColumnSelector, + visibleColumns, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + isAdminUser, + copyText, + showUserInfoFunc, + t, +}) => { + // Get all columns for display in selector + const allColumns = getLogsColumns({ + t, + COLUMN_KEYS, + copyText, + showUserInfoFunc, + isAdminUser, + }); + + return ( + setShowColumnSelector(false)} + footer={ +
+ + + +
+ } + > +
+ v === true)} + indeterminate={ + Object.values(visibleColumns).some((v) => v === true) && + !Object.values(visibleColumns).every((v) => v === true) + } + onChange={(e) => handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {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 ( +
+ + handleColumnVisibilityChange(column.key, e.target.checked) + } + > + {column.title} + +
+ ); + })} +
+
+ ); +}; + +export default ColumnSelectorModal; \ No newline at end of file diff --git a/web/src/components/table/usage-logs/modals/UserInfoModal.jsx b/web/src/components/table/usage-logs/modals/UserInfoModal.jsx new file mode 100644 index 00000000..294f55ef --- /dev/null +++ b/web/src/components/table/usage-logs/modals/UserInfoModal.jsx @@ -0,0 +1,160 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Badge } from '@douyinfe/semi-ui'; +import { renderQuota, renderNumber } from '../../../../helpers'; + +const UserInfoModal = ({ + showUserInfo, + setShowUserInfoModal, + userInfoData, + t, +}) => { + const infoItemStyle = { + marginBottom: '16px' + }; + + const labelStyle = { + display: 'flex', + alignItems: 'center', + marginBottom: '2px', + fontSize: '12px', + color: 'var(--semi-color-text-2)', + gap: '6px' + }; + + const renderLabel = (text, type = 'tertiary') => ( +
+ + {text} +
+ ); + + const valueStyle = { + fontSize: '14px', + fontWeight: '600', + color: 'var(--semi-color-text-0)' + }; + + const rowStyle = { + display: 'flex', + justifyContent: 'space-between', + marginBottom: '16px', + gap: '20px' + }; + + const colStyle = { + flex: 1, + minWidth: 0 + }; + + return ( + setShowUserInfoModal(false)} + footer={null} + centered + closable + maskClosable + width={600} + > + {userInfoData && ( +
+ {/* 基本信息 */} +
+
+ {renderLabel(t('用户名'), 'primary')} +
{userInfoData.username}
+
+ {userInfoData.display_name && ( +
+ {renderLabel(t('显示名称'), 'primary')} +
{userInfoData.display_name}
+
+ )} +
+ + {/* 余额信息 */} +
+
+ {renderLabel(t('余额'), 'success')} +
{renderQuota(userInfoData.quota)}
+
+
+ {renderLabel(t('已用额度'), 'warning')} +
{renderQuota(userInfoData.used_quota)}
+
+
+ + {/* 统计信息 */} +
+
+ {renderLabel(t('请求次数'), 'warning')} +
{renderNumber(userInfoData.request_count)}
+
+ {userInfoData.group && ( +
+ {renderLabel(t('用户组'), 'tertiary')} +
{userInfoData.group}
+
+ )} +
+ + {/* 邀请信息 */} + {(userInfoData.aff_code || userInfoData.aff_count !== undefined) && ( +
+ {userInfoData.aff_code && ( +
+ {renderLabel(t('邀请码'), 'tertiary')} +
{userInfoData.aff_code}
+
+ )} + {userInfoData.aff_count !== undefined && ( +
+ {renderLabel(t('邀请人数'), 'tertiary')} +
{renderNumber(userInfoData.aff_count)}
+
+ )} +
+ )} + + {/* 邀请获得额度 */} + {userInfoData.aff_quota !== undefined && userInfoData.aff_quota > 0 && ( +
+ {renderLabel(t('邀请获得额度'), 'success')} +
{renderQuota(userInfoData.aff_quota)}
+
+ )} + + {/* 备注 */} + {userInfoData.remark && ( +
+ {renderLabel(t('备注'), 'tertiary')} +
{userInfoData.remark}
+
+ )} +
+ )} +
+ ); +}; + +export default UserInfoModal; \ No newline at end of file diff --git a/web/src/components/table/users/UsersActions.jsx b/web/src/components/table/users/UsersActions.jsx new file mode 100644 index 00000000..bf505baf --- /dev/null +++ b/web/src/components/table/users/UsersActions.jsx @@ -0,0 +1,46 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Button } from '@douyinfe/semi-ui'; + +const UsersActions = ({ + setShowAddUser, + t +}) => { + + // Add new user + const handleAddUser = () => { + setShowAddUser(true); + }; + + return ( +
+ +
+ ); +}; + +export default UsersActions; \ No newline at end of file diff --git a/web/src/components/table/users/UsersColumnDefs.js b/web/src/components/table/users/UsersColumnDefs.js new file mode 100644 index 00000000..774554cb --- /dev/null +++ b/web/src/components/table/users/UsersColumnDefs.js @@ -0,0 +1,313 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { + Button, + Space, + Tag, + Tooltip, + Progress, + Switch, +} from '@douyinfe/semi-ui'; +import { renderGroup, renderNumber, renderQuota } from '../../../helpers'; + +/** + * Render user role + */ +const renderRole = (role, t) => { + switch (role) { + case 1: + return ( + + {t('普通用户')} + + ); + case 10: + return ( + + {t('管理员')} + + ); + case 100: + return ( + + {t('超级管理员')} + + ); + default: + return ( + + {t('未知身份')} + + ); + } +}; + +/** + * Render username with remark + */ +const renderUsername = (text, record) => { + const remark = record.remark; + if (!remark) { + return {text}; + } + const maxLen = 10; + const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark; + return ( + + {text} + + +
+
+ {displayRemark} +
+ + + + ); +}; + +/** + * Render user statistics + */ +const renderStatistics = (text, record, showEnableDisableModal, t) => { + const enabled = record.status === 1; + const isDeleted = record.DeletedAt !== null; + + // Determine tag text & color like original status column + let tagColor = 'grey'; + let tagText = t('未知状态'); + if (isDeleted) { + tagColor = 'red'; + tagText = t('已注销'); + } else if (record.status === 1) { + tagColor = 'green'; + tagText = t('已激活'); + } else if (record.status === 2) { + tagColor = 'red'; + tagText = t('已封禁'); + } + + const handleToggle = (checked) => { + if (checked) { + showEnableDisableModal(record, 'enable'); + } else { + showEnableDisableModal(record, 'disable'); + } + }; + + const used = parseInt(record.used_quota) || 0; + const remain = parseInt(record.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 = ( +
+ {`${renderQuota(remain)} / ${renderQuota(total)}`} + `${percent.toFixed(0)}%`} + style={{ width: '100%', marginTop: '1px', marginBottom: 0 }} + /> +
+ ); + + const content = ( + + } + suffixIcon={quotaSuffix} + > + {tagText} + + ); + + const tooltipContent = ( +
+
{t('已用额度')}: {renderQuota(used)}
+
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+
{t('总额度')}: {renderQuota(total)}
+
{t('调用次数')}: {renderNumber(record.request_count)}
+
+ ); + + return ( + + {content} + + ); +}; + +/** + * Render invite information + */ +const renderInviteInfo = (text, record, t) => { + return ( +
+ + + {t('邀请')}: {renderNumber(record.aff_count)} + + + {t('收益')}: {renderQuota(record.aff_history_quota)} + + + {record.inviter_id === 0 ? t('无邀请人') : `${t('邀请人')}: ${record.inviter_id}`} + + +
+ ); +}; + +/** + * Render operations column + */ +const renderOperations = (text, record, { + setEditingUser, + setShowEditUser, + showPromoteModal, + showDemoteModal, + showDeleteModal, + t +}) => { + if (record.DeletedAt !== null) { + return <>; + } + + return ( + + + + + + + ); +}; + +/** + * Get users table column definitions + */ +export const getUsersColumns = ({ + t, + setEditingUser, + setShowEditUser, + showPromoteModal, + showDemoteModal, + showEnableDisableModal, + showDeleteModal +}) => { + return [ + { + title: 'ID', + dataIndex: 'id', + }, + { + title: t('用户名'), + dataIndex: 'username', + render: (text, record) => renderUsername(text, record), + }, + { + title: t('分组'), + dataIndex: 'group', + render: (text, record, index) => { + return
{renderGroup(text)}
; + }, + }, + { + title: t('角色'), + dataIndex: 'role', + render: (text, record, index) => { + return
{renderRole(text, t)}
; + }, + }, + { + title: t('状态'), + dataIndex: 'info', + render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t), + }, + { + title: t('邀请信息'), + dataIndex: 'invite', + render: (text, record, index) => renderInviteInfo(text, record, t), + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + width: 200, + render: (text, record, index) => renderOperations(text, record, { + setEditingUser, + setShowEditUser, + showPromoteModal, + showDemoteModal, + showEnableDisableModal, + showDeleteModal, + t + }), + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/users/UsersDescription.jsx b/web/src/components/table/users/UsersDescription.jsx new file mode 100644 index 00000000..2ab1c696 --- /dev/null +++ b/web/src/components/table/users/UsersDescription.jsx @@ -0,0 +1,43 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Typography } from '@douyinfe/semi-ui'; +import { IconUserAdd } from '@douyinfe/semi-icons'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; + +const { Text } = Typography; + +const UsersDescription = ({ compactMode, setCompactMode, t }) => { + return ( +
+
+ + {t('用户管理')} +
+ +
+ ); +}; + +export default UsersDescription; \ No newline at end of file diff --git a/web/src/components/table/users/UsersFilters.jsx b/web/src/components/table/users/UsersFilters.jsx new file mode 100644 index 00000000..a2fcaa35 --- /dev/null +++ b/web/src/components/table/users/UsersFilters.jsx @@ -0,0 +1,116 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useRef } from 'react'; +import { Form, Button } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; + +const UsersFilters = ({ + formInitValues, + setFormApi, + searchUsers, + loadUsers, + activePage, + pageSize, + groupOptions, + loading, + searching, + t +}) => { + + const formApiRef = useRef(null); + + const handleReset = () => { + if (!formApiRef.current) return; + formApiRef.current.reset(); + setTimeout(() => { + loadUsers(1, pageSize); + }, 100); + }; + + return ( +
{ + setFormApi(api); + formApiRef.current = api; + }} + onSubmit={() => { + searchUsers(1, pageSize); + }} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')} + showClear + pure + size="small" + /> +
+
+ { + // Group change triggers automatic search + setTimeout(() => { + searchUsers(1, pageSize); + }, 100); + }} + className="w-full" + showClear + pure + size="small" + /> +
+
+ + +
+
+ + ); +}; + +export default UsersFilters; \ No newline at end of file diff --git a/web/src/components/table/users/UsersTable.jsx b/web/src/components/table/users/UsersTable.jsx new file mode 100644 index 00000000..cd93bf95 --- /dev/null +++ b/web/src/components/table/users/UsersTable.jsx @@ -0,0 +1,195 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useMemo, useState } from 'react'; +import { Empty } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable.js'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import { getUsersColumns } from './UsersColumnDefs'; +import PromoteUserModal from './modals/PromoteUserModal'; +import DemoteUserModal from './modals/DemoteUserModal'; +import EnableDisableUserModal from './modals/EnableDisableUserModal'; +import DeleteUserModal from './modals/DeleteUserModal'; + +const UsersTable = (usersData) => { + const { + users, + loading, + activePage, + pageSize, + userCount, + compactMode, + handlePageChange, + handlePageSizeChange, + handleRow, + setEditingUser, + setShowEditUser, + manageUser, + refresh, + t, + } = usersData; + + // Modal states + const [showPromoteModal, setShowPromoteModal] = useState(false); + const [showDemoteModal, setShowDemoteModal] = useState(false); + const [showEnableDisableModal, setShowEnableDisableModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [modalUser, setModalUser] = useState(null); + const [enableDisableAction, setEnableDisableAction] = useState(''); + + // Modal handlers + const showPromoteUserModal = (user) => { + setModalUser(user); + setShowPromoteModal(true); + }; + + const showDemoteUserModal = (user) => { + setModalUser(user); + setShowDemoteModal(true); + }; + + const showEnableDisableUserModal = (user, action) => { + setModalUser(user); + setEnableDisableAction(action); + setShowEnableDisableModal(true); + }; + + const showDeleteUserModal = (user) => { + setModalUser(user); + setShowDeleteModal(true); + }; + + // Modal confirm handlers + const handlePromoteConfirm = () => { + manageUser(modalUser.id, 'promote', modalUser); + setShowPromoteModal(false); + }; + + const handleDemoteConfirm = () => { + manageUser(modalUser.id, 'demote', modalUser); + setShowDemoteModal(false); + }; + + const handleEnableDisableConfirm = () => { + manageUser(modalUser.id, enableDisableAction, modalUser); + setShowEnableDisableModal(false); + }; + + // Get all columns + const columns = useMemo(() => { + return getUsersColumns({ + t, + setEditingUser, + setShowEditUser, + showPromoteModal: showPromoteUserModal, + showDemoteModal: showDemoteUserModal, + showEnableDisableModal: showEnableDisableUserModal, + showDeleteModal: showDeleteUserModal + }); + }, [ + t, + setEditingUser, + setShowEditUser, + ]); + + // Handle compact mode by removing fixed positioning + const tableColumns = useMemo(() => { + return compactMode ? columns.map(col => { + if (col.dataIndex === 'operate') { + const { fixed, ...rest } = col; + return rest; + } + return col; + }) : columns; + }, [compactMode, columns]); + + return ( + <> + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className="overflow-hidden" + size="middle" + /> + + {/* Modal components */} + setShowPromoteModal(false)} + onConfirm={handlePromoteConfirm} + user={modalUser} + t={t} + /> + + setShowDemoteModal(false)} + onConfirm={handleDemoteConfirm} + user={modalUser} + t={t} + /> + + setShowEnableDisableModal(false)} + onConfirm={handleEnableDisableConfirm} + user={modalUser} + action={enableDisableAction} + t={t} + /> + + setShowDeleteModal(false)} + user={modalUser} + users={users} + activePage={activePage} + refresh={refresh} + manageUser={manageUser} + t={t} + /> + + ); +}; + +export default UsersTable; \ No newline at end of file diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx new file mode 100644 index 00000000..adc9a570 --- /dev/null +++ b/web/src/components/table/users/index.jsx @@ -0,0 +1,126 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import CardPro from '../../common/ui/CardPro'; +import UsersTable from './UsersTable.jsx'; +import UsersActions from './UsersActions.jsx'; +import UsersFilters from './UsersFilters.jsx'; +import UsersDescription from './UsersDescription.jsx'; +import AddUserModal from './modals/AddUserModal.jsx'; +import EditUserModal from './modals/EditUserModal.jsx'; +import { useUsersData } from '../../../hooks/users/useUsersData'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { createCardProPagination } from '../../../helpers/utils'; + +const UsersPage = () => { + const usersData = useUsersData(); + const isMobile = useIsMobile(); + + const { + // Modal state + showAddUser, + showEditUser, + editingUser, + setShowAddUser, + closeAddUser, + closeEditUser, + refresh, + + // Form state + formInitValues, + setFormApi, + searchUsers, + loadUsers, + activePage, + pageSize, + groupOptions, + loading, + searching, + + // Description state + compactMode, + setCompactMode, + + // Translation + t, + } = usersData; + + return ( + <> + + + + + + } + actionsArea={ +
+ + + +
+ } + paginationArea={createCardProPagination({ + currentPage: usersData.activePage, + pageSize: usersData.pageSize, + total: usersData.userCount, + onPageChange: usersData.handlePageChange, + onPageSizeChange: usersData.handlePageSizeChange, + isMobile: isMobile, + })} + t={t} + > + +
+ + ); +}; + +export default UsersPage; \ No newline at end of file diff --git a/web/src/pages/User/AddUser.js b/web/src/components/table/users/modals/AddUserModal.jsx similarity index 83% rename from web/src/pages/User/AddUser.js rename to web/src/components/table/users/modals/AddUserModal.jsx index fa4c97e6..caf33a64 100644 --- a/web/src/pages/User/AddUser.js +++ b/web/src/components/table/users/modals/AddUserModal.jsx @@ -1,6 +1,25 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useState, useRef } from 'react'; -import { API, showError, showSuccess } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, SideSheet, @@ -23,7 +42,7 @@ import { useTranslation } from 'react-i18next'; const { Text, Title } = Typography; -const AddUser = (props) => { +const AddUserModal = (props) => { const { t } = useTranslation(); const formApiRef = useRef(null); const [loading, setLoading] = useState(false); @@ -164,4 +183,4 @@ const AddUser = (props) => { ); }; -export default AddUser; +export default AddUserModal; \ No newline at end of file diff --git a/web/src/components/table/users/modals/DeleteUserModal.jsx b/web/src/components/table/users/modals/DeleteUserModal.jsx new file mode 100644 index 00000000..aa4e0539 --- /dev/null +++ b/web/src/components/table/users/modals/DeleteUserModal.jsx @@ -0,0 +1,58 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; + +const DeleteUserModal = ({ + visible, + onCancel, + onConfirm, + user, + users, + activePage, + refresh, + manageUser, + t +}) => { + const handleConfirm = async () => { + await manageUser(user.id, 'delete', user); + await refresh(); + setTimeout(() => { + if (users.length === 0 && activePage > 1) { + refresh(activePage - 1); + } + }, 100); + onCancel(); // Close modal after success + }; + + return ( + + {t('相当于删除用户,此修改将不可逆')} + + ); +}; + +export default DeleteUserModal; \ No newline at end of file diff --git a/web/src/components/table/users/modals/DemoteUserModal.jsx b/web/src/components/table/users/modals/DemoteUserModal.jsx new file mode 100644 index 00000000..e9bebc50 --- /dev/null +++ b/web/src/components/table/users/modals/DemoteUserModal.jsx @@ -0,0 +1,37 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; + +const DemoteUserModal = ({ visible, onCancel, onConfirm, user, t }) => { + return ( + + {t('此操作将降低用户的权限级别')} + + ); +}; + +export default DemoteUserModal; \ No newline at end of file diff --git a/web/src/pages/User/EditUser.js b/web/src/components/table/users/modals/EditUserModal.jsx similarity index 92% rename from web/src/pages/User/EditUser.js rename to web/src/components/table/users/modals/EditUserModal.jsx index bfccf37b..a075f14b 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/components/table/users/modals/EditUserModal.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React, { useEffect, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -6,8 +25,8 @@ import { showSuccess, renderQuota, renderQuotaWithPrompt, -} from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +} from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, Modal, @@ -35,7 +54,7 @@ import { const { Text, Title } = Typography; -const EditUser = (props) => { +const EditUserModal = (props) => { const { t } = useTranslation(); const userId = props.editingUser.id; const [loading, setLoading] = useState(true); @@ -348,4 +367,4 @@ const EditUser = (props) => { ); }; -export default EditUser; +export default EditUserModal; \ No newline at end of file diff --git a/web/src/components/table/users/modals/EnableDisableUserModal.jsx b/web/src/components/table/users/modals/EnableDisableUserModal.jsx new file mode 100644 index 00000000..c1c383ec --- /dev/null +++ b/web/src/components/table/users/modals/EnableDisableUserModal.jsx @@ -0,0 +1,46 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; + +const EnableDisableUserModal = ({ + visible, + onCancel, + onConfirm, + user, + action, + t +}) => { + const isDisable = action === 'disable'; + + return ( + + {isDisable ? t('此操作将禁用用户账户') : t('此操作将启用用户账户')} + + ); +}; + +export default EnableDisableUserModal; \ No newline at end of file diff --git a/web/src/components/table/users/modals/PromoteUserModal.jsx b/web/src/components/table/users/modals/PromoteUserModal.jsx new file mode 100644 index 00000000..da2a1c37 --- /dev/null +++ b/web/src/components/table/users/modals/PromoteUserModal.jsx @@ -0,0 +1,37 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; + +const PromoteUserModal = ({ visible, onCancel, onConfirm, user, t }) => { + return ( + + {t('此操作将提升用户的权限级别')} + + ); +}; + +export default PromoteUserModal; \ No newline at end of file diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index b145ea11..c2468ec7 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const CHANNEL_OPTIONS = [ { value: 1, color: 'green', label: 'OpenAI' }, { diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js index 6556ffef..de0d1d6f 100644 --- a/web/src/constants/common.constant.js +++ b/web/src/constants/common.constant.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend! export const DEFAULT_ENDPOINT = '/api/ratio_config'; diff --git a/web/src/constants/dashboard.constants.js b/web/src/constants/dashboard.constants.js new file mode 100644 index 00000000..332687e5 --- /dev/null +++ b/web/src/constants/dashboard.constants.js @@ -0,0 +1,149 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +// ========== UI 配置常量 ========== +export const CHART_CONFIG = { mode: 'desktop-browser' }; + +export const CARD_PROPS = { + shadows: 'always', + bordered: false, + headerLine: true +}; + +export const FORM_FIELD_PROPS = { + className: "w-full mb-2 !rounded-lg", + size: 'large' +}; + +export const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full"; +export const FLEX_CENTER_GAP2 = "flex items-center gap-2"; + +export const ILLUSTRATION_SIZE = { width: 96, height: 96 }; + +// ========== 时间相关常量 ========== +export const TIME_OPTIONS = [ + { label: '小时', value: 'hour' }, + { label: '天', value: 'day' }, + { label: '周', value: 'week' }, +]; + +export const DEFAULT_TIME_INTERVALS = { + hour: { seconds: 3600, minutes: 60 }, + day: { seconds: 86400, minutes: 1440 }, + week: { seconds: 604800, minutes: 10080 } +}; + +// ========== 默认时间设置 ========== +export const DEFAULT_TIME_RANGE = { + HOUR: 'hour', + DAY: 'day', + WEEK: 'week' +}; + +// ========== 图表默认配置 ========== +export const DEFAULT_CHART_SPECS = { + PIE: { + type: 'pie', + outerRadius: 0.8, + innerRadius: 0.5, + padAngle: 0.6, + valueField: 'value', + categoryField: 'type', + pie: { + style: { + cornerRadius: 10, + }, + state: { + hover: { + outerRadius: 0.85, + stroke: '#000', + lineWidth: 1, + }, + selected: { + outerRadius: 0.85, + stroke: '#000', + lineWidth: 1, + }, + }, + }, + legends: { + visible: true, + orient: 'left', + }, + label: { + visible: true, + }, + }, + + BAR: { + type: 'bar', + stack: true, + legends: { + visible: true, + selectMode: 'single', + }, + bar: { + state: { + hover: { + stroke: '#000', + lineWidth: 1, + }, + }, + }, + }, + + LINE: { + type: 'line', + legends: { + visible: true, + selectMode: 'single', + }, + } +}; + +// ========== 公告图例数据 ========== +export const ANNOUNCEMENT_LEGEND_DATA = [ + { color: 'grey', label: '默认', type: 'default' }, + { color: 'blue', label: '进行中', type: 'ongoing' }, + { color: 'green', label: '成功', type: 'success' }, + { color: 'orange', label: '警告', type: 'warning' }, + { color: 'red', label: '异常', type: 'error' } +]; + +// ========== Uptime 状态映射 ========== +export const UPTIME_STATUS_MAP = { + 1: { color: '#10b981', label: '正常', text: '可用率' }, // UP + 0: { color: '#ef4444', label: '异常', text: '有异常' }, // DOWN + 2: { color: '#f59e0b', label: '高延迟', text: '高延迟' }, // PENDING + 3: { color: '#3b82f6', label: '维护中', text: '维护中' } // MAINTENANCE +}; + +// ========== 本地存储键名 ========== +export const STORAGE_KEYS = { + DATA_EXPORT_DEFAULT_TIME: 'data_export_default_time', + MJ_NOTIFY_ENABLED: 'mj_notify_enabled' +}; + +// ========== 默认值 ========== +export const DEFAULTS = { + PAGE_SIZE: 20, + CHART_HEIGHT: 96, + MODEL_TABLE_PAGE_SIZE: 10, + MAX_TREND_POINTS: 7 +}; \ No newline at end of file diff --git a/web/src/constants/index.js b/web/src/constants/index.js index f92e2b19..623885d4 100644 --- a/web/src/constants/index.js +++ b/web/src/constants/index.js @@ -1,5 +1,26 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export * from './channel.constants'; export * from './user.constants'; export * from './toast.constants'; export * from './common.constant'; +export * from './dashboard.constants'; export * from './playground.constants'; +export * from './redemption.constants'; diff --git a/web/src/constants/playground.constants.js b/web/src/constants/playground.constants.js index c5eb47fa..ed6d37c8 100644 --- a/web/src/constants/playground.constants.js +++ b/web/src/constants/playground.constants.js @@ -1,4 +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const MESSAGE_STATUS = { LOADING: 'loading', INCOMPLETE: 'incomplete', diff --git a/web/src/constants/redemption.constants.js b/web/src/constants/redemption.constants.js new file mode 100644 index 00000000..3149df0c --- /dev/null +++ b/web/src/constants/redemption.constants.js @@ -0,0 +1,47 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +export const REDEMPTION_STATUS = { + UNUSED: 1, // Unused + DISABLED: 2, // Disabled + USED: 3, // Used +}; + +// Redemption code status display mapping +export const REDEMPTION_STATUS_MAP = { + [REDEMPTION_STATUS.UNUSED]: { + color: 'green', + text: '未使用' + }, + [REDEMPTION_STATUS.DISABLED]: { + color: 'red', + text: '已禁用' + }, + [REDEMPTION_STATUS.USED]: { + color: 'grey', + text: '已使用' + } +}; + +// Action type constants +export const REDEMPTION_ACTIONS = { + DELETE: 'delete', + ENABLE: 'enable', + DISABLE: 'disable' +}; \ No newline at end of file diff --git a/web/src/constants/toast.constants.js b/web/src/constants/toast.constants.js index f8853df6..901caa49 100644 --- a/web/src/constants/toast.constants.js +++ b/web/src/constants/toast.constants.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const toastConstants = { SUCCESS_TIMEOUT: 1500, INFO_TIMEOUT: 3000, diff --git a/web/src/constants/user.constants.js b/web/src/constants/user.constants.js index cde70df7..05d3e1fa 100644 --- a/web/src/constants/user.constants.js +++ b/web/src/constants/user.constants.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const userConstants = { REGISTER_REQUEST: 'USERS_REGISTER_REQUEST', REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS', diff --git a/web/src/context/Status/index.js b/web/src/context/Status/index.js index 5a5319ed..baae8a17 100644 --- a/web/src/context/Status/index.js +++ b/web/src/context/Status/index.js @@ -1,4 +1,21 @@ -// contexts/User/index.jsx +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ import React from 'react'; import { initialState, reducer } from './reducer'; diff --git a/web/src/context/Status/reducer.js b/web/src/context/Status/reducer.js index ec9ac6ae..457b5f1d 100644 --- a/web/src/context/Status/reducer.js +++ b/web/src/context/Status/reducer.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const reducer = (state, action) => { switch (action.type) { case 'set': diff --git a/web/src/context/Theme/index.js b/web/src/context/Theme/index.js index 76549886..04e51042 100644 --- a/web/src/context/Theme/index.js +++ b/web/src/context/Theme/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import { createContext, useCallback, useContext, useState } from 'react'; const ThemeContext = createContext(null); diff --git a/web/src/context/User/index.js b/web/src/context/User/index.js index 033b3613..a57aab1b 100644 --- a/web/src/context/User/index.js +++ b/web/src/context/User/index.js @@ -1,4 +1,21 @@ -// contexts/User/index.jsx +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ import React from 'react'; import { reducer, initialState } from './reducer'; diff --git a/web/src/context/User/reducer.js b/web/src/context/User/reducer.js index d44cffcc..80275e1f 100644 --- a/web/src/context/User/reducer.js +++ b/web/src/context/User/reducer.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const reducer = (state, action) => { switch (action.type) { case 'login': diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index cad1dd13..55228fd8 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import { getUserIdFromLocalStorage, showError, formatMessageForAPI, isValidMessage } from './utils'; import axios from 'axios'; import { MESSAGE_ROLES } from '../constants/playground.constants'; diff --git a/web/src/helpers/auth.js b/web/src/helpers/auth.js index cb694ccf..d182ccd6 100644 --- a/web/src/helpers/auth.js +++ b/web/src/helpers/auth.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import React from 'react'; import { Navigate } from 'react-router-dom'; import { history } from './history'; diff --git a/web/src/helpers/boolean.js b/web/src/helpers/boolean.js index 692196e0..992e163b 100644 --- a/web/src/helpers/boolean.js +++ b/web/src/helpers/boolean.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export const toBoolean = (value) => { // 兼容字符串、数字以及布尔原生类型 if (typeof value === 'boolean') return value; diff --git a/web/src/helpers/dashboard.js b/web/src/helpers/dashboard.js new file mode 100644 index 00000000..374f1ea6 --- /dev/null +++ b/web/src/helpers/dashboard.js @@ -0,0 +1,314 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Progress, Divider, Empty } from '@douyinfe/semi-ui'; +import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations'; +import { timestamp2string, timestamp2string1, copy, showSuccess } from './utils'; +import { STORAGE_KEYS, DEFAULT_TIME_INTERVALS, DEFAULTS, ILLUSTRATION_SIZE } from '../constants/dashboard.constants'; + +// ========== 时间相关工具函数 ========== +export const getDefaultTime = () => { + return localStorage.getItem(STORAGE_KEYS.DATA_EXPORT_DEFAULT_TIME) || 'hour'; +}; + +export const getTimeInterval = (timeType, isSeconds = false) => { + const intervals = DEFAULT_TIME_INTERVALS[timeType] || DEFAULT_TIME_INTERVALS.hour; + return isSeconds ? intervals.seconds : intervals.minutes; +}; + +export const getInitialTimestamp = () => { + const defaultTime = getDefaultTime(); + const now = new Date().getTime() / 1000; + + switch (defaultTime) { + case 'hour': + return timestamp2string(now - 86400); + case 'week': + return timestamp2string(now - 86400 * 30); + default: + return timestamp2string(now - 86400 * 7); + } +}; + +// ========== 数据处理工具函数 ========== +export const updateMapValue = (map, key, value) => { + if (!map.has(key)) { + map.set(key, 0); + } + map.set(key, map.get(key) + value); +}; + +export const initializeMaps = (key, ...maps) => { + maps.forEach(map => { + if (!map.has(key)) { + map.set(key, 0); + } + }); +}; + +// ========== 图表相关工具函数 ========== +export const updateChartSpec = (setterFunc, newData, subtitle, newColors, dataId) => { + setterFunc(prev => ({ + ...prev, + data: [{ id: dataId, values: newData }], + title: { + ...prev.title, + subtext: subtitle, + }, + color: { + specified: newColors, + }, + })); +}; + +export const getTrendSpec = (data, color) => ({ + type: 'line', + data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }], + xField: 'x', + yField: 'y', + height: 40, + width: 100, + axes: [ + { + orient: 'bottom', + visible: false + }, + { + orient: 'left', + visible: false + } + ], + padding: 0, + autoFit: false, + legends: { visible: false }, + tooltip: { visible: false }, + crosshair: { visible: false }, + line: { + style: { + stroke: color, + lineWidth: 2 + } + }, + point: { + visible: false + }, + background: { + fill: 'transparent' + } +}); + +// ========== UI 工具函数 ========== +export const createSectionTitle = (Icon, text) => ( +
+ + {text} +
+); + +export const createFormField = (Component, props, FORM_FIELD_PROPS) => ( + +); + +// ========== 操作处理函数 ========== +export const handleCopyUrl = async (url, t) => { + if (await copy(url)) { + showSuccess(t('复制成功')); + } +}; + +export const handleSpeedTest = (apiUrl) => { + const encodedUrl = encodeURIComponent(apiUrl); + const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`; + window.open(speedTestUrl, '_blank', 'noopener,noreferrer'); +}; + +// ========== 状态映射函数 ========== +export const getUptimeStatusColor = (status, uptimeStatusMap) => + uptimeStatusMap[status]?.color || '#8b9aa7'; + +export const getUptimeStatusText = (status, uptimeStatusMap, t) => + uptimeStatusMap[status]?.text || t('未知'); + +// ========== 监控列表渲染函数 ========== +export const renderMonitorList = (monitors, getUptimeStatusColor, getUptimeStatusText, t) => { + if (!monitors || monitors.length === 0) { + return ( +
+ } + darkModeImage={} + title={t('暂无监控数据')} + /> +
+ ); + } + + const grouped = {}; + monitors.forEach((m) => { + const g = m.group || ''; + if (!grouped[g]) grouped[g] = []; + grouped[g].push(m); + }); + + const renderItem = (monitor, idx) => ( +
+
+
+
+ {monitor.name} +
+ {((monitor.uptime || 0) * 100).toFixed(2)}% +
+
+ {getUptimeStatusText(monitor.status)} +
+ +
+
+
+ ); + + return Object.entries(grouped).map(([gname, list]) => ( +
+ {gname && ( + <> +
+ {gname} +
+ + + )} + {list.map(renderItem)} +
+ )); +}; + +// ========== 数据处理函数 ========== +export const processRawData = (data, dataExportDefaultTime, initializeMaps, updateMapValue) => { + const result = { + totalQuota: 0, + totalTimes: 0, + totalTokens: 0, + uniqueModels: new Set(), + timePoints: [], + timeQuotaMap: new Map(), + timeTokensMap: new Map(), + timeCountMap: new Map() + }; + + data.forEach((item) => { + result.uniqueModels.add(item.model_name); + result.totalTokens += item.token_used; + result.totalQuota += item.quota; + result.totalTimes += item.count; + + const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime); + if (!result.timePoints.includes(timeKey)) { + result.timePoints.push(timeKey); + } + + initializeMaps(timeKey, result.timeQuotaMap, result.timeTokensMap, result.timeCountMap); + updateMapValue(result.timeQuotaMap, timeKey, item.quota); + updateMapValue(result.timeTokensMap, timeKey, item.token_used); + updateMapValue(result.timeCountMap, timeKey, item.count); + }); + + result.timePoints.sort(); + return result; +}; + +export const calculateTrendData = (timePoints, timeQuotaMap, timeTokensMap, timeCountMap, dataExportDefaultTime) => { + const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0); + const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0); + const countTrend = timePoints.map(time => timeCountMap.get(time) || 0); + + const rpmTrend = []; + const tpmTrend = []; + + if (timePoints.length >= 2) { + const interval = getTimeInterval(dataExportDefaultTime); + + for (let i = 0; i < timePoints.length; i++) { + rpmTrend.push(timeCountMap.get(timePoints[i]) / interval); + tpmTrend.push(timeTokensMap.get(timePoints[i]) / interval); + } + } + + return { + balance: [], + usedQuota: [], + requestCount: [], + times: countTrend, + consumeQuota: quotaTrend, + tokens: tokensTrend, + rpm: rpmTrend, + tpm: tpmTrend + }; +}; + +export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => { + const aggregatedData = new Map(); + + data.forEach((item) => { + const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime); + const modelKey = item.model_name; + const key = `${timeKey}-${modelKey}`; + + if (!aggregatedData.has(key)) { + aggregatedData.set(key, { + time: timeKey, + model: modelKey, + quota: 0, + count: 0, + }); + } + + const existing = aggregatedData.get(key); + existing.quota += item.quota; + existing.count += item.count; + }); + + return aggregatedData; +}; + +export const generateChartTimePoints = (aggregatedData, data, dataExportDefaultTime) => { + let chartTimePoints = Array.from( + new Set([...aggregatedData.values()].map((d) => d.time)), + ); + + if (chartTimePoints.length < DEFAULTS.MAX_TREND_POINTS) { + const lastTime = Math.max(...data.map((item) => item.created_at)); + const interval = getTimeInterval(dataExportDefaultTime, true); + + chartTimePoints = Array.from({ length: DEFAULTS.MAX_TREND_POINTS }, (_, i) => + timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime), + ); + } + + return chartTimePoints; +}; \ No newline at end of file diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js index afc29384..62353327 100644 --- a/web/src/helpers/data.js +++ b/web/src/helpers/data.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export function setStatusData(data) { localStorage.setItem('status', JSON.stringify(data)); localStorage.setItem('system_name', data.system_name); diff --git a/web/src/helpers/history.js b/web/src/helpers/history.js index f529e5d6..f6f4d9a8 100644 --- a/web/src/helpers/history.js +++ b/web/src/helpers/history.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import { createBrowserHistory } from 'history'; export const history = createBrowserHistory(); diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js index 507a3df1..ecdeb20f 100644 --- a/web/src/helpers/index.js +++ b/web/src/helpers/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export * from './history'; export * from './auth'; export * from './utils'; @@ -7,3 +26,4 @@ export * from './log'; export * from './data'; export * from './token'; export * from './boolean'; +export * from './dashboard'; diff --git a/web/src/helpers/log.js b/web/src/helpers/log.js index ffbe0d74..648afe2a 100644 --- a/web/src/helpers/log.js +++ b/web/src/helpers/log.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + export function getLogOther(otherStr) { if (otherStr === undefined || otherStr === '') { otherStr = '{}'; diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 34ba78d7..bd0a8131 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -1,7 +1,26 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import i18next from 'i18next'; import { Modal, Tag, Typography } from '@douyinfe/semi-ui'; import { copy, showSuccess } from './utils'; -import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js'; +import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js'; import { visit } from 'unist-util-visit'; import { OpenAI, diff --git a/web/src/helpers/token.js b/web/src/helpers/token.js index 2c6e9f86..f4d4aeec 100644 --- a/web/src/helpers/token.js +++ b/web/src/helpers/token.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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + import { API } from './api'; /** diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 6c4f1275..b9b2d550 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -1,10 +1,29 @@ -import { Toast } from '@douyinfe/semi-ui'; +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import { Toast, Pagination } from '@douyinfe/semi-ui'; import { toastConstants } from '../constants'; import React from 'react'; import { toast } from 'react-toastify'; import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants'; import { TABLE_COMPACT_MODES_KEY } from '../constants'; -import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js'; +import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js'; const HTMLToastContent = ({ htmlContent }) => { return
; @@ -538,3 +557,44 @@ export function setTableCompactMode(compact, tableKey = 'global') { modes[tableKey] = compact; writeTableCompactModes(modes); } + +// ------------------------------- +// Select 组件统一过滤逻辑 +// 解决 label 为 ReactNode(带图标等)时无法用内置 filter 搜索的问题。 +// 使用方式: