From 9dc153eda12fe4b01c096fb056d44aa7bcd61aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:13:28 +0800 Subject: [PATCH 001/582] feat: `/api/token/usage` --- controller/token.go | 54 +++++++++++++++++++++++++++++++++++++++++++- router/api-router.go | 2 +- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/controller/token.go b/controller/token.go index a8803279..0afb1391 100644 --- a/controller/token.go +++ b/controller/token.go @@ -1,11 +1,13 @@ package controller import ( - "github.com/gin-gonic/gin" "net/http" "one-api/common" "one-api/model" "strconv" + "strings" + + "github.com/gin-gonic/gin" ) func GetAllTokens(c *gin.Context) { @@ -106,6 +108,56 @@ func GetTokenStatus(c *gin.Context) { }) } +func GetTokenUsage(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "No Authorization header", + }) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Invalid Bearer token", + }) + return + } + tokenKey := parts[1] + + token, err := model.GetTokenByKey(tokenKey, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + expiredAt := token.ExpiredTime + if expiredAt == -1 { + expiredAt = 0 + } + + c.JSON(http.StatusOK, gin.H{ + "code": true, + "message": "ok", + "data": gin.H{ + "object": "token_usage", + "id": token.Id, + "name": token.Name, + "total_granted": token.RemainQuota + token.UsedQuota, + "total_used": token.UsedQuota, + "total_available": token.RemainQuota, + "unlimited_quota": token.UnlimitedQuota, + "expires_at": expiredAt, + }, + }) +} + func AddToken(c *gin.Context) { token := model.Token{} err := c.ShouldBindJSON(&token) diff --git a/router/api-router.go b/router/api-router.go index 1720ff57..7bbc654a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -111,6 +111,7 @@ func SetApiRouter(router *gin.Engine) { { tokenRoute.GET("/", controller.GetAllTokens) tokenRoute.GET("/search", controller.SearchTokens) + tokenRoute.GET("/usage", controller.GetTokenUsage) tokenRoute.GET("/:id", controller.GetToken) tokenRoute.POST("/", controller.AddToken) tokenRoute.PUT("/", controller.UpdateToken) @@ -142,7 +143,6 @@ func SetApiRouter(router *gin.Engine) { logRoute.Use(middleware.CORS()) { logRoute.GET("/token", controller.GetLogByKey) - } groupRoute := apiRouter.Group("/group") groupRoute.Use(middleware.AdminAuth()) From b30cf70c9112b1157dcaab368d757d2016c6a132 Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Mon, 9 Jun 2025 22:14:51 +0800 Subject: [PATCH 002/582] =?UTF-8?q?feat:=20dalle=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dto/dalle.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/dto/dalle.go b/dto/dalle.go index a1309b6c..6ad5b6b6 100644 --- a/dto/dalle.go +++ b/dto/dalle.go @@ -1,6 +1,9 @@ package dto -import "encoding/json" +import ( + "encoding/json" + "reflect" +) type ImageRequest struct { Model string `json:"model"` @@ -15,6 +18,58 @@ type ImageRequest struct { Background string `json:"background,omitempty"` Moderation string `json:"moderation,omitempty"` OutputFormat string `json:"output_format,omitempty"` + // 用匿名字段接住额外的字段 + Extra map[string]json.RawMessage `json:"-"` +} + +func (r *ImageRequest) UnmarshalJSON(data []byte) error { + // 先解析成 map[string]interface{} + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } + + // 用 struct tag 获取所有已定义字段名 + knownFields := GetJSONFieldNames(reflect.TypeOf(*r)) + + // 再正常解析已定义字段 + type Alias ImageRequest + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *r = ImageRequest(known) + + // 提取多余字段 + r.Extra = make(map[string]json.RawMessage) + for k, v := range rawMap { + if _, ok := knownFields[k]; !ok { + r.Extra[k] = v + } + } + return nil +} + +func (r ImageRequest) MarshalJSON() ([]byte, error) { + // 将已定义字段转为 map + type Alias ImageRequest + alias := Alias(r) + base, err := json.Marshal(alias) + if err != nil { + return nil, err + } + + var baseMap map[string]json.RawMessage + if err := json.Unmarshal(base, &baseMap); err != nil { + return nil, err + } + + // 合并 ExtraFields + for k, v := range r.Extra { + baseMap[k] = v + } + + return json.Marshal(baseMap) } type ImageResponse struct { @@ -26,3 +81,37 @@ type ImageData struct { B64Json string `json:"b64_json"` RevisedPrompt string `json:"revised_prompt"` } + +func GetJSONFieldNames(t reflect.Type) map[string]struct{} { + fields := make(map[string]struct{}) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // 跳过匿名字段(例如 ExtraFields) + if field.Anonymous { + continue + } + + tag := field.Tag.Get("json") + if tag == "-" || tag == "" { + continue + } + + // 取逗号前字段名(排除 omitempty 等) + name := tag + if commaIdx := indexComma(tag); commaIdx != -1 { + name = tag[:commaIdx] + } + fields[name] = struct{}{} + } + return fields +} + +func indexComma(s string) int { + for i := 0; i < len(s); i++ { + if s[i] == ',' { + return i + } + } + return -1 +} From 88f6d29dcb16774dc481b177f3009ed188875de8 Mon Sep 17 00:00:00 2001 From: Glaxy <90437693+QingyeSC@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:00:33 +0800 Subject: [PATCH 003/582] Update relay-claude.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优化claude messages接口启用思考时的参数设置 --- relay/channel/claude/relay-claude.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index a8607d86..9638853f 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -118,7 +118,10 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla } // TODO: 临时处理 // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking - claudeRequest.TopP = 0 + // Anthropic 要求去掉 top_k + claudeRequest.TopK = nil + //top_p值可以在0.95-1之间 + claudeRequest.TopP = 0.95 claudeRequest.Temperature = common.GetPointer[float64](1.0) claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") } From b0e7b9ae80f3fa91d3b42fff6888d5a0c0c59010 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 01:06:18 +0800 Subject: [PATCH 004/582] =?UTF-8?q?=F0=9F=8E=A8=20refactor(ui):=20scope=20?= =?UTF-8?q?table=20scrolling=20to=20console=20cards=20&=20refine=20overall?= =?UTF-8?q?=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Implement a dedicated, reusable scrolling mechanism for all console-table pages while keeping header and sidebar fixed, plus related layout improvements. Key Changes • Added `.table-scroll-card` utility class  – Provides flex column layout and internal vertical scrolling  – Desktop height: `calc(100vh - 110px)`; Mobile (<768 px) height: `calc(100vh - 77px)`  – Hides scrollbars cross-browser (`-ms-overflow-style`, `scrollbar-width`, `::-webkit-scrollbar`) • Replaced global `.semi-card` scrolling rules with `.table-scroll-card` to avoid affecting non-table cards • Updated table components (Channels, Tokens, Users, Logs, MjLogs, TaskLogs, Redemptions) to use the new class • PageLayout  – Footer is now suppressed for all `/console` routes  – Confirmed only central content area scrolls; header & sidebar remain fixed • Restored hidden scrollbar rules for `.semi-layout-content` and removed unnecessary global overrides • Minor CSS cleanup & comment improvements for readability Result Console table pages now fill the viewport with smooth, internal scrolling and no visible scrollbars, while other cards and pages remain unaffected. --- web/src/components/auth/LoginForm.js | 2 +- .../components/auth/PasswordResetConfirm.js | 2 +- web/src/components/auth/PasswordResetForm.js | 2 +- web/src/components/auth/RegisterForm.js | 2 +- web/src/components/layout/PageLayout.js | 2 +- .../components/settings/PersonalSetting.js | 2 +- web/src/components/table/ChannelsTable.js | 2 +- web/src/components/table/LogsTable.js | 2 +- web/src/components/table/MjLogsTable.js | 2 +- web/src/components/table/RedemptionsTable.js | 2 +- web/src/components/table/TaskLogsTable.js | 2 +- web/src/components/table/TokensTable.js | 2 +- web/src/components/table/UsersTable.js | 2 +- web/src/index.css | 35 +++++++++++++++---- web/src/pages/About/index.js | 2 +- web/src/pages/Channel/index.js | 2 +- web/src/pages/Chat/index.js | 2 +- web/src/pages/Chat2Link/index.js | 2 +- web/src/pages/Detail/index.js | 2 +- web/src/pages/Home/index.js | 2 +- web/src/pages/Log/index.js | 2 +- web/src/pages/Midjourney/index.js | 2 +- web/src/pages/NotFound/index.js | 2 +- web/src/pages/Playground/index.js | 2 +- web/src/pages/Pricing/index.js | 2 +- web/src/pages/Redemption/index.js | 2 +- web/src/pages/Setting/index.js | 2 +- web/src/pages/Setup/index.js | 2 +- web/src/pages/Task/index.js | 2 +- web/src/pages/Token/index.js | 2 +- web/src/pages/TopUp/index.js | 2 +- web/src/pages/User/index.js | 2 +- 32 files changed, 59 insertions(+), 38 deletions(-) diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js index ae7fc0fc..16cece25 100644 --- a/web/src/components/auth/LoginForm.js +++ b/web/src/components/auth/LoginForm.js @@ -523,7 +523,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/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js index 5fbd1fc5..9b454f76 100644 --- a/web/src/components/auth/PasswordResetConfirm.js +++ b/web/src/components/auth/PasswordResetConfirm.js @@ -82,7 +82,7 @@ const PasswordResetConfirm = () => { {/* 背景模糊晕染球 */}
-
+
diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js index 033989e0..fcbd9189 100644 --- a/web/src/components/auth/PasswordResetForm.js +++ b/web/src/components/auth/PasswordResetForm.js @@ -82,7 +82,7 @@ const PasswordResetForm = () => { {/* 背景模糊晕染球 */}
-
+
diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js index 9d213a60..6d8a9466 100644 --- a/web/src/components/auth/RegisterForm.js +++ b/web/src/components/auth/RegisterForm.js @@ -540,7 +540,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/layout/PageLayout.js b/web/src/components/layout/PageLayout.js index 7ef42eb7..365df7da 100644 --- a/web/src/components/layout/PageLayout.js +++ b/web/src/components/layout/PageLayout.js @@ -23,7 +23,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/settings/PersonalSetting.js b/web/src/components/settings/PersonalSetting.js index 7e2b85fd..fda43d7d 100644 --- a/web/src/components/settings/PersonalSetting.js +++ b/web/src/components/settings/PersonalSetting.js @@ -379,7 +379,7 @@ const PersonalSetting = () => { }; return ( -
+
{/* 主卡片容器 */} diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index fba5db79..d49f23de 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -1902,7 +1902,7 @@ const ChannelsTable = () => { /> { <> {renderColumnSelector()} diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js index 57e221d9..af7d1a1e 100644 --- a/web/src/components/table/MjLogsTable.js +++ b/web/src/components/table/MjLogsTable.js @@ -799,7 +799,7 @@ const LogsTable = () => { {renderColumnSelector()}
diff --git a/web/src/components/table/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js index b463294e..6e096b84 100644 --- a/web/src/components/table/RedemptionsTable.js +++ b/web/src/components/table/RedemptionsTable.js @@ -574,7 +574,7 @@ const RedemptionsTable = () => { > { {renderColumnSelector()}
diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js index ac7fca92..09e180b1 100644 --- a/web/src/components/table/TokensTable.js +++ b/web/src/components/table/TokensTable.js @@ -872,7 +872,7 @@ const TokensTable = () => { > { > { ); return ( -
+
{aboutLoaded && about === '' ? (
{ return ( -
+
); diff --git a/web/src/pages/Chat/index.js b/web/src/pages/Chat/index.js index 4b354752..52e91526 100644 --- a/web/src/pages/Chat/index.js +++ b/web/src/pages/Chat/index.js @@ -42,7 +42,7 @@ const ChatPage = () => { allow='camera;microphone' /> ) : ( -
+
{ } return ( -
+

正在加载,请稍候...

); diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index b5553cbf..704093bb 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -1120,7 +1120,7 @@ const Detail = (props) => { }, []); return ( -
+

{ className="w-full h-screen border-none" /> ) : ( -
+
)}
)} diff --git a/web/src/pages/Log/index.js b/web/src/pages/Log/index.js index 74c570bb..fa919964 100644 --- a/web/src/pages/Log/index.js +++ b/web/src/pages/Log/index.js @@ -2,7 +2,7 @@ import React from 'react'; import LogsTable from '../../components/table/LogsTable'; const Token = () => ( -
+
); diff --git a/web/src/pages/Midjourney/index.js b/web/src/pages/Midjourney/index.js index 71d4c3a8..67d9f76c 100644 --- a/web/src/pages/Midjourney/index.js +++ b/web/src/pages/Midjourney/index.js @@ -2,7 +2,7 @@ import React from 'react'; import MjLogsTable from '../../components/table/MjLogsTable'; const Midjourney = () => ( -
+
); diff --git a/web/src/pages/NotFound/index.js b/web/src/pages/NotFound/index.js index c64b5a40..c6c9e96c 100644 --- a/web/src/pages/NotFound/index.js +++ b/web/src/pages/NotFound/index.js @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; const NotFound = () => { const { t } = useTranslation(); return ( -
+
} darkModeImage={} diff --git a/web/src/pages/Playground/index.js b/web/src/pages/Playground/index.js index 9a41bc18..345959a1 100644 --- a/web/src/pages/Playground/index.js +++ b/web/src/pages/Playground/index.js @@ -352,7 +352,7 @@ const Playground = () => { }, [setMessage, saveMessagesImmediately]); return ( -
+
{(showSettings || !isMobile) && ( ( -
+
); diff --git a/web/src/pages/Redemption/index.js b/web/src/pages/Redemption/index.js index b55f8fdc..44bb1c87 100644 --- a/web/src/pages/Redemption/index.js +++ b/web/src/pages/Redemption/index.js @@ -3,7 +3,7 @@ import RedemptionsTable from '../../components/table/RedemptionsTable'; const Redemption = () => { return ( -
+
); diff --git a/web/src/pages/Setting/index.js b/web/src/pages/Setting/index.js index 43907826..a74e9b97 100644 --- a/web/src/pages/Setting/index.js +++ b/web/src/pages/Setting/index.js @@ -150,7 +150,7 @@ const Setting = () => { } }, [location.search]); return ( -
+
{ }; return ( -
+
diff --git a/web/src/pages/Task/index.js b/web/src/pages/Task/index.js index 4e3a9af4..261bd7da 100644 --- a/web/src/pages/Task/index.js +++ b/web/src/pages/Task/index.js @@ -2,7 +2,7 @@ import React from 'react'; import TaskLogsTable from '../../components/table/TaskLogsTable.js'; const Task = () => ( -
+
); diff --git a/web/src/pages/Token/index.js b/web/src/pages/Token/index.js index 33921eb6..5f825741 100644 --- a/web/src/pages/Token/index.js +++ b/web/src/pages/Token/index.js @@ -3,7 +3,7 @@ import TokensTable from '../../components/table/TokensTable'; const Token = () => { return ( -
+
); diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index dc986077..6fb57fe3 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -382,7 +382,7 @@ const TopUp = () => { }; return ( -
+
{/* 划转模态框 */} { return ( -
+
); From 9496156d212f6557352f6886a94a4e1fd1439c88 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 10:55:05 +0800 Subject: [PATCH 005/582] =?UTF-8?q?=F0=9F=8E=89=20feat(i18n):=20integrate?= =?UTF-8?q?=20Semi=20UI=20LocaleProvider=20with=20dynamic=20i18next=20lang?= =?UTF-8?q?uage=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Semi UI internationalization to the project by wrapping the root component tree with `LocaleProvider`. A new `SemiLocaleWrapper` component maps the current `i18next` language code to the corresponding Semi locale (currently `zh_CN` and `en_GB`) and falls back to Chinese when no match is found. Key changes ----------- 1. web/src/index.js • Import `LocaleProvider`, `useTranslation`, and Semi locale files. • Introduce `SemiLocaleWrapper` to determine `semiLocale` from `i18next.language` using a concise prefix-based mapping. • Wrap `PageLayout` with `SemiLocaleWrapper` inside the existing `ThemeProvider`. 2. Ensures that all Semi components automatically display the correct language when the app language is switched via i18next. BREAKING CHANGE --------------- Applications embedding this project must now ensure that `i18next` initialization occurs before React render so that `LocaleProvider` receives the correct initial language. --- web/src/components/table/ChannelsTable.js | 5 ----- web/src/components/table/LogsTable.js | 6 ------ web/src/components/table/MjLogsTable.js | 6 ------ web/src/components/table/ModelPricing.js | 6 ------ web/src/components/table/RedemptionsTable.js | 6 ------ web/src/components/table/TaskLogsTable.js | 6 ------ web/src/components/table/TokensTable.js | 6 ------ web/src/components/table/UsersTable.js | 6 ------ web/src/i18n/i18n.js | 1 + web/src/i18n/locales/en.json | 1 - web/src/index.js | 21 +++++++++++++++++--- 11 files changed, 19 insertions(+), 51 deletions(-) diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index d49f23de..4bf94cb8 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -1917,11 +1917,6 @@ const ChannelsTable = () => { 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); }, diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js index a59b9128..e3116e41 100644 --- a/web/src/components/table/LogsTable.js +++ b/web/src/components/table/LogsTable.js @@ -1439,12 +1439,6 @@ const LogsTable = () => { /> } pagination={{ - formatPageText: (page) => - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: logCount, - }), currentPage: activePage, pageSize: pageSize, total: logCount, diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js index af7d1a1e..0efe5e25 100644 --- a/web/src/components/table/MjLogsTable.js +++ b/web/src/components/table/MjLogsTable.js @@ -942,12 +942,6 @@ const LogsTable = () => { /> } pagination={{ - formatPageText: (page) => - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: logCount, - }), currentPage: activePage, pageSize: pageSize, total: logCount, diff --git a/web/src/components/table/ModelPricing.js b/web/src/components/table/ModelPricing.js index e3f68a76..7e8d3995 100644 --- a/web/src/components/table/ModelPricing.js +++ b/web/src/components/table/ModelPricing.js @@ -535,12 +535,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 index 6e096b84..108cde4b 100644 --- a/web/src/components/table/RedemptionsTable.js +++ b/web/src/components/table/RedemptionsTable.js @@ -589,12 +589,6 @@ const RedemptionsTable = () => { 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); diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js index 86e63b35..dcfad292 100644 --- a/web/src/components/table/TaskLogsTable.js +++ b/web/src/components/table/TaskLogsTable.js @@ -778,12 +778,6 @@ const LogsTable = () => { /> } pagination={{ - formatPageText: (page) => - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: logCount, - }), currentPage: activePage, pageSize: pageSize, total: logCount, diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js index 09e180b1..4d5a346f 100644 --- a/web/src/components/table/TokensTable.js +++ b/web/src/components/table/TokensTable.js @@ -893,12 +893,6 @@ const TokensTable = () => { 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, }} diff --git a/web/src/components/table/UsersTable.js b/web/src/components/table/UsersTable.js index c85395f0..8cfc35b8 100644 --- a/web/src/components/table/UsersTable.js +++ b/web/src/components/table/UsersTable.js @@ -649,12 +649,6 @@ const UsersTable = () => { 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, diff --git a/web/src/i18n/i18n.js b/web/src/i18n/i18n.js index c1bf5860..c7d69868 100644 --- a/web/src/i18n/i18n.js +++ b/web/src/i18n/i18n.js @@ -9,6 +9,7 @@ i18n .use(LanguageDetector) .use(initReactI18next) .init({ + load: 'languageOnly', resources: { en: { translation: enTranslation, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1ff11e1f..cfddb57f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1189,7 +1189,6 @@ "令牌无法精确控制使用额度,只允许自用,请勿直接将令牌分发给他人。": "Tokens cannot accurately control usage, only for self-use, please do not distribute tokens directly to others.", "添加兑换码": "Add redemption code", "复制所选兑换码到剪贴板": "Copy selected redemption codes to clipboard", - "第 {{start}} - {{end}} 条,共 {{total}} 条": "Items {{start}} - {{end}} of {{total}}", "新建兑换码": "Code", "兑换码更新成功!": "Redemption code updated successfully!", "兑换码创建成功!": "Redemption code created successfully!", diff --git a/web/src/index.js b/web/src/index.js index 2a097023..77d129e6 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -9,15 +9,28 @@ import { ThemeProvider } from './context/Theme'; import PageLayout from './components/layout/PageLayout.js'; import './i18n/i18n.js'; import './index.css'; +import { LocaleProvider } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; +import zh_CN from '@douyinfe/semi-ui/lib/es/locale/source/zh_CN'; +import en_GB from '@douyinfe/semi-ui/lib/es/locale/source/en_GB'; -// 欢迎信息(二次开发者不准将此移除) -// Welcome message (Secondary developers are not allowed to remove this) +// 欢迎信息(二次开发者未经允许不准将此移除) +// Welcome message (Do not remove this without permission from the original developer) if (typeof window !== 'undefined') { console.log('%cWe ❤ NewAPI%c Github: https://github.com/QuantumNous/new-api', 'color: #10b981; font-weight: bold; font-size: 24px;', 'color: inherit; font-size: 14px;'); } +function SemiLocaleWrapper({ children }) { + const { i18n } = useTranslation(); + const semiLocale = React.useMemo( + () => ({ zh: zh_CN, en: en_GB }[i18n.language] || zh_CN), + [i18n.language], + ); + return {children}; +} + // initialization const root = ReactDOM.createRoot(document.getElementById('root')); @@ -32,7 +45,9 @@ root.render( }} > - + + + From eac90f67c0243ce55db9cc135cf2971ee304a79f Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 10:59:24 +0800 Subject: [PATCH 006/582] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20refactor(table)?= =?UTF-8?q?:=20remove=20custom=20`formatPageText`=20from=20all=20table=20c?= =?UTF-8?q?omponents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminated the manual `formatPageText` function that previously rendered pagination text (e.g. “第 {{start}} - {{end}} 条,共 {{total}} 条”) in each Table. Pagination now relies on the default Semi UI text or the global i18n configuration, reducing duplication and making future language updates centralized. Why --- * Keeps table components cleaner and more maintainable. * Ensures pagination text automatically respects the app-wide i18n settings without per-component overrides. --- web/src/components/settings/ChannelSelectorModal.js | 5 ----- web/src/pages/Setting/Dashboard/SettingsAPIInfo.js | 5 ----- web/src/pages/Setting/Dashboard/SettingsAnnouncements.js | 5 ----- web/src/pages/Setting/Dashboard/SettingsFAQ.js | 5 ----- web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js | 5 ----- web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js | 6 ------ web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js | 6 ------ web/src/pages/Setting/Ratio/UpstreamRatioSync.js | 5 ----- 8 files changed, 42 deletions(-) diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js index 998c2bf3..558f0bef 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -212,11 +212,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/pages/Setting/Dashboard/SettingsAPIInfo.js b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js index d59aacec..54f5035b 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js +++ b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js @@ -403,11 +403,6 @@ const SettingsAPIInfo = ({ options, refresh }) => { total: apiInfoList.length, showSizeChanger: true, showQuickJumper: true, - formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: apiInfoList.length, - }), pageSizeOptions: ['5', '10', '20', '50'], onChange: (page, size) => { setCurrentPage(page); diff --git a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js index f81a8c2f..06f9f0ab 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js +++ b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js @@ -444,11 +444,6 @@ const SettingsAnnouncements = ({ options, refresh }) => { total: announcementsList.length, showSizeChanger: true, showQuickJumper: true, - formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: announcementsList.length, - }), pageSizeOptions: ['5', '10', '20', '50'], onChange: (page, size) => { setCurrentPage(page); diff --git a/web/src/pages/Setting/Dashboard/SettingsFAQ.js b/web/src/pages/Setting/Dashboard/SettingsFAQ.js index 3ab211e6..7c15ddc8 100644 --- a/web/src/pages/Setting/Dashboard/SettingsFAQ.js +++ b/web/src/pages/Setting/Dashboard/SettingsFAQ.js @@ -370,11 +370,6 @@ const SettingsFAQ = ({ options, refresh }) => { total: faqList.length, showSizeChanger: true, showQuickJumper: true, - formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: faqList.length, - }), pageSizeOptions: ['5', '10', '20', '50'], onChange: (page, size) => { setCurrentPage(page); diff --git a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js index d9137d7d..f84561d6 100644 --- a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js +++ b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js @@ -386,11 +386,6 @@ const SettingsUptimeKuma = ({ options, refresh }) => { total: uptimeGroupsList.length, showSizeChanger: true, showQuickJumper: true, - formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: uptimeGroupsList.length, - }), pageSizeOptions: ['5', '10', '20', '50'], onChange: (page, size) => { setCurrentPage(page); diff --git a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js index 25c67eee..21d1fbb8 100644 --- a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js +++ b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js @@ -420,12 +420,6 @@ export default function ModelRatioNotSetEditor(props) { onPageChange: (page) => setCurrentPage(page), onPageSizeChange: handlePageSizeChange, pageSizeOptions: pageSizeOptions, - formatPageText: (page) => - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: filteredModels.length, - }), showTotal: true, showSizeChanger: true, }} diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js index b897968f..a1090516 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js @@ -475,12 +475,6 @@ export default function ModelSettingsVisualEditor(props) { pageSize: pageSize, total: filteredModels.length, onPageChange: (page) => setCurrentPage(page), - formatPageText: (page) => - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: filteredModels.length, - }), showTotal: true, showSizeChanger: false, }} diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 647ca758..5a82f40b 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -689,11 +689,6 @@ export default function UpstreamRatioSync(props) { total: filteredDataSource.length, showSizeChanger: true, showQuickJumper: true, - formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: filteredDataSource.length, - }), pageSizeOptions: ['5', '10', '20', '50'], onChange: (page, size) => { setCurrentPage(page); From 217c657e2b5f1d3dc41140dd3f5982588d405f1d Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 21:05:36 +0800 Subject: [PATCH 007/582] =?UTF-8?q?=F0=9F=9A=80=20feat(web/channels):=20De?= =?UTF-8?q?ep=20modular=20refactor=20of=20Channels=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Split monolithic `ChannelsTable` (2200+ LOC) into focused components • `channels/index.jsx` – composition entry • `ChannelsTable.jsx` – pure `` rendering • `ChannelsActions.jsx` – bulk & settings toolbar • `ChannelsFilters.jsx` – search / create / column-settings form • `ChannelsTabs.jsx` – type tabs • `ChannelsColumnDefs.js` – column definitions & render helpers • `modals/` – BatchTag, ColumnSelector, ModelTest modals 2. Extract domain hook • Moved `useChannelsData.js` → `src/hooks/channels/useChannelsData.js` – centralises state, API calls, pagination, filters, batch ops – now exports `setActivePage`, fixing tab / status switch errors 3. Update wiring • All sub-components consume data via `useChannelsData` props • Adjusted import paths after hook relocation 4. Clean legacy file • Legacy `components/table/ChannelsTable.js` now re-exports new module 5. Bug fixes • Tab switching, status filter & tag aggregation restored • Column selector & batch actions operate via unified hook This commit completes the first phase of modularising the Channels feature, laying groundwork for consistent, maintainable table architecture across the app. --- web/src/App.js | 2 +- web/src/components/auth/OAuth2Callback.js | 2 +- web/src/components/common/ui/CardPro.js | 127 + web/src/components/common/{ => ui}/Loading.js | 0 web/src/components/layout/HeaderBar.js | 4 +- web/src/components/layout/PageLayout.js | 4 +- web/src/components/layout/SiderBar.js | 2 +- .../settings/ChannelSelectorModal.js | 2 +- web/src/components/table/ChannelsTable.js | 2209 +---------------- web/src/components/table/LogsTable.js | 394 ++- web/src/components/table/MjLogsTable.js | 80 +- web/src/components/table/RedemptionsTable.js | 292 ++- web/src/components/table/TaskLogsTable.js | 204 +- web/src/components/table/TokensTable.js | 333 +-- web/src/components/table/UsersTable.js | 226 +- .../table/channels/ChannelsActions.jsx | 240 ++ .../table/channels/ChannelsColumnDefs.js | 604 +++++ .../table/channels/ChannelsFilters.jsx | 140 ++ .../table/channels/ChannelsTable.jsx | 138 + .../table/channels/ChannelsTabs.jsx | 70 + web/src/components/table/channels/index.jsx | 49 + .../table/channels/modals/BatchTagModal.jsx | 41 + .../channels/modals/ColumnSelectorModal.jsx | 114 + .../table/channels/modals/ModelTestModal.jsx | 256 ++ web/src/helpers/render.js | 2 +- web/src/helpers/utils.js | 2 +- web/src/hooks/channels/useChannelsData.js | 917 +++++++ web/src/hooks/{ => chat}/useTokenKeys.js | 4 +- web/src/hooks/{ => common}/useIsMobile.js | 0 .../hooks/{ => common}/useSidebarCollapsed.js | 0 .../hooks/{ => common}/useTableCompactMode.js | 4 +- .../hooks/{ => playground}/useApiRequest.js | 4 +- .../hooks/{ => playground}/useDataLoader.js | 4 +- .../{ => playground}/useMessageActions.js | 4 +- .../hooks/{ => playground}/useMessageEdit.js | 4 +- .../{ => playground}/usePlaygroundState.js | 6 +- .../useSyncMessageAndCustomBody.js | 2 +- web/src/pages/Channel/EditChannel.js | 2 +- web/src/pages/Chat/index.js | 2 +- web/src/pages/Chat2Link/index.js | 2 +- web/src/pages/Detail/index.js | 2 +- web/src/pages/Home/index.js | 2 +- web/src/pages/Playground/index.js | 14 +- web/src/pages/Redemption/EditRedemption.js | 2 +- .../pages/Setting/Ratio/UpstreamRatioSync.js | 2 +- web/src/pages/Token/EditToken.js | 2 +- web/src/pages/User/AddUser.js | 2 +- web/src/pages/User/EditUser.js | 2 +- 48 files changed, 3489 insertions(+), 3031 deletions(-) create mode 100644 web/src/components/common/ui/CardPro.js rename web/src/components/common/{ => ui}/Loading.js (100%) create mode 100644 web/src/components/table/channels/ChannelsActions.jsx create mode 100644 web/src/components/table/channels/ChannelsColumnDefs.js create mode 100644 web/src/components/table/channels/ChannelsFilters.jsx create mode 100644 web/src/components/table/channels/ChannelsTable.jsx create mode 100644 web/src/components/table/channels/ChannelsTabs.jsx create mode 100644 web/src/components/table/channels/index.jsx create mode 100644 web/src/components/table/channels/modals/BatchTagModal.jsx create mode 100644 web/src/components/table/channels/modals/ColumnSelectorModal.jsx create mode 100644 web/src/components/table/channels/modals/ModelTestModal.jsx create mode 100644 web/src/hooks/channels/useChannelsData.js rename web/src/hooks/{ => chat}/useTokenKeys.js (87%) rename web/src/hooks/{ => common}/useIsMobile.js (100%) rename web/src/hooks/{ => common}/useSidebarCollapsed.js (100%) rename web/src/hooks/{ => common}/useTableCompactMode.js (89%) rename web/src/hooks/{ => playground}/useApiRequest.js (99%) rename web/src/hooks/{ => playground}/useDataLoader.js (92%) rename web/src/hooks/{ => playground}/useMessageActions.js (98%) rename web/src/hooks/{ => playground}/useMessageEdit.js (97%) rename web/src/hooks/{ => playground}/usePlaygroundState.js (97%) rename web/src/hooks/{ => playground}/useSyncMessageAndCustomBody.js (98%) diff --git a/web/src/App.js b/web/src/App.js index 2d715767..995ae2bb 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -1,6 +1,6 @@ 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'; diff --git a/web/src/components/auth/OAuth2Callback.js b/web/src/components/auth/OAuth2Callback.js index 7d435574..0bd92f58 100644 --- a/web/src/components/auth/OAuth2Callback.js +++ b/web/src/components/auth/OAuth2Callback.js @@ -3,7 +3,7 @@ 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/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js new file mode 100644 index 00000000..4f240e9e --- /dev/null +++ b/web/src/components/common/ui/CardPro.js @@ -0,0 +1,127 @@ +import React from 'react'; +import { Card, Divider, Typography } from '@douyinfe/semi-ui'; +import PropTypes from 'prop-types'; + +const { Text } = Typography; + +/** + * CardPro 高级卡片组件 + * + * 布局分为5个区域: + * 1. 统计信息区域 (statsArea) + * 2. 描述信息区域 (descriptionArea) + * 3. 类型切换/标签区域 (tabsArea) + * 4. 操作按钮区域 (actionsArea) + * 5. 搜索表单区域 (searchArea) + * + * 支持三种布局类型: + * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单 + * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单 + * - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单 + */ +const CardPro = ({ + type = 'type1', + className = '', + children, + // 各个区域的内容 + statsArea, + descriptionArea, + tabsArea, + actionsArea, + searchArea, + // 卡片属性 + shadows = 'always', + bordered = false, + // 自定义样式 + style, + ...props +}) => { + // 渲染头部内容 + 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} +
+ )} + + {/* 操作按钮和搜索表单的容器 */} +
+ {/* 操作按钮区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && actionsArea && ( +
+ {actionsArea} +
+ )} + + {/* 搜索表单区域 - 所有类型都可能有 */} + {searchArea && ( +
+ {searchArea} +
+ )} +
+
+ ); + }; + + const headerContent = renderHeader(); + + 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.node, + searchArea: PropTypes.node, + // 表格内容 + children: PropTypes.node, +}; + +export default CardPro; \ No newline at end of file diff --git a/web/src/components/common/Loading.js b/web/src/components/common/ui/Loading.js similarity index 100% rename from web/src/components/common/Loading.js rename to web/src/components/common/ui/Loading.js diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index 4d83d48b..6b365345 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -31,8 +31,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(); diff --git a/web/src/components/layout/PageLayout.js b/web/src/components/layout/PageLayout.js index 365df7da..da955ccc 100644 --- a/web/src/components/layout/PageLayout.js +++ b/web/src/components/layout/PageLayout.js @@ -5,8 +5,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'; diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js index b18dad6c..4b61667f 100644 --- a/web/src/components/layout/SiderBar.js +++ b/web/src/components/layout/SiderBar.js @@ -3,7 +3,7 @@ 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, diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js index 558f0bef..eec5fb88 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Modal, Table, diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 4bf94cb8..6a423997 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -1,2207 +1,2 @@ -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, - 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; +// 重构后的 ChannelsTable - 使用新的模块化架构 +export { default } from './channels/index.jsx'; diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js index e3116e41..f181d9c6 100644 --- a/web/src/components/table/LogsTable.js +++ b/web/src/components/table/LogsTable.js @@ -36,11 +36,10 @@ import { Tag, Tooltip, Checkbox, - Card, Typography, - Divider, Form, } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark, @@ -49,7 +48,7 @@ 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'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; const { Text } = Typography; @@ -1201,216 +1200,211 @@ const LogsTable = () => { return ( <> {renderColumnSelector()} - - -
- - - {t('消耗额度')}: {renderQuota(stat.quota)} - - - RPM: {stat.rpm} - - - TPM: {stat.tpm} - - - - -
-
+ {t('消耗额度')}: {renderQuota(stat.quota)} + + + RPM: {stat.rpm} + + + TPM: {stat.tpm} + + - + + + + } + searchArea={ +
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete='off' + layout='vertical' + trigger='change' + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
- {/* 搜索表单区域 */} - 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" - /> - - } - 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" - /> - - )} -
- - {/* 操作按钮区域 */} -
- {/* 日志类型选择器 */} -
- } + placeholder={t('用户名称')} showClear pure - onChange={() => { - // 延迟执行搜索,让表单值先更新 + size="small" + /> + + )} +
+ + {/* 操作按钮区域 */} +
+ {/* 日志类型选择器 */} +
+ { + // 延迟执行搜索,让表单值先更新 + setTimeout(() => { + refresh(); + }, 0); + }} + size="small" + > + + {t('全部')} + + + {t('充值')} + + + {t('消费')} + + + {t('管理')} + + + {t('系统')} + + + {t('错误')} + + +
+ +
+ +
- -
- - - -
+ }, 100); + } + }} + size="small" + > + {t('重置')} + +
- -
+
+ } - shadows='always' - bordered={false} >
rest) : getVisibleColumns()} @@ -1450,7 +1444,7 @@ const LogsTable = () => { onPageChange: handlePageChange, }} /> - + ); }; diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js index 0efe5e25..267a5be9 100644 --- a/web/src/components/table/MjLogsTable.js +++ b/web/src/components/table/MjLogsTable.js @@ -37,9 +37,7 @@ import { import { Button, - Card, Checkbox, - Divider, Empty, Form, ImagePreview, @@ -51,6 +49,7 @@ import { Tag, Typography } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark @@ -60,7 +59,7 @@ import { IconEyeOpened, IconSearch, } from '@douyinfe/semi-icons'; -import { useTableCompactMode } from '../../hooks/useTableCompactMode'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; const { Text } = Typography; @@ -798,42 +797,40 @@ const LogsTable = () => { <> {renderColumnSelector()} - -
-
- - {loading ? ( - - ) : ( - - {isAdminUser && showBanner - ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') - : t('Midjourney 任务记录')} - - )} -
- + +
+ + {loading ? ( + + ) : ( + + {isAdminUser && showBanner + ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。') + : t('Midjourney 任务记录')} + + )}
- - - - {/* 搜索表单区域 */} + +
+ } + searchArea={
setFormApi(api)} @@ -920,10 +917,7 @@ const LogsTable = () => { - } - shadows='always' - bordered={false} >
rest) : getVisibleColumns()} @@ -950,8 +944,8 @@ const LogsTable = () => { onPageSizeChange: handlePageSizeChange, onPageChange: handlePageChange, }} - /> - + /> + { } }; - const renderHeader = () => ( -
-
-
-
- - {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')} -
- -
-
- - - -
-
-
- - -
- -
- -
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 ( <> { handleClose={closeEdit} > - +
+ + {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')} +
+ + + } + actionsArea={ +
+
+
+ + +
+ +
+ +
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" + /> +
+
+ + +
+
+ +
+ } >
rest) : columns} @@ -615,7 +605,7 @@ const RedemptionsTable = () => { className="rounded-xl overflow-hidden" size="middle" >
- + ); }; diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js index dcfad292..0e3abbb7 100644 --- a/web/src/components/table/TaskLogsTable.js +++ b/web/src/components/table/TaskLogsTable.js @@ -26,9 +26,7 @@ import { import { Button, - Card, Checkbox, - Divider, Empty, Form, Layout, @@ -38,6 +36,7 @@ import { Tag, Typography } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark @@ -47,7 +46,7 @@ import { IconEyeOpened, IconSearch, } from '@douyinfe/semi-icons'; -import { useTableCompactMode } from '../../hooks/useTableCompactMode'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant'; const { Text } = Typography; @@ -648,118 +647,113 @@ const LogsTable = () => { <> {renderColumnSelector()} - -
-
- - {t('任务记录')} -
- + +
+ + {t('任务记录')}
- - - - {/* 搜索表单区域 */} -
setFormApi(api)} - onSubmit={refresh} - allowEmpty={true} - autoComplete="off" - layout="vertical" - trigger="change" - stopValidateWithError={false} + +
+ } + searchArea={ + setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete="off" + layout="vertical" + trigger="change" + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ - - {/* 渠道 ID - 仅管理员可见 */} - {isAdminUser && ( - } - placeholder={t('渠道 ID')} - showClear - pure - size="small" - /> - )}
- {/* 操作按钮区域 */} -
-
-
- - - -
+ {/* 任务 ID */} + } + placeholder={t('任务 ID')} + showClear + pure + size="small" + /> + + {/* 渠道 ID - 仅管理员可见 */} + {isAdminUser && ( + } + placeholder={t('渠道 ID')} + showClear + pure + size="small" + /> + )} +
+ + {/* 操作按钮区域 */} +
+
+
+ + +
- -
+
+ } - shadows='always' - bordered={false} > rest) : getVisibleColumns()} @@ -787,7 +781,7 @@ const LogsTable = () => { onPageChange: handlePageChange, }} /> - + { } }; - const renderHeader = () => ( -
-
-
-
- - {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} -
+ const renderDescriptionArea = () => ( +
+
+ + {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} +
+ +
+ ); + + const renderActionsArea = () => ( +
+ + + + + ), + }); + }} + size="small" + > + {t('复制所选令牌')} + + +
+ ); + + const renderSearchArea = () => ( +
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" + /> +
+
-
-
- - - -
-
- - - - ), - }); }} + className="flex-1 md:flex-initial md:w-auto" 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 ( @@ -871,11 +866,19 @@ const TokensTable = () => { handleClose={closeEdit} > - +
+ {renderActionsArea()} +
+
+ {renderSearchArea()} +
+
+ } >
{ @@ -910,7 +913,7 @@ const TokensTable = () => { className="rounded-xl overflow-hidden" size="middle" >
-
+ ); }; diff --git a/web/src/components/table/UsersTable.js b/web/src/components/table/UsersTable.js index 8cfc35b8..7a38fc03 100644 --- a/web/src/components/table/UsersTable.js +++ b/web/src/components/table/UsersTable.js @@ -17,8 +17,6 @@ import { } from 'lucide-react'; import { Button, - Card, - Divider, Dropdown, Empty, Form, @@ -29,6 +27,7 @@ import { Tooltip, Typography } from '@douyinfe/semi-ui'; +import CardPro from '../common/ui/CardPro'; import { IllustrationNoResult, IllustrationNoResultDark @@ -42,7 +41,7 @@ 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'; +import { useTableCompactMode } from '../../hooks/common/useTableCompactMode'; const { Text } = Typography; @@ -514,115 +513,7 @@ const UsersTable = () => { } }; - const renderHeader = () => ( -
-
-
-
- - {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} -
- -
-
- - -
-
- -
- -
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 ( <> @@ -638,11 +529,112 @@ const UsersTable = () => { editingUser={editingUser} > - +
+ + {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} +
+ +
+ } + actionsArea={ +
+
+ +
+ +
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" + /> +
+
+ + +
+
+
+
+ } > rest) : columns} @@ -672,7 +664,7 @@ const UsersTable = () => { className="overflow-hidden" size="middle" /> - + ); }; diff --git a/web/src/components/table/channels/ChannelsActions.jsx b/web/src/components/table/channels/ChannelsActions.jsx new file mode 100644 index 00000000..f244243c --- /dev/null +++ b/web/src/components/table/channels/ChannelsActions.jsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { + Button, + Dropdown, + Modal, + Switch, + Typography, + Select +} from '@douyinfe/semi-ui'; + +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..9f7c50de --- /dev/null +++ b/web/src/components/table/channels/ChannelsColumnDefs.js @@ -0,0 +1,604 @@ +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..4b3804df --- /dev/null +++ b/web/src/components/table/channels/ChannelsFilters.jsx @@ -0,0 +1,140 @@ +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-4 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..c95d0b17 --- /dev/null +++ b/web/src/components/table/channels/ChannelsTable.jsx @@ -0,0 +1,138 @@ +import React, { useMemo } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +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..9115c4f5 --- /dev/null +++ b/web/src/components/table/channels/ChannelsTabs.jsx @@ -0,0 +1,70 @@ +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..45699306 --- /dev/null +++ b/web/src/components/table/channels/index.jsx @@ -0,0 +1,49 @@ +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 BatchTagModal from './modals/BatchTagModal.jsx'; +import ModelTestModal from './modals/ModelTestModal.jsx'; +import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx'; +import EditChannel from '../../../pages/Channel/EditChannel.js'; +import EditTagModal from '../../../pages/Channel/EditTagModal.js'; + +const ChannelsPage = () => { + const channelsData = useChannelsData(); + + return ( + <> + {/* Modals */} + + channelsData.setShowEditTag(false)} + refresh={channelsData.refresh} + /> + + + + + {/* Main Content */} + } + actionsArea={} + searchArea={} + > + + + + ); +}; + +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..5f3a7a93 --- /dev/null +++ b/web/src/components/table/channels/modals/BatchTagModal.jsx @@ -0,0 +1,41 @@ +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..8805a84b --- /dev/null +++ b/web/src/components/table/channels/modals/ColumnSelectorModal.jsx @@ -0,0 +1,114 @@ +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/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx new file mode 100644 index 00000000..05d272c0 --- /dev/null +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -0,0 +1,256 @@ +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 +}) => { + if (!showModelTestModal || !currentTestChannel) { + return null; + } + + const filteredModels = 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 = (() => { + 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('个模型')} + +
+ + } + visible={showModelTestModal} + onCancel={handleCloseModal} + footer={ +
+ {isBatchTesting ? ( + + ) : ( + + )} + +
+ } + maskClosable={!isBatchTesting} + className="!rounded-lg" + size={isMobile ? 'full-width' : 'large'} + > +
+ {/* 搜索与操作按钮 */} +
+ { + 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/helpers/render.js b/web/src/helpers/render.js index 34ba78d7..8c7cb20f 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -1,7 +1,7 @@ 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/utils.js b/web/src/helpers/utils.js index 6c4f1275..f74b437a 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -4,7 +4,7 @@ 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
; diff --git a/web/src/hooks/channels/useChannelsData.js b/web/src/hooks/channels/useChannelsData.js new file mode 100644 index 00000000..b6890f95 --- /dev/null +++ b/web/src/hooks/channels/useChannelsData.js @@ -0,0 +1,917 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + API, + showError, + showInfo, + showSuccess, + loadChannelModels, + copy +} from '../../helpers/index.js'; +import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js'; +import { useIsMobile } from '../common/useIsMobile.js'; +import { useTableCompactMode } from '../common/useTableCompactMode.js'; +import { Modal } from '@douyinfe/semi-ui'; + +export const useChannelsData = () => { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + + // Basic states + 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(ITEMS_PER_PAGE); + const [groupOptions, setGroupOptions] = useState([]); + + // UI states + 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 [compactMode, setCompactMode] = useTableCompactMode('channels'); + + // Column visibility states + const [visibleColumns, setVisibleColumns] = useState({}); + const [showColumnSelector, setShowColumnSelector] = useState(false); + + // Status filter + const [statusFilter, setStatusFilter] = useState( + localStorage.getItem('channel-status-filter') || 'all' + ); + + // Type tabs states + const [activeTypeKey, setActiveTypeKey] = useState('all'); + const [typeCounts, setTypeCounts] = useState({}); + + // Model test states + 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); + + // Refs + const requestCounter = useRef(0); + const allSelectingRef = useRef(false); + const [formApi, setFormApi] = useState(null); + + const formInitValues = { + searchKeyword: '', + searchGroup: '', + searchModel: '', + }; + + // Column keys + 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', + }; + + // Initialize from localStorage + 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(); + }, []); + + // Column visibility management + 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, + }; + }; + + const initDefaultColumns = () => { + const defaults = getDefaultColumnVisibility(); + setVisibleColumns(defaults); + }; + + // Load saved column preferences + useEffect(() => { + const savedColumns = localStorage.getItem('channels-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(); + } + }, []); + + // Save column preferences + useEffect(() => { + if (Object.keys(visibleColumns).length > 0) { + localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns)); + } + }, [visibleColumns]); + + 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) => { + updatedColumns[key] = checked; + }); + setVisibleColumns(updatedColumns); + }; + + // Data formatting + 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 : ''; + let tagIndex = channelTags[tag]; + let tagChannelDates = undefined; + + if (tagIndex === undefined) { + 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 { + 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) { + 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); + }; + + // Get form values helper + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchGroup: formValues.searchGroup || '', + searchModel: formValues.searchModel || '', + }; + }; + + // Load channels + 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); + }; + + // Search channels + 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); + } + }; + + // Refresh + 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); + } + }; + + // Channel management + 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') { + record.status = channel.status; + } + setChannels(newChannels); + } else { + showError(message); + } + }; + + // Tag management + const manageTag = async (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); + } + }; + + // Page handlers + 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); + } + }; + + // Fetch groups + 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); + } + }; + + // Copy channel + 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)); + } + }; + + // Update channel property + const updateChannelProperty = (channelId, updateFn) => { + const newChannels = [...channels]; + let updated = false; + + newChannels.forEach((channel) => { + if (channel.children !== undefined) { + channel.children.forEach((child) => { + if (child.id === channelId) { + updateFn(child); + updated = true; + } + }); + } else if (channel.id === channelId) { + updateFn(channel); + updated = true; + } + }); + + if (updated) { + setChannels(newChannels); + } + }; + + // Tag edit + 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); + } + }; + + // Close edit + const closeEdit = () => { + setShowEdit(false); + }; + + // Row style + const handleRow = (record, index) => { + if (record.status !== 1) { + return { + style: { + background: 'var(--semi-color-disabled-border)', + }, + }; + } else { + return {}; + } + }; + + // Batch operations + 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 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); + }; + + // Channel operations + 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 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); + } + }; + + // Test channel + const testChannel = async (record, model) => { + setTestQueue(prev => [...prev, { channel: record, model }]); + if (!isProcessingQueue) { + setIsProcessingQueue(true); + } + }; + + // Process test queue + 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)); + }; + + // Monitor queue changes + useEffect(() => { + if (testQueue.length > 0 && isProcessingQueue) { + processTestQueue(); + } else if (testQueue.length === 0 && isProcessingQueue) { + setIsProcessingQueue(false); + setIsBatchTesting(false); + } + }, [testQueue, isProcessingQueue]); + + // Batch test models + 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); + }; + + // Handle close modal + const handleCloseModal = () => { + if (isBatchTesting) { + setTestQueue([]); + setIsProcessingQueue(false); + setIsBatchTesting(false); + showSuccess(t('已停止测试')); + } else { + setShowModelTestModal(false); + setModelSearchKeyword(''); + setSelectedModelKeys([]); + setModelTablePage(1); + } + }; + + // Type counts + const channelTypeCounts = useMemo(() => { + if (Object.keys(typeCounts).length > 0) return typeCounts; + 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]); + + return { + // Basic states + channels, + loading, + searching, + activePage, + pageSize, + channelCount, + groupOptions, + idSort, + enableTagMode, + enableBatchDelete, + statusFilter, + compactMode, + + // UI states + showEdit, + setShowEdit, + editingChannel, + setEditingChannel, + showEditTag, + setShowEditTag, + editingTag, + setEditingTag, + selectedChannels, + setSelectedChannels, + showBatchSetTag, + setShowBatchSetTag, + batchSetTagValue, + setBatchSetTagValue, + + // Column states + visibleColumns, + showColumnSelector, + setShowColumnSelector, + COLUMN_KEYS, + + // Type tab states + activeTypeKey, + setActiveTypeKey, + typeCounts, + channelTypeCounts, + availableTypeKeys, + + // Model test states + showModelTestModal, + setShowModelTestModal, + currentTestChannel, + setCurrentTestChannel, + modelSearchKeyword, + setModelSearchKeyword, + modelTestResults, + testingModels, + selectedModelKeys, + setSelectedModelKeys, + isBatchTesting, + modelTablePage, + setModelTablePage, + allSelectingRef, + + // Form + formApi, + setFormApi, + formInitValues, + + // Helpers + t, + isMobile, + + // Functions + loadChannels, + searchChannels, + refresh, + manageChannel, + manageTag, + handlePageChange, + handlePageSizeChange, + copySelectedChannel, + updateChannelProperty, + submitTagEdit, + closeEdit, + handleRow, + batchSetChannelTag, + batchDeleteChannels, + testAllChannels, + deleteAllDisabledChannels, + updateAllChannelsBalance, + updateChannelBalance, + fixChannelsAbilities, + testChannel, + batchTestModels, + handleCloseModal, + getFormValues, + + // Column functions + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + getDefaultColumnVisibility, + + // Setters + setIdSort, + setEnableTagMode, + setEnableBatchDelete, + setStatusFilter, + setCompactMode, + setActivePage, + }; +}; \ No newline at end of file diff --git a/web/src/hooks/useTokenKeys.js b/web/src/hooks/chat/useTokenKeys.js similarity index 87% rename from web/src/hooks/useTokenKeys.js rename to web/src/hooks/chat/useTokenKeys.js index eba69e08..24e5b95e 100644 --- a/web/src/hooks/useTokenKeys.js +++ b/web/src/hooks/chat/useTokenKeys.js @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { fetchTokenKeys, getServerAddress } from '../helpers/token'; -import { showError } from '../helpers'; +import { fetchTokenKeys, getServerAddress } from '../../helpers/token'; +import { showError } from '../../helpers'; export function useTokenKeys(id) { const [keys, setKeys] = useState([]); diff --git a/web/src/hooks/useIsMobile.js b/web/src/hooks/common/useIsMobile.js similarity index 100% rename from web/src/hooks/useIsMobile.js rename to web/src/hooks/common/useIsMobile.js diff --git a/web/src/hooks/useSidebarCollapsed.js b/web/src/hooks/common/useSidebarCollapsed.js similarity index 100% rename from web/src/hooks/useSidebarCollapsed.js rename to web/src/hooks/common/useSidebarCollapsed.js diff --git a/web/src/hooks/useTableCompactMode.js b/web/src/hooks/common/useTableCompactMode.js similarity index 89% rename from web/src/hooks/useTableCompactMode.js rename to web/src/hooks/common/useTableCompactMode.js index f943bda7..1238a173 100644 --- a/web/src/hooks/useTableCompactMode.js +++ b/web/src/hooks/common/useTableCompactMode.js @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { getTableCompactMode, setTableCompactMode } from '../helpers'; -import { TABLE_COMPACT_MODES_KEY } from '../constants'; +import { getTableCompactMode, setTableCompactMode } from '../../helpers'; +import { TABLE_COMPACT_MODES_KEY } from '../../constants'; /** * 自定义 Hook:管理表格紧凑/自适应模式 diff --git a/web/src/hooks/useApiRequest.js b/web/src/hooks/playground/useApiRequest.js similarity index 99% rename from web/src/hooks/useApiRequest.js rename to web/src/hooks/playground/useApiRequest.js index 62c57032..f7bb2139 100644 --- a/web/src/hooks/useApiRequest.js +++ b/web/src/hooks/playground/useApiRequest.js @@ -5,13 +5,13 @@ import { API_ENDPOINTS, MESSAGE_STATUS, DEBUG_TABS -} from '../constants/playground.constants'; +} from '../../constants/playground.constants'; import { getUserIdFromLocalStorage, handleApiError, processThinkTags, processIncompleteThinkTags -} from '../helpers'; +} from '../../helpers'; export const useApiRequest = ( setMessage, diff --git a/web/src/hooks/useDataLoader.js b/web/src/hooks/playground/useDataLoader.js similarity index 92% rename from web/src/hooks/useDataLoader.js rename to web/src/hooks/playground/useDataLoader.js index 83d53199..4927fcf5 100644 --- a/web/src/hooks/useDataLoader.js +++ b/web/src/hooks/playground/useDataLoader.js @@ -1,7 +1,7 @@ import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { API, processModelsData, processGroupsData } from '../helpers'; -import { API_ENDPOINTS } from '../constants/playground.constants'; +import { API, processModelsData, processGroupsData } from '../../helpers'; +import { API_ENDPOINTS } from '../../constants/playground.constants'; export const useDataLoader = ( userState, diff --git a/web/src/hooks/useMessageActions.js b/web/src/hooks/playground/useMessageActions.js similarity index 98% rename from web/src/hooks/useMessageActions.js rename to web/src/hooks/playground/useMessageActions.js index 4cfcf9f1..e400f56f 100644 --- a/web/src/hooks/useMessageActions.js +++ b/web/src/hooks/playground/useMessageActions.js @@ -1,8 +1,8 @@ import { useCallback } from 'react'; import { Toast, Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; -import { getTextContent } from '../helpers'; -import { ERROR_MESSAGES } from '../constants/playground.constants'; +import { getTextContent } from '../../helpers'; +import { ERROR_MESSAGES } from '../../constants/playground.constants'; export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => { const { t } = useTranslation(); diff --git a/web/src/hooks/useMessageEdit.js b/web/src/hooks/playground/useMessageEdit.js similarity index 97% rename from web/src/hooks/useMessageEdit.js rename to web/src/hooks/playground/useMessageEdit.js index 479524b6..5a8bfdc4 100644 --- a/web/src/hooks/useMessageEdit.js +++ b/web/src/hooks/playground/useMessageEdit.js @@ -1,8 +1,8 @@ import { useCallback, useState, useRef } from 'react'; import { Toast, Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; -import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers'; -import { MESSAGE_ROLES } from '../constants/playground.constants'; +import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../../helpers'; +import { MESSAGE_ROLES } from '../../constants/playground.constants'; export const useMessageEdit = ( setMessage, diff --git a/web/src/hooks/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js similarity index 97% rename from web/src/hooks/usePlaygroundState.js rename to web/src/hooks/playground/usePlaygroundState.js index e8c4727d..253b95da 100644 --- a/web/src/hooks/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.js @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useEffect } from 'react'; -import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants'; -import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage'; -import { processIncompleteThinkTags } from '../helpers'; +import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../../constants/playground.constants'; +import { loadConfig, saveConfig, loadMessages, saveMessages } from '../../components/playground/configStorage'; +import { processIncompleteThinkTags } from '../../helpers'; export const usePlaygroundState = () => { // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息 diff --git a/web/src/hooks/useSyncMessageAndCustomBody.js b/web/src/hooks/playground/useSyncMessageAndCustomBody.js similarity index 98% rename from web/src/hooks/useSyncMessageAndCustomBody.js rename to web/src/hooks/playground/useSyncMessageAndCustomBody.js index 6f0c19ad..f0f36734 100644 --- a/web/src/hooks/useSyncMessageAndCustomBody.js +++ b/web/src/hooks/playground/useSyncMessageAndCustomBody.js @@ -1,5 +1,5 @@ import { useCallback, useRef } from 'react'; -import { MESSAGE_ROLES } from '../constants/playground.constants'; +import { MESSAGE_ROLES } from '../../constants/playground.constants'; export const useSyncMessageAndCustomBody = ( customRequestMode, diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 0934d891..c882fe10 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -8,7 +8,7 @@ import { showSuccess, verifyJSON, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { CHANNEL_OPTIONS } from '../../constants'; import { SideSheet, diff --git a/web/src/pages/Chat/index.js b/web/src/pages/Chat/index.js index 52e91526..53fa03fb 100644 --- a/web/src/pages/Chat/index.js +++ b/web/src/pages/Chat/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useTokenKeys } from '../../hooks/useTokenKeys'; +import { useTokenKeys } from '../../hooks/chat/useTokenKeys'; import { Spin } from '@douyinfe/semi-ui'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/pages/Chat2Link/index.js b/web/src/pages/Chat2Link/index.js index f46bbd50..b3e17ac3 100644 --- a/web/src/pages/Chat2Link/index.js +++ b/web/src/pages/Chat2Link/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useTokenKeys } from '../../hooks/useTokenKeys'; +import { useTokenKeys } from '../../hooks/chat/useTokenKeys'; const chat2page = () => { const { keys, chatLink, serverAddress, isLoading } = useTokenKeys(); diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 704093bb..f124452a 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -54,7 +54,7 @@ import { copy, getRelativeTime } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { UserContext } from '../../context/User/index.js'; import { StatusContext } from '../../context/Status/index.js'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/pages/Home/index.js b/web/src/pages/Home/index.js index 582410d4..bf859091 100644 --- a/web/src/pages/Home/index.js +++ b/web/src/pages/Home/index.js @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui'; import { API, showError, copy, showSuccess } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { API_ENDPOINTS } from '../../constants/common.constant'; import { StatusContext } from '../../context/Status'; import { marked } from 'marked'; diff --git a/web/src/pages/Playground/index.js b/web/src/pages/Playground/index.js index 345959a1..bc95d489 100644 --- a/web/src/pages/Playground/index.js +++ b/web/src/pages/Playground/index.js @@ -5,15 +5,15 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui'; // Context import { UserContext } from '../../context/User/index.js'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; // hooks -import { usePlaygroundState } from '../../hooks/usePlaygroundState.js'; -import { useMessageActions } from '../../hooks/useMessageActions.js'; -import { useApiRequest } from '../../hooks/useApiRequest.js'; -import { useSyncMessageAndCustomBody } from '../../hooks/useSyncMessageAndCustomBody.js'; -import { useMessageEdit } from '../../hooks/useMessageEdit.js'; -import { useDataLoader } from '../../hooks/useDataLoader.js'; +import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState.js'; +import { useMessageActions } from '../../hooks/playground/useMessageActions.js'; +import { useApiRequest } from '../../hooks/playground/useApiRequest.js'; +import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody.js'; +import { useMessageEdit } from '../../hooks/playground/useMessageEdit.js'; +import { useDataLoader } from '../../hooks/playground/useDataLoader.js'; // Constants and utils import { diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/pages/Redemption/EditRedemption.js index 44d17e62..310fdcd0 100644 --- a/web/src/pages/Redemption/EditRedemption.js +++ b/web/src/pages/Redemption/EditRedemption.js @@ -8,7 +8,7 @@ import { renderQuota, renderQuotaWithPrompt, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, Modal, diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 5a82f40b..3bb8d091 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -19,7 +19,7 @@ import { CheckCircle, } from 'lucide-react'; import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; -import { useIsMobile } from '../../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../../hooks/common/useIsMobile.js'; import { DEFAULT_ENDPOINT } from '../../../constants'; import { useTranslation } from 'react-i18next'; import { diff --git a/web/src/pages/Token/EditToken.js b/web/src/pages/Token/EditToken.js index 4eb9bcf4..7c7a61e9 100644 --- a/web/src/pages/Token/EditToken.js +++ b/web/src/pages/Token/EditToken.js @@ -8,7 +8,7 @@ import { renderQuotaWithPrompt, getModelCategories, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, SideSheet, diff --git a/web/src/pages/User/AddUser.js b/web/src/pages/User/AddUser.js index fa4c97e6..54d9b002 100644 --- a/web/src/pages/User/AddUser.js +++ b/web/src/pages/User/AddUser.js @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react'; import { API, showError, showSuccess } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, SideSheet, diff --git a/web/src/pages/User/EditUser.js b/web/src/pages/User/EditUser.js index bfccf37b..53fa9b20 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/pages/User/EditUser.js @@ -7,7 +7,7 @@ import { renderQuota, renderQuotaWithPrompt, } from '../../helpers'; -import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { Button, Modal, From acf6ec9349af96436cf668d095d8133a86eded58 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 22:04:54 +0800 Subject: [PATCH 008/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20restru?= =?UTF-8?q?cture=20LogsTable=20into=20modular=20component=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the monolithic LogsTable component (1453 lines) into a modular, maintainable architecture following the channels table pattern. ## What Changed ### 🏗️ Architecture - Split single large file into focused, single-responsibility components - Introduced custom hook `useLogsData` for centralized state management - Created dedicated column definitions file for better organization - Implemented modal components for user interactions ### 📁 New Structure ``` web/src/components/table/usage-logs/ ├── index.jsx # Main page component orchestrator ├── LogsTable.jsx # Pure table rendering component ├── LogsActions.jsx # Actions area (stats + compact mode) ├── LogsFilters.jsx # Search form component ├── LogsColumnDefs.js # Column definitions and renderers └── modals/ ├── ColumnSelectorModal.jsx # Column visibility settings └── UserInfoModal.jsx # User information display web/src/hooks/logs/ └── useLogsData.js # Custom hook for state & logic ``` ### 🎯 Key Improvements - **Maintainability**: Clear separation of concerns, easier to understand - **Reusability**: Modular components can be reused independently - **Performance**: Optimized with `useMemo` for column rendering - **Testing**: Single-responsibility components easier to test - **Developer Experience**: Better code organization and readability ### 🔧 Technical Details - Preserved all existing functionality and user experience - Maintained backward compatibility through existing import path - Centralized all business logic in `useLogsData` custom hook - Extracted column definitions to separate module with render functions - Split complex UI into focused components (table, actions, filters, modals) ### 🐛 Fixes - Fixed Semi UI component import issues (`Typography.Paragraph`) - Resolved module export dependencies - Maintained consistent prop passing patterns ## Breaking Changes None - all existing imports and functionality preserved. --- web/src/components/common/ui/CardPro.js | 18 +- web/src/components/table/LogsTable.js | 1454 +---------------- .../table/channels/ChannelsActions.jsx | 6 +- .../table/channels/ChannelsFilters.jsx | 6 +- .../table/channels/ChannelsTabs.jsx | 2 +- .../table/usage-logs/UsageLogsActions.jsx | 65 + .../table/usage-logs/UsageLogsColumnDefs.js | 549 +++++++ .../table/usage-logs/UsageLogsFilters.jsx | 169 ++ .../table/usage-logs/UsageLogsTable.jsx | 107 ++ web/src/components/table/usage-logs/index.jsx | 31 + .../usage-logs/modals/ColumnSelectorModal.jsx | 91 ++ .../table/usage-logs/modals/UserInfoModal.jsx | 39 + web/src/hooks/usage-logs/useUsageLogsData.js | 601 +++++++ 13 files changed, 1670 insertions(+), 1468 deletions(-) create mode 100644 web/src/components/table/usage-logs/UsageLogsActions.jsx create mode 100644 web/src/components/table/usage-logs/UsageLogsColumnDefs.js create mode 100644 web/src/components/table/usage-logs/UsageLogsFilters.jsx create mode 100644 web/src/components/table/usage-logs/UsageLogsTable.jsx create mode 100644 web/src/components/table/usage-logs/index.jsx create mode 100644 web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx create mode 100644 web/src/components/table/usage-logs/modals/UserInfoModal.jsx create mode 100644 web/src/hooks/usage-logs/useUsageLogsData.js diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index 4f240e9e..944f33c1 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -45,33 +45,33 @@ const CardPro = ({
{/* 统计信息区域 - 用于type2 */} {type === 'type2' && statsArea && ( -
+ <> {statsArea} -
+ )} {/* 描述信息区域 - 用于type1和type3 */} {(type === 'type1' || type === 'type3') && descriptionArea && ( -
+ <> {descriptionArea} -
+ )} {/* 第一个分隔线 - 在描述信息或统计信息后面 */} - {((type === 'type1' || type === 'type3') && descriptionArea) || - (type === 'type2' && statsArea) ? ( + {((type === 'type1' || type === 'type3') && descriptionArea) || + (type === 'type2' && statsArea) ? ( ) : null} {/* 类型切换/标签区域 - 主要用于type3 */} {type === 'type3' && tabsArea && ( -
+ <> {tabsArea} -
+ )} {/* 操作按钮和搜索表单的容器 */} -
+
{/* 操作按钮区域 - 用于type1和type3 */} {(type === 'type1' || type === 'type3') && actionsArea && (
diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js index f181d9c6..cea5d9bd 100644 --- a/web/src/components/table/LogsTable.js +++ b/web/src/components/table/LogsTable.js @@ -1,1452 +1,2 @@ -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, - Typography, - Form, -} from '@douyinfe/semi-ui'; -import CardPro from '../common/ui/CardPro'; -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/common/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} - - - - -
- - } - searchArea={ -
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('错误')} - - -
- -
- - - -
-
-
- - } - > -
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={{ - currentPage: activePage, - pageSize: pageSize, - total: logCount, - pageSizeOptions: [10, 20, 50, 100], - showSizeChanger: true, - onPageSizeChange: (size) => { - handlePageSizeChange(size); - }, - onPageChange: handlePageChange, - }} - /> - - - ); -}; - -export default LogsTable; +// 重构后的 LogsTable - 使用新的模块化架构 +export { default } from './usage-logs/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsActions.jsx b/web/src/components/table/channels/ChannelsActions.jsx index f244243c..ae64b188 100644 --- a/web/src/components/table/channels/ChannelsActions.jsx +++ b/web/src/components/table/channels/ChannelsActions.jsx @@ -35,9 +35,9 @@ const ChannelsActions = ({ t }) => { return ( -
+
{/* 第一行:批量操作按钮 + 设置开关 */} -
+
{/* 左侧:批量操作按钮 */}
-
+
setFormApi(api)} @@ -64,7 +64,7 @@ const ChannelsFilters = ({ layout="horizontal" trigger="change" stopValidateWithError={false} - className="flex flex-col md:flex-row items-center gap-4 w-full" + className="flex flex-col md:flex-row items-center gap-2 w-full" >
{ + 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..628835d7 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.js @@ -0,0 +1,549 @@ +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..6db77906 --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogsFilters.jsx @@ -0,0 +1,169 @@ +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..a6a33bbf --- /dev/null +++ b/web/src/components/table/usage-logs/UsageLogsTable.jsx @@ -0,0 +1,107 @@ +import React, { useMemo } from 'react'; +import { Table, Empty, Descriptions } from '@douyinfe/semi-ui'; +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, + }} + /> + ); +}; + +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..e53d71b3 --- /dev/null +++ b/web/src/components/table/usage-logs/index.jsx @@ -0,0 +1,31 @@ +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'; + +const LogsPage = () => { + const logsData = useLogsData(); + + return ( + <> + {/* Modals */} + + + + {/* Main Content */} + } + searchArea={} + > + + + + ); +}; + +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..cfc20e2e --- /dev/null +++ b/web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx @@ -0,0 +1,91 @@ +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..5b9abe71 --- /dev/null +++ b/web/src/components/table/usage-logs/modals/UserInfoModal.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Modal } from '@douyinfe/semi-ui'; +import { renderQuota, renderNumber } from '../../../../helpers'; + +const UserInfoModal = ({ + showUserInfo, + setShowUserInfoModal, + userInfoData, + t, +}) => { + return ( + setShowUserInfoModal(false)} + footer={null} + centered={true} + > + {userInfoData && ( +
+

+ {t('用户名')}: {userInfoData.username} +

+

+ {t('余额')}: {renderQuota(userInfoData.quota)} +

+

+ {t('已用额度')}:{renderQuota(userInfoData.used_quota)} +

+

+ {t('请求次数')}:{renderNumber(userInfoData.request_count)} +

+
+ )} +
+ ); +}; + +export default UserInfoModal; \ No newline at end of file diff --git a/web/src/hooks/usage-logs/useUsageLogsData.js b/web/src/hooks/usage-logs/useUsageLogsData.js new file mode 100644 index 00000000..326f6afc --- /dev/null +++ b/web/src/hooks/usage-logs/useUsageLogsData.js @@ -0,0 +1,601 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@douyinfe/semi-ui'; +import { + API, + getTodayStartTimestamp, + isAdmin, + showError, + showSuccess, + timestamp2string, + renderQuota, + renderNumber, + getLogOther, + copy, + renderClaudeLogContent, + renderLogContent, + renderAudioModelPrice, + renderClaudeModelPrice, + renderModelPrice +} from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useLogsData = () => { + const { t } = useTranslation(); + + // 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', + }; + + // Basic state + 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); + + // User and admin + const isAdminUser = isAdmin(); + + // Statistics state + const [stat, setStat] = useState({ + quota: 0, + token: 0, + }); + + // Form state + const [formApi, setFormApi] = useState(null); + let now = new Date(); + const formInitValues = { + username: '', + token_name: '', + model_name: '', + channel: '', + group: '', + dateRange: [ + timestamp2string(getTodayStartTimestamp()), + timestamp2string(now.getTime() / 1000 + 3600), + ], + logType: '0', + }; + + // Column visibility state + const [visibleColumns, setVisibleColumns] = useState({}); + const [showColumnSelector, setShowColumnSelector] = useState(false); + + // Compact mode + const [compactMode, setCompactMode] = useTableCompactMode('logs'); + + // User info modal state + const [showUserInfo, setShowUserInfoModal] = useState(false); + const [userInfoData, setUserInfoData] = useState(null); + + // Load saved column preferences from localStorage + useEffect(() => { + const savedColumns = localStorage.getItem('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(); + } + }, []); + + // 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) => { + if ( + (key === COLUMN_KEYS.CHANNEL || + key === COLUMN_KEYS.USERNAME || + key === COLUMN_KEYS.RETRY) && + !isAdminUser + ) { + updatedColumns[key] = false; + } else { + updatedColumns[key] = checked; + } + }); + + setVisibleColumns(updatedColumns); + }; + + // Update table when column visibility changes + useEffect(() => { + if (Object.keys(visibleColumns).length > 0) { + localStorage.setItem( + 'logs-table-columns', + JSON.stringify(visibleColumns), + ); + } + }, [visibleColumns]); + + // 获取表单值的辅助函数,确保所有值都是字符串 + 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, + }; + }; + + // Statistics functions + 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); + }; + + // User info function + const showUserInfoFunc = async (userId) => { + if (!isAdminUser) { + return; + } + const res = await API.get(`/api/user/${userId}`); + const { success, message, data } = res.data; + if (success) { + setUserInfoData(data); + setShowUserInfoModal(true); + } else { + showError(message); + } + }; + + // Format logs data + 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 (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); + }; + + // Load logs function + 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(); + + 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); + }; + + // Page handlers + const handlePageChange = (page) => { + setActivePage(page); + loadLogs(page, pageSize).then((r) => { }); + }; + + const handlePageSizeChange = async (size) => { + localStorage.setItem('page-size', size + ''); + setPageSize(size); + setActivePage(1); + loadLogs(activePage, size) + .then() + .catch((reason) => { + showError(reason); + }); + }; + + // Refresh function + const refresh = async () => { + setActivePage(1); + handleEyeClick(); + await loadLogs(1, pageSize); + }; + + // Copy text function + const copyText = async (e, text) => { + e.stopPropagation(); + if (await copy(text)) { + showSuccess('已复制:' + text); + } else { + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); + } + }; + + // Initialize data + useEffect(() => { + const localPageSize = + parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; + setPageSize(localPageSize); + loadLogs(activePage, localPageSize) + .then() + .catch((reason) => { + showError(reason); + }); + }, []); + + // Initialize statistics when formApi is available + useEffect(() => { + if (formApi) { + handleEyeClick(); + } + }, [formApi]); + + // Check if any record has expandable content + const hasExpandableRows = () => { + return logs.some( + (log) => expandData[log.key] && expandData[log.key].length > 0, + ); + }; + + return { + // Basic state + logs, + expandData, + showStat, + loading, + loadingStat, + activePage, + logCount, + pageSize, + logType, + stat, + isAdminUser, + + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, + + // Column visibility + visibleColumns, + showColumnSelector, + setShowColumnSelector, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + + // Compact mode + compactMode, + setCompactMode, + + // User info modal + showUserInfo, + setShowUserInfoModal, + userInfoData, + showUserInfoFunc, + + // Functions + loadLogs, + handlePageChange, + handlePageSizeChange, + refresh, + copyText, + handleEyeClick, + setLogsFormat, + hasExpandableRows, + setLogType, + + // Translation + t, + }; +}; \ No newline at end of file From 51695befd6aa53898b14a9c51194600ac32c2b5c Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 22:19:58 +0800 Subject: [PATCH 009/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20restru?= =?UTF-8?q?cture=20MjLogsTable=20into=20modular=20component=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the monolithic MjLogsTable component (971 lines) into a modular, maintainable architecture following the same pattern as LogsTable refactor. ## What Changed ### 🏗️ Architecture - Split large single file into focused, single-responsibility components - Introduced custom hook `useMjLogsData` for centralized state management - Created dedicated column definitions file for better organization - Implemented specialized modal components for Midjourney-specific features ### 📁 New Structure ``` web/src/components/table/mj-logs/ ├── index.jsx # Main page component orchestrator ├── MjLogsTable.jsx # Pure table rendering component ├── MjLogsActions.jsx # Actions area (banner + compact mode) ├── MjLogsFilters.jsx # Search form component ├── MjLogsColumnDefs.js # Column definitions and renderers └── modals/ ├── ColumnSelectorModal.jsx # Column visibility settings └── ContentModal.jsx # Content viewer (text + image preview) web/src/hooks/mj-logs/ └── useMjLogsData.js # Custom hook for state & logic ``` ### 🎯 Key Improvements - **Maintainability**: Clear separation of concerns, easier to understand - **Reusability**: Modular components can be reused independently - **Performance**: Optimized with `useMemo` for column rendering - **Testing**: Single-responsibility components easier to test - **Developer Experience**: Better code organization and readability ### 🎨 Midjourney-Specific Features Preserved - All task type rendering with icons (IMAGINE, UPSCALE, VARIATION, etc.) - Status rendering with appropriate colors and icons - Image preview functionality for generated artwork - Progress indicators for task completion - Admin-only columns for channel and submission results - Banner notification system for callback settings ### 🔧 Technical Details - Centralized all business logic in `useMjLogsData` custom hook - Extracted comprehensive column definitions with Lucide React icons - Split complex UI into focused components (table, actions, filters, modals) - Maintained responsive design patterns for mobile compatibility - Preserved admin permission handling for restricted features ### 🐛 Fixes - Improved component prop passing patterns - Enhanced type safety through better state management - Optimized rendering performance with proper memoization ## Breaking Changes None - all existing imports and functionality preserved. --- web/src/components/table/LogsTable.js | 2 - web/src/components/table/MjLogsTable.js | 972 +-------------- web/src/components/table/UsageLogsTable.js | 2 + .../table/mj-logs/MjLogsActions.jsx | 47 + .../table/mj-logs/MjLogsColumnDefs.js | 477 ++++++++ .../table/mj-logs/MjLogsFilters.jsx | 104 ++ .../components/table/mj-logs/MjLogsTable.jsx | 96 ++ web/src/components/table/mj-logs/index.jsx | 33 + .../mj-logs/modals/ColumnSelectorModal.jsx | 92 ++ .../table/mj-logs/modals/ContentModal.jsx | 36 + web/src/hooks/mj-logs/useMjLogsData.js | 307 +++++ web/src/hooks/usage-logs/useUsageLogsData.js | 1090 ++++++++--------- 12 files changed, 1741 insertions(+), 1517 deletions(-) delete mode 100644 web/src/components/table/LogsTable.js create mode 100644 web/src/components/table/UsageLogsTable.js create mode 100644 web/src/components/table/mj-logs/MjLogsActions.jsx create mode 100644 web/src/components/table/mj-logs/MjLogsColumnDefs.js create mode 100644 web/src/components/table/mj-logs/MjLogsFilters.jsx create mode 100644 web/src/components/table/mj-logs/MjLogsTable.jsx create mode 100644 web/src/components/table/mj-logs/index.jsx create mode 100644 web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx create mode 100644 web/src/components/table/mj-logs/modals/ContentModal.jsx create mode 100644 web/src/hooks/mj-logs/useMjLogsData.js diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js deleted file mode 100644 index cea5d9bd..00000000 --- a/web/src/components/table/LogsTable.js +++ /dev/null @@ -1,2 +0,0 @@ -// 重构后的 LogsTable - 使用新的模块化架构 -export { default } from './usage-logs/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js index 267a5be9..a5f614d0 100644 --- a/web/src/components/table/MjLogsTable.js +++ b/web/src/components/table/MjLogsTable.js @@ -1,970 +1,2 @@ -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, - Checkbox, - Empty, - Form, - ImagePreview, - Layout, - Modal, - Progress, - Skeleton, - Table, - Tag, - Typography -} from '@douyinfe/semi-ui'; -import CardPro from '../common/ui/CardPro'; -import { - IllustrationNoResult, - IllustrationNoResultDark -} from '@douyinfe/semi-illustrations'; -import { ITEMS_PER_PAGE } from '../../constants'; -import { - IconEyeOpened, - IconSearch, -} from '@douyinfe/semi-icons'; -import { useTableCompactMode } from '../../hooks/common/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 任务记录')} - - )} -
- - - } - searchArea={ -
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" - /> - )} -
- - {/* 操作按钮区域 */} -
-
-
- - - -
-
-
- - } - > -
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={{ - 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; +// 重构后的 MjLogsTable - 使用新的模块化架构 +export { default } from './mj-logs/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/UsageLogsTable.js b/web/src/components/table/UsageLogsTable.js new file mode 100644 index 00000000..da0623ae --- /dev/null +++ b/web/src/components/table/UsageLogsTable.js @@ -0,0 +1,2 @@ +// 重构后的 UsageLogsTable - 使用新的模块化架构 +export { default } from './usage-logs/index.jsx'; \ 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..85815c33 --- /dev/null +++ b/web/src/components/table/mj-logs/MjLogsActions.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Button, Skeleton, Typography } from '@douyinfe/semi-ui'; +import { IconEyeOpened } from '@douyinfe/semi-icons'; + +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..9e993785 --- /dev/null +++ b/web/src/components/table/mj-logs/MjLogsColumnDefs.js @@ -0,0 +1,477 @@ +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..3cfa6d3b --- /dev/null +++ b/web/src/components/table/mj-logs/MjLogsFilters.jsx @@ -0,0 +1,104 @@ +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..f440c8df --- /dev/null +++ b/web/src/components/table/mj-logs/MjLogsTable.jsx @@ -0,0 +1,96 @@ +import React, { useMemo } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +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, + }} + /> + ); +}; + +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..a017d390 --- /dev/null +++ b/web/src/components/table/mj-logs/index.jsx @@ -0,0 +1,33 @@ +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'; + +const MjLogsPage = () => { + const mjLogsData = useMjLogsData(); + + return ( + <> + {/* Modals */} + + + + + } + searchArea={} + > + + + + + ); +}; + +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..3a9f0070 --- /dev/null +++ b/web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx @@ -0,0 +1,92 @@ +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..0dd63bec --- /dev/null +++ b/web/src/components/table/mj-logs/modals/ContentModal.jsx @@ -0,0 +1,36 @@ +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/hooks/mj-logs/useMjLogsData.js b/web/src/hooks/mj-logs/useMjLogsData.js new file mode 100644 index 00000000..906cd6fc --- /dev/null +++ b/web/src/hooks/mj-logs/useMjLogsData.js @@ -0,0 +1,307 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@douyinfe/semi-ui'; +import { + API, + copy, + isAdmin, + showError, + showSuccess, + timestamp2string +} from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useMjLogsData = () => { + const { t } = useTranslation(); + + // Define column keys for selection + 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', + }; + + // Basic state + 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 [showBanner, setShowBanner] = useState(false); + + // User and admin + const isAdminUser = isAdmin(); + + // Modal states + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalContent, setModalContent] = useState(''); + const [isModalOpenurl, setIsModalOpenurl] = useState(false); + const [modalImageUrl, setModalImageUrl] = useState(''); + + // Form state + const [formApi, setFormApi] = useState(null); + let now = new Date(); + const formInitValues = { + channel_id: '', + mj_id: '', + dateRange: [ + timestamp2string(now.getTime() / 1000 - 2592000), + timestamp2string(now.getTime() / 1000 + 3600) + ], + }; + + // Column visibility state + const [visibleColumns, setVisibleColumns] = useState({}); + const [showColumnSelector, setShowColumnSelector] = useState(false); + + // Compact mode + const [compactMode, setCompactMode] = useTableCompactMode('mjLogs'); + + // Load saved column preferences from localStorage + 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(); + } + }, []); + + // Check banner notification + useEffect(() => { + const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled'); + if (mjNotifyEnabled !== 'true') { + setShowBanner(true); + } + }, []); + + // Get default column visibility based on user role + 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, + }; + }; + + // Initialize default column visibility + const initDefaultColumns = () => { + const defaults = getDefaultColumnVisibility(); + setVisibleColumns(defaults); + localStorage.setItem('mj-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) => { + if ( + (key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) && + !isAdminUser + ) { + updatedColumns[key] = false; + } else { + updatedColumns[key] = checked; + } + }); + + setVisibleColumns(updatedColumns); + }; + + // Update table when column visibility changes + useEffect(() => { + if (Object.keys(visibleColumns).length > 0) { + localStorage.setItem('mj-logs-table-columns', JSON.stringify(visibleColumns)); + } + }, [visibleColumns]); + + // Get form values helper function + 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, + }; + }; + + // Enrich logs data + const enrichLogs = (items) => { + return items.map((log) => ({ + ...log, + timestamp2string: timestamp2string(log.created_at), + key: '' + log.id, + })); + }; + + // Sync page data + const syncPageData = (payload) => { + const items = enrichLogs(payload.items || []); + setLogs(items); + setLogCount(payload.total || 0); + setActivePage(payload.page || 1); + setPageSize(payload.page_size || pageSize); + }; + + // Load logs function + 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); + }; + + // Page handlers + const handlePageChange = (page) => { + loadLogs(page, pageSize).then(); + }; + + const handlePageSizeChange = async (size) => { + localStorage.setItem('mj-page-size', size + ''); + await loadLogs(1, size); + }; + + // Refresh function + const refresh = async () => { + await loadLogs(1, pageSize); + }; + + // Copy text function + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制:') + text); + } else { + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); + } + }; + + // Modal handlers + const openContentModal = (content) => { + setModalContent(content); + setIsModalOpen(true); + }; + + const openImageModal = (imageUrl) => { + setModalImageUrl(imageUrl); + setIsModalOpenurl(true); + }; + + // Initialize data + useEffect(() => { + const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE; + setPageSize(localPageSize); + loadLogs(1, localPageSize).then(); + }, []); + + return { + // Basic state + logs, + loading, + activePage, + logCount, + pageSize, + showBanner, + isAdminUser, + + // Modal state + isModalOpen, + setIsModalOpen, + modalContent, + isModalOpenurl, + setIsModalOpenurl, + modalImageUrl, + + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, + + // Column visibility + visibleColumns, + showColumnSelector, + setShowColumnSelector, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + + // Compact mode + compactMode, + setCompactMode, + + // Functions + loadLogs, + handlePageChange, + handlePageSizeChange, + refresh, + copyText, + openContentModal, + openImageModal, + enrichLogs, + syncPageData, + + // Translation + t, + }; +}; \ No newline at end of file diff --git a/web/src/hooks/usage-logs/useUsageLogsData.js b/web/src/hooks/usage-logs/useUsageLogsData.js index 326f6afc..5959714b 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.js +++ b/web/src/hooks/usage-logs/useUsageLogsData.js @@ -2,600 +2,600 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from '@douyinfe/semi-ui'; import { - API, - getTodayStartTimestamp, - isAdmin, - showError, - showSuccess, - timestamp2string, - renderQuota, - renderNumber, - getLogOther, - copy, - renderClaudeLogContent, - renderLogContent, - renderAudioModelPrice, - renderClaudeModelPrice, - renderModelPrice + API, + getTodayStartTimestamp, + isAdmin, + showError, + showSuccess, + timestamp2string, + renderQuota, + renderNumber, + getLogOther, + copy, + renderClaudeLogContent, + renderLogContent, + renderAudioModelPrice, + renderClaudeModelPrice, + renderModelPrice } from '../../helpers'; import { ITEMS_PER_PAGE } from '../../constants'; import { useTableCompactMode } from '../common/useTableCompactMode'; export const useLogsData = () => { - const { t } = useTranslation(); + const { t } = useTranslation(); - // 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', - }; + // 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', + }; - // Basic state - 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); + // Basic state + 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); - // User and admin - const isAdminUser = isAdmin(); + // User and admin + const isAdminUser = isAdmin(); - // Statistics state - const [stat, setStat] = useState({ - quota: 0, - token: 0, - }); + // Statistics state + const [stat, setStat] = useState({ + quota: 0, + token: 0, + }); - // Form state - const [formApi, setFormApi] = useState(null); - let now = new Date(); - const formInitValues = { - username: '', - token_name: '', - model_name: '', - channel: '', - group: '', - dateRange: [ - timestamp2string(getTodayStartTimestamp()), - timestamp2string(now.getTime() / 1000 + 3600), - ], - logType: '0', - }; + // Form state + const [formApi, setFormApi] = useState(null); + let now = new Date(); + const formInitValues = { + username: '', + token_name: '', + model_name: '', + channel: '', + group: '', + dateRange: [ + timestamp2string(getTodayStartTimestamp()), + timestamp2string(now.getTime() / 1000 + 3600), + ], + logType: '0', + }; - // Column visibility state - const [visibleColumns, setVisibleColumns] = useState({}); - const [showColumnSelector, setShowColumnSelector] = useState(false); + // Column visibility state + const [visibleColumns, setVisibleColumns] = useState({}); + const [showColumnSelector, setShowColumnSelector] = useState(false); - // Compact mode - const [compactMode, setCompactMode] = useTableCompactMode('logs'); + // Compact mode + const [compactMode, setCompactMode] = useTableCompactMode('logs'); - // User info modal state - const [showUserInfo, setShowUserInfoModal] = useState(false); - const [userInfoData, setUserInfoData] = useState(null); + // User info modal state + const [showUserInfo, setShowUserInfoModal] = useState(false); + const [userInfoData, setUserInfoData] = useState(null); - // Load saved column preferences from localStorage - useEffect(() => { - const savedColumns = localStorage.getItem('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(); - } - }, []); + // Load saved column preferences from localStorage + useEffect(() => { + const savedColumns = localStorage.getItem('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(); + } + }, []); - // 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, - }; - }; + // 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)); - }; + // 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 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 = {}; + // Handle "Select All" checkbox + 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.USERNAME || - key === COLUMN_KEYS.RETRY) && - !isAdminUser - ) { - updatedColumns[key] = false; - } else { - updatedColumns[key] = checked; - } - }); + allKeys.forEach((key) => { + if ( + (key === COLUMN_KEYS.CHANNEL || + key === COLUMN_KEYS.USERNAME || + key === COLUMN_KEYS.RETRY) && + !isAdminUser + ) { + updatedColumns[key] = false; + } else { + updatedColumns[key] = checked; + } + }); - setVisibleColumns(updatedColumns); - }; + setVisibleColumns(updatedColumns); + }; - // Update table when column visibility changes - useEffect(() => { - if (Object.keys(visibleColumns).length > 0) { - localStorage.setItem( - 'logs-table-columns', - JSON.stringify(visibleColumns), - ); - } - }, [visibleColumns]); + // Update table when column visibility changes + useEffect(() => { + if (Object.keys(visibleColumns).length > 0) { + localStorage.setItem( + 'logs-table-columns', + JSON.stringify(visibleColumns), + ); + } + }, [visibleColumns]); - // 获取表单值的辅助函数,确保所有值都是字符串 - const getFormValues = () => { - const formValues = formApi ? formApi.getValues() : {}; + // 获取表单值的辅助函数,确保所有值都是字符串 + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; - let start_timestamp = timestamp2string(getTodayStartTimestamp()); - let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600); + 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]; - } + 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, - }; - }; + 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, + }; + }; - // Statistics functions - 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); - } - }; + // Statistics functions + 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 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 handleEyeClick = async () => { + if (loadingStat) { + return; + } + setLoadingStat(true); + if (isAdminUser) { + await getLogStat(); + } else { + await getLogSelfStat(); + } + setShowStat(true); + setLoadingStat(false); + }; - // User info function - const showUserInfoFunc = async (userId) => { - if (!isAdminUser) { - return; - } - const res = await API.get(`/api/user/${userId}`); - const { success, message, data } = res.data; - if (success) { - setUserInfoData(data); - setShowUserInfoModal(true); - } else { - showError(message); - } - }; + // User info function + const showUserInfoFunc = async (userId) => { + if (!isAdminUser) { + return; + } + const res = await API.get(`/api/user/${userId}`); + const { success, message, data } = res.data; + if (success) { + setUserInfoData(data); + setShowUserInfoModal(true); + } else { + showError(message); + } + }; - // Format logs data - 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 = []; + // Format logs data + 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 (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; - } + 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); - }; + setExpandData(expandDatesLocal); + setLogs(logs); + }; - // Load logs function - const loadLogs = async (startIdx, pageSize, customLogType = null) => { - setLoading(true); + // Load logs function + 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(); + let url = ''; + const { + username, + token_name, + model_name, + start_timestamp, + end_timestamp, + channel, + group, + logType: formLogType, + } = getFormValues(); - const currentLogType = - customLogType !== null - ? customLogType - : formLogType !== undefined - ? formLogType - : 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); + 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); - }; + setLogsFormat(newPageData); + } else { + showError(message); + } + setLoading(false); + }; - // Page handlers - const handlePageChange = (page) => { - setActivePage(page); - loadLogs(page, pageSize).then((r) => { }); - }; + // Page handlers + const handlePageChange = (page) => { + setActivePage(page); + loadLogs(page, pageSize).then((r) => { }); + }; - const handlePageSizeChange = async (size) => { - localStorage.setItem('page-size', size + ''); - setPageSize(size); - setActivePage(1); - loadLogs(activePage, size) - .then() - .catch((reason) => { - showError(reason); - }); - }; + const handlePageSizeChange = async (size) => { + localStorage.setItem('page-size', size + ''); + setPageSize(size); + setActivePage(1); + loadLogs(activePage, size) + .then() + .catch((reason) => { + showError(reason); + }); + }; - // Refresh function - const refresh = async () => { - setActivePage(1); - handleEyeClick(); - await loadLogs(1, pageSize); - }; + // Refresh function + const refresh = async () => { + setActivePage(1); + handleEyeClick(); + await loadLogs(1, pageSize); + }; - // Copy text function - const copyText = async (e, text) => { - e.stopPropagation(); - if (await copy(text)) { - showSuccess('已复制:' + text); - } else { - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; + // Copy text function + const copyText = async (e, text) => { + e.stopPropagation(); + if (await copy(text)) { + showSuccess('已复制:' + text); + } else { + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); + } + }; - // Initialize data - useEffect(() => { - const localPageSize = - parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; - setPageSize(localPageSize); - loadLogs(activePage, localPageSize) - .then() - .catch((reason) => { - showError(reason); - }); - }, []); + // Initialize data + useEffect(() => { + const localPageSize = + parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE; + setPageSize(localPageSize); + loadLogs(activePage, localPageSize) + .then() + .catch((reason) => { + showError(reason); + }); + }, []); - // Initialize statistics when formApi is available - useEffect(() => { - if (formApi) { - handleEyeClick(); - } - }, [formApi]); + // Initialize statistics when formApi is available + useEffect(() => { + if (formApi) { + handleEyeClick(); + } + }, [formApi]); - // Check if any record has expandable content - const hasExpandableRows = () => { - return logs.some( - (log) => expandData[log.key] && expandData[log.key].length > 0, - ); - }; + // Check if any record has expandable content + const hasExpandableRows = () => { + return logs.some( + (log) => expandData[log.key] && expandData[log.key].length > 0, + ); + }; - return { - // Basic state - logs, - expandData, - showStat, - loading, - loadingStat, - activePage, - logCount, - pageSize, - logType, - stat, - isAdminUser, + return { + // Basic state + logs, + expandData, + showStat, + loading, + loadingStat, + activePage, + logCount, + pageSize, + logType, + stat, + isAdminUser, - // Form state - formApi, - setFormApi, - formInitValues, - getFormValues, + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, - // Column visibility - visibleColumns, - showColumnSelector, - setShowColumnSelector, - handleColumnVisibilityChange, - handleSelectAll, - initDefaultColumns, - COLUMN_KEYS, + // Column visibility + visibleColumns, + showColumnSelector, + setShowColumnSelector, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, - // Compact mode - compactMode, - setCompactMode, + // Compact mode + compactMode, + setCompactMode, - // User info modal - showUserInfo, - setShowUserInfoModal, - userInfoData, - showUserInfoFunc, + // User info modal + showUserInfo, + setShowUserInfoModal, + userInfoData, + showUserInfoFunc, - // Functions - loadLogs, - handlePageChange, - handlePageSizeChange, - refresh, - copyText, - handleEyeClick, - setLogsFormat, - hasExpandableRows, - setLogType, + // Functions + loadLogs, + handlePageChange, + handlePageSizeChange, + refresh, + copyText, + handleEyeClick, + setLogsFormat, + hasExpandableRows, + setLogType, - // Translation - t, - }; + // Translation + t, + }; }; \ No newline at end of file From baffe556439e02d24005a389beddc134739ffb0e Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 22:33:05 +0800 Subject: [PATCH 010/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20restru?= =?UTF-8?q?cture=20TaskLogsTable=20into=20modular=20component=20architectu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the monolithic TaskLogsTable component (802 lines) into a modular, maintainable architecture following the established pattern from LogsTable and MjLogsTable refactors. ## What Changed ### 🏗️ Architecture - Split large single file into focused, single-responsibility components - Introduced custom hook `useTaskLogsData` for centralized state management - Created dedicated column definitions file for better organization - Implemented modal components for user interactions ### 📁 New Structure ``` web/src/components/table/task-logs/ ├── index.jsx # Main page component orchestrator ├── TaskLogsTable.jsx # Pure table rendering component ├── TaskLogsActions.jsx # Actions area (task records + compact mode) ├── TaskLogsFilters.jsx # Search form component ├── TaskLogsColumnDefs.js # Column definitions and renderers └── modals/ ├── ColumnSelectorModal.jsx # Column visibility settings └── ContentModal.jsx # Content viewer for JSON data web/src/hooks/task-logs/ └── useTaskLogsData.js # Custom hook for state & logic ``` ### 🎯 Key Improvements - **Maintainability**: Clear separation of concerns, easier to understand - **Reusability**: Modular components can be reused independently - **Performance**: Optimized with `useMemo` for column rendering - **Testing**: Single-responsibility components easier to test - **Developer Experience**: Better code organization and readability ### 🎨 Task-Specific Features Preserved - All task type rendering with icons (MUSIC, LYRICS, video generation) - Platform-specific rendering (Suno, Kling, Jimeng) with distinct colors - Progress indicators for task completion status - Video preview links for successful video generation tasks - Admin-only columns for channel information - Status rendering with appropriate colors and icons ### 🔧 Technical Details - Centralized all business logic in `useTaskLogsData` custom hook - Extracted comprehensive column definitions with Lucide React icons - Split complex UI into focused components (table, actions, filters, modals) - Maintained responsive design patterns for mobile compatibility - Preserved admin permission handling for restricted features - Optimized spacing and layout (reduced gap from 4 to 2 for better density) ### 🎮 Platform Support - **Suno**: Music and lyrics generation with music icons - **Kling**: Video generation with video icons - **Jimeng**: Video generation with distinct purple styling ### 🐛 Fixes - Improved component prop passing patterns - Enhanced type safety through better state management - Optimized rendering performance with proper memoization - Streamlined export pattern using `export { default }` ## Breaking Changes None - all existing imports and functionality preserved. --- web/src/components/table/TaskLogsTable.js | 803 +----------------- .../table/task-logs/TaskLogsActions.jsx | 30 + .../table/task-logs/TaskLogsColumnDefs.js | 351 ++++++++ .../table/task-logs/TaskLogsFilters.jsx | 105 +++ .../table/task-logs/TaskLogsTable.jsx | 93 ++ web/src/components/table/task-logs/index.jsx | 33 + .../task-logs/modals/ColumnSelectorModal.jsx | 86 ++ .../table/task-logs/modals/ContentModal.jsx | 23 + web/src/hooks/task-logs/useTaskLogsData.js | 280 ++++++ web/src/pages/Log/index.js | 4 +- 10 files changed, 1005 insertions(+), 803 deletions(-) create mode 100644 web/src/components/table/task-logs/TaskLogsActions.jsx create mode 100644 web/src/components/table/task-logs/TaskLogsColumnDefs.js create mode 100644 web/src/components/table/task-logs/TaskLogsFilters.jsx create mode 100644 web/src/components/table/task-logs/TaskLogsTable.jsx create mode 100644 web/src/components/table/task-logs/index.jsx create mode 100644 web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx create mode 100644 web/src/components/table/task-logs/modals/ContentModal.jsx create mode 100644 web/src/hooks/task-logs/useTaskLogsData.js diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js index 0e3abbb7..a6996611 100644 --- a/web/src/components/table/TaskLogsTable.js +++ b/web/src/components/table/TaskLogsTable.js @@ -1,801 +1,2 @@ -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, - Checkbox, - Empty, - Form, - Layout, - Modal, - Progress, - Table, - Tag, - Typography -} from '@douyinfe/semi-ui'; -import CardPro from '../common/ui/CardPro'; -import { - IllustrationNoResult, - IllustrationNoResultDark -} from '@douyinfe/semi-illustrations'; -import { ITEMS_PER_PAGE } from '../../constants'; -import { - IconEyeOpened, - IconSearch, -} from '@douyinfe/semi-icons'; -import { useTableCompactMode } from '../../hooks/common/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('任务记录')} -
- - - } - searchArea={ -
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" - /> - )} -
- - {/* 操作按钮区域 */} -
-
-
- - - -
-
-
- - } - > -
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={{ - 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; +// 重构后的 TaskLogsTable - 使用新的模块化架构 +export { default } from './task-logs/index.jsx'; \ 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..0e1cec11 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsActions.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Button, Typography } from '@douyinfe/semi-ui'; +import { IconEyeOpened } from '@douyinfe/semi-icons'; + +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..92936abc --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.js @@ -0,0 +1,351 @@ +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..509f57b7 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx @@ -0,0 +1,105 @@ +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..b9ec6cb6 --- /dev/null +++ b/web/src/components/table/task-logs/TaskLogsTable.jsx @@ -0,0 +1,93 @@ +import React, { useMemo } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +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, + }} + /> + ); +}; + +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..f0c2b1b7 --- /dev/null +++ b/web/src/components/table/task-logs/index.jsx @@ -0,0 +1,33 @@ +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'; + +const TaskLogsPage = () => { + const taskLogsData = useTaskLogsData(); + + return ( + <> + {/* Modals */} + + + + + } + searchArea={} + > + + + + + ); +}; + +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..23624a72 --- /dev/null +++ b/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx @@ -0,0 +1,86 @@ +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..f82baf90 --- /dev/null +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -0,0 +1,23 @@ +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/hooks/task-logs/useTaskLogsData.js b/web/src/hooks/task-logs/useTaskLogsData.js new file mode 100644 index 00000000..64f1cc93 --- /dev/null +++ b/web/src/hooks/task-logs/useTaskLogsData.js @@ -0,0 +1,280 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@douyinfe/semi-ui'; +import { + API, + copy, + isAdmin, + showError, + showSuccess, + timestamp2string +} from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useTaskLogsData = () => { + const { t } = useTranslation(); + + // Define column keys for selection + 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', + }; + + // Basic state + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [activePage, setActivePage] = useState(1); + const [logCount, setLogCount] = useState(0); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + + // User and admin + const isAdminUser = isAdmin(); + + // Modal state + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalContent, setModalContent] = useState(''); + + // Form state + const [formApi, setFormApi] = useState(null); + let now = new Date(); + let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const formInitValues = { + channel_id: '', + task_id: '', + dateRange: [ + timestamp2string(zeroNow.getTime() / 1000), + timestamp2string(now.getTime() / 1000 + 3600) + ], + }; + + // Column visibility state + const [visibleColumns, setVisibleColumns] = useState({}); + const [showColumnSelector, setShowColumnSelector] = useState(false); + + // Compact mode + const [compactMode, setCompactMode] = useTableCompactMode('taskLogs'); + + // Load saved column preferences from localStorage + 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(); + } + }, []); + + // Get default column visibility based on user role + 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, + }; + }; + + // Initialize default column visibility + const initDefaultColumns = () => { + const defaults = getDefaultColumnVisibility(); + setVisibleColumns(defaults); + localStorage.setItem('task-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) => { + if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) { + updatedColumns[key] = false; + } else { + updatedColumns[key] = checked; + } + }); + + setVisibleColumns(updatedColumns); + }; + + // Update table when column visibility changes + useEffect(() => { + if (Object.keys(visibleColumns).length > 0) { + localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns)); + } + }, [visibleColumns]); + + // Get form values helper function + 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, + }; + }; + + // Enrich logs data + const enrichLogs = (items) => { + return items.map((log) => ({ + ...log, + timestamp2string: timestamp2string(log.created_at), + key: '' + log.id, + })); + }; + + // Sync page data + const syncPageData = (payload) => { + const items = enrichLogs(payload.items || []); + setLogs(items); + setLogCount(payload.total || 0); + setActivePage(payload.page || 1); + setPageSize(payload.page_size || pageSize); + }; + + // Load logs function + 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); + }; + + // Page handlers + const handlePageChange = (page) => { + loadLogs(page, pageSize).then(); + }; + + const handlePageSizeChange = async (size) => { + localStorage.setItem('task-page-size', size + ''); + await loadLogs(1, size); + }; + + // Refresh function + const refresh = async () => { + await loadLogs(1, pageSize); + }; + + // Copy text function + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制:') + text); + } else { + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); + } + }; + + // Modal handlers + const openContentModal = (content) => { + setModalContent(content); + setIsModalOpen(true); + }; + + // Initialize data + useEffect(() => { + const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE; + setPageSize(localPageSize); + loadLogs(1, localPageSize).then(); + }, []); + + return { + // Basic state + logs, + loading, + activePage, + logCount, + pageSize, + isAdminUser, + + // Modal state + isModalOpen, + setIsModalOpen, + modalContent, + + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, + + // Column visibility + visibleColumns, + showColumnSelector, + setShowColumnSelector, + handleColumnVisibilityChange, + handleSelectAll, + initDefaultColumns, + COLUMN_KEYS, + + // Compact mode + compactMode, + setCompactMode, + + // Functions + loadLogs, + handlePageChange, + handlePageSizeChange, + refresh, + copyText, + openContentModal, + enrichLogs, + syncPageData, + + // Translation + t, + }; +}; \ No newline at end of file diff --git a/web/src/pages/Log/index.js b/web/src/pages/Log/index.js index fa919964..f4bed060 100644 --- a/web/src/pages/Log/index.js +++ b/web/src/pages/Log/index.js @@ -1,9 +1,9 @@ import React from 'react'; -import LogsTable from '../../components/table/LogsTable'; +import UsageLogsTable from '../../components/table/UsageLogsTable'; const Token = () => (
- +
); From 67df60e76c38210df384ca02b9bc06d5d1e38151 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 18 Jul 2025 22:56:34 +0800 Subject: [PATCH 011/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20modula?= =?UTF-8?q?rize=20TokensTable=20component=20into=20maintainable=20architec?= =?UTF-8?q?ture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split monolithic 922-line TokensTable.js into modular components: * useTokensData.js: Custom hook for centralized state and logic management * TokensColumnDefs.js: Column definitions and rendering functions * TokensTable.jsx: Pure table component for rendering * TokensActions.jsx: Actions area (add, copy, delete tokens) * TokensFilters.jsx: Search form component with keyword and token filters * TokensDescription.jsx: Description area with compact mode toggle * index.jsx: Main orchestrator component - Features preserved: * Token status management with switch controls * Quota progress bars and visual indicators * Model limitations display with vendor avatars * IP restrictions handling and display * Chat integrations with dropdown menu * Batch operations (copy, delete) with confirmations * Key visibility toggle and copy functionality * Compact mode for responsive layouts * Search and filtering capabilities * Pagination and loading states - Improvements: * Better separation of concerns * Enhanced reusability and testability * Simplified maintenance and debugging * Consistent modular architecture pattern * Performance optimizations with useMemo * Backward compatibility maintained This refactoring follows the same successful pattern used for LogsTable, MjLogsTable, and TaskLogsTable, significantly improving code maintainability while preserving all existing functionality. --- web/src/components/table/TokensTable.js | 922 +----------------- .../components/table/tokens/TokensActions.jsx | 113 +++ .../table/tokens/TokensColumnDefs.js | 453 +++++++++ .../table/tokens/TokensDescription.jsx | 27 + .../components/table/tokens/TokensFilters.jsx | 84 ++ .../components/table/tokens/TokensTable.jsx | 99 ++ web/src/components/table/tokens/index.jsx | 90 ++ web/src/hooks/task-logs/useTaskLogsData.js | 10 +- web/src/hooks/tokens/useTokensData.js | 369 +++++++ 9 files changed, 1244 insertions(+), 923 deletions(-) create mode 100644 web/src/components/table/tokens/TokensActions.jsx create mode 100644 web/src/components/table/tokens/TokensColumnDefs.js create mode 100644 web/src/components/table/tokens/TokensDescription.jsx create mode 100644 web/src/components/table/tokens/TokensFilters.jsx create mode 100644 web/src/components/table/tokens/TokensTable.jsx create mode 100644 web/src/components/table/tokens/index.jsx create mode 100644 web/src/hooks/tokens/useTokensData.js diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js index e0b29df8..a30cb36d 100644 --- a/web/src/components/table/TokensTable.js +++ b/web/src/components/table/TokensTable.js @@ -1,921 +1,7 @@ -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, - Dropdown, - Empty, - Form, - Modal, - Space, - SplitButtonGroup, - Table, - Tag, - AvatarGroup, - Avatar, - Tooltip, - Progress, - Switch, - Input, - Typography -} from '@douyinfe/semi-ui'; -import CardPro from '../common/ui/CardPro'; -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/common/useTableCompactMode'; +// Import the new modular tokens table +import TokensPage from './tokens'; -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 renderDescriptionArea = () => ( -
-
- - {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} -
- -
- ); - - const renderActionsArea = () => ( -
- - - - - ), - }); - }} - size="small" - > - {t('复制所选令牌')} - - -
- ); - - const renderSearchArea = () => ( -
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 ( - <> - - - -
- {renderActionsArea()} -
-
- {renderSearchArea()} -
- - } - > -
{ - 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], - 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 the new component for backward compatibility +const TokensTable = TokensPage; export default TokensTable; diff --git a/web/src/components/table/tokens/TokensActions.jsx b/web/src/components/table/tokens/TokensActions.jsx new file mode 100644 index 00000000..09cb29eb --- /dev/null +++ b/web/src/components/table/tokens/TokensActions.jsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Button, Modal, Space } from '@douyinfe/semi-ui'; +import { showError } from '../../../helpers'; + +const TokensActions = ({ + selectedKeys, + setEditingToken, + setShowEdit, + batchCopyTokens, + batchDeleteTokens, + copyText, + t, +}) => { + // Handle copy selected tokens with options + const handleCopySelectedTokens = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + + Modal.info({ + title: t('复制令牌'), + icon: null, + content: t('请选择你的复制方式'), + footer: ( + + + + + ), + }); + }; + + // Handle delete selected tokens with confirmation + const handleDeleteSelectedTokens = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + + Modal.confirm({ + title: t('批量删除令牌'), + content: ( +
+ {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })} +
+ ), + onOk: () => batchDeleteTokens(), + }); + }; + + return ( +
+ + + + + +
+ ); +}; + +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..dc53eb74 --- /dev/null +++ b/web/src/components/table/tokens/TokensColumnDefs.js @@ -0,0 +1,453 @@ +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} + + ); + + if (record.unlimited_quota) { + return content; + } + + return ( + +
{t('已用额度')}: {renderQuota(used)}
+
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+
{t('总额度')}: {renderQuota(total)}
+
+ } + > + {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..d56d769c --- /dev/null +++ b/web/src/components/table/tokens/TokensDescription.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Button, Typography } from '@douyinfe/semi-ui'; +import { Key } from 'lucide-react'; + +const { Text } = Typography; + +const TokensDescription = ({ compactMode, setCompactMode, t }) => { + return ( +
+
+ + {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} +
+ + +
+ ); +}; + +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..63912c1b --- /dev/null +++ b/web/src/components/table/tokens/TokensFilters.jsx @@ -0,0 +1,84 @@ +import React 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 handleReset = (formApi) => { + if (formApi) { + formApi.reset(); + // Reset and search immediately + setTimeout(() => { + searchTokens(); + }, 100); + } + }; + + return ( +
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" + /> +
+ +
+ + + +
+
+
+ ); +}; + +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..ae1e8d0a --- /dev/null +++ b/web/src/components/table/tokens/TokensTable.jsx @@ -0,0 +1,99 @@ +import React, { useMemo } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +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..3a3a8fb7 --- /dev/null +++ b/web/src/components/table/tokens/index.jsx @@ -0,0 +1,90 @@ +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 EditToken from '../../../pages/Token/EditToken'; +import { useTokensData } from '../../../hooks/tokens/useTokensData'; + +const TokensPage = () => { + const tokensData = useTokensData(); + + const { + // Edit state + showEdit, + editingToken, + closeEdit, + refresh, + + // Actions state + selectedKeys, + setEditingToken, + setShowEdit, + batchDeleteTokens, + copyText, + + // Filters state + formInitValues, + setFormApi, + searchTokens, + loading, + searching, + + // Description state + compactMode, + setCompactMode, + + // Translation + t, + } = tokensData; + + return ( + <> + + + + } + actionsArea={ +
+ + +
+ +
+
+ } + > + +
+ + ); +}; + +export default TokensPage; \ No newline at end of file diff --git a/web/src/hooks/task-logs/useTaskLogsData.js b/web/src/hooks/task-logs/useTaskLogsData.js index 64f1cc93..479d3c46 100644 --- a/web/src/hooks/task-logs/useTaskLogsData.js +++ b/web/src/hooks/task-logs/useTaskLogsData.js @@ -14,7 +14,7 @@ import { useTableCompactMode } from '../common/useTableCompactMode'; export const useTaskLogsData = () => { const { t } = useTranslation(); - + // Define column keys for selection const COLUMN_KEYS = { SUBMIT_TIME: 'submit_time', @@ -36,10 +36,10 @@ export const useTaskLogsData = () => { const [activePage, setActivePage] = useState(1); const [logCount, setLogCount] = useState(0); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - + // User and admin const isAdminUser = isAdmin(); - + // Modal state const [isModalOpen, setIsModalOpen] = useState(false); const [modalContent, setModalContent] = useState(''); @@ -48,7 +48,7 @@ export const useTaskLogsData = () => { const [formApi, setFormApi] = useState(null); let now = new Date(); let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - + const formInitValues = { channel_id: '', task_id: '', @@ -239,7 +239,7 @@ export const useTaskLogsData = () => { logCount, pageSize, isAdminUser, - + // Modal state isModalOpen, setIsModalOpen, diff --git a/web/src/hooks/tokens/useTokensData.js b/web/src/hooks/tokens/useTokensData.js new file mode 100644 index 00000000..fc035ee5 --- /dev/null +++ b/web/src/hooks/tokens/useTokensData.js @@ -0,0 +1,369 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@douyinfe/semi-ui'; +import { + API, + copy, + showError, + showSuccess, +} from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useTokensData = () => { + const { t } = useTranslation(); + + // Basic state + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [searching, setSearching] = useState(false); + + // Selection state + const [selectedKeys, setSelectedKeys] = useState([]); + + // Edit state + const [showEdit, setShowEdit] = useState(false); + const [editingToken, setEditingToken] = useState({ + id: undefined, + }); + + // UI state + const [compactMode, setCompactMode] = useTableCompactMode('tokens'); + const [showKeys, setShowKeys] = useState({}); + + // Form state + const [formApi, setFormApi] = useState(null); + const formInitValues = { + searchKeyword: '', + searchToken: '', + }; + + // Get form values helper function + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchToken: formValues.searchToken || '', + }; + }; + + // Close edit modal + const closeEdit = () => { + setShowEdit(false); + setTimeout(() => { + setEditingToken({ + id: undefined, + }); + }, 500); + }; + + // Sync page data from API response + const syncPageData = (payload) => { + setTokens(payload.items || []); + setTokenCount(payload.total || 0); + setActivePage(payload.page || 1); + setPageSize(payload.page_size || pageSize); + }; + + // Load tokens function + 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); + }; + + // Refresh function + const refresh = async (page = activePage) => { + await loadTokens(page); + setSelectedKeys([]); + }; + + // Copy text function + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制到剪贴板!')); + } else { + Modal.error({ + title: t('无法复制到剪贴板,请手动复制'), + content: text, + size: 'large', + }); + } + }; + + // Open link function for chat integrations + 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, + } + 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'); + }; + + // Manage token function (delete, enable, disable) + 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') { + record.status = token.status; + } + setTokens(newTokens); + } else { + showError(message); + } + setLoading(false); + }; + + // Search tokens function + 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); + }; + + // Sort tokens function + 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); + }; + + // Page handlers + const handlePageChange = (page) => { + loadTokens(page, pageSize).then(); + }; + + const handlePageSizeChange = async (size) => { + setPageSize(size); + await loadTokens(1, size); + }; + + // Row selection handlers + const rowSelection = { + onSelect: (record, selected) => { }, + onSelectAll: (selected, selectedRows) => { }, + onChange: (selectedRowKeys, selectedRows) => { + setSelectedKeys(selectedRows); + }, + }; + + // Handle row styling + const handleRow = (record, index) => { + if (record.status !== 1) { + return { + style: { + background: 'var(--semi-color-disabled-border)', + }, + }; + } else { + return {}; + } + }; + + // Batch delete tokens + 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); + } + }; + + // Batch copy tokens + const batchCopyTokens = (copyType) => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个令牌!')); + return; + } + + Modal.info({ + title: t('复制令牌'), + icon: null, + content: t('请选择你的复制方式'), + footer: ( +
+ + +
+ ), + }); + }; + + // Initialize data + useEffect(() => { + loadTokens(1) + .then() + .catch((reason) => { + showError(reason); + }); + }, [pageSize]); + + return { + // Basic state + tokens, + loading, + activePage, + tokenCount, + pageSize, + searching, + + // Selection state + selectedKeys, + setSelectedKeys, + + // Edit state + showEdit, + setShowEdit, + editingToken, + setEditingToken, + closeEdit, + + // UI state + compactMode, + setCompactMode, + showKeys, + setShowKeys, + + // Form state + formApi, + setFormApi, + formInitValues, + getFormValues, + + // Functions + loadTokens, + refresh, + copyText, + onOpenLink, + manageToken, + searchTokens, + sortToken, + handlePageChange, + handlePageSizeChange, + rowSelection, + handleRow, + batchDeleteTokens, + batchCopyTokens, + syncPageData, + + // Translation + t, + }; +}; \ No newline at end of file From 4d9b5bcf48a70f9ea14d423d70615a6ffbef1f72 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 18 Jul 2025 23:38:35 +0800 Subject: [PATCH 012/582] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20DisablePing=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=BB=A5=E6=8E=A7=E5=88=B6=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E5=8F=91=E9=80=81=E8=87=AA=E5=AE=9A=E4=B9=89=20Ping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/api_request.go | 2 +- relay/common/relay_info.go | 1 + relay/helper/stream_scanner.go | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index ff7c63fa..3ccd2d78 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -223,7 +223,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http helper.SetEventStreamHeaders(c) // 处理流式请求的 ping 保活 generalSettings := operation_setting.GetGeneralSetting() - if generalSettings.PingIntervalEnabled { + if generalSettings.PingIntervalEnabled && !info.DisablePing { pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second stopPinger = startPingKeepAlive(c, pingInterval) // 使用defer确保在任何情况下都能停止ping goroutine diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 5b7dee80..26f668ab 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -88,6 +88,7 @@ type RelayInfo struct { BaseUrl string SupportStreamOptions bool ShouldIncludeUsage bool + DisablePing bool // 是否禁止向下游发送自定义 Ping IsModelMapped bool ClientWs *websocket.Conn TargetWs *websocket.Conn diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index b526b1c0..64919020 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -54,7 +54,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ) generalSettings := operation_setting.GetGeneralSetting() - pingEnabled := generalSettings.PingIntervalEnabled + pingEnabled := generalSettings.PingIntervalEnabled && !info.DisablePing pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second if pingInterval <= 0 { pingInterval = DefaultPingInterval From 20c132bf13e8e89626abadc0ef1967042661ba6e Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 18 Jul 2025 23:39:01 +0800 Subject: [PATCH 013/582] =?UTF-8?q?=E7=A6=81=E7=94=A8=E5=8E=9F=E7=94=9FGem?= =?UTF-8?q?ini=E6=A8=A1=E5=BC=8F=E4=B8=AD=E7=9A=84ping=E4=BF=9D=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/gemini/adaptor.go | 1 + 1 file changed, 1 insertion(+) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 71eb9ba4..a97e9b76 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -171,6 +171,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { if info.RelayMode == constant.RelayModeGemini { if info.IsStream { + info.DisablePing = true return GeminiTextGenerationStreamHandler(c, info, resp) } else { return GeminiTextGenerationHandler(c, info, resp) From 1e0b56ac51dee267b8852fe0daedd994e0e05dca Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 00:12:04 +0800 Subject: [PATCH 014/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(component?= =?UTF-8?q?s):=20restructure=20RedemptionsTable=20to=20modular=20architect?= =?UTF-8?q?ure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the monolithic RedemptionsTable component (614 lines) into a clean, modular structure following the established tokens component pattern. ### Changes Made: **New Components:** - `RedemptionsColumnDefs.js` - Extract table column definitions and render logic - `RedemptionsActions.jsx` - Extract action buttons (add, batch copy, clear invalid) - `RedemptionsFilters.jsx` - Extract search and filter form components - `RedemptionsDescription.jsx` - Extract description area component - `redemptions/index.jsx` - Main container component managing state and composition **New Hook:** - `useRedemptionsData.js` - Extract all data management, CRUD operations, and business logic **New Constants:** - `redemption.constants.js` - Extract redemption status, actions, and form constants **Architecture Changes:** - Transform RedemptionsTable.jsx into pure table rendering component - Move state management and component composition to index.jsx - Implement consistent prop drilling pattern matching tokens module - Add memoization for performance optimization - Centralize translation function distribution ### Benefits: - **Maintainability**: Each component has single responsibility - **Reusability**: Components and hooks can be used elsewhere - **Testability**: Individual modules can be unit tested - **Team Collaboration**: Multiple developers can work on different modules - **Consistency**: Follows established architectural patterns ### File Structure: ``` redemptions/ ├── index.jsx # Main container (state + composition) ├── RedemptionsTable.jsx # Pure table component ├── RedemptionsActions.jsx # Action buttons ├── RedemptionsFilters.jsx # Search/filter form ├── RedemptionsDescription.jsx # Description area └── RedemptionsColumnDefs.js # Column definitions --- web/src/components/table/RedemptionsTable.js | 615 +----------------- web/src/components/table/TokensTable.js | 9 +- .../table/redemptions/RedemptionsActions.jsx | 53 ++ .../redemptions/RedemptionsColumnDefs.js | 198 ++++++ .../redemptions/RedemptionsDescription.jsx | 27 + .../table/redemptions/RedemptionsFilters.jsx | 72 ++ .../table/redemptions/RedemptionsTable.jsx | 119 ++++ .../components/table/redemptions/index.jsx | 90 +++ .../modals/DeleteRedemptionModal.jsx | 39 ++ .../modals/EditRedemptionModal.jsx} | 8 +- .../components/table/tokens/TokensActions.jsx | 142 ++-- web/src/components/table/tokens/index.jsx | 6 +- .../table/tokens/modals/CopyTokensModal.jsx | 52 ++ .../table/tokens/modals/DeleteTokensModal.jsx | 20 + .../table/tokens/modals/EditTokenModal.jsx} | 10 +- web/src/constants/index.js | 1 + web/src/constants/redemption.constants.js | 29 + .../hooks/redemptions/useRedemptionsData.js | 336 ++++++++++ web/src/index.css | 21 - 19 files changed, 1117 insertions(+), 730 deletions(-) create mode 100644 web/src/components/table/redemptions/RedemptionsActions.jsx create mode 100644 web/src/components/table/redemptions/RedemptionsColumnDefs.js create mode 100644 web/src/components/table/redemptions/RedemptionsDescription.jsx create mode 100644 web/src/components/table/redemptions/RedemptionsFilters.jsx create mode 100644 web/src/components/table/redemptions/RedemptionsTable.jsx create mode 100644 web/src/components/table/redemptions/index.jsx create mode 100644 web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx rename web/src/{pages/Redemption/EditRedemption.js => components/table/redemptions/modals/EditRedemptionModal.jsx} (98%) create mode 100644 web/src/components/table/tokens/modals/CopyTokensModal.jsx create mode 100644 web/src/components/table/tokens/modals/DeleteTokensModal.jsx rename web/src/{pages/Token/EditToken.js => components/table/tokens/modals/EditTokenModal.jsx} (98%) create mode 100644 web/src/constants/redemption.constants.js create mode 100644 web/src/hooks/redemptions/useRedemptionsData.js diff --git a/web/src/components/table/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js index 877990da..d2e89b97 100644 --- a/web/src/components/table/RedemptionsTable.js +++ b/web/src/components/table/RedemptionsTable.js @@ -1,613 +1,2 @@ -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, - Dropdown, - Empty, - Form, - Modal, - Popover, - Space, - Table, - Tag, - Typography -} from '@douyinfe/semi-ui'; -import CardPro from '../common/ui/CardPro'; -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/common/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 ( - - - - - - - - - - } - actionsArea={ -
-
-
- - -
- -
- -
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" - /> -
-
- - -
-
- -
- } - > -
rest) : columns} - dataSource={pageData} - scroll={compactMode ? undefined : { x: 'max-content' }} - pagination={{ - currentPage: activePage, - pageSize: pageSize, - total: tokenCount, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - 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; +// 重构后的 RedemptionsTable - 使用新的模块化架构 +export { default } from './redemptions/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js index a30cb36d..d74a49e2 100644 --- a/web/src/components/table/TokensTable.js +++ b/web/src/components/table/TokensTable.js @@ -1,7 +1,2 @@ -// Import the new modular tokens table -import TokensPage from './tokens'; - -// Export the new component for backward compatibility -const TokensTable = TokensPage; - -export default TokensTable; +// 重构后的 TokensTable - 使用新的模块化架构 +export { default } from './tokens/index.jsx'; \ 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..1d86dd38 --- /dev/null +++ b/web/src/components/table/redemptions/RedemptionsActions.jsx @@ -0,0 +1,53 @@ +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..4f4cd808 --- /dev/null +++ b/web/src/components/table/redemptions/RedemptionsColumnDefs.js @@ -0,0 +1,198 @@ +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 RedemptionsDescription; \ No newline at end of file diff --git a/web/src/components/table/redemptions/RedemptionsFilters.jsx b/web/src/components/table/redemptions/RedemptionsFilters.jsx new file mode 100644 index 00000000..888f016e --- /dev/null +++ b/web/src/components/table/redemptions/RedemptionsFilters.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Form, Button } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; + +const RedemptionsFilters = ({ + formInitValues, + setFormApi, + searchRedemptions, + loading, + searching, + t +}) => { + + // Handle form reset and immediate search + const handleReset = (formApi) => { + if (formApi) { + formApi.reset(); + // Reset and search immediately + setTimeout(() => { + searchRedemptions(); + }, 100); + } + }; + + return ( +
setFormApi(api)} + onSubmit={searchRedemptions} + 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" + /> +
+
+ + +
+
+
+ ); +}; + +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..e039df0c --- /dev/null +++ b/web/src/components/table/redemptions/RedemptionsTable.jsx @@ -0,0 +1,119 @@ +import React, { useMemo, useState } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +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..064743d5 --- /dev/null +++ b/web/src/components/table/redemptions/index.jsx @@ -0,0 +1,90 @@ +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'; + +const RedemptionsPage = () => { + const redemptionsData = useRedemptionsData(); + + 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={ +
+ + +
+ +
+
+ } + > + +
+ + ); +}; + +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..3b7668d9 --- /dev/null +++ b/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx @@ -0,0 +1,39 @@ +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 98% rename from web/src/pages/Redemption/EditRedemption.js rename to web/src/components/table/redemptions/modals/EditRedemptionModal.jsx index 310fdcd0..9d06866f 100644 --- a/web/src/pages/Redemption/EditRedemption.js +++ b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx @@ -7,8 +7,8 @@ import { showSuccess, renderQuota, renderQuotaWithPrompt, -} from '../../helpers'; -import { useIsMobile } from '../../hooks/common/useIsMobile.js'; +} from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, Modal, @@ -32,7 +32,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 +302,4 @@ const EditRedemption = (props) => { ); }; -export default EditRedemption; +export default EditRedemptionModal; \ No newline at end of file diff --git a/web/src/components/table/tokens/TokensActions.jsx b/web/src/components/table/tokens/TokensActions.jsx index 09cb29eb..85703d24 100644 --- a/web/src/components/table/tokens/TokensActions.jsx +++ b/web/src/components/table/tokens/TokensActions.jsx @@ -1,6 +1,8 @@ -import React from 'react'; -import { Button, Modal, Space } from '@douyinfe/semi-ui'; +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, @@ -11,48 +13,17 @@ const TokensActions = ({ 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; } - - Modal.info({ - title: t('复制令牌'), - icon: null, - content: t('请选择你的复制方式'), - footer: ( - - - - - ), - }); + setShowCopyModal(true); }; // Handle delete selected tokens with confirmation @@ -61,52 +32,67 @@ const TokensActions = ({ showError(t('请至少选择一个令牌!')); return; } + setShowDeleteModal(true); + }; - Modal.confirm({ - title: t('批量删除令牌'), - content: ( -
- {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })} -
- ), - onOk: () => batchDeleteTokens(), - }); + // 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} + /> + ); }; diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx index 3a3a8fb7..91d14054 100644 --- a/web/src/components/table/tokens/index.jsx +++ b/web/src/components/table/tokens/index.jsx @@ -4,7 +4,7 @@ import TokensTable from './TokensTable.jsx'; import TokensActions from './TokensActions.jsx'; import TokensFilters from './TokensFilters.jsx'; import TokensDescription from './TokensDescription.jsx'; -import EditToken from '../../../pages/Token/EditToken'; +import EditTokenModal from './modals/EditTokenModal'; import { useTokensData } from '../../../hooks/tokens/useTokensData'; const TokensPage = () => { @@ -21,6 +21,7 @@ const TokensPage = () => { selectedKeys, setEditingToken, setShowEdit, + batchCopyTokens, batchDeleteTokens, copyText, @@ -41,7 +42,7 @@ const TokensPage = () => { return ( <> - { selectedKeys={selectedKeys} setEditingToken={setEditingToken} setShowEdit={setShowEdit} + batchCopyTokens={batchCopyTokens} batchDeleteTokens={batchDeleteTokens} copyText={copyText} t={t} 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..41f9627b --- /dev/null +++ b/web/src/components/table/tokens/modals/CopyTokensModal.jsx @@ -0,0 +1,52 @@ +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..5bc3ee5a --- /dev/null +++ b/web/src/components/table/tokens/modals/DeleteTokensModal.jsx @@ -0,0 +1,20 @@ +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 98% rename from web/src/pages/Token/EditToken.js rename to web/src/components/table/tokens/modals/EditTokenModal.jsx index 7c7a61e9..119cc41c 100644 --- a/web/src/pages/Token/EditToken.js +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -7,8 +7,8 @@ import { renderGroupOption, renderQuotaWithPrompt, getModelCategories, -} from '../../helpers'; -import { useIsMobile } from '../../hooks/common/useIsMobile.js'; +} from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, SideSheet, @@ -30,11 +30,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); @@ -522,4 +522,4 @@ const EditToken = (props) => { ); }; -export default EditToken; +export default EditTokenModal; \ No newline at end of file diff --git a/web/src/constants/index.js b/web/src/constants/index.js index f92e2b19..27107eea 100644 --- a/web/src/constants/index.js +++ b/web/src/constants/index.js @@ -3,3 +3,4 @@ export * from './user.constants'; export * from './toast.constants'; export * from './common.constant'; export * from './playground.constants'; +export * from './redemption.constants'; diff --git a/web/src/constants/redemption.constants.js b/web/src/constants/redemption.constants.js new file mode 100644 index 00000000..418b4393 --- /dev/null +++ b/web/src/constants/redemption.constants.js @@ -0,0 +1,29 @@ +// Redemption code status constants +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/hooks/redemptions/useRedemptionsData.js b/web/src/hooks/redemptions/useRedemptionsData.js new file mode 100644 index 00000000..e31ddd76 --- /dev/null +++ b/web/src/hooks/redemptions/useRedemptionsData.js @@ -0,0 +1,336 @@ +import { useState, useEffect } from 'react'; +import { API, showError, showSuccess, copy } from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { REDEMPTION_ACTIONS, REDEMPTION_STATUS } from '../../constants/redemption.constants'; +import { Modal } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useRedemptionsData = () => { + const { t } = useTranslation(); + + // Basic state + const [redemptions, setRedemptions] = useState([]); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + const [activePage, setActivePage] = useState(1); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); + const [selectedKeys, setSelectedKeys] = useState([]); + + // Edit state + const [editingRedemption, setEditingRedemption] = useState({ + id: undefined, + }); + const [showEdit, setShowEdit] = useState(false); + + // Form API + const [formApi, setFormApi] = useState(null); + + // UI state + const [compactMode, setCompactMode] = useTableCompactMode('redemptions'); + + // Form state + const formInitValues = { + searchKeyword: '', + }; + + // Get form values + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + }; + }; + + // Set redemption data format + const setRedemptionFormat = (redemptions) => { + setRedemptions(redemptions); + }; + + // Load redemption list + const loadRedemptions = async (page = 1, pageSize) => { + setLoading(true); + try { + const res = await API.get( + `/api/redemption/?p=${page}&page_size=${pageSize}`, + ); + const { success, message, data } = res.data; + if (success) { + const newPageData = data.items; + setActivePage(data.page <= 0 ? 1 : data.page); + setTokenCount(data.total); + setRedemptionFormat(newPageData); + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } + setLoading(false); + }; + + // Search redemption codes + const searchRedemptions = async () => { + const { searchKeyword } = getFormValues(); + if (searchKeyword === '') { + await loadRedemptions(1, pageSize); + return; + } + + setSearching(true); + try { + const res = await API.get( + `/api/redemption/search?keyword=${searchKeyword}&p=1&page_size=${pageSize}`, + ); + const { success, message, data } = res.data; + if (success) { + const newPageData = data.items; + setActivePage(data.page || 1); + setTokenCount(data.total); + setRedemptionFormat(newPageData); + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } + setSearching(false); + }; + + // Manage redemption codes (CRUD operations) + const manageRedemption = async (id, action, record) => { + setLoading(true); + let data = { id }; + let res; + + try { + switch (action) { + case REDEMPTION_ACTIONS.DELETE: + res = await API.delete(`/api/redemption/${id}/`); + break; + case REDEMPTION_ACTIONS.ENABLE: + data.status = REDEMPTION_STATUS.UNUSED; + res = await API.put('/api/redemption/?status_only=true', data); + break; + case REDEMPTION_ACTIONS.DISABLE: + data.status = REDEMPTION_STATUS.DISABLED; + res = await API.put('/api/redemption/?status_only=true', data); + break; + default: + throw new Error('Unknown operation type'); + } + + const { success, message } = res.data; + if (success) { + showSuccess('操作成功完成!'); + let redemption = res.data.data; + let newRedemptions = [...redemptions]; + if (action !== REDEMPTION_ACTIONS.DELETE) { + record.status = redemption.status; + } + setRedemptions(newRedemptions); + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } + setLoading(false); + }; + + // Refresh data + const refresh = async (page = activePage) => { + const { searchKeyword } = getFormValues(); + if (searchKeyword === '') { + await loadRedemptions(page, pageSize); + } else { + await searchRedemptions(); + } + }; + + // Handle page change + const handlePageChange = (page) => { + setActivePage(page); + const { searchKeyword } = getFormValues(); + if (searchKeyword === '') { + loadRedemptions(page, pageSize); + } else { + searchRedemptions(); + } + }; + + // Handle page size change + const handlePageSizeChange = (size) => { + setPageSize(size); + setActivePage(1); + const { searchKeyword } = getFormValues(); + if (searchKeyword === '') { + loadRedemptions(1, size); + } else { + searchRedemptions(); + } + }; + + // Row selection configuration + const rowSelection = { + onSelect: (record, selected) => { }, + onSelectAll: (selected, selectedRows) => { }, + onChange: (selectedRowKeys, selectedRows) => { + setSelectedKeys(selectedRows); + }, + }; + + // Row style handling - using isExpired function + const handleRow = (record, index) => { + // Local isExpired function + const isExpired = (rec) => { + return rec.status === REDEMPTION_STATUS.UNUSED && + rec.expired_time !== 0 && + rec.expired_time < Math.floor(Date.now() / 1000); + }; + + if (record.status !== REDEMPTION_STATUS.UNUSED || isExpired(record)) { + return { + style: { + background: 'var(--semi-color-disabled-border)', + }, + }; + } else { + return {}; + } + }; + + // Copy text + const copyText = async (text) => { + if (await copy(text)) { + showSuccess('已复制到剪贴板!'); + } else { + Modal.error({ + title: '无法复制到剪贴板,请手动复制', + content: text, + size: 'large' + }); + } + }; + + // Batch copy redemption codes + const batchCopyRedemptions = async () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个兑换码!')); + return; + } + + let keys = ''; + for (let i = 0; i < selectedKeys.length; i++) { + keys += selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n'; + } + await copyText(keys); + }; + + // Batch delete redemption codes (clear invalid) + const batchDeleteRedemptions = async () => { + Modal.confirm({ + title: t('确定清除所有失效兑换码?'), + content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'), + onOk: async () => { + setLoading(true); + const res = await API.delete('/api/redemption/invalid'); + const { success, message, data } = res.data; + if (success) { + showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data })); + await refresh(); + } else { + showError(message); + } + setLoading(false); + }, + }); + }; + + // Close edit modal + const closeEdit = () => { + setShowEdit(false); + setTimeout(() => { + setEditingRedemption({ + id: undefined, + }); + }, 500); + }; + + // Remove record (for UI update after deletion) + const removeRecord = (key) => { + let newDataSource = [...redemptions]; + if (key != null) { + let idx = newDataSource.findIndex((data) => data.key === key); + if (idx > -1) { + newDataSource.splice(idx, 1); + setRedemptions(newDataSource); + } + } + }; + + // Initialize data loading + useEffect(() => { + loadRedemptions(1, pageSize) + .then() + .catch((reason) => { + showError(reason); + }); + }, [pageSize]); + + return { + // Data state + redemptions, + loading, + searching, + activePage, + pageSize, + tokenCount, + selectedKeys, + + // Edit state + editingRedemption, + showEdit, + + // Form state + formApi, + formInitValues, + + // UI state + compactMode, + setCompactMode, + + // Data operations + loadRedemptions, + searchRedemptions, + manageRedemption, + refresh, + copyText, + removeRecord, + + // State updates + setActivePage, + setPageSize, + setSelectedKeys, + setEditingRedemption, + setShowEdit, + setFormApi, + setLoading, + + // Event handlers + handlePageChange, + handlePageSizeChange, + rowSelection, + handleRow, + closeEdit, + getFormValues, + + // Batch operations + batchCopyRedemptions, + batchDeleteRedemptions, + + // Translation function + t, + }; +}; \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index 742ec5ca..6a102b31 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -432,27 +432,6 @@ code { background: transparent; } -/* ==================== 响应式/移动端样式 ==================== */ -@media only screen and (max-width: 767px) { - - /* 移动端表格样式调整 */ - .semi-table-tbody, - .semi-table-row, - .semi-table-row-cell { - display: block !important; - width: auto !important; - padding: 2px !important; - } - - .semi-table-row-cell { - border-bottom: 0 !important; - } - - .semi-table-tbody>.semi-table-row { - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - } -} - /* ==================== 同步倍率 - 渠道选择器 ==================== */ .components-transfer-source-item, From f2c2a352f058b3daa5c100b9fe198881f04d58fe Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 00:32:56 +0800 Subject: [PATCH 015/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(users):?= =?UTF-8?q?=20modularize=20UsersTable=20component=20into=20microcomponent?= =?UTF-8?q?=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Removed standalone user edit routes (/console/user/edit, /console/user/edit/:id) - Decompose 673-line monolithic UsersTable.js into 8 specialized components - Extract column definitions to UsersColumnDefs.js with render functions - Create dedicated UsersActions.jsx for action buttons - Create UsersFilters.jsx for search and filtering logic - Create UsersDescription.jsx for description area - Extract all data management logic to useUsersData.js hook - Move AddUser.js and EditUser.js to users/modals/ folder as modal components - Create 4 new confirmation modal components (Promote, Demote, EnableDisable, Delete) - Implement pure UsersTable.jsx component for table rendering only - Create main container component users/index.jsx to compose all subcomponents - Update import paths in pages/User/index.js to use new modular structure - Remove obsolete EditUser imports and routes from App.js - Delete original monolithic files: UsersTable.js, AddUser.js, EditUser.js The new architecture follows the same modular pattern as tokens and redemptions modules: - Consistent file organization across all table modules - Better separation of concerns and maintainability - Enhanced reusability and testability - Unified modal management approach All existing functionality preserved with improved code organization. --- web/src/App.js | 18 +- web/src/components/table/UsersTable.js | 672 ------------------ .../components/table/users/UsersActions.jsx | 27 + .../components/table/users/UsersColumnDefs.js | 310 ++++++++ .../table/users/UsersDescription.jsx | 26 + .../components/table/users/UsersFilters.jsx | 95 +++ web/src/components/table/users/UsersTable.jsx | 174 +++++ web/src/components/table/users/index.jsx | 95 +++ .../table/users/modals/AddUserModal.jsx} | 8 +- .../table/users/modals/DeleteUserModal.jsx | 39 + .../table/users/modals/DemoteUserModal.jsx | 18 + .../table/users/modals/EditUserModal.jsx} | 8 +- .../users/modals/EnableDisableUserModal.jsx | 27 + .../table/users/modals/PromoteUserModal.jsx | 18 + web/src/hooks/users/useUsersData.js | 259 +++++++ web/src/pages/User/index.js | 4 +- 16 files changed, 1099 insertions(+), 699 deletions(-) delete mode 100644 web/src/components/table/UsersTable.js create mode 100644 web/src/components/table/users/UsersActions.jsx create mode 100644 web/src/components/table/users/UsersColumnDefs.js create mode 100644 web/src/components/table/users/UsersDescription.jsx create mode 100644 web/src/components/table/users/UsersFilters.jsx create mode 100644 web/src/components/table/users/UsersTable.jsx create mode 100644 web/src/components/table/users/index.jsx rename web/src/{pages/User/AddUser.js => components/table/users/modals/AddUserModal.jsx} (95%) create mode 100644 web/src/components/table/users/modals/DeleteUserModal.jsx create mode 100644 web/src/components/table/users/modals/DemoteUserModal.jsx rename web/src/{pages/User/EditUser.js => components/table/users/modals/EditUserModal.jsx} (98%) create mode 100644 web/src/components/table/users/modals/EnableDisableUserModal.jsx create mode 100644 web/src/components/table/users/modals/PromoteUserModal.jsx create mode 100644 web/src/hooks/users/useUsersData.js diff --git a/web/src/App.js b/web/src/App.js index 995ae2bb..41ab040e 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -7,7 +7,7 @@ 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'; @@ -109,22 +109,6 @@ function App() { } /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> { - 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 ( - - - - -
- } - actionsArea={ -
-
- -
- -
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" - /> -
-
- - -
-
- -
- } - > -
rest) : columns} - dataSource={users} - scroll={compactMode ? undefined : { x: 'max-content' }} - pagination={{ - 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/users/UsersActions.jsx b/web/src/components/table/users/UsersActions.jsx new file mode 100644 index 00000000..c486cedc --- /dev/null +++ b/web/src/components/table/users/UsersActions.jsx @@ -0,0 +1,27 @@ +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..8c8bd5ac --- /dev/null +++ b/web/src/components/table/users/UsersColumnDefs.js @@ -0,0 +1,310 @@ +import React from 'react'; +import { + Button, + Dropdown, + Space, + Tag, + Tooltip, + Typography +} from '@douyinfe/semi-ui'; +import { + User, + Shield, + Crown, + HelpCircle, + CheckCircle, + XCircle, + Minus, + Coins, + Activity, + Users, + DollarSign, + UserPlus, +} from 'lucide-react'; +import { IconMore } from '@douyinfe/semi-icons'; +import { renderGroup, renderNumber, renderQuota } from '../../../helpers'; + +const { Text } = Typography; + +/** + * 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 user status + */ +const renderStatus = (status, t) => { + switch (status) { + case 1: + return }>{t('已激活')}; + case 2: + 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, t) => { + return ( +
+ + }> + {t('剩余')}: {renderQuota(record.quota)} + + }> + {t('已用')}: {renderQuota(record.used_quota)} + + }> + {t('调用')}: {renderNumber(record.request_count)} + + +
+ ); +}; + +/** + * 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('无邀请人') : `邀请人: ${record.inviter_id}`} + + +
+ ); +}; + +/** + * Render overall status including deleted status + */ +const renderOverallStatus = (status, record, t) => { + if (record.DeletedAt !== null) { + return }>{t('已注销')}; + } else { + return renderStatus(status, t); + } +}; + +/** + * Render operations column + */ +const renderOperations = (text, record, { + setEditingUser, + setShowEditUser, + showPromoteModal, + showDemoteModal, + showEnableDisableModal, + showDeleteModal, + t +}) => { + if (record.DeletedAt !== null) { + return <>; + } + + // Create more operations dropdown menu items + const moreMenuItems = [ + { + node: 'item', + name: t('提升'), + type: 'warning', + onClick: () => showPromoteModal(record), + }, + { + node: 'item', + name: t('降级'), + type: 'secondary', + onClick: () => showDemoteModal(record), + }, + { + node: 'item', + name: t('注销'), + type: 'danger', + onClick: () => showDeleteModal(record), + } + ]; + + // Add enable/disable button dynamically + if (record.status === 1) { + moreMenuItems.splice(-1, 0, { + node: 'item', + name: t('禁用'), + type: 'warning', + onClick: () => showEnableDisableModal(record, 'disable'), + }); + } else { + moreMenuItems.splice(-1, 0, { + node: 'item', + name: t('启用'), + type: 'secondary', + onClick: () => showEnableDisableModal(record, 'enable'), + disabled: record.status === 3, + }); + } + + return ( + + + + +
+ ); +}; + +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..201b1d1a --- /dev/null +++ b/web/src/components/table/users/UsersFilters.jsx @@ -0,0 +1,95 @@ +import React 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 +}) => { + + // Handle form reset and immediate search + const handleReset = (formApi) => { + if (formApi) { + formApi.reset(); + // Reset and search immediately + setTimeout(() => { + loadUsers(1, pageSize); + }, 100); + } + }; + + return ( +
setFormApi(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..459145fb --- /dev/null +++ b/web/src/components/table/users/UsersTable.jsx @@ -0,0 +1,174 @@ +import React, { useMemo, useState } from 'react'; +import { Table, Empty } from '@douyinfe/semi-ui'; +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..5eba39a6 --- /dev/null +++ b/web/src/components/table/users/index.jsx @@ -0,0 +1,95 @@ +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'; + +const UsersPage = () => { + const usersData = useUsersData(); + + 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={ +
+ + + +
+ } + > + +
+ + ); +}; + +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 95% rename from web/src/pages/User/AddUser.js rename to web/src/components/table/users/modals/AddUserModal.jsx index 54d9b002..59df7ef7 100644 --- a/web/src/pages/User/AddUser.js +++ b/web/src/components/table/users/modals/AddUserModal.jsx @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react'; -import { API, showError, showSuccess } from '../../helpers'; -import { useIsMobile } from '../../hooks/common/useIsMobile.js'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, SideSheet, @@ -23,7 +23,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 +164,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..8ba89d90 --- /dev/null +++ b/web/src/components/table/users/modals/DeleteUserModal.jsx @@ -0,0 +1,39 @@ +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..c3885ebf --- /dev/null +++ b/web/src/components/table/users/modals/DemoteUserModal.jsx @@ -0,0 +1,18 @@ +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 98% rename from web/src/pages/User/EditUser.js rename to web/src/components/table/users/modals/EditUserModal.jsx index 53fa9b20..330f4702 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/components/table/users/modals/EditUserModal.jsx @@ -6,8 +6,8 @@ import { showSuccess, renderQuota, renderQuotaWithPrompt, -} from '../../helpers'; -import { useIsMobile } from '../../hooks/common/useIsMobile.js'; +} from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { Button, Modal, @@ -35,7 +35,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 +348,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..be95cf40 --- /dev/null +++ b/web/src/components/table/users/modals/EnableDisableUserModal.jsx @@ -0,0 +1,27 @@ +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..0a47d15a --- /dev/null +++ b/web/src/components/table/users/modals/PromoteUserModal.jsx @@ -0,0 +1,18 @@ +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/hooks/users/useUsersData.js b/web/src/hooks/users/useUsersData.js new file mode 100644 index 00000000..a9952a76 --- /dev/null +++ b/web/src/hooks/users/useUsersData.js @@ -0,0 +1,259 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess } from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useUsersData = () => { + const { t } = useTranslation(); + const [compactMode, setCompactMode] = useTableCompactMode('users'); + + // State management + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [searching, setSearching] = useState(false); + const [groupOptions, setGroupOptions] = useState([]); + const [userCount, setUserCount] = useState(ITEMS_PER_PAGE); + + // Modal states + const [showAddUser, setShowAddUser] = useState(false); + const [showEditUser, setShowEditUser] = useState(false); + const [editingUser, setEditingUser] = useState({ + id: undefined, + }); + + // Form initial values + const formInitValues = { + searchKeyword: '', + searchGroup: '', + }; + + // Form API reference + const [formApi, setFormApi] = useState(null); + + // Get form values helper function + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchGroup: formValues.searchGroup || '', + }; + }; + + // Set user format with key field + const setUserFormat = (users) => { + for (let i = 0; i < users.length; i++) { + users[i].key = users[i].id; + } + setUsers(users); + }; + + // Load users data + const loadUsers = async (startIdx, pageSize) => { + const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`); + const { success, message, data } = res.data; + if (success) { + const newPageData = data.items; + setActivePage(data.page); + setUserCount(data.total); + setUserFormat(newPageData); + } else { + showError(message); + } + setLoading(false); + }; + + // Search users with keyword and group + const searchUsers = async ( + startIdx, + pageSize, + searchKeyword = null, + searchGroup = null, + ) => { + // If no parameters passed, get values from form + if (searchKeyword === null || searchGroup === null) { + const formValues = getFormValues(); + searchKeyword = formValues.searchKeyword; + searchGroup = formValues.searchGroup; + } + + if (searchKeyword === '' && searchGroup === '') { + // If keyword is blank, load files instead + await loadUsers(startIdx, pageSize); + return; + } + setSearching(true); + const res = await API.get( + `/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`, + ); + const { success, message, data } = res.data; + if (success) { + const newPageData = data.items; + setActivePage(data.page); + setUserCount(data.total); + setUserFormat(newPageData); + } else { + showError(message); + } + setSearching(false); + }; + + // Manage user operations (promote, demote, enable, disable, delete) + const manageUser = async (userId, action, record) => { + const res = await API.post('/api/user/manage', { + id: userId, + action, + }); + const { success, message } = res.data; + if (success) { + showSuccess('操作成功完成!'); + let user = res.data.data; + let newUsers = [...users]; + if (action === 'delete') { + // Mark as deleted + const index = newUsers.findIndex(u => u.id === userId); + if (index > -1) { + newUsers[index].DeletedAt = new Date(); + } + } else { + // Update status and role + record.status = user.status; + record.role = user.role; + } + setUsers(newUsers); + } else { + showError(message); + } + }; + + // Handle page change + const handlePageChange = (page) => { + setActivePage(page); + const { searchKeyword, searchGroup } = getFormValues(); + if (searchKeyword === '' && searchGroup === '') { + loadUsers(page, pageSize).then(); + } else { + searchUsers(page, pageSize, searchKeyword, searchGroup).then(); + } + }; + + // Handle page size change + const handlePageSizeChange = async (size) => { + localStorage.setItem('page-size', size + ''); + setPageSize(size); + setActivePage(1); + loadUsers(activePage, size) + .then() + .catch((reason) => { + showError(reason); + }); + }; + + // Handle table row styling for disabled/deleted users + const handleRow = (record, index) => { + if (record.DeletedAt !== null || record.status !== 1) { + return { + style: { + background: 'var(--semi-color-disabled-border)', + }, + }; + } else { + return {}; + } + }; + + // Refresh data + const refresh = async (page = activePage) => { + const { searchKeyword, searchGroup } = getFormValues(); + if (searchKeyword === '' && searchGroup === '') { + await loadUsers(page, pageSize); + } else { + await searchUsers(page, pageSize, searchKeyword, searchGroup); + } + }; + + // Fetch groups data + 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); + } + }; + + // Modal control functions + const closeAddUser = () => { + setShowAddUser(false); + }; + + const closeEditUser = () => { + setShowEditUser(false); + setEditingUser({ + id: undefined, + }); + }; + + // Initialize data on component mount + useEffect(() => { + loadUsers(0, pageSize) + .then() + .catch((reason) => { + showError(reason); + }); + fetchGroups().then(); + }, []); + + return { + // Data state + users, + loading, + activePage, + pageSize, + userCount, + searching, + groupOptions, + + // Modal state + showAddUser, + showEditUser, + editingUser, + setShowAddUser, + setShowEditUser, + setEditingUser, + + // Form state + formInitValues, + formApi, + setFormApi, + + // UI state + compactMode, + setCompactMode, + + // Actions + loadUsers, + searchUsers, + manageUser, + handlePageChange, + handlePageSizeChange, + handleRow, + refresh, + closeAddUser, + closeEditUser, + getFormValues, + + // Translation + t, + }; +}; \ No newline at end of file diff --git a/web/src/pages/User/index.js b/web/src/pages/User/index.js index 12b6f4ee..d06ee7ed 100644 --- a/web/src/pages/User/index.js +++ b/web/src/pages/User/index.js @@ -1,10 +1,10 @@ import React from 'react'; -import UsersTable from '../../components/table/UsersTable'; +import UsersPage from '../../components/table/users'; const User = () => { return (
- +
); }; From 09557101db1fb2b2b625ba7e51ff38a609ea703c Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 00:44:09 +0800 Subject: [PATCH 016/582] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor:=20com?= =?UTF-8?q?plete=20table=20module=20architecture=20unification=20and=20cle?= =?UTF-8?q?anup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Removed standalone user edit routes and intermediate export files ## Major Refactoring - Decompose 673-line monolithic UsersTable.js into 8 specialized components - Extract column definitions to UsersColumnDefs.js with render functions - Create dedicated UsersActions.jsx for action buttons - Create UsersFilters.jsx for search and filtering logic - Create UsersDescription.jsx for description area - Extract all data management logic to useUsersData.js hook - Move AddUser.js and EditUser.js to users/modals/ folder as modal components - Create 4 new confirmation modal components (Promote, Demote, EnableDisable, Delete) - Implement pure UsersTable.jsx component for table rendering only - Create main container component users/index.jsx to compose all subcomponents ## Import Path Optimization - Remove 6 intermediate re-export files: ChannelsTable.js, TokensTable.js, RedemptionsTable.js, UsageLogsTable.js, MjLogsTable.js, TaskLogsTable.js - Update all pages to import directly from module folders (e.g., '../../components/table/tokens') - Standardize naming convention: all pages import as XxxTable while internal components use XxxPage ## Route Cleanup - Remove obsolete EditUser imports and routes from App.js (/console/user/edit, /console/user/edit/:id) - Delete original monolithic files: UsersTable.js, AddUser.js, EditUser.js ## Architecture Benefits - Unified modular pattern across all table modules (tokens, redemptions, users, channels, logs) - Consistent file organization and naming conventions - Better separation of concerns and maintainability - Enhanced reusability and testability - Eliminated unnecessary intermediate layers - Improved import clarity and performance All existing functionality preserved with significantly improved code organization. --- web/src/components/table/ChannelsTable.js | 2 -- web/src/components/table/MjLogsTable.js | 2 -- web/src/components/table/RedemptionsTable.js | 2 -- web/src/components/table/TaskLogsTable.js | 2 -- web/src/components/table/TokensTable.js | 2 -- web/src/components/table/UsageLogsTable.js | 2 -- web/src/components/table/users/index.jsx | 2 +- .../table/users/modals/DeleteUserModal.jsx | 10 +++++----- .../table/users/modals/EnableDisableUserModal.jsx | 14 +++++++------- web/src/pages/Channel/index.js | 2 +- web/src/pages/Log/index.js | 2 +- web/src/pages/Midjourney/index.js | 2 +- web/src/pages/Redemption/index.js | 2 +- web/src/pages/Task/index.js | 2 +- web/src/pages/Token/index.js | 2 +- web/src/pages/User/index.js | 4 ++-- 16 files changed, 21 insertions(+), 33 deletions(-) delete mode 100644 web/src/components/table/ChannelsTable.js delete mode 100644 web/src/components/table/MjLogsTable.js delete mode 100644 web/src/components/table/RedemptionsTable.js delete mode 100644 web/src/components/table/TaskLogsTable.js delete mode 100644 web/src/components/table/TokensTable.js delete mode 100644 web/src/components/table/UsageLogsTable.js diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js deleted file mode 100644 index 6a423997..00000000 --- a/web/src/components/table/ChannelsTable.js +++ /dev/null @@ -1,2 +0,0 @@ -// 重构后的 ChannelsTable - 使用新的模块化架构 -export { default } from './channels/index.jsx'; diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js deleted file mode 100644 index a5f614d0..00000000 --- a/web/src/components/table/MjLogsTable.js +++ /dev/null @@ -1,2 +0,0 @@ -// 重构后的 MjLogsTable - 使用新的模块化架构 -export { default } from './mj-logs/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js deleted file mode 100644 index d2e89b97..00000000 --- a/web/src/components/table/RedemptionsTable.js +++ /dev/null @@ -1,2 +0,0 @@ -// 重构后的 RedemptionsTable - 使用新的模块化架构 -export { default } from './redemptions/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js deleted file mode 100644 index a6996611..00000000 --- a/web/src/components/table/TaskLogsTable.js +++ /dev/null @@ -1,2 +0,0 @@ -// 重构后的 TaskLogsTable - 使用新的模块化架构 -export { default } from './task-logs/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js deleted file mode 100644 index d74a49e2..00000000 --- a/web/src/components/table/TokensTable.js +++ /dev/null @@ -1,2 +0,0 @@ -// 重构后的 TokensTable - 使用新的模块化架构 -export { default } from './tokens/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/UsageLogsTable.js b/web/src/components/table/UsageLogsTable.js deleted file mode 100644 index da0623ae..00000000 --- a/web/src/components/table/UsageLogsTable.js +++ /dev/null @@ -1,2 +0,0 @@ -// 重构后的 UsageLogsTable - 使用新的模块化架构 -export { default } from './usage-logs/index.jsx'; \ No newline at end of file diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx index 5eba39a6..64885e99 100644 --- a/web/src/components/table/users/index.jsx +++ b/web/src/components/table/users/index.jsx @@ -47,7 +47,7 @@ const UsersPage = () => { visible={showAddUser} handleClose={closeAddUser} /> - + { const handleConfirm = async () => { await manageUser(user.id, 'delete', user); diff --git a/web/src/components/table/users/modals/EnableDisableUserModal.jsx b/web/src/components/table/users/modals/EnableDisableUserModal.jsx index be95cf40..9c2ed54f 100644 --- a/web/src/components/table/users/modals/EnableDisableUserModal.jsx +++ b/web/src/components/table/users/modals/EnableDisableUserModal.jsx @@ -1,16 +1,16 @@ import React from 'react'; import { Modal } from '@douyinfe/semi-ui'; -const EnableDisableUserModal = ({ - visible, - onCancel, - onConfirm, - user, +const EnableDisableUserModal = ({ + visible, + onCancel, + onConfirm, + user, action, - t + t }) => { const isDisable = action === 'disable'; - + return ( { return ( diff --git a/web/src/pages/Log/index.js b/web/src/pages/Log/index.js index f4bed060..a7c3fa37 100644 --- a/web/src/pages/Log/index.js +++ b/web/src/pages/Log/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import UsageLogsTable from '../../components/table/UsageLogsTable'; +import UsageLogsTable from '../../components/table/usage-logs'; const Token = () => (
diff --git a/web/src/pages/Midjourney/index.js b/web/src/pages/Midjourney/index.js index 67d9f76c..04414c95 100644 --- a/web/src/pages/Midjourney/index.js +++ b/web/src/pages/Midjourney/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import MjLogsTable from '../../components/table/MjLogsTable'; +import MjLogsTable from '../../components/table/mj-logs'; const Midjourney = () => (
diff --git a/web/src/pages/Redemption/index.js b/web/src/pages/Redemption/index.js index 44bb1c87..60bb3ac6 100644 --- a/web/src/pages/Redemption/index.js +++ b/web/src/pages/Redemption/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import RedemptionsTable from '../../components/table/RedemptionsTable'; +import RedemptionsTable from '../../components/table/redemptions'; const Redemption = () => { return ( diff --git a/web/src/pages/Task/index.js b/web/src/pages/Task/index.js index 261bd7da..f7b78ec2 100644 --- a/web/src/pages/Task/index.js +++ b/web/src/pages/Task/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import TaskLogsTable from '../../components/table/TaskLogsTable.js'; +import TaskLogsTable from '../../components/table/task-logs'; const Task = () => (
diff --git a/web/src/pages/Token/index.js b/web/src/pages/Token/index.js index 5f825741..4bb376a6 100644 --- a/web/src/pages/Token/index.js +++ b/web/src/pages/Token/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import TokensTable from '../../components/table/TokensTable'; +import TokensTable from '../../components/table/tokens'; const Token = () => { return ( diff --git a/web/src/pages/User/index.js b/web/src/pages/User/index.js index d06ee7ed..b1956ec6 100644 --- a/web/src/pages/User/index.js +++ b/web/src/pages/User/index.js @@ -1,10 +1,10 @@ import React from 'react'; -import UsersPage from '../../components/table/users'; +import UsersTable from '../../components/table/users'; const User = () => { return (
- +
); }; From 7ae715c7d9c0e1eaa8254f92a66e4d5c3f359e5a Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 00:58:18 +0800 Subject: [PATCH 017/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(channels)?= =?UTF-8?q?:=20migrate=20edit=20components=20to=20modals=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move EditChannel and EditTagModal from standalone pages to modal components within the channels module structure for consistency with other table modules. Changes: - Move EditChannel.js → components/table/channels/modals/EditChannelModal.jsx - Move EditTagModal.js → components/table/channels/modals/EditTagModal.jsx - Update import paths in channels/index.jsx - Remove standalone routes for EditChannel from App.js - Delete original files from pages/Channel/ This change aligns the channels module with the established modular pattern used by tokens, users, redemptions, and other table modules, centralizing all channel management functionality within integrated modal components instead of separate page routes. BREAKING CHANGE: EditChannel standalone routes (/console/channel/edit/:id and /console/channel/add) have been removed. All channel editing is now handled through modal components within the main channels page. --- web/src/App.js | 17 --------- web/src/components/table/channels/index.jsx | 6 +-- .../channels/modals/EditChannelModal.jsx} | 38 +++++++++---------- .../table/channels/modals/EditTagModal.jsx} | 6 +-- 4 files changed, 24 insertions(+), 43 deletions(-) rename web/src/{pages/Channel/EditChannel.js => components/table/channels/modals/EditChannelModal.jsx} (98%) rename web/src/{pages/Channel/EditTagModal.js => components/table/channels/modals/EditTagModal.jsx} (99%) diff --git a/web/src/App.js b/web/src/App.js index 41ab040e..bab3707c 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -12,7 +12,6 @@ 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'; @@ -61,22 +60,6 @@ function App() { } /> - } key={location.pathname}> - - - } - /> - } key={location.pathname}> - - - } - /> { const channelsData = useChannelsData(); @@ -24,7 +24,7 @@ const ChannelsPage = () => { handleClose={() => channelsData.setShowEditTag(false)} refresh={channelsData.refresh} /> - { +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 +191,7 @@ const EditChannel = (props) => { setInputs((inputs) => ({ ...inputs, models: localModels })); } setBasicModels(localModels); - + // 重置手动输入模式状态 setUseManualInput(false); } @@ -726,9 +724,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 +736,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 +749,8 @@ const EditChannel = (props) => { {getChannelIcon(value)}
- @@ -760,7 +758,7 @@ const EditChannel = (props) => { {selected && (
- +
)} @@ -926,7 +924,7 @@ const EditChannel = (props) => {
)} - + {batch && ( { className='!rounded-lg mb-3' /> )} - + {useManualInput && !batch ? ( { ); }; -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 99% rename from web/src/pages/Channel/EditTagModal.js rename to web/src/components/table/channels/modals/EditTagModal.jsx index 433d4f09..9ebc8bd6 100644 --- a/web/src/pages/Channel/EditTagModal.js +++ b/web/src/components/table/channels/modals/EditTagModal.jsx @@ -6,7 +6,7 @@ import { showSuccess, showWarning, verifyJSON, -} from '../../helpers'; +} from '../../../../helpers'; import { SideSheet, Space, @@ -26,7 +26,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; @@ -441,4 +441,4 @@ const EditTagModal = (props) => { ); }; -export default EditTagModal; +export default EditTagModal; \ No newline at end of file From 8d964629e0917a1ea3591e863b2f83004fa3ff77 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 01:34:59 +0800 Subject: [PATCH 018/582] =?UTF-8?q?=F0=9F=8C=9F=20feat(ui):=20reusable=20C?= =?UTF-8?q?ompactModeToggle=20&=20mobile-friendly=20CardPro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ------- Introduce a reusable compact-mode toggle component and greatly improve the CardPro header for small screens. Removes duplicated code, adds i18n support, and refines overall responsiveness. Details ------- 🎨 UI / Components • Create `common/ui/CompactModeToggle.js` – Provides a single source of truth for switching between “Compact list” and “Adaptive list” – Automatically hides itself on mobile devices via `useIsMobile()` • Refactor table modules to use the new component – `Users`, `Tokens`, `Redemptions`, `Channels`, `TaskLogs`, `MjLogs`, `UsageLogs` – Deletes legacy in-file toggle buttons & reduces repetition 📱 CardPro improvements • Hide `actionsArea` and `searchArea` on mobile, showing a single “Show Actions / Hide Actions” toggle button • Add i18n: texts are now pulled from injected `t()` function (`显示操作项` / `隐藏操作项` etc.) • Extend PropTypes to accept the `t` prop; supply a safe fallback • Minor cleanup: remove legacy DOM observers & flag CSS, simplify logic 🔧 Integration • Pass the `t` translation function to every `CardPro` usage across table pages • Remove temporary custom class hooks after logic simplification Benefits -------- ✓ Consistent, DRY compact-mode handling across the entire dashboard ✓ Better mobile experience with decluttered headers ✓ Full translation support for newly added strings ✓ Easier future maintenance (single compact toggle, unified CardPro API) --- web/src/components/common/ui/CardPro.js | 69 ++++++++++++++----- .../components/common/ui/CompactModeToggle.js | 49 +++++++++++++ .../table/channels/ChannelsActions.jsx | 14 ++-- web/src/components/table/channels/index.jsx | 1 + .../table/mj-logs/MjLogsActions.jsx | 16 ++--- web/src/components/table/mj-logs/index.jsx | 1 + .../redemptions/RedemptionsDescription.jsx | 16 ++--- .../components/table/redemptions/index.jsx | 1 + .../table/task-logs/TaskLogsActions.jsx | 16 ++--- web/src/components/table/task-logs/index.jsx | 1 + .../table/tokens/TokensDescription.jsx | 16 ++--- web/src/components/table/tokens/index.jsx | 1 + .../table/usage-logs/UsageLogsActions.jsx | 16 ++--- web/src/components/table/usage-logs/index.jsx | 1 + .../table/users/UsersDescription.jsx | 16 ++--- web/src/components/table/users/index.jsx | 1 + web/src/i18n/locales/en.json | 4 +- 17 files changed, 160 insertions(+), 79 deletions(-) create mode 100644 web/src/components/common/ui/CompactModeToggle.js diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index 944f33c1..e295df58 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -1,6 +1,8 @@ -import React from 'react'; -import { Card, Divider, Typography } from '@douyinfe/semi-ui'; +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; @@ -34,8 +36,21 @@ const CardPro = ({ bordered = false, // 自定义样式 style, + // 国际化函数 + t = (key) => 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; @@ -70,22 +85,42 @@ const CardPro = ({ )} - {/* 操作按钮和搜索表单的容器 */} -
- {/* 操作按钮区域 - 用于type1和type3 */} - {(type === 'type1' || type === 'type3') && actionsArea && ( -
- {actionsArea} + {/* 移动端操作切换按钮 */} + {isMobile && hasMobileHideableContent && ( + <> +
+
- )} + + )} - {/* 搜索表单区域 - 所有类型都可能有 */} - {searchArea && ( -
- {searchArea} -
- )} -
+ {/* 操作按钮和搜索表单的容器 */} + {/* 在移动端时根据showMobileActions状态控制显示,在桌面端时始终显示 */} + {(!isMobile || showMobileActions) && ( +
+ {/* 操作按钮区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && actionsArea && ( +
+ {actionsArea} +
+ )} + + {/* 搜索表单区域 - 所有类型都可能有 */} + {searchArea && ( +
+ {searchArea} +
+ )} +
+ )}
); }; @@ -122,6 +157,8 @@ CardPro.propTypes = { searchArea: 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/CompactModeToggle.js b/web/src/components/common/ui/CompactModeToggle.js new file mode 100644 index 00000000..356c2d8f --- /dev/null +++ b/web/src/components/common/ui/CompactModeToggle.js @@ -0,0 +1,49 @@ +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/table/channels/ChannelsActions.jsx b/web/src/components/table/channels/ChannelsActions.jsx index ae64b188..ae3f5152 100644 --- a/web/src/components/table/channels/ChannelsActions.jsx +++ b/web/src/components/table/channels/ChannelsActions.jsx @@ -7,6 +7,7 @@ import { Typography, Select } from '@douyinfe/semi-ui'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; const ChannelsActions = ({ enableBatchDelete, @@ -150,14 +151,11 @@ const ChannelsActions = ({ - +
{/* 右侧:设置开关区域 */} diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index f101ba95..a26c1d49 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -39,6 +39,7 @@ const ChannelsPage = () => { tabsArea={} actionsArea={} searchArea={} + t={channelsData.t} > diff --git a/web/src/components/table/mj-logs/MjLogsActions.jsx b/web/src/components/table/mj-logs/MjLogsActions.jsx index 85815c33..9c8a297a 100644 --- a/web/src/components/table/mj-logs/MjLogsActions.jsx +++ b/web/src/components/table/mj-logs/MjLogsActions.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Button, Skeleton, Typography } from '@douyinfe/semi-ui'; +import { Skeleton, Typography } from '@douyinfe/semi-ui'; import { IconEyeOpened } from '@douyinfe/semi-icons'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; const { Text } = Typography; @@ -32,14 +33,11 @@ const MjLogsActions = ({ )}
- +
); }; diff --git a/web/src/components/table/mj-logs/index.jsx b/web/src/components/table/mj-logs/index.jsx index a017d390..20ea4d33 100644 --- a/web/src/components/table/mj-logs/index.jsx +++ b/web/src/components/table/mj-logs/index.jsx @@ -22,6 +22,7 @@ const MjLogsPage = () => { type="type2" statsArea={} searchArea={} + t={mjLogsData.t} > diff --git a/web/src/components/table/redemptions/RedemptionsDescription.jsx b/web/src/components/table/redemptions/RedemptionsDescription.jsx index ef5e1b06..d7db7514 100644 --- a/web/src/components/table/redemptions/RedemptionsDescription.jsx +++ b/web/src/components/table/redemptions/RedemptionsDescription.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Button, Typography } from '@douyinfe/semi-ui'; +import { Typography } from '@douyinfe/semi-ui'; import { Ticket } from 'lucide-react'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; const { Text } = Typography; @@ -12,14 +13,11 @@ const RedemptionsDescription = ({ compactMode, setCompactMode, t }) => { {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}
- + ); }; diff --git a/web/src/components/table/redemptions/index.jsx b/web/src/components/table/redemptions/index.jsx index 064743d5..77a79c3a 100644 --- a/web/src/components/table/redemptions/index.jsx +++ b/web/src/components/table/redemptions/index.jsx @@ -80,6 +80,7 @@ const RedemptionsPage = () => { } + t={t} > diff --git a/web/src/components/table/task-logs/TaskLogsActions.jsx b/web/src/components/table/task-logs/TaskLogsActions.jsx index 0e1cec11..3d77e242 100644 --- a/web/src/components/table/task-logs/TaskLogsActions.jsx +++ b/web/src/components/table/task-logs/TaskLogsActions.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Button, Typography } from '@douyinfe/semi-ui'; +import { Typography } from '@douyinfe/semi-ui'; import { IconEyeOpened } from '@douyinfe/semi-icons'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; const { Text } = Typography; @@ -15,14 +16,11 @@ const TaskLogsActions = ({ {t('任务记录')} - + ); }; diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx index f0c2b1b7..4b9f2208 100644 --- a/web/src/components/table/task-logs/index.jsx +++ b/web/src/components/table/task-logs/index.jsx @@ -22,6 +22,7 @@ const TaskLogsPage = () => { type="type2" statsArea={} searchArea={} + t={taskLogsData.t} > diff --git a/web/src/components/table/tokens/TokensDescription.jsx b/web/src/components/table/tokens/TokensDescription.jsx index d56d769c..a8af1917 100644 --- a/web/src/components/table/tokens/TokensDescription.jsx +++ b/web/src/components/table/tokens/TokensDescription.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Button, Typography } from '@douyinfe/semi-ui'; +import { Typography } from '@douyinfe/semi-ui'; import { Key } from 'lucide-react'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; const { Text } = Typography; @@ -12,14 +13,11 @@ const TokensDescription = ({ compactMode, setCompactMode, t }) => { {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} - + ); }; diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx index 91d14054..dc18461f 100644 --- a/web/src/components/table/tokens/index.jsx +++ b/web/src/components/table/tokens/index.jsx @@ -82,6 +82,7 @@ const TokensPage = () => { } + t={t} > diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx index 6e3d8012..e69c78e6 100644 --- a/web/src/components/table/usage-logs/UsageLogsActions.jsx +++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Button, Tag, Space, Spin } from '@douyinfe/semi-ui'; +import { Tag, Space, Spin } from '@douyinfe/semi-ui'; import { renderQuota } from '../../../helpers'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; const LogsActions = ({ stat, @@ -49,14 +50,11 @@ const LogsActions = ({ - + ); diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index e53d71b3..43a53edc 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.jsx @@ -21,6 +21,7 @@ const LogsPage = () => { type="type2" statsArea={} searchArea={} + t={logsData.t} > diff --git a/web/src/components/table/users/UsersDescription.jsx b/web/src/components/table/users/UsersDescription.jsx index 39e0b43f..80d8aa74 100644 --- a/web/src/components/table/users/UsersDescription.jsx +++ b/web/src/components/table/users/UsersDescription.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Button, Typography } from '@douyinfe/semi-ui'; +import { Typography } from '@douyinfe/semi-ui'; import { IconUserAdd } from '@douyinfe/semi-icons'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; const { Text } = Typography; @@ -11,14 +12,11 @@ const UsersDescription = ({ compactMode, setCompactMode, t }) => { {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} - + ); }; diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx index 64885e99..95e3293e 100644 --- a/web/src/components/table/users/index.jsx +++ b/web/src/components/table/users/index.jsx @@ -85,6 +85,7 @@ const UsersPage = () => { /> } + t={t} > diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index cfddb57f..6cf1019a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1780,5 +1780,7 @@ "启用全部密钥": "Enable all keys", "以充值价格显示": "Show with recharge price", "美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)", - "美元汇率": "USD exchange rate" + "美元汇率": "USD exchange rate", + "隐藏操作项": "Hide actions", + "显示操作项": "Show actions" } \ No newline at end of file From 3c1652ff9de24caef9232806be486dbcb8e41d5e Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 02:27:57 +0800 Subject: [PATCH 019/582] =?UTF-8?q?=F0=9F=93=B1=20feat(ui):=20Introduce=20?= =?UTF-8?q?responsive=20`CardTable`=20with=20mobile=20card=20view,=20dynam?= =?UTF-8?q?ic=20skeletons=20&=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add `web/src/components/common/ui/CardTable.js` • Renders Semi-UI `Table` on desktop; on mobile, transforms each row into a rounded `Card`. • Supports all standard `Table` props, including `rowSelection`, `scroll`, `pagination`, etc. • Adds mobile pagination via Semi-UI `Pagination`. • Implements a 500 ms minimum, active Skeleton loader that mimics real column layout (including operation-button row). 2. Replace legacy `Table` with `CardTable` • Updated all major data pages: Channels, MJ-Logs, Redemptions, Tokens, Task-Logs, Usage-Logs and Users. • Removed unused `Table` imports; kept behaviour on desktop unchanged. 3. UI polish • Right-aligned operation buttons and sensitive fields (e.g., token keys) inside mobile cards. • Improved Skeleton placeholders to better reflect actual UI hierarchy and preserve the active animation. These changes dramatically improve the mobile experience while retaining full functionality on larger screens. --- web/src/components/common/ui/CardTable.js | 164 ++++++++++++++++++ .../table/channels/ChannelsTable.jsx | 5 +- .../components/table/mj-logs/MjLogsTable.jsx | 5 +- .../table/redemptions/RedemptionsTable.jsx | 5 +- .../table/task-logs/TaskLogsTable.jsx | 5 +- .../components/table/tokens/TokensTable.jsx | 5 +- .../table/usage-logs/UsageLogsTable.jsx | 5 +- web/src/components/table/users/UsersTable.jsx | 5 +- 8 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 web/src/components/common/ui/CardTable.js diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js new file mode 100644 index 00000000..3418b51b --- /dev/null +++ b/web/src/components/common/ui/CardTable.js @@ -0,0 +1,164 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Table, Card, Skeleton, Pagination } from '@douyinfe/semi-ui'; +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', ...tableProps }) => { + const isMobile = useIsMobile(); + + // Skeleton 显示控制,确保至少展示 500ms 动效 + 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; + }; + + // 如果不是移动端,直接渲染原 Table + if (!isMobile) { + 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))} +
+ ); + } + + // 渲染移动端卡片 + return ( +
+ {dataSource.map((record, index) => { + const rowKeyVal = getRowKey(record, index); + 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 : '-'} +
+
+ ); + })} +
+ ); + })} + {/* 分页组件 */} + {tableProps.pagination && ( +
+ +
+ )} +
+ ); +}; + +CardTable.propTypes = { + columns: PropTypes.array.isRequired, + dataSource: PropTypes.array, + loading: PropTypes.bool, + rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), +}; + +export default CardTable; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsTable.jsx b/web/src/components/table/channels/ChannelsTable.jsx index c95d0b17..618039d2 100644 --- a/web/src/components/table/channels/ChannelsTable.jsx +++ b/web/src/components/table/channels/ChannelsTable.jsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; -import { Table, Empty } from '@douyinfe/semi-ui'; +import { Empty } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable.js'; import { IllustrationNoResult, IllustrationNoResultDark @@ -96,7 +97,7 @@ const ChannelsTable = (channelsData) => { }, [compactMode, visibleColumnsList]); return ( -
{ }, [compactMode, visibleColumnsList]); return ( -
{ return ( <> -
{ }, [compactMode, visibleColumnsList]); return ( -
{ }, [compactMode, columns]); return ( -
{ }; return ( -
{ return ( <> -
Date: Sat, 19 Jul 2025 02:35:01 +0800 Subject: [PATCH 020/582] =?UTF-8?q?=F0=9F=92=84=20refactor(CardTable):=20p?= =?UTF-8?q?roper=20empty-state=20handling=20&=20pagination=20visibility=20?= =?UTF-8?q?on=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Imported Semi-UI `Empty` component. • Detect when `dataSource` is empty on mobile card view: – Renders supplied `empty` placeholder (`tableProps.empty`) or a default ``. – Suppresses the mobile `Pagination` component to avoid blank pages. • Pagination now renders only when `dataSource.length > 0`, preserving UX parity with desktop tables. --- web/src/components/common/ui/CardTable.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index 3418b51b..b90f38af 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Table, Card, Skeleton, Pagination } from '@douyinfe/semi-ui'; +import { Table, Card, Skeleton, Pagination, Empty } from '@douyinfe/semi-ui'; import PropTypes from 'prop-types'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; @@ -97,6 +97,18 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k } // 渲染移动端卡片 + const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0); + + if (isEmpty) { + // 若传入 empty 属性则使用之,否则使用默认 Empty + if (tableProps.empty) return tableProps.empty; + return ( +
+ +
+ ); + } + return (
{dataSource.map((record, index) => { @@ -145,7 +157,7 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k ); })} {/* 分页组件 */} - {tableProps.pagination && ( + {tableProps.pagination && dataSource.length > 0 && (
From 75c51ab81a77a28032761227476440da51324838 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 02:45:41 +0800 Subject: [PATCH 021/582] =?UTF-8?q?=F0=9F=93=9D=20docs(Table):=20simplify?= =?UTF-8?q?=20table=20description=20for=20cleaner=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- i18n/zh-cn.json | 2 +- web/src/components/layout/HeaderBar.js | 2 +- web/src/components/layout/SiderBar.js | 2 +- .../components/table/redemptions/RedemptionsDescription.jsx | 2 +- web/src/components/table/tokens/TokensDescription.jsx | 2 +- web/src/components/table/users/UsersDescription.jsx | 2 +- web/src/i18n/locales/en.json | 6 ++---- 7 files changed, 8 insertions(+), 10 deletions(-) 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/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index 6b365345..b3eaecd3 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -336,7 +336,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { >
- {t('API令牌')} + {t('令牌管理')}
{ } }) => { : 'tableHiddle', }, { - text: t('API令牌'), + text: t('令牌管理'), itemKey: 'token', to: '/token', }, diff --git a/web/src/components/table/redemptions/RedemptionsDescription.jsx b/web/src/components/table/redemptions/RedemptionsDescription.jsx index d7db7514..7eb8ab9d 100644 --- a/web/src/components/table/redemptions/RedemptionsDescription.jsx +++ b/web/src/components/table/redemptions/RedemptionsDescription.jsx @@ -10,7 +10,7 @@ const RedemptionsDescription = ({ compactMode, setCompactMode, t }) => {
- {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')} + {t('兑换码管理')}
{
- {t('令牌用于API访问认证,可以设置额度限制和模型权限。')} + {t('令牌管理')}
{
- {t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')} + {t('用户管理')}
Date: Sat, 19 Jul 2025 02:49:14 +0800 Subject: [PATCH 022/582] =?UTF-8?q?=F0=9F=8E=A8=20style(card-table):=20rep?= =?UTF-8?q?lace=20Tailwind=20border=E2=80=90gray=20util=20with=20Semi=20UI?= =?UTF-8?q?=20border=20variable=20for=20consistent=20theming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed changes 1. Removed `border-gray-200` Tailwind utility from two `
` elements in `web/src/components/common/ui/CardTable.js`. 2. Added inline style `borderColor: 'var(--semi-color-border)'` while keeping existing `border-b border-dashed` classes. 3. Ensures all borders use Semi UI’s design token, keeping visual consistency across light/dark themes and custom palettes. Why • Aligns component styling with Semi UI’s design system. • Avoids hard-coded colors and prevents theme mismatch issues on future updates. No breaking changes; visual update only. --- web/src/components/common/ui/CardTable.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index b90f38af..421de9cc 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.js @@ -73,7 +73,7 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k } return ( -
+
@@ -142,7 +142,8 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k return (
{title} From 0990561f234075a22925563e736138bcbc68c627 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 03:30:44 +0800 Subject: [PATCH 023/582] =?UTF-8?q?=F0=9F=8E=A8=20chore:=20integrate=20ESL?= =?UTF-8?q?int=20header=20automation=20with=20AGPL-3.0=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added `.eslintrc.cjs` - Enables `header` + `react-hooks` plugins - Inserts standardized AGPL-3.0 license banner for © 2025 QuantumNous - JS/JSX parsing & JSX support configured • Installed dev-deps: `eslint`, `eslint-plugin-header`, `eslint-plugin-react-hooks` • Updated `web/package.json` scripts - `eslint` → lint with cache - `eslint:fix` → auto-insert/repair license headers • Executed `eslint --fix` to prepend license banner to all JS/JSX files • Ignored runtime cache - Added `.eslintcache` to `.gitignore` & `.dockerignore` Result: consistent AGPL-3.0 license headers, reproducible linting across local dev & CI. --- .dockerignore | 3 +- .gitignore | 3 +- web/.eslintrc.cjs | 34 ++++ web/bun.lock | 163 ++++++++++++++++-- web/package.json | 5 + web/postcss.config.js | 19 ++ web/src/App.js | 19 ++ web/src/components/auth/LoginForm.js | 19 ++ web/src/components/auth/OAuth2Callback.js | 19 ++ .../components/auth/PasswordResetConfirm.js | 19 ++ web/src/components/auth/PasswordResetForm.js | 19 ++ web/src/components/auth/RegisterForm.js | 19 ++ web/src/components/common/logo/LinuxDoIcon.js | 19 ++ web/src/components/common/logo/OIDCIcon.js | 19 ++ web/src/components/common/logo/WeChatIcon.js | 19 ++ .../common/markdown/MarkdownRenderer.js | 19 ++ web/src/components/common/ui/CardPro.js | 19 ++ web/src/components/common/ui/CardTable.js | 19 ++ .../components/common/ui/CompactModeToggle.js | 19 ++ web/src/components/common/ui/Loading.js | 19 ++ web/src/components/layout/Footer.js | 19 ++ web/src/components/layout/HeaderBar.js | 19 ++ web/src/components/layout/NoticeModal.js | 19 ++ web/src/components/layout/PageLayout.js | 19 ++ web/src/components/layout/SetupCheck.js | 19 ++ web/src/components/layout/SiderBar.js | 19 ++ web/src/components/playground/ChatArea.js | 19 ++ web/src/components/playground/CodeViewer.js | 19 ++ .../components/playground/ConfigManager.js | 19 ++ .../playground/CustomInputRender.js | 19 ++ .../playground/CustomRequestEditor.js | 19 ++ web/src/components/playground/DebugPanel.js | 19 ++ .../components/playground/FloatingButtons.js | 19 ++ .../components/playground/ImageUrlInput.js | 19 ++ .../components/playground/MessageActions.js | 19 ++ .../components/playground/MessageContent.js | 19 ++ .../playground/OptimizedComponents.js | 19 ++ .../components/playground/ParameterControl.js | 19 ++ .../components/playground/SettingsPanel.js | 19 ++ .../components/playground/ThinkingContent.js | 19 ++ .../components/playground/configStorage.js | 19 ++ web/src/components/playground/index.js | 19 ++ .../settings/ChannelSelectorModal.js | 19 ++ web/src/components/settings/ChatsSetting.js | 19 ++ .../components/settings/DashboardSetting.js | 19 ++ web/src/components/settings/DrawingSetting.js | 19 ++ web/src/components/settings/ModelSetting.js | 19 ++ .../components/settings/OperationSetting.js | 19 ++ web/src/components/settings/OtherSetting.js | 19 ++ web/src/components/settings/PaymentSetting.js | 19 ++ .../components/settings/PersonalSetting.js | 19 ++ .../components/settings/RateLimitSetting.js | 19 ++ web/src/components/settings/RatioSetting.js | 19 ++ web/src/components/settings/SystemSetting.js | 19 ++ web/src/components/table/ModelPricing.js | 19 ++ .../table/channels/ChannelsActions.jsx | 19 ++ .../table/channels/ChannelsColumnDefs.js | 19 ++ .../table/channels/ChannelsFilters.jsx | 19 ++ .../table/channels/ChannelsTable.jsx | 19 ++ .../table/channels/ChannelsTabs.jsx | 19 ++ web/src/components/table/channels/index.jsx | 19 ++ .../table/channels/modals/BatchTagModal.jsx | 19 ++ .../channels/modals/ColumnSelectorModal.jsx | 19 ++ .../channels/modals/EditChannelModal.jsx | 19 ++ .../table/channels/modals/EditTagModal.jsx | 19 ++ .../table/channels/modals/ModelTestModal.jsx | 19 ++ .../table/mj-logs/MjLogsActions.jsx | 19 ++ .../table/mj-logs/MjLogsColumnDefs.js | 19 ++ .../table/mj-logs/MjLogsFilters.jsx | 19 ++ .../components/table/mj-logs/MjLogsTable.jsx | 19 ++ web/src/components/table/mj-logs/index.jsx | 19 ++ .../mj-logs/modals/ColumnSelectorModal.jsx | 19 ++ .../table/mj-logs/modals/ContentModal.jsx | 19 ++ .../table/redemptions/RedemptionsActions.jsx | 19 ++ .../redemptions/RedemptionsColumnDefs.js | 19 ++ .../redemptions/RedemptionsDescription.jsx | 19 ++ .../table/redemptions/RedemptionsFilters.jsx | 19 ++ .../table/redemptions/RedemptionsTable.jsx | 19 ++ .../components/table/redemptions/index.jsx | 19 ++ .../modals/DeleteRedemptionModal.jsx | 19 ++ .../modals/EditRedemptionModal.jsx | 19 ++ .../table/task-logs/TaskLogsActions.jsx | 19 ++ .../table/task-logs/TaskLogsColumnDefs.js | 19 ++ .../table/task-logs/TaskLogsFilters.jsx | 19 ++ .../table/task-logs/TaskLogsTable.jsx | 19 ++ web/src/components/table/task-logs/index.jsx | 19 ++ .../task-logs/modals/ColumnSelectorModal.jsx | 19 ++ .../table/task-logs/modals/ContentModal.jsx | 19 ++ .../components/table/tokens/TokensActions.jsx | 19 ++ .../table/tokens/TokensColumnDefs.js | 19 ++ .../table/tokens/TokensDescription.jsx | 19 ++ .../components/table/tokens/TokensFilters.jsx | 19 ++ .../components/table/tokens/TokensTable.jsx | 19 ++ web/src/components/table/tokens/index.jsx | 19 ++ .../table/tokens/modals/CopyTokensModal.jsx | 19 ++ .../table/tokens/modals/DeleteTokensModal.jsx | 19 ++ .../table/tokens/modals/EditTokenModal.jsx | 19 ++ .../table/usage-logs/UsageLogsActions.jsx | 19 ++ .../table/usage-logs/UsageLogsColumnDefs.js | 19 ++ .../table/usage-logs/UsageLogsFilters.jsx | 19 ++ .../table/usage-logs/UsageLogsTable.jsx | 19 ++ web/src/components/table/usage-logs/index.jsx | 19 ++ .../usage-logs/modals/ColumnSelectorModal.jsx | 19 ++ .../table/usage-logs/modals/UserInfoModal.jsx | 19 ++ .../components/table/users/UsersActions.jsx | 19 ++ .../components/table/users/UsersColumnDefs.js | 19 ++ .../table/users/UsersDescription.jsx | 19 ++ .../components/table/users/UsersFilters.jsx | 19 ++ web/src/components/table/users/UsersTable.jsx | 19 ++ web/src/components/table/users/index.jsx | 19 ++ .../table/users/modals/AddUserModal.jsx | 19 ++ .../table/users/modals/DeleteUserModal.jsx | 19 ++ .../table/users/modals/DemoteUserModal.jsx | 19 ++ .../table/users/modals/EditUserModal.jsx | 19 ++ .../users/modals/EnableDisableUserModal.jsx | 19 ++ .../table/users/modals/PromoteUserModal.jsx | 19 ++ web/src/constants/channel.constants.js | 19 ++ web/src/constants/common.constant.js | 19 ++ web/src/constants/index.js | 19 ++ web/src/constants/playground.constants.js | 20 ++- web/src/constants/redemption.constants.js | 20 ++- web/src/constants/toast.constants.js | 19 ++ web/src/constants/user.constants.js | 19 ++ web/src/context/Status/index.js | 19 +- web/src/context/Status/reducer.js | 19 ++ web/src/context/Theme/index.js | 19 ++ web/src/context/User/index.js | 19 +- web/src/context/User/reducer.js | 19 ++ web/src/helpers/api.js | 19 ++ web/src/helpers/auth.js | 19 ++ web/src/helpers/boolean.js | 19 ++ web/src/helpers/data.js | 19 ++ web/src/helpers/history.js | 19 ++ web/src/helpers/index.js | 19 ++ web/src/helpers/log.js | 19 ++ web/src/helpers/render.js | 19 ++ web/src/helpers/token.js | 19 ++ web/src/helpers/utils.js | 19 ++ web/src/hooks/channels/useChannelsData.js | 19 ++ web/src/hooks/chat/useTokenKeys.js | 19 ++ web/src/hooks/common/useIsMobile.js | 19 ++ web/src/hooks/common/useSidebarCollapsed.js | 19 ++ web/src/hooks/common/useTableCompactMode.js | 19 ++ web/src/hooks/mj-logs/useMjLogsData.js | 19 ++ web/src/hooks/playground/useApiRequest.js | 19 ++ web/src/hooks/playground/useDataLoader.js | 19 ++ web/src/hooks/playground/useMessageActions.js | 19 ++ web/src/hooks/playground/useMessageEdit.js | 19 ++ .../hooks/playground/usePlaygroundState.js | 19 ++ .../playground/useSyncMessageAndCustomBody.js | 19 ++ .../hooks/redemptions/useRedemptionsData.js | 19 ++ web/src/hooks/task-logs/useTaskLogsData.js | 19 ++ web/src/hooks/tokens/useTokensData.js | 19 ++ web/src/hooks/usage-logs/useUsageLogsData.js | 19 ++ web/src/hooks/users/useUsersData.js | 19 ++ web/src/i18n/i18n.js | 19 ++ web/src/index.js | 19 ++ web/src/pages/About/index.js | 19 ++ web/src/pages/Channel/index.js | 19 ++ web/src/pages/Chat/index.js | 19 ++ web/src/pages/Chat2Link/index.js | 19 ++ web/src/pages/Detail/index.js | 19 ++ web/src/pages/Home/index.js | 19 ++ web/src/pages/Log/index.js | 19 ++ web/src/pages/Midjourney/index.js | 19 ++ web/src/pages/NotFound/index.js | 19 ++ web/src/pages/Playground/index.js | 19 ++ web/src/pages/Pricing/index.js | 19 ++ web/src/pages/Redemption/index.js | 19 ++ web/src/pages/Setting/Chat/SettingsChats.js | 19 ++ .../Setting/Dashboard/SettingsAPIInfo.js | 19 ++ .../Dashboard/SettingsAnnouncements.js | 19 ++ .../Dashboard/SettingsDataDashboard.js | 19 ++ .../pages/Setting/Dashboard/SettingsFAQ.js | 19 ++ .../Setting/Dashboard/SettingsUptimeKuma.js | 19 ++ .../pages/Setting/Drawing/SettingsDrawing.js | 19 ++ .../pages/Setting/Model/SettingClaudeModel.js | 19 ++ .../pages/Setting/Model/SettingGeminiModel.js | 19 ++ .../pages/Setting/Model/SettingGlobalModel.js | 19 ++ .../Setting/Operation/SettingsCreditLimit.js | 19 ++ .../Setting/Operation/SettingsGeneral.js | 19 ++ .../pages/Setting/Operation/SettingsLog.js | 19 ++ .../Setting/Operation/SettingsMonitoring.js | 19 ++ .../Operation/SettingsSensitiveWords.js | 19 ++ .../Setting/Payment/SettingsGeneralPayment.js | 19 ++ .../Setting/Payment/SettingsPaymentGateway.js | 19 ++ .../Payment/SettingsPaymentGatewayStripe.js | 19 ++ .../RateLimit/SettingsRequestRateLimit.js | 19 ++ .../pages/Setting/Ratio/GroupRatioSettings.js | 19 ++ .../pages/Setting/Ratio/ModelRatioSettings.js | 19 ++ .../Setting/Ratio/ModelRationNotSetEditor.js | 19 ++ .../Ratio/ModelSettingsVisualEditor.js | 20 ++- .../pages/Setting/Ratio/UpstreamRatioSync.js | 19 ++ web/src/pages/Setting/index.js | 19 ++ web/src/pages/Setup/index.js | 19 ++ web/src/pages/Task/index.js | 19 ++ web/src/pages/Token/index.js | 19 ++ web/src/pages/TopUp/index.js | 19 ++ web/src/pages/User/index.js | 19 ++ web/tailwind.config.js | 20 ++- web/vite.config.js | 19 ++ 201 files changed, 3911 insertions(+), 25 deletions(-) create mode 100644 web/.eslintrc.cjs 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/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 bab3707c..fa935683 100644 --- a/web/src/App.js +++ b/web/src/App.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, { lazy, Suspense } from 'react'; import { Route, Routes, useLocation } from 'react-router-dom'; import Loading from './components/common/ui/Loading.js'; diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js index 16cece25..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'; diff --git a/web/src/components/auth/OAuth2Callback.js b/web/src/components/auth/OAuth2Callback.js index 0bd92f58..4fb3a512 100644 --- a/web/src/components/auth/OAuth2Callback.js +++ b/web/src/components/auth/OAuth2Callback.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 { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/components/auth/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js index 9b454f76..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'; diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js index fcbd9189..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'; diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js index 6d8a9466..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 { 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 index e295df58..5c194c74 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.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 } from 'react'; import { Card, Divider, Typography, Button } from '@douyinfe/semi-ui'; import PropTypes from 'prop-types'; diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index 421de9cc..f39c6d48 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.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, useRef } from 'react'; import { Table, Card, Skeleton, Pagination, Empty } from '@douyinfe/semi-ui'; import PropTypes from 'prop-types'; diff --git a/web/src/components/common/ui/CompactModeToggle.js b/web/src/components/common/ui/CompactModeToggle.js index 356c2d8f..631156ee 100644 --- a/web/src/components/common/ui/CompactModeToggle.js +++ b/web/src/components/common/ui/CompactModeToggle.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 PropTypes from 'prop-types'; diff --git a/web/src/components/common/ui/Loading.js b/web/src/components/common/ui/Loading.js index 73822755..60f94748 100644 --- a/web/src/components/common/ui/Loading.js +++ b/web/src/components/common/ui/Loading.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 { Spin } from '@douyinfe/semi-ui'; 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 b3eaecd3..a097f79c 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'; diff --git a/web/src/components/layout/NoticeModal.js b/web/src/components/layout/NoticeModal.js index 2a79540c..0dae4f88 100644 --- a/web/src/components/layout/NoticeModal.js +++ b/web/src/components/layout/NoticeModal.js @@ -1,3 +1,22 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +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 da955ccc..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'; 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 afbc7a51..714e556e 100644 --- a/web/src/components/layout/SiderBar.js +++ b/web/src/components/layout/SiderBar.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, useMemo, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; 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..539c53b3 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 { 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..1da05881 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, 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 eec5fb88..2e3e5c20 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/web/src/components/settings/ChannelSelectorModal.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, forwardRef, useImperativeHandle } from 'react'; import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { 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 fda43d7d..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 { 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/ModelPricing.js b/web/src/components/table/ModelPricing.js index 7e8d3995..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'; diff --git a/web/src/components/table/channels/ChannelsActions.jsx b/web/src/components/table/channels/ChannelsActions.jsx index ae3f5152..d88b66ed 100644 --- a/web/src/components/table/channels/ChannelsActions.jsx +++ b/web/src/components/table/channels/ChannelsActions.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 from 'react'; import { Button, diff --git a/web/src/components/table/channels/ChannelsColumnDefs.js b/web/src/components/table/channels/ChannelsColumnDefs.js index 9f7c50de..beb5fe55 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.js +++ b/web/src/components/table/channels/ChannelsColumnDefs.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/table/channels/ChannelsFilters.jsx b/web/src/components/table/channels/ChannelsFilters.jsx index 65a7e7f8..0d607f5f 100644 --- a/web/src/components/table/channels/ChannelsFilters.jsx +++ b/web/src/components/table/channels/ChannelsFilters.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 from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/channels/ChannelsTable.jsx b/web/src/components/table/channels/ChannelsTable.jsx index 618039d2..e0270558 100644 --- a/web/src/components/table/channels/ChannelsTable.jsx +++ b/web/src/components/table/channels/ChannelsTable.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, { useMemo } from 'react'; import { Empty } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable.js'; diff --git a/web/src/components/table/channels/ChannelsTabs.jsx b/web/src/components/table/channels/ChannelsTabs.jsx index 32345e8a..f0448efc 100644 --- a/web/src/components/table/channels/ChannelsTabs.jsx +++ b/web/src/components/table/channels/ChannelsTabs.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 from 'react'; import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui'; import { CHANNEL_OPTIONS } from '../../../constants/index.js'; diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index a26c1d49..91dd3200 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.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 from 'react'; import CardPro from '../../common/ui/CardPro.js'; import ChannelsTable from './ChannelsTable.jsx'; diff --git a/web/src/components/table/channels/modals/BatchTagModal.jsx b/web/src/components/table/channels/modals/BatchTagModal.jsx index 5f3a7a93..121ba87f 100644 --- a/web/src/components/table/channels/modals/BatchTagModal.jsx +++ b/web/src/components/table/channels/modals/BatchTagModal.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 from 'react'; import { Modal, Input, Typography } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/channels/modals/ColumnSelectorModal.jsx b/web/src/components/table/channels/modals/ColumnSelectorModal.jsx index 8805a84b..291992ce 100644 --- a/web/src/components/table/channels/modals/ColumnSelectorModal.jsx +++ b/web/src/components/table/channels/modals/ColumnSelectorModal.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 from 'react'; import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; import { getChannelsColumns } from '../ChannelsColumnDefs.js'; diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 36d70160..4ceafd93 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.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, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { diff --git a/web/src/components/table/channels/modals/EditTagModal.jsx b/web/src/components/table/channels/modals/EditTagModal.jsx index 9ebc8bd6..44e921ce 100644 --- a/web/src/components/table/channels/modals/EditTagModal.jsx +++ 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, diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx index 05d272c0..b59e9ab6 100644 --- a/web/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/src/components/table/channels/modals/ModelTestModal.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 from 'react'; import { Modal, diff --git a/web/src/components/table/mj-logs/MjLogsActions.jsx b/web/src/components/table/mj-logs/MjLogsActions.jsx index 9c8a297a..b924c36a 100644 --- a/web/src/components/table/mj-logs/MjLogsActions.jsx +++ b/web/src/components/table/mj-logs/MjLogsActions.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 from 'react'; import { Skeleton, Typography } from '@douyinfe/semi-ui'; import { IconEyeOpened } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/mj-logs/MjLogsColumnDefs.js b/web/src/components/table/mj-logs/MjLogsColumnDefs.js index 9e993785..5d9db7d7 100644 --- a/web/src/components/table/mj-logs/MjLogsColumnDefs.js +++ b/web/src/components/table/mj-logs/MjLogsColumnDefs.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/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx index 3cfa6d3b..4aced0f2 100644 --- a/web/src/components/table/mj-logs/MjLogsFilters.jsx +++ b/web/src/components/table/mj-logs/MjLogsFilters.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 from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/mj-logs/MjLogsTable.jsx b/web/src/components/table/mj-logs/MjLogsTable.jsx index 8ab47263..5b1cfa92 100644 --- a/web/src/components/table/mj-logs/MjLogsTable.jsx +++ b/web/src/components/table/mj-logs/MjLogsTable.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, { useMemo } from 'react'; import { Empty } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable.js'; diff --git a/web/src/components/table/mj-logs/index.jsx b/web/src/components/table/mj-logs/index.jsx index 20ea4d33..3b0560b8 100644 --- a/web/src/components/table/mj-logs/index.jsx +++ b/web/src/components/table/mj-logs/index.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 from 'react'; import { Layout } from '@douyinfe/semi-ui'; import CardPro from '../../common/ui/CardPro.js'; diff --git a/web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx b/web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx index 3a9f0070..d05f9cf0 100644 --- a/web/src/components/table/mj-logs/modals/ColumnSelectorModal.jsx +++ b/web/src/components/table/mj-logs/modals/ColumnSelectorModal.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 from 'react'; import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; import { getMjLogsColumns } from '../MjLogsColumnDefs.js'; diff --git a/web/src/components/table/mj-logs/modals/ContentModal.jsx b/web/src/components/table/mj-logs/modals/ContentModal.jsx index 0dd63bec..f73cda24 100644 --- a/web/src/components/table/mj-logs/modals/ContentModal.jsx +++ b/web/src/components/table/mj-logs/modals/ContentModal.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 from 'react'; import { Modal, ImagePreview } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/redemptions/RedemptionsActions.jsx b/web/src/components/table/redemptions/RedemptionsActions.jsx index 1d86dd38..5b10fb00 100644 --- a/web/src/components/table/redemptions/RedemptionsActions.jsx +++ b/web/src/components/table/redemptions/RedemptionsActions.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 from 'react'; import { Button } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/redemptions/RedemptionsColumnDefs.js b/web/src/components/table/redemptions/RedemptionsColumnDefs.js index 4f4cd808..fc1601c1 100644 --- a/web/src/components/table/redemptions/RedemptionsColumnDefs.js +++ b/web/src/components/table/redemptions/RedemptionsColumnDefs.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 { Tag, Button, Space, Popover, Dropdown } from '@douyinfe/semi-ui'; import { IconMore } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/redemptions/RedemptionsDescription.jsx b/web/src/components/table/redemptions/RedemptionsDescription.jsx index 7eb8ab9d..56e63464 100644 --- a/web/src/components/table/redemptions/RedemptionsDescription.jsx +++ b/web/src/components/table/redemptions/RedemptionsDescription.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 from 'react'; import { Typography } from '@douyinfe/semi-ui'; import { Ticket } from 'lucide-react'; diff --git a/web/src/components/table/redemptions/RedemptionsFilters.jsx b/web/src/components/table/redemptions/RedemptionsFilters.jsx index 888f016e..f659200c 100644 --- a/web/src/components/table/redemptions/RedemptionsFilters.jsx +++ b/web/src/components/table/redemptions/RedemptionsFilters.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 from 'react'; import { Form, Button } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/redemptions/RedemptionsTable.jsx b/web/src/components/table/redemptions/RedemptionsTable.jsx index d016a3ff..58fc5444 100644 --- a/web/src/components/table/redemptions/RedemptionsTable.jsx +++ b/web/src/components/table/redemptions/RedemptionsTable.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, { useMemo, useState } from 'react'; import { Empty } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable.js'; diff --git a/web/src/components/table/redemptions/index.jsx b/web/src/components/table/redemptions/index.jsx index 77a79c3a..1886c59f 100644 --- a/web/src/components/table/redemptions/index.jsx +++ b/web/src/components/table/redemptions/index.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 from 'react'; import CardPro from '../../common/ui/CardPro'; import RedemptionsTable from './RedemptionsTable.jsx'; diff --git a/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx b/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx index 3b7668d9..d99968e7 100644 --- a/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx +++ b/web/src/components/table/redemptions/modals/DeleteRedemptionModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; import { REDEMPTION_ACTIONS } from '../../../../constants/redemption.constants'; diff --git a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx index 9d06866f..79b834a3 100644 --- a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx +++ 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 { diff --git a/web/src/components/table/task-logs/TaskLogsActions.jsx b/web/src/components/table/task-logs/TaskLogsActions.jsx index 3d77e242..5df27e69 100644 --- a/web/src/components/table/task-logs/TaskLogsActions.jsx +++ b/web/src/components/table/task-logs/TaskLogsActions.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 from 'react'; import { Typography } from '@douyinfe/semi-ui'; import { IconEyeOpened } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.js b/web/src/components/table/task-logs/TaskLogsColumnDefs.js index 92936abc..26a72fe5 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.js +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.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 { Progress, diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx index 509f57b7..c3e26eea 100644 --- a/web/src/components/table/task-logs/TaskLogsFilters.jsx +++ b/web/src/components/table/task-logs/TaskLogsFilters.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 from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/task-logs/TaskLogsTable.jsx b/web/src/components/table/task-logs/TaskLogsTable.jsx index 950b80d5..c148709c 100644 --- a/web/src/components/table/task-logs/TaskLogsTable.jsx +++ b/web/src/components/table/task-logs/TaskLogsTable.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, { useMemo } from 'react'; import { Empty } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable.js'; diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx index 4b9f2208..944f49df 100644 --- a/web/src/components/table/task-logs/index.jsx +++ b/web/src/components/table/task-logs/index.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 from 'react'; import { Layout } from '@douyinfe/semi-ui'; import CardPro from '../../common/ui/CardPro.js'; diff --git a/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx b/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx index 23624a72..6a66304b 100644 --- a/web/src/components/table/task-logs/modals/ColumnSelectorModal.jsx +++ b/web/src/components/table/task-logs/modals/ColumnSelectorModal.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 from 'react'; import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; import { getTaskLogsColumns } from '../TaskLogsColumnDefs.js'; diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx index f82baf90..11869614 100644 --- a/web/src/components/table/task-logs/modals/ContentModal.jsx +++ b/web/src/components/table/task-logs/modals/ContentModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/tokens/TokensActions.jsx b/web/src/components/table/tokens/TokensActions.jsx index 85703d24..765069e1 100644 --- a/web/src/components/table/tokens/TokensActions.jsx +++ b/web/src/components/table/tokens/TokensActions.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 } from 'react'; import { Button, Space } from '@douyinfe/semi-ui'; import { showError } from '../../../helpers'; diff --git a/web/src/components/table/tokens/TokensColumnDefs.js b/web/src/components/table/tokens/TokensColumnDefs.js index dc53eb74..0c1f966e 100644 --- a/web/src/components/table/tokens/TokensColumnDefs.js +++ b/web/src/components/table/tokens/TokensColumnDefs.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/table/tokens/TokensDescription.jsx b/web/src/components/table/tokens/TokensDescription.jsx index 3ce06f1a..3dcfebac 100644 --- a/web/src/components/table/tokens/TokensDescription.jsx +++ b/web/src/components/table/tokens/TokensDescription.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 from 'react'; import { Typography } from '@douyinfe/semi-ui'; import { Key } from 'lucide-react'; diff --git a/web/src/components/table/tokens/TokensFilters.jsx b/web/src/components/table/tokens/TokensFilters.jsx index 63912c1b..0889cacb 100644 --- a/web/src/components/table/tokens/TokensFilters.jsx +++ b/web/src/components/table/tokens/TokensFilters.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 from 'react'; import { Form, Button } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/tokens/TokensTable.jsx b/web/src/components/table/tokens/TokensTable.jsx index d1a1d1aa..237d05ae 100644 --- a/web/src/components/table/tokens/TokensTable.jsx +++ b/web/src/components/table/tokens/TokensTable.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, { useMemo } from 'react'; import { Empty } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable.js'; diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx index dc18461f..35ff6102 100644 --- a/web/src/components/table/tokens/index.jsx +++ b/web/src/components/table/tokens/index.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 from 'react'; import CardPro from '../../common/ui/CardPro'; import TokensTable from './TokensTable.jsx'; diff --git a/web/src/components/table/tokens/modals/CopyTokensModal.jsx b/web/src/components/table/tokens/modals/CopyTokensModal.jsx index 41f9627b..93ea3cfa 100644 --- a/web/src/components/table/tokens/modals/CopyTokensModal.jsx +++ b/web/src/components/table/tokens/modals/CopyTokensModal.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 from 'react'; import { Modal, Button, Space } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/tokens/modals/DeleteTokensModal.jsx b/web/src/components/table/tokens/modals/DeleteTokensModal.jsx index 5bc3ee5a..4f339ec3 100644 --- a/web/src/components/table/tokens/modals/DeleteTokensModal.jsx +++ b/web/src/components/table/tokens/modals/DeleteTokensModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index 119cc41c..04a22e0d 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ 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, diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx index e69c78e6..a2e68fcd 100644 --- a/web/src/components/table/usage-logs/UsageLogsActions.jsx +++ b/web/src/components/table/usage-logs/UsageLogsActions.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 from 'react'; import { Tag, Space, Spin } from '@douyinfe/semi-ui'; import { renderQuota } from '../../../helpers'; diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.js b/web/src/components/table/usage-logs/UsageLogsColumnDefs.js index 628835d7..2de5f7e2 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.js +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.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 { Avatar, diff --git a/web/src/components/table/usage-logs/UsageLogsFilters.jsx b/web/src/components/table/usage-logs/UsageLogsFilters.jsx index 6db77906..4ff33628 100644 --- a/web/src/components/table/usage-logs/UsageLogsFilters.jsx +++ b/web/src/components/table/usage-logs/UsageLogsFilters.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 from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/usage-logs/UsageLogsTable.jsx b/web/src/components/table/usage-logs/UsageLogsTable.jsx index e41463af..b089f5cb 100644 --- a/web/src/components/table/usage-logs/UsageLogsTable.jsx +++ b/web/src/components/table/usage-logs/UsageLogsTable.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, { useMemo } from 'react'; import { Empty, Descriptions } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable.js'; diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index 43a53edc..d14a2d65 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.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 from 'react'; import CardPro from '../../common/ui/CardPro.js'; import LogsTable from './UsageLogsTable.jsx'; diff --git a/web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx b/web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx index cfc20e2e..262041fe 100644 --- a/web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx +++ b/web/src/components/table/usage-logs/modals/ColumnSelectorModal.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 from 'react'; import { Modal, Button, Checkbox } from '@douyinfe/semi-ui'; import { getLogsColumns } from '../UsageLogsColumnDefs.js'; diff --git a/web/src/components/table/usage-logs/modals/UserInfoModal.jsx b/web/src/components/table/usage-logs/modals/UserInfoModal.jsx index 5b9abe71..586e9c53 100644 --- a/web/src/components/table/usage-logs/modals/UserInfoModal.jsx +++ b/web/src/components/table/usage-logs/modals/UserInfoModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; import { renderQuota, renderNumber } from '../../../../helpers'; diff --git a/web/src/components/table/users/UsersActions.jsx b/web/src/components/table/users/UsersActions.jsx index c486cedc..bf505baf 100644 --- a/web/src/components/table/users/UsersActions.jsx +++ b/web/src/components/table/users/UsersActions.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 from 'react'; import { Button } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/users/UsersColumnDefs.js b/web/src/components/table/users/UsersColumnDefs.js index 8c8bd5ac..d668760b 100644 --- a/web/src/components/table/users/UsersColumnDefs.js +++ b/web/src/components/table/users/UsersColumnDefs.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/table/users/UsersDescription.jsx b/web/src/components/table/users/UsersDescription.jsx index 1088d7aa..2ab1c696 100644 --- a/web/src/components/table/users/UsersDescription.jsx +++ b/web/src/components/table/users/UsersDescription.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 from 'react'; import { Typography } from '@douyinfe/semi-ui'; import { IconUserAdd } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/users/UsersFilters.jsx b/web/src/components/table/users/UsersFilters.jsx index 201b1d1a..21aa8a42 100644 --- a/web/src/components/table/users/UsersFilters.jsx +++ b/web/src/components/table/users/UsersFilters.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 from 'react'; import { Form, Button } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; diff --git a/web/src/components/table/users/UsersTable.jsx b/web/src/components/table/users/UsersTable.jsx index 7e7efe47..53ca747e 100644 --- a/web/src/components/table/users/UsersTable.jsx +++ b/web/src/components/table/users/UsersTable.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, { useMemo, useState } from 'react'; import { Empty } from '@douyinfe/semi-ui'; import CardTable from '../../common/ui/CardTable.js'; diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx index 95e3293e..ce282aaf 100644 --- a/web/src/components/table/users/index.jsx +++ b/web/src/components/table/users/index.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 from 'react'; import CardPro from '../../common/ui/CardPro'; import UsersTable from './UsersTable.jsx'; diff --git a/web/src/components/table/users/modals/AddUserModal.jsx b/web/src/components/table/users/modals/AddUserModal.jsx index 59df7ef7..caf33a64 100644 --- a/web/src/components/table/users/modals/AddUserModal.jsx +++ b/web/src/components/table/users/modals/AddUserModal.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, useRef } from 'react'; import { API, showError, showSuccess } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; diff --git a/web/src/components/table/users/modals/DeleteUserModal.jsx b/web/src/components/table/users/modals/DeleteUserModal.jsx index f9e19ec0..aa4e0539 100644 --- a/web/src/components/table/users/modals/DeleteUserModal.jsx +++ b/web/src/components/table/users/modals/DeleteUserModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/users/modals/DemoteUserModal.jsx b/web/src/components/table/users/modals/DemoteUserModal.jsx index c3885ebf..e9bebc50 100644 --- a/web/src/components/table/users/modals/DemoteUserModal.jsx +++ b/web/src/components/table/users/modals/DemoteUserModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/users/modals/EditUserModal.jsx b/web/src/components/table/users/modals/EditUserModal.jsx index 330f4702..a075f14b 100644 --- a/web/src/components/table/users/modals/EditUserModal.jsx +++ 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 { diff --git a/web/src/components/table/users/modals/EnableDisableUserModal.jsx b/web/src/components/table/users/modals/EnableDisableUserModal.jsx index 9c2ed54f..c1c383ec 100644 --- a/web/src/components/table/users/modals/EnableDisableUserModal.jsx +++ b/web/src/components/table/users/modals/EnableDisableUserModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/components/table/users/modals/PromoteUserModal.jsx b/web/src/components/table/users/modals/PromoteUserModal.jsx index 0a47d15a..da2a1c37 100644 --- a/web/src/components/table/users/modals/PromoteUserModal.jsx +++ b/web/src/components/table/users/modals/PromoteUserModal.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 from 'react'; import { Modal } from '@douyinfe/semi-ui'; 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/index.js b/web/src/constants/index.js index 27107eea..5e81b7db 100644 --- a/web/src/constants/index.js +++ b/web/src/constants/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 './channel.constants'; export * from './user.constants'; export * from './toast.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 index 418b4393..3149df0c 100644 --- a/web/src/constants/redemption.constants.js +++ b/web/src/constants/redemption.constants.js @@ -1,4 +1,22 @@ -// Redemption code status constants +/* +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 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/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..e906e254 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'; 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 8c7cb20f..bd0a8131 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.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 i18next from 'i18next'; import { Modal, Tag, Typography } from '@douyinfe/semi-ui'; import { copy, showSuccess } from './utils'; 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 f74b437a..734c716b 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.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 { Toast } from '@douyinfe/semi-ui'; import { toastConstants } from '../constants'; import React from 'react'; diff --git a/web/src/hooks/channels/useChannelsData.js b/web/src/hooks/channels/useChannelsData.js index b6890f95..2dc77a13 100644 --- a/web/src/hooks/channels/useChannelsData.js +++ b/web/src/hooks/channels/useChannelsData.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 { useState, useEffect, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { diff --git a/web/src/hooks/chat/useTokenKeys.js b/web/src/hooks/chat/useTokenKeys.js index 24e5b95e..d7ac8399 100644 --- a/web/src/hooks/chat/useTokenKeys.js +++ b/web/src/hooks/chat/useTokenKeys.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 { useEffect, useState } from 'react'; import { fetchTokenKeys, getServerAddress } from '../../helpers/token'; import { showError } from '../../helpers'; diff --git a/web/src/hooks/common/useIsMobile.js b/web/src/hooks/common/useIsMobile.js index 08f9c5e2..eb5d78ad 100644 --- a/web/src/hooks/common/useIsMobile.js +++ b/web/src/hooks/common/useIsMobile.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 MOBILE_BREAKPOINT = 768; import { useSyncExternalStore } from 'react'; diff --git a/web/src/hooks/common/useSidebarCollapsed.js b/web/src/hooks/common/useSidebarCollapsed.js index 2982ff9b..c88256be 100644 --- a/web/src/hooks/common/useSidebarCollapsed.js +++ b/web/src/hooks/common/useSidebarCollapsed.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 { useState, useCallback } from 'react'; const KEY = 'default_collapse_sidebar'; diff --git a/web/src/hooks/common/useTableCompactMode.js b/web/src/hooks/common/useTableCompactMode.js index 1238a173..129a71c0 100644 --- a/web/src/hooks/common/useTableCompactMode.js +++ b/web/src/hooks/common/useTableCompactMode.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 { useState, useEffect, useCallback } from 'react'; import { getTableCompactMode, setTableCompactMode } from '../../helpers'; import { TABLE_COMPACT_MODES_KEY } from '../../constants'; diff --git a/web/src/hooks/mj-logs/useMjLogsData.js b/web/src/hooks/mj-logs/useMjLogsData.js index 906cd6fc..4720629a 100644 --- a/web/src/hooks/mj-logs/useMjLogsData.js +++ b/web/src/hooks/mj-logs/useMjLogsData.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 { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/hooks/playground/useApiRequest.js b/web/src/hooks/playground/useApiRequest.js index f7bb2139..7a89111f 100644 --- a/web/src/hooks/playground/useApiRequest.js +++ b/web/src/hooks/playground/useApiRequest.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 { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { SSE } from 'sse.js'; diff --git a/web/src/hooks/playground/useDataLoader.js b/web/src/hooks/playground/useDataLoader.js index 4927fcf5..679ba478 100644 --- a/web/src/hooks/playground/useDataLoader.js +++ b/web/src/hooks/playground/useDataLoader.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 { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { API, processModelsData, processGroupsData } from '../../helpers'; diff --git a/web/src/hooks/playground/useMessageActions.js b/web/src/hooks/playground/useMessageActions.js index e400f56f..06ce730f 100644 --- a/web/src/hooks/playground/useMessageActions.js +++ b/web/src/hooks/playground/useMessageActions.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 { useCallback } from 'react'; import { Toast, Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/hooks/playground/useMessageEdit.js b/web/src/hooks/playground/useMessageEdit.js index 5a8bfdc4..25b1d3d5 100644 --- a/web/src/hooks/playground/useMessageEdit.js +++ b/web/src/hooks/playground/useMessageEdit.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 { useCallback, useState, useRef } from 'react'; import { Toast, Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/hooks/playground/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js index 253b95da..da3b84dc 100644 --- a/web/src/hooks/playground/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.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 { useState, useCallback, useRef, useEffect } from 'react'; import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../../constants/playground.constants'; import { loadConfig, saveConfig, loadMessages, saveMessages } from '../../components/playground/configStorage'; diff --git a/web/src/hooks/playground/useSyncMessageAndCustomBody.js b/web/src/hooks/playground/useSyncMessageAndCustomBody.js index f0f36734..98795208 100644 --- a/web/src/hooks/playground/useSyncMessageAndCustomBody.js +++ b/web/src/hooks/playground/useSyncMessageAndCustomBody.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 { useCallback, useRef } from 'react'; import { MESSAGE_ROLES } from '../../constants/playground.constants'; diff --git a/web/src/hooks/redemptions/useRedemptionsData.js b/web/src/hooks/redemptions/useRedemptionsData.js index e31ddd76..ce6d6219 100644 --- a/web/src/hooks/redemptions/useRedemptionsData.js +++ b/web/src/hooks/redemptions/useRedemptionsData.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 { useState, useEffect } from 'react'; import { API, showError, showSuccess, copy } from '../../helpers'; import { ITEMS_PER_PAGE } from '../../constants'; diff --git a/web/src/hooks/task-logs/useTaskLogsData.js b/web/src/hooks/task-logs/useTaskLogsData.js index 479d3c46..70e2bf00 100644 --- a/web/src/hooks/task-logs/useTaskLogsData.js +++ b/web/src/hooks/task-logs/useTaskLogsData.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 { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/hooks/tokens/useTokensData.js b/web/src/hooks/tokens/useTokensData.js index fc035ee5..3e97618f 100644 --- a/web/src/hooks/tokens/useTokensData.js +++ b/web/src/hooks/tokens/useTokensData.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 { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/hooks/usage-logs/useUsageLogsData.js b/web/src/hooks/usage-logs/useUsageLogsData.js index 5959714b..f13d0dc9 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.js +++ b/web/src/hooks/usage-logs/useUsageLogsData.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 { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from '@douyinfe/semi-ui'; diff --git a/web/src/hooks/users/useUsersData.js b/web/src/hooks/users/useUsersData.js index a9952a76..56211057 100644 --- a/web/src/hooks/users/useUsersData.js +++ b/web/src/hooks/users/useUsersData.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 { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { API, showError, showSuccess } from '../../helpers'; diff --git a/web/src/i18n/i18n.js b/web/src/i18n/i18n.js index c7d69868..7198ee33 100644 --- a/web/src/i18n/i18n.js +++ b/web/src/i18n/i18n.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 i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; diff --git a/web/src/index.js b/web/src/index.js index 77d129e6..310637ea 100644 --- a/web/src/index.js +++ b/web/src/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 React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; diff --git a/web/src/pages/About/index.js b/web/src/pages/About/index.js index ca9578ad..232b3224 100644 --- a/web/src/pages/About/index.js +++ b/web/src/pages/About/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 React, { useEffect, useState } from 'react'; import { API, showError } from '../../helpers'; import { marked } from 'marked'; diff --git a/web/src/pages/Channel/index.js b/web/src/pages/Channel/index.js index d9167e3b..b6996b06 100644 --- a/web/src/pages/Channel/index.js +++ b/web/src/pages/Channel/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 React from 'react'; import ChannelsTable from '../../components/table/channels'; diff --git a/web/src/pages/Chat/index.js b/web/src/pages/Chat/index.js index 53fa03fb..0b8c3cab 100644 --- a/web/src/pages/Chat/index.js +++ b/web/src/pages/Chat/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 React from 'react'; import { useTokenKeys } from '../../hooks/chat/useTokenKeys'; import { Spin } from '@douyinfe/semi-ui'; diff --git a/web/src/pages/Chat2Link/index.js b/web/src/pages/Chat2Link/index.js index b3e17ac3..70bdfcce 100644 --- a/web/src/pages/Chat2Link/index.js +++ b/web/src/pages/Chat2Link/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 React from 'react'; import { useTokenKeys } from '../../hooks/chat/useTokenKeys'; diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index f124452a..76625424 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/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 React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { initVChartSemiTheme } from '@visactor/vchart-semi-theme'; import { useNavigate } from 'react-router-dom'; diff --git a/web/src/pages/Home/index.js b/web/src/pages/Home/index.js index bf859091..3d8ac68f 100644 --- a/web/src/pages/Home/index.js +++ b/web/src/pages/Home/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 React, { useContext, useEffect, useState } from 'react'; import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui'; import { API, showError, copy, showSuccess } from '../../helpers'; diff --git a/web/src/pages/Log/index.js b/web/src/pages/Log/index.js index a7c3fa37..5e52459b 100644 --- a/web/src/pages/Log/index.js +++ b/web/src/pages/Log/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 React from 'react'; import UsageLogsTable from '../../components/table/usage-logs'; diff --git a/web/src/pages/Midjourney/index.js b/web/src/pages/Midjourney/index.js index 04414c95..2b168294 100644 --- a/web/src/pages/Midjourney/index.js +++ b/web/src/pages/Midjourney/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 React from 'react'; import MjLogsTable from '../../components/table/mj-logs'; diff --git a/web/src/pages/NotFound/index.js b/web/src/pages/NotFound/index.js index c6c9e96c..be236822 100644 --- a/web/src/pages/NotFound/index.js +++ b/web/src/pages/NotFound/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 React from 'react'; import { Empty } from '@douyinfe/semi-ui'; import { IllustrationNotFound, IllustrationNotFoundDark } from '@douyinfe/semi-illustrations'; diff --git a/web/src/pages/Playground/index.js b/web/src/pages/Playground/index.js index bc95d489..88ebc538 100644 --- a/web/src/pages/Playground/index.js +++ b/web/src/pages/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 +*/ + import React, { useContext, useEffect, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/pages/Pricing/index.js b/web/src/pages/Pricing/index.js index eaaf640d..48f69f54 100644 --- a/web/src/pages/Pricing/index.js +++ b/web/src/pages/Pricing/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 React from 'react'; import ModelPricing from '../../components/table/ModelPricing.js'; diff --git a/web/src/pages/Redemption/index.js b/web/src/pages/Redemption/index.js index 60bb3ac6..c77d0677 100644 --- a/web/src/pages/Redemption/index.js +++ b/web/src/pages/Redemption/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 React from 'react'; import RedemptionsTable from '../../components/table/redemptions'; diff --git a/web/src/pages/Setting/Chat/SettingsChats.js b/web/src/pages/Setting/Chat/SettingsChats.js index 76f3f9f2..bef38eaf 100644 --- a/web/src/pages/Setting/Chat/SettingsChats.js +++ b/web/src/pages/Setting/Chat/SettingsChats.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 { Banner, diff --git a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js index 54f5035b..3dac07e7 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js +++ b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.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 { Button, diff --git a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js index 06f9f0ab..c2d57944 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js +++ b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.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/pages/Setting/Dashboard/SettingsDataDashboard.js b/web/src/pages/Setting/Dashboard/SettingsDataDashboard.js index af6079b6..c33ba77a 100644 --- a/web/src/pages/Setting/Dashboard/SettingsDataDashboard.js +++ b/web/src/pages/Setting/Dashboard/SettingsDataDashboard.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, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Dashboard/SettingsFAQ.js b/web/src/pages/Setting/Dashboard/SettingsFAQ.js index 7c15ddc8..96c81fd6 100644 --- a/web/src/pages/Setting/Dashboard/SettingsFAQ.js +++ b/web/src/pages/Setting/Dashboard/SettingsFAQ.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 { Button, diff --git a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js index f84561d6..9c9cda19 100644 --- a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js +++ b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.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 { Button, diff --git a/web/src/pages/Setting/Drawing/SettingsDrawing.js b/web/src/pages/Setting/Drawing/SettingsDrawing.js index 0c9394df..fbea6702 100644 --- a/web/src/pages/Setting/Drawing/SettingsDrawing.js +++ b/web/src/pages/Setting/Drawing/SettingsDrawing.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, Col, Form, Row, Spin, Tag } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Model/SettingClaudeModel.js b/web/src/pages/Setting/Model/SettingClaudeModel.js index 3eff92a0..04d7956a 100644 --- a/web/src/pages/Setting/Model/SettingClaudeModel.js +++ b/web/src/pages/Setting/Model/SettingClaudeModel.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, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Model/SettingGeminiModel.js b/web/src/pages/Setting/Model/SettingGeminiModel.js index a5daace6..13d45083 100644 --- a/web/src/pages/Setting/Model/SettingGeminiModel.js +++ b/web/src/pages/Setting/Model/SettingGeminiModel.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, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Model/SettingGlobalModel.js b/web/src/pages/Setting/Model/SettingGlobalModel.js index 837508c7..e71593d5 100644 --- a/web/src/pages/Setting/Model/SettingGlobalModel.js +++ b/web/src/pages/Setting/Model/SettingGlobalModel.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, Col, Form, Row, Spin, Banner } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Operation/SettingsCreditLimit.js b/web/src/pages/Setting/Operation/SettingsCreditLimit.js index 1e2911ed..131ade44 100644 --- a/web/src/pages/Setting/Operation/SettingsCreditLimit.js +++ b/web/src/pages/Setting/Operation/SettingsCreditLimit.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, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.js b/web/src/pages/Setting/Operation/SettingsGeneral.js index 3ca9c377..162dc338 100644 --- a/web/src/pages/Setting/Operation/SettingsGeneral.js +++ b/web/src/pages/Setting/Operation/SettingsGeneral.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 { Banner, diff --git a/web/src/pages/Setting/Operation/SettingsLog.js b/web/src/pages/Setting/Operation/SettingsLog.js index 6ac27014..dcd17081 100644 --- a/web/src/pages/Setting/Operation/SettingsLog.js +++ b/web/src/pages/Setting/Operation/SettingsLog.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, Col, Form, Row, Spin, DatePicker } from '@douyinfe/semi-ui'; import dayjs from 'dayjs'; diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.js b/web/src/pages/Setting/Operation/SettingsMonitoring.js index 857bb8da..f4de4f6e 100644 --- a/web/src/pages/Setting/Operation/SettingsMonitoring.js +++ b/web/src/pages/Setting/Operation/SettingsMonitoring.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, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Operation/SettingsSensitiveWords.js b/web/src/pages/Setting/Operation/SettingsSensitiveWords.js index 41481bd4..8310ddb2 100644 --- a/web/src/pages/Setting/Operation/SettingsSensitiveWords.js +++ b/web/src/pages/Setting/Operation/SettingsSensitiveWords.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, Col, Form, Row, Spin, Tag } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Payment/SettingsGeneralPayment.js b/web/src/pages/Setting/Payment/SettingsGeneralPayment.js index c5b6511c..b9252839 100644 --- a/web/src/pages/Setting/Payment/SettingsGeneralPayment.js +++ b/web/src/pages/Setting/Payment/SettingsGeneralPayment.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/pages/Setting/Payment/SettingsPaymentGateway.js b/web/src/pages/Setting/Payment/SettingsPaymentGateway.js index 0bb63b53..46c18a47 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.js +++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.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/pages/Setting/Payment/SettingsPaymentGatewayStripe.js b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js index 4c4a1af6..23bd1b67 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.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 { Banner, diff --git a/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js b/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js index 85473ec9..efb355df 100644 --- a/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js +++ b/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.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, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Ratio/GroupRatioSettings.js b/web/src/pages/Setting/Ratio/GroupRatioSettings.js index 12e634ba..1e6e4af3 100644 --- a/web/src/pages/Setting/Ratio/GroupRatioSettings.js +++ b/web/src/pages/Setting/Ratio/GroupRatioSettings.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, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.js b/web/src/pages/Setting/Ratio/ModelRatioSettings.js index 80238fc8..c0e5991b 100644 --- a/web/src/pages/Setting/Ratio/ModelRatioSettings.js +++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.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/pages/Setting/Ratio/ModelRationNotSetEditor.js b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js index 21d1fbb8..5ca8686b 100644 --- a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js +++ b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.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 { Table, diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js index a1090516..2aa45ace 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js @@ -1,4 +1,22 @@ -// ModelSettingsVisualEditor.js +/* +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 { Table, diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 3bb8d091..8b408062 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.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, useCallback, useMemo, useEffect } from 'react'; import { Button, diff --git a/web/src/pages/Setting/index.js b/web/src/pages/Setting/index.js index a74e9b97..4e8bb2f6 100644 --- a/web/src/pages/Setting/index.js +++ b/web/src/pages/Setting/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 React, { useEffect, useState } from 'react'; import { Layout, TabPane, Tabs } from '@douyinfe/semi-ui'; import { useNavigate, useLocation } from 'react-router-dom'; diff --git a/web/src/pages/Setup/index.js b/web/src/pages/Setup/index.js index bca92506..8d72a473 100644 --- a/web/src/pages/Setup/index.js +++ b/web/src/pages/Setup/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 React, { useEffect, useState, useRef } from 'react'; import { Card, diff --git a/web/src/pages/Task/index.js b/web/src/pages/Task/index.js index f7b78ec2..d29777da 100644 --- a/web/src/pages/Task/index.js +++ b/web/src/pages/Task/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 React from 'react'; import TaskLogsTable from '../../components/table/task-logs'; diff --git a/web/src/pages/Token/index.js b/web/src/pages/Token/index.js index 4bb376a6..8764db76 100644 --- a/web/src/pages/Token/index.js +++ b/web/src/pages/Token/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 React from 'react'; import TokensTable from '../../components/table/tokens'; diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index dc088ff1..867e623e 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/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 React, { useEffect, useState, useContext } from 'react'; import { API, diff --git a/web/src/pages/User/index.js b/web/src/pages/User/index.js index b1956ec6..49bf3cd1 100644 --- a/web/src/pages/User/index.js +++ b/web/src/pages/User/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 React from 'react'; import UsersTable from '../../components/table/users'; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 09cb9782..1f092b4d 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,4 +1,22 @@ -/** @type {import('tailwindcss').Config} */ +/* +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 { content: [ "./index.html", diff --git a/web/vite.config.js b/web/vite.config.js index 78825b4a..50ca06a5 100644 --- a/web/vite.config.js +++ b/web/vite.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 +*/ + import react from '@vitejs/plugin-react'; import { defineConfig, transformWithEsbuild } from 'vite'; import pkg from '@douyinfe/vite-plugin-semi'; From 784b753148d7d27068c9b3d09332aae5fdba50cf Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 03:43:35 +0800 Subject: [PATCH 024/582] =?UTF-8?q?=E2=9C=A8=20fix(cardpro):=20Keep=20acti?= =?UTF-8?q?ons=20&=20search=20areas=20mounted=20on=20mobile=20to=20auto-lo?= =?UTF-8?q?ad=20RPM/TPM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses an issue where RPM and TPM statistics did not load automatically on mobile devices. Key changes • Replaced conditional rendering with persistent rendering of `actionsArea` and `searchArea` in `CardPro` and applied the `hidden` CSS class when the sections should be concealed. • Ensures internal hooks (e.g. `useUsageLogsData`) always run, allowing stats to be fetched without requiring the user to tap “Show Actions”. • Maintains existing desktop behaviour; only mobile handling is affected. Files updated • `web/src/components/common/ui/CardPro.js` Result Mobile users now see up-to-date RPM/TPM (and other statistics) immediately after page load, improving usability and consistency with the desktop experience. --- web/src/components/common/ui/CardPro.js | 34 ++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index 5c194c74..2c8f7d30 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -122,24 +122,24 @@ const CardPro = ({ )} {/* 操作按钮和搜索表单的容器 */} - {/* 在移动端时根据showMobileActions状态控制显示,在桌面端时始终显示 */} - {(!isMobile || showMobileActions) && ( -
- {/* 操作按钮区域 - 用于type1和type3 */} - {(type === 'type1' || type === 'type3') && actionsArea && ( -
- {actionsArea} -
- )} +
+ {/* 操作按钮区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && actionsArea && ( +
+ {actionsArea} +
+ )} + + {/* 搜索表单区域 - 所有类型都可能有 */} + {searchArea && ( +
+ {searchArea} +
+ )} +
- {/* 搜索表单区域 - 所有类型都可能有 */} - {searchArea && ( -
- {searchArea} -
- )} -
- )}
); }; From dbced40e0180a878749b15e0061e8c6014cc2ffe Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 18 Jul 2025 16:54:16 +0800 Subject: [PATCH 025/582] feat: add kling video text2Video when image is empty --- relay/channel/task/kling/adaptor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index afa39201..75f6cad8 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -143,6 +143,9 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel if err != nil { return nil, err } + if body.Image == "" && body.ImageTail == "" { + c.Set("action", constant.TaskActionTextGenerate) + } data, err := json.Marshal(body) if err != nil { return nil, err From 7bc4737ff13564bf6cb60b051b8587e1895d20cc Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 11:34:34 +0800 Subject: [PATCH 026/582] =?UTF-8?q?=F0=9F=90=9B=20fix(model-test-modal):?= =?UTF-8?q?=20keep=20Modal=20mounted=20to=20restore=20body=20overflow=20co?= =?UTF-8?q?rrectly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the component unmounted the Modal as soon as `showModelTestModal` became false, preventing Semi UI from running its cleanup routine. This left `body` stuck with `overflow: hidden`, disabling page scroll after the dialog closed. Changes made – Removed the early `return null` and always keep the Modal mounted; visibility is now controlled solely via the `visible` prop. – Introduced a `hasChannel` guard to safely skip data processing/rendering when no channel is selected. – Added defensive checks for table data, footer and title to avoid undefined access when the Modal is hidden. This fix ensures that closing the test-model dialog correctly restores the page’s scroll behaviour on both desktop and mobile. --- web/src/components/common/ui/CardPro.js | 1 - web/src/components/layout/SiderBar.js | 2 +- .../table/channels/modals/ModelTestModal.jsx | 29 ++++++++++--------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index 2c8f7d30..fc57cd53 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -139,7 +139,6 @@ const CardPro = ({
)}
-
); }; diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js index 714e556e..c7f7df31 100644 --- a/web/src/components/layout/SiderBar.js +++ b/web/src/components/layout/SiderBar.js @@ -440,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/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx index b59e9ab6..1d159473 100644 --- a/web/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -49,15 +49,15 @@ const ModelTestModal = ({ isMobile, t }) => { - if (!showModelTestModal || !currentTestChannel) { - return null; - } + const hasChannel = Boolean(currentTestChannel); - const filteredModels = currentTestChannel.models - .split(',') - .filter((model) => - model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) - ); + const filteredModels = hasChannel + ? currentTestChannel.models + .split(',') + .filter((model) => + model.toLowerCase().includes(modelSearchKeyword.toLowerCase()) + ) + : []; const handleCopySelected = () => { if (selectedModelKeys.length === 0) { @@ -158,6 +158,7 @@ const ModelTestModal = ({ ]; 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) => ({ @@ -168,7 +169,7 @@ const ModelTestModal = ({ return (
@@ -179,10 +180,10 @@ const ModelTestModal = ({
- } + ) : null} visible={showModelTestModal} onCancel={handleCloseModal} - footer={ + footer={hasChannel ? (
{isBatchTesting ? (
- } + ) : null} maskClosable={!isBatchTesting} className="!rounded-lg" size={isMobile ? 'full-width' : 'large'} > -
+ {hasChannel && (
{/* 搜索与操作按钮 */}
setModelTablePage(page), }} /> -
+
)} ); }; From 189da68b401d5b481ebd55f608319b2b7822f590 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 12:14:08 +0800 Subject: [PATCH 027/582] =?UTF-8?q?=F0=9F=92=84=20style(CardPro):=20Enhanc?= =?UTF-8?q?e=20CardPro=20layout=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Accept an array for `actionsArea`, enabling multiple action blocks in one card • Automatically insert a `Divider` between consecutive action blocks • Add a `Divider` between `actionsArea` and `searchArea` when both exist • Standardize `Divider` spacing by removing custom `margin` props • Update `PropTypes`: `actionsArea` now supports `arrayOf(node)` These changes improve visual separation and usability for complex table cards (e.g., Channels), making the UI cleaner and more consistent. --- web/src/components/common/ui/CardPro.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index fc57cd53..3325381c 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -127,11 +127,25 @@ const CardPro = ({ > {/* 操作按钮区域 - 用于type1和type3 */} {(type === 'type1' || type === 'type3') && actionsArea && ( -
- {actionsArea} -
+ Array.isArray(actionsArea) ? ( + actionsArea.map((area, idx) => ( + + {idx !== 0 && } +
+ {area} +
+
+ )) + ) : ( +
+ {actionsArea} +
+ ) )} + {/* 当同时存在操作区和搜索区时,插入分隔线 */} + {(actionsArea && searchArea) && } + {/* 搜索表单区域 - 所有类型都可能有 */} {searchArea && (
@@ -171,7 +185,10 @@ CardPro.propTypes = { statsArea: PropTypes.node, descriptionArea: PropTypes.node, tabsArea: PropTypes.node, - actionsArea: PropTypes.node, + actionsArea: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf(PropTypes.node), + ]), searchArea: PropTypes.node, // 表格内容 children: PropTypes.node, From ba7a3b3a2cc1f9efe2832c591cbe2bf01a1ff60c Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 13:28:09 +0800 Subject: [PATCH 028/582] =?UTF-8?q?=E2=9C=A8=20refactor:=20unify=20model-s?= =?UTF-8?q?elect=20searching=20&=20UX=20across=20the=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch standardises how all “model” (and related) ` +export const modelSelectFilter = (input, option) => { + if (!input) return true; + const val = (option?.value || '').toString().toLowerCase(); + return val.includes(input.trim().toLowerCase()); +}; From ab912d744c8a706e31c459785311eb507c076d88 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 13:44:56 +0800 Subject: [PATCH 029/582] =?UTF-8?q?=E2=9C=A8=20**fix:=20Always=20display?= =?UTF-8?q?=20token=20quota=20tooltip=20for=20unlimited=20tokens**?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provide a consistent UX by ensuring the status column tooltip is shown for all tokens, including those with unlimited quota. Details: • Removed early‐return that skipped tooltip rendering when `record.unlimited_quota` was true. • Refactored tooltip content: – Unlimited quota: shows only “used quota”. – Limited quota: continues to show used, remaining (with percentage) and total. • Leaves existing tag, switch and progress-bar behaviour unchanged. This prevents missing hover information for unlimited tokens and avoids meaningless “remaining / total” figures (e.g. Infinity), improving clarity for administrators. --- .../table/tokens/TokensColumnDefs.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/components/table/tokens/TokensColumnDefs.js b/web/src/components/table/tokens/TokensColumnDefs.js index 0c1f966e..ffa5ff79 100644 --- a/web/src/components/table/tokens/TokensColumnDefs.js +++ b/web/src/components/table/tokens/TokensColumnDefs.js @@ -124,20 +124,20 @@ const renderStatus = (text, record, manageToken, t) => { ); - if (record.unlimited_quota) { - return content; - } + const tooltipContent = record.unlimited_quota ? ( +
+
{t('已用额度')}: {renderQuota(used)}
+
+ ) : ( +
+
{t('已用额度')}: {renderQuota(used)}
+
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
+
{t('总额度')}: {renderQuota(total)}
+
+ ); return ( - -
{t('已用额度')}: {renderQuota(used)}
-
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
-
{t('总额度')}: {renderQuota(total)}
-
- } - > + {content} ); From 03f594d2b06cb2ea570dc448b53a9253a65b5124 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 14:09:02 +0800 Subject: [PATCH 030/582] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20Replace=20Spin?= =?UTF-8?q?=20with=20animated=20Skeleton=20in=20UsageLogsActions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary • Swapped out the obsolete `` loader for a modern, animated Semi-UI `` implementation in `UsageLogsActions.jsx`. Details 1. Added animated Skeleton placeholders mirroring real Tag sizes (108 × 26, 65 × 26, 64 × 26). 2. Introduced `showSkeleton` state with 500 ms minimum display to eliminate flicker. 3. Leveraged existing `showStat` flag to decide when real data is ready. 4. Ensured only the three Tags are under loading state - `CompactModeToggle` renders immediately. 5. Adopted CardTable‐style `Skeleton` pattern (`loading` + `placeholder`) for consistency. 6. Removed all references to the original `Spin` component. Outcome A smoother and more consistent loading experience across devices, aligning UI behaviour with the project’s latest Skeleton standards. --- .../table/usage-logs/UsageLogsActions.jsx | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx index a2e68fcd..728733d1 100644 --- a/web/src/components/table/usage-logs/UsageLogsActions.jsx +++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx @@ -17,21 +17,51 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; -import { Tag, Space, Spin } from '@douyinfe/semi-ui'; +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 ( - -
+
+ + - -
- + +
); }; From 8a720078a9f5977849a23af0e3e670d68aa2426c Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 15:05:31 +0800 Subject: [PATCH 031/582] =?UTF-8?q?=F0=9F=93=B1=20feat(ui):=20Enhance=20mo?= =?UTF-8?q?bile=20log=20table=20UX=20&=20fix=20StrictMode=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary 1. CardTable • Added collapsible “Details / Collapse” section on mobile cards using Semi-UI Button + Collapsible with chevron icons. • Integrated i18n (`useTranslation`) for the toggle labels. • Restored original variable-width skeleton placeholders (50 % / 60 % / 70 % …) for more natural loading states. 2. UsageLogsColumnDefs • Wrapped each `Tag` inside a native `` when used as Tooltip trigger, removing `findDOMNode` deprecation warnings in React StrictMode. Impact • Cleaner, shorter rows on small screens with optional expansion. • Fully translated UI controls. • No more console noise in development & CI caused by StrictMode warnings. --- web/src/components/common/ui/CardTable.js | 135 +++++++++++------- .../table/usage-logs/UsageLogsColumnDefs.js | 34 +++-- 2 files changed, 106 insertions(+), 63 deletions(-) diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index f39c6d48..b24bc708 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.js @@ -18,7 +18,9 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useState, useEffect, useRef } from 'react'; -import { Table, Card, Skeleton, Pagination, Empty } from '@douyinfe/semi-ui'; +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'; @@ -30,6 +32,7 @@ import { useIsMobile } from '../../../hooks/common/useIsMobile'; */ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'key', ...tableProps }) => { const isMobile = useIsMobile(); + const { t } = useTranslation(); // Skeleton 显示控制,确保至少展示 500ms 动效 const [showSkeleton, setShowSkeleton] = useState(loading); @@ -94,7 +97,14 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k return (
- +
); })} @@ -118,6 +128,78 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k // 渲染移动端卡片 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) { // 若传入 empty 属性则使用之,否则使用默认 Empty if (tableProps.empty) return tableProps.empty; @@ -130,52 +212,9 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k return (
- {dataSource.map((record, index) => { - const rowKeyVal = getRowKey(record, index); - 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 : '-'} -
-
- ); - })} -
- ); - })} + {dataSource.map((record, index) => ( + + ))} {/* 分页组件 */} {tableProps.pagination && dataSource.length > 0 && (
diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.js b/web/src/components/table/usage-logs/UsageLogsColumnDefs.js index 2de5f7e2..d4ff1713 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.js +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.js @@ -268,12 +268,14 @@ export const getLogsColumns = ({ return isAdminUser && (record.type === 0 || record.type === 2 || record.type === 5) ? ( - - {text} - + + + {text} + + {isMultiKey && ( @@ -466,15 +468,17 @@ export const getLogsColumns = ({ render: (text, record, index) => { return (record.type === 2 || record.type === 5) && text ? ( - { - copyText(event, text); - }} - > - {text} - + + { + copyText(event, text); + }} + > + {text} + + ) : ( <> From 6ddc11b1f860155e61200f6816d9b07c5ea22628 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 15:21:42 +0800 Subject: [PATCH 032/582] =?UTF-8?q?=F0=9F=A4=A2=20fix(ui):=20UsageLogsTabl?= =?UTF-8?q?e=20skeleton=20dimensions=20to=20avoid=20layout=20shift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/table/usage-logs/UsageLogsActions.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx index 728733d1..72db01e4 100644 --- a/web/src/components/table/usage-logs/UsageLogsActions.jsx +++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx @@ -53,9 +53,9 @@ const LogsActions = ({ // Skeleton placeholder layout (three tag-size blocks) const placeholder = ( - - - + + + ); From 7ff1921fca938d76d9f4a335d432bb42130598a0 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 19 Jul 2025 21:11:14 +0800 Subject: [PATCH 033/582] =?UTF-8?q?=F0=9F=8E=A8=20feat(ui):=20enhance=20Us?= =?UTF-8?q?erInfoModal=20with=20improved=20layout=20and=20additional=20fie?= =?UTF-8?q?lds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesign modal layout from single column to responsive two-column grid - Add new user information fields: display name, user group, invitation code, invitation count, invitation quota, and remarks - Implement Badge components with color-coded categories for better visual hierarchy: * Primary (blue): basic identity info (username, display name) * Success (green): positive/earning info (balance, invitation quota) * Warning (orange): usage/consumption info (used quota, request count) * Tertiary (gray): supplementary info (user group, invitation details, remarks) - Optimize spacing and typography for better readability: * Reduce row spacing from 24px to 16px * Decrease font size from 16px to 14px for values * Adjust label margins from 4px to 2px - Implement conditional rendering for optional fields - Add proper text wrapping for long remarks content - Reduce overall modal height while maintaining information clarity This update significantly improves the user experience by presenting comprehensive user information in a more organized and visually appealing format. --- .../table/usage-logs/modals/UserInfoModal.jsx | 132 ++++++++++++++++-- web/src/i18n/locales/en.json | 4 +- 2 files changed, 120 insertions(+), 16 deletions(-) diff --git a/web/src/components/table/usage-logs/modals/UserInfoModal.jsx b/web/src/components/table/usage-logs/modals/UserInfoModal.jsx index 586e9c53..294f55ef 100644 --- a/web/src/components/table/usage-logs/modals/UserInfoModal.jsx +++ b/web/src/components/table/usage-logs/modals/UserInfoModal.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Modal } from '@douyinfe/semi-ui'; +import { Modal, Badge } from '@douyinfe/semi-ui'; import { renderQuota, renderNumber } from '../../../../helpers'; const UserInfoModal = ({ @@ -27,28 +27,130 @@ const UserInfoModal = ({ 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={true} + centered + closable + maskClosable + width={600} > {userInfoData && ( -
-

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

-

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

-

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

-

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

+
+ {/* 基本信息 */} +
+
+ {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}
+
+ )}
)} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5b4e94b6..23d1a5e8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1780,5 +1780,7 @@ "美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)", "美元汇率": "USD exchange rate", "隐藏操作项": "Hide actions", - "显示操作项": "Show actions" + "显示操作项": "Show actions", + "用户组": "User group", + "邀请获得额度": "Invitation quota" } \ No newline at end of file From ba7ade4d8123cb82dfb651893577696ccb92c13d Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 01:00:53 +0800 Subject: [PATCH 034/582] =?UTF-8?q?=F0=9F=92=84=20refactor:=20Users=20tabl?= =?UTF-8?q?e=20UI=20&=20state=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary of changes 1. UI clean-up • Removed all `prefixIcon` props from `Tag` components in `UsersColumnDefs.js`. • Corrected i18n string in invite info (`${t('邀请人')}: …`). 2. “Statistics” column overhaul • Added a Switch (enable / disable) and quota Progress bar, mirroring the Tokens table design. • Moved enable / disable action out of the “More” dropdown; user status is now toggled directly via the Switch. • Disabled the Switch for deleted (注销) users. • Restored column title to “Statistics” to avoid duplication. 3. State consistency / refresh • Updated `manageUser` in `useUsersData.js` to: – set `loading` while processing actions; – update users list immutably (new objects & array) to trigger React re-render. 4. Imports / plumbing • Added `Progress` and `Switch` to UI imports in `UsersColumnDefs.js`. These changes streamline the user table’s appearance, align interaction patterns with the token table, and ensure immediate visual feedback after user status changes. --- .../components/table/users/UsersColumnDefs.js | 258 ++++++++---------- web/src/hooks/users/useUsersData.js | 30 +- web/src/i18n/locales/en.json | 2 +- 3 files changed, 140 insertions(+), 150 deletions(-) diff --git a/web/src/components/table/users/UsersColumnDefs.js b/web/src/components/table/users/UsersColumnDefs.js index d668760b..774554cb 100644 --- a/web/src/components/table/users/UsersColumnDefs.js +++ b/web/src/components/table/users/UsersColumnDefs.js @@ -20,31 +20,14 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Button, - Dropdown, Space, Tag, Tooltip, - Typography + Progress, + Switch, } from '@douyinfe/semi-ui'; -import { - User, - Shield, - Crown, - HelpCircle, - CheckCircle, - XCircle, - Minus, - Coins, - Activity, - Users, - DollarSign, - UserPlus, -} from 'lucide-react'; -import { IconMore } from '@douyinfe/semi-icons'; import { renderGroup, renderNumber, renderQuota } from '../../../helpers'; -const { Text } = Typography; - /** * Render user role */ @@ -52,53 +35,31 @@ const renderRole = (role, t) => { switch (role) { case 1: return ( - }> + {t('普通用户')} ); case 10: return ( - }> + {t('管理员')} ); case 100: return ( - }> + {t('超级管理员')} ); default: return ( - }> + {t('未知身份')} ); } }; -/** - * Render user status - */ -const renderStatus = (status, t) => { - switch (status) { - case 1: - return }>{t('已激活')}; - case 2: - return ( - }> - {t('已封禁')} - - ); - default: - return ( - }> - {t('未知状态')} - - ); - } -}; - /** * Render username with remark */ @@ -127,22 +88,91 @@ const renderUsername = (text, record) => { /** * Render user statistics */ -const renderStatistics = (text, record, t) => { - return ( -
- - }> - {t('剩余')}: {renderQuota(record.quota)} - - }> - {t('已用')}: {renderQuota(record.used_quota)} - - }> - {t('调用')}: {renderNumber(record.request_count)} - - +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} + + ); }; /** @@ -152,31 +182,20 @@ const renderInviteInfo = (text, record, t) => { return (
- }> + {t('邀请')}: {renderNumber(record.aff_count)} - }> + {t('收益')}: {renderQuota(record.aff_history_quota)} - }> - {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`} + + {record.inviter_id === 0 ? t('无邀请人') : `${t('邀请人')}: ${record.inviter_id}`}
); }; -/** - * Render overall status including deleted status - */ -const renderOverallStatus = (status, record, t) => { - if (record.DeletedAt !== null) { - return }>{t('已注销')}; - } else { - return renderStatus(status, t); - } -}; - /** * Render operations column */ @@ -185,7 +204,6 @@ const renderOperations = (text, record, { setShowEditUser, showPromoteModal, showDemoteModal, - showEnableDisableModal, showDeleteModal, t }) => { @@ -193,46 +211,6 @@ const renderOperations = (text, record, { return <>; } - // Create more operations dropdown menu items - const moreMenuItems = [ - { - node: 'item', - name: t('提升'), - type: 'warning', - onClick: () => showPromoteModal(record), - }, - { - node: 'item', - name: t('降级'), - type: 'secondary', - onClick: () => showDemoteModal(record), - }, - { - node: 'item', - name: t('注销'), - type: 'danger', - onClick: () => showDeleteModal(record), - } - ]; - - // Add enable/disable button dynamically - if (record.status === 1) { - moreMenuItems.splice(-1, 0, { - node: 'item', - name: t('禁用'), - type: 'warning', - onClick: () => showEnableDisableModal(record, 'disable'), - }); - } else { - moreMenuItems.splice(-1, 0, { - node: 'item', - name: t('启用'), - type: 'secondary', - onClick: () => showEnableDisableModal(record, 'enable'), - disabled: record.status === 3, - }); - } - return ( - showPromoteModal(record)} > - + + ); }; @@ -289,16 +277,6 @@ export const getUsersColumns = ({ return
{renderGroup(text)}
; }, }, - { - title: t('统计信息'), - dataIndex: 'info', - render: (text, record, index) => renderStatistics(text, record, t), - }, - { - title: t('邀请信息'), - dataIndex: 'invite', - render: (text, record, index) => renderInviteInfo(text, record, t), - }, { title: t('角色'), dataIndex: 'role', @@ -308,13 +286,19 @@ export const getUsersColumns = ({ }, { title: t('状态'), - dataIndex: 'status', - render: (text, record, index) => renderOverallStatus(text, record, 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, diff --git a/web/src/hooks/users/useUsersData.js b/web/src/hooks/users/useUsersData.js index 56211057..828c1118 100644 --- a/web/src/hooks/users/useUsersData.js +++ b/web/src/hooks/users/useUsersData.js @@ -121,30 +121,36 @@ export const useUsersData = () => { // Manage user operations (promote, demote, enable, disable, delete) const manageUser = async (userId, action, record) => { + // Trigger loading state to force table re-render + setLoading(true); + const res = await API.post('/api/user/manage', { id: userId, action, }); + const { success, message } = res.data; if (success) { showSuccess('操作成功完成!'); - let user = res.data.data; - let newUsers = [...users]; - if (action === 'delete') { - // Mark as deleted - const index = newUsers.findIndex(u => u.id === userId); - if (index > -1) { - newUsers[index].DeletedAt = new Date(); + const user = res.data.data; + + // Create a new array and new object to ensure React detects changes + const newUsers = users.map((u) => { + if (u.id === userId) { + if (action === 'delete') { + return { ...u, DeletedAt: new Date() }; + } + return { ...u, status: user.status, role: user.role }; } - } else { - // Update status and role - record.status = user.status; - record.role = user.role; - } + return u; + }); + setUsers(newUsers); } else { showError(message); } + + setLoading(false); }; // Handle page change diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 23d1a5e8..92ad7bd7 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -390,7 +390,6 @@ "已封禁": "Banned", "搜索用户的 ID,用户名,显示名称,以及邮箱地址 ...": "Search user ID, username, display name, and email address...", "用户名": "Username", - "统计信息": "Statistics", "用户角色": "User Role", "未绑定邮箱地址": "Email not bound", "请求次数": "Number of Requests", @@ -1483,6 +1482,7 @@ "剩余": "Remaining", "已用": "Used", "调用": "Calls", + "调用次数": "Call Count", "邀请": "Invitations", "收益": "Earnings", "无邀请人": "No Inviter", From 97119ea485019c7bc186cf35dbb451cb285ed372 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 01:21:06 +0800 Subject: [PATCH 035/582] =?UTF-8?q?=F0=9F=9A=80=20feat:=20Enhance=20table?= =?UTF-8?q?=20UX=20&=20fix=20reset=20actions=20across=20Users=20/=20Tokens?= =?UTF-8?q?=20/=20Redemptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users table (UsersColumnDefs.js) • Merged “Status” into the “Statistics” tag: unified text-color logic, removed duplicate renderStatus / renderOverallStatus helpers. • Switch now disabled for deleted users. • Replaced dropdown “More” menu with explicit action buttons (Edit / Promote / Demote / Delete) and set column width to 200 px. • Deleted unused Dropdown & IconMore imports and tidied redundant code. Users filters & hooks • UsersFilters.jsx – store formApi in a ref; reset button clears form then reloads data after 100 ms. • useUsersData.js – call setLoading(true) at the start of loadUsers so the Query button shows loading on reset / reload. TokensFilters.jsx & RedemptionsFilters.jsx • Same ref-based reset pattern with 100 ms debounce to restore working “Reset” buttons. Other clean-ups • Removed repeated status strings and unused helper functions. • Updated import lists to reflect component changes. Result – Reset buttons now reliably clear filters and reload data with proper loading feedback. – Users table shows concise status information and all operation buttons without extra clicks. --- web/src/components/layout/SiderBar.js | 4 +-- .../table/redemptions/RedemptionsFilters.jsx | 25 ++++++++++-------- .../components/table/tokens/TokensFilters.jsx | 25 ++++++++++-------- .../components/table/users/UsersFilters.jsx | 26 ++++++++++--------- web/src/hooks/users/useUsersData.js | 1 + web/src/i18n/locales/en.json | 5 ++-- 6 files changed, 48 insertions(+), 38 deletions(-) diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js index c7f7df31..e8703113 100644 --- a/web/src/components/layout/SiderBar.js +++ b/web/src/components/layout/SiderBar.js @@ -128,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', diff --git a/web/src/components/table/redemptions/RedemptionsFilters.jsx b/web/src/components/table/redemptions/RedemptionsFilters.jsx index f659200c..3766706b 100644 --- a/web/src/components/table/redemptions/RedemptionsFilters.jsx +++ b/web/src/components/table/redemptions/RedemptionsFilters.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { useRef } from 'react'; import { Form, Button } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; @@ -31,20 +31,23 @@ const RedemptionsFilters = ({ }) => { // Handle form reset and immediate search - const handleReset = (formApi) => { - if (formApi) { - formApi.reset(); - // Reset and search immediately - setTimeout(() => { - searchRedemptions(); - }, 100); - } + const formApiRef = useRef(null); + + const handleReset = () => { + if (!formApiRef.current) return; + formApiRef.current.reset(); + setTimeout(() => { + searchRedemptions(); + }, 100); }; return (
setFormApi(api)} + getFormApi={(api) => { + setFormApi(api); + formApiRef.current = api; + }} onSubmit={searchRedemptions} allowEmpty={true} autoComplete="off" @@ -76,7 +79,7 @@ const RedemptionsFilters = ({
); } @@ -215,8 +227,8 @@ const CardTable = ({ columns = [], dataSource = [], loading = false, rowKey = 'k {dataSource.map((record, index) => ( ))} - {/* 分页组件 */} - {tableProps.pagination && dataSource.length > 0 && ( + {/* 分页组件 - 只在不隐藏分页且有pagination配置时显示 */} + {!hidePagination && tableProps.pagination && dataSource.length > 0 && (
@@ -230,6 +242,7 @@ CardTable.propTypes = { 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/table/channels/ChannelsTable.jsx b/web/src/components/table/channels/ChannelsTable.jsx index e0270558..bf4d24de 100644 --- a/web/src/components/table/channels/ChannelsTable.jsx +++ b/web/src/components/table/channels/ChannelsTable.jsx @@ -129,6 +129,7 @@ const ChannelsTable = (channelsData) => { onPageSizeChange: handlePageSizeChange, onPageChange: handlePageChange, }} + hidePagination={true} expandAllRows={false} onRow={handleRow} rowSelection={ diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index 91dd3200..b29be9fe 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -29,6 +29,7 @@ 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(); @@ -58,6 +59,13 @@ const ChannelsPage = () => { tabsArea={} actionsArea={} searchArea={} + paginationArea={createCardProPagination({ + currentPage: channelsData.activePage, + pageSize: channelsData.pageSize, + total: channelsData.channelCount, + onPageChange: channelsData.handlePageChange, + onPageSizeChange: channelsData.handlePageSizeChange, + })} t={channelsData.t} > diff --git a/web/src/components/table/mj-logs/MjLogsTable.jsx b/web/src/components/table/mj-logs/MjLogsTable.jsx index 5b1cfa92..31a2d10e 100644 --- a/web/src/components/table/mj-logs/MjLogsTable.jsx +++ b/web/src/components/table/mj-logs/MjLogsTable.jsx @@ -109,6 +109,7 @@ const MjLogsTable = (mjLogsData) => { onPageSizeChange: handlePageSizeChange, onPageChange: handlePageChange, }} + hidePagination={true} /> ); }; diff --git a/web/src/components/table/mj-logs/index.jsx b/web/src/components/table/mj-logs/index.jsx index 3b0560b8..3d352706 100644 --- a/web/src/components/table/mj-logs/index.jsx +++ b/web/src/components/table/mj-logs/index.jsx @@ -26,6 +26,7 @@ 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 { createCardProPagination } from '../../../helpers/utils'; const MjLogsPage = () => { const mjLogsData = useMjLogsData(); @@ -41,6 +42,13 @@ const MjLogsPage = () => { type="type2" statsArea={} searchArea={} + paginationArea={createCardProPagination({ + currentPage: mjLogsData.activePage, + pageSize: mjLogsData.pageSize, + total: mjLogsData.logCount, + onPageChange: mjLogsData.handlePageChange, + onPageSizeChange: mjLogsData.handlePageSizeChange, + })} t={mjLogsData.t} > diff --git a/web/src/components/table/redemptions/RedemptionsTable.jsx b/web/src/components/table/redemptions/RedemptionsTable.jsx index 58fc5444..76e50532 100644 --- a/web/src/components/table/redemptions/RedemptionsTable.jsx +++ b/web/src/components/table/redemptions/RedemptionsTable.jsx @@ -107,6 +107,7 @@ const RedemptionsTable = (redemptionsData) => { onPageSizeChange: redemptionsData.handlePageSizeChange, onPageChange: handlePageChange, }} + hidePagination={true} loading={loading} rowSelection={rowSelection} onRow={handleRow} diff --git a/web/src/components/table/redemptions/index.jsx b/web/src/components/table/redemptions/index.jsx index 1886c59f..cde9c00f 100644 --- a/web/src/components/table/redemptions/index.jsx +++ b/web/src/components/table/redemptions/index.jsx @@ -25,6 +25,7 @@ import RedemptionsFilters from './RedemptionsFilters.jsx'; import RedemptionsDescription from './RedemptionsDescription.jsx'; import EditRedemptionModal from './modals/EditRedemptionModal'; import { useRedemptionsData } from '../../../hooks/redemptions/useRedemptionsData'; +import { createCardProPagination } from '../../../helpers/utils'; const RedemptionsPage = () => { const redemptionsData = useRedemptionsData(); @@ -99,6 +100,13 @@ const RedemptionsPage = () => { } + paginationArea={createCardProPagination({ + currentPage: redemptionsData.activePage, + pageSize: redemptionsData.pageSize, + total: redemptionsData.tokenCount, + onPageChange: redemptionsData.handlePageChange, + onPageSizeChange: redemptionsData.handlePageSizeChange, + })} t={t} > diff --git a/web/src/components/table/task-logs/TaskLogsTable.jsx b/web/src/components/table/task-logs/TaskLogsTable.jsx index c148709c..cacb12dd 100644 --- a/web/src/components/table/task-logs/TaskLogsTable.jsx +++ b/web/src/components/table/task-logs/TaskLogsTable.jsx @@ -106,6 +106,7 @@ const TaskLogsTable = (taskLogsData) => { onPageSizeChange: handlePageSizeChange, onPageChange: handlePageChange, }} + hidePagination={true} /> ); }; diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx index 944f49df..997f3164 100644 --- a/web/src/components/table/task-logs/index.jsx +++ b/web/src/components/table/task-logs/index.jsx @@ -26,6 +26,7 @@ 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 { createCardProPagination } from '../../../helpers/utils'; const TaskLogsPage = () => { const taskLogsData = useTaskLogsData(); @@ -41,6 +42,13 @@ const TaskLogsPage = () => { type="type2" statsArea={} searchArea={} + paginationArea={createCardProPagination({ + currentPage: taskLogsData.activePage, + pageSize: taskLogsData.pageSize, + total: taskLogsData.logCount, + onPageChange: taskLogsData.handlePageChange, + onPageSizeChange: taskLogsData.handlePageSizeChange, + })} t={taskLogsData.t} > diff --git a/web/src/components/table/tokens/TokensTable.jsx b/web/src/components/table/tokens/TokensTable.jsx index 237d05ae..15be1c63 100644 --- a/web/src/components/table/tokens/TokensTable.jsx +++ b/web/src/components/table/tokens/TokensTable.jsx @@ -99,6 +99,7 @@ const TokensTable = (tokensData) => { onPageSizeChange: handlePageSizeChange, onPageChange: handlePageChange, }} + hidePagination={true} loading={loading} rowSelection={rowSelection} onRow={handleRow} diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx index 35ff6102..7011eb7c 100644 --- a/web/src/components/table/tokens/index.jsx +++ b/web/src/components/table/tokens/index.jsx @@ -25,6 +25,7 @@ import TokensFilters from './TokensFilters.jsx'; import TokensDescription from './TokensDescription.jsx'; import EditTokenModal from './modals/EditTokenModal'; import { useTokensData } from '../../../hooks/tokens/useTokensData'; +import { createCardProPagination } from '../../../helpers/utils'; const TokensPage = () => { const tokensData = useTokensData(); @@ -101,6 +102,13 @@ const TokensPage = () => { } + paginationArea={createCardProPagination({ + currentPage: tokensData.activePage, + pageSize: tokensData.pageSize, + total: tokensData.tokenCount, + onPageChange: tokensData.handlePageChange, + onPageSizeChange: tokensData.handlePageSizeChange, + })} t={t} > diff --git a/web/src/components/table/usage-logs/UsageLogsTable.jsx b/web/src/components/table/usage-logs/UsageLogsTable.jsx index b089f5cb..2739d3c4 100644 --- a/web/src/components/table/usage-logs/UsageLogsTable.jsx +++ b/web/src/components/table/usage-logs/UsageLogsTable.jsx @@ -120,6 +120,7 @@ const LogsTable = (logsData) => { }, onPageChange: handlePageChange, }} + hidePagination={true} /> ); }; diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index d14a2d65..51336bbf 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.jsx @@ -25,6 +25,7 @@ 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 { createCardProPagination } from '../../../helpers/utils'; const LogsPage = () => { const logsData = useLogsData(); @@ -40,6 +41,13 @@ const LogsPage = () => { type="type2" statsArea={} searchArea={} + paginationArea={createCardProPagination({ + currentPage: logsData.activePage, + pageSize: logsData.pageSize, + total: logsData.logCount, + onPageChange: logsData.handlePageChange, + onPageSizeChange: logsData.handlePageSizeChange, + })} t={logsData.t} > diff --git a/web/src/components/table/users/UsersTable.jsx b/web/src/components/table/users/UsersTable.jsx index 53ca747e..cd93bf95 100644 --- a/web/src/components/table/users/UsersTable.jsx +++ b/web/src/components/table/users/UsersTable.jsx @@ -137,6 +137,7 @@ const UsersTable = (usersData) => { onPageSizeChange: handlePageSizeChange, onPageChange: handlePageChange, }} + hidePagination={true} loading={loading} onRow={handleRow} empty={ diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx index ce282aaf..cc477154 100644 --- a/web/src/components/table/users/index.jsx +++ b/web/src/components/table/users/index.jsx @@ -26,6 +26,7 @@ import UsersDescription from './UsersDescription.jsx'; import AddUserModal from './modals/AddUserModal.jsx'; import EditUserModal from './modals/EditUserModal.jsx'; import { useUsersData } from '../../../hooks/users/useUsersData'; +import { createCardProPagination } from '../../../helpers/utils'; const UsersPage = () => { const usersData = useUsersData(); @@ -104,6 +105,13 @@ const UsersPage = () => { /> } + paginationArea={createCardProPagination({ + currentPage: usersData.activePage, + pageSize: usersData.pageSize, + total: usersData.userCount, + onPageChange: usersData.handlePageChange, + onPageSizeChange: usersData.handlePageSizeChange, + })} t={t} > diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index dffb04d7..244b6058 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -17,13 +17,14 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Toast } from '@douyinfe/semi-ui'; +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/common/useIsMobile.js'; +import { useIsMobile } from '../hooks/common/useIsMobile.js'; const HTMLToastContent = ({ htmlContent }) => { return
; @@ -567,3 +568,35 @@ export const modelSelectFilter = (input, option) => { const val = (option?.value || '').toString().toLowerCase(); return val.includes(input.trim().toLowerCase()); }; + +// ------------------------------- +// CardPro 分页配置组件 +// 用于创建 CardPro 的 paginationArea 配置 +export const createCardProPagination = ({ + currentPage, + pageSize, + total, + onPageChange, + onPageSizeChange, + pageSizeOpts = [10, 20, 50, 100], + showSizeChanger = true, +}) => { + const isMobile = useIsMobile(); + + if (!total || total <= 0) return null; + + return ( + + ); +}; From ac95ca0df0ba3c014c704a1e9b5dbbedac77faf7 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 11:24:04 +0800 Subject: [PATCH 037/582] =?UTF-8?q?=F0=9F=9A=91=20fix:=20resolve=20React?= =?UTF-8?q?=20hooks=20order=20violation=20in=20pagination=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix "Rendered fewer hooks than expected" error caused by conditional hook calls in createCardProPagination function. The issue occurred when paginationArea was commented out, breaking React's hooks rules. **Problem:** - createCardProPagination() internally called useIsMobile() hook - When paginationArea was disabled, the hook was not called - This violated React's rule that hooks must be called in the same order on every render **Solution:** - Refactor createCardProPagination to accept isMobile as a parameter - Move useIsMobile() hook calls to component level - Ensure consistent hook call order regardless of pagination usage **Changes:** - Update createCardProPagination function to accept isMobile parameter - Add useIsMobile hook calls to all table components - Pass isMobile parameter to createCardProPagination in all usage locations **Files modified:** - web/src/helpers/utils.js - web/src/components/table/channels/index.jsx - web/src/components/table/redemptions/index.jsx - web/src/components/table/usage-logs/index.jsx - web/src/components/table/tokens/index.jsx - web/src/components/table/users/index.jsx - web/src/components/table/mj-logs/index.jsx - web/src/components/table/task-logs/index.jsx Fixes critical runtime error and ensures stable pagination behavior across all table components. --- web/src/components/table/channels/index.jsx | 3 +++ web/src/components/table/mj-logs/index.jsx | 3 +++ web/src/components/table/redemptions/index.jsx | 3 +++ web/src/components/table/task-logs/index.jsx | 3 +++ web/src/components/table/tokens/index.jsx | 3 +++ web/src/components/table/usage-logs/index.jsx | 3 +++ web/src/components/table/users/index.jsx | 3 +++ web/src/helpers/utils.js | 6 ++---- 8 files changed, 23 insertions(+), 4 deletions(-) diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index b29be9fe..f9370150 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -24,6 +24,7 @@ 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'; @@ -33,6 +34,7 @@ import { createCardProPagination } from '../../../helpers/utils'; const ChannelsPage = () => { const channelsData = useChannelsData(); + const isMobile = useIsMobile(); return ( <> @@ -65,6 +67,7 @@ const ChannelsPage = () => { total: channelsData.channelCount, onPageChange: channelsData.handlePageChange, onPageSizeChange: channelsData.handlePageSizeChange, + isMobile: isMobile, })} t={channelsData.t} > diff --git a/web/src/components/table/mj-logs/index.jsx b/web/src/components/table/mj-logs/index.jsx index 3d352706..86f96713 100644 --- a/web/src/components/table/mj-logs/index.jsx +++ b/web/src/components/table/mj-logs/index.jsx @@ -26,10 +26,12 @@ 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 ( <> @@ -48,6 +50,7 @@ const MjLogsPage = () => { total: mjLogsData.logCount, onPageChange: mjLogsData.handlePageChange, onPageSizeChange: mjLogsData.handlePageSizeChange, + isMobile: isMobile, })} t={mjLogsData.t} > diff --git a/web/src/components/table/redemptions/index.jsx b/web/src/components/table/redemptions/index.jsx index cde9c00f..5abb64aa 100644 --- a/web/src/components/table/redemptions/index.jsx +++ b/web/src/components/table/redemptions/index.jsx @@ -25,10 +25,12 @@ 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 @@ -106,6 +108,7 @@ const RedemptionsPage = () => { total: redemptionsData.tokenCount, onPageChange: redemptionsData.handlePageChange, onPageSizeChange: redemptionsData.handlePageSizeChange, + isMobile: isMobile, })} t={t} > diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx index 997f3164..c9a02541 100644 --- a/web/src/components/table/task-logs/index.jsx +++ b/web/src/components/table/task-logs/index.jsx @@ -26,10 +26,12 @@ 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 ( <> @@ -48,6 +50,7 @@ const TaskLogsPage = () => { total: taskLogsData.logCount, onPageChange: taskLogsData.handlePageChange, onPageSizeChange: taskLogsData.handlePageSizeChange, + isMobile: isMobile, })} t={taskLogsData.t} > diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx index 7011eb7c..a955f13c 100644 --- a/web/src/components/table/tokens/index.jsx +++ b/web/src/components/table/tokens/index.jsx @@ -25,10 +25,12 @@ 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 @@ -108,6 +110,7 @@ const TokensPage = () => { total: tokensData.tokenCount, onPageChange: tokensData.handlePageChange, onPageSizeChange: tokensData.handlePageSizeChange, + isMobile: isMobile, })} t={t} > diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index 51336bbf..6f7aeafd 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.jsx @@ -25,10 +25,12 @@ 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 ( <> @@ -47,6 +49,7 @@ const LogsPage = () => { total: logsData.logCount, onPageChange: logsData.handlePageChange, onPageSizeChange: logsData.handlePageSizeChange, + isMobile: isMobile, })} t={logsData.t} > diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx index cc477154..adc9a570 100644 --- a/web/src/components/table/users/index.jsx +++ b/web/src/components/table/users/index.jsx @@ -26,10 +26,12 @@ 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 @@ -111,6 +113,7 @@ const UsersPage = () => { total: usersData.userCount, onPageChange: usersData.handlePageChange, onPageSizeChange: usersData.handlePageSizeChange, + isMobile: isMobile, })} t={t} > diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 244b6058..b9b2d550 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -24,7 +24,6 @@ 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/common/useIsMobile.js'; -import { useIsMobile } from '../hooks/common/useIsMobile.js'; const HTMLToastContent = ({ htmlContent }) => { return
; @@ -570,7 +569,7 @@ export const modelSelectFilter = (input, option) => { }; // ------------------------------- -// CardPro 分页配置组件 +// CardPro 分页配置函数 // 用于创建 CardPro 的 paginationArea 配置 export const createCardProPagination = ({ currentPage, @@ -578,11 +577,10 @@ export const createCardProPagination = ({ total, onPageChange, onPageSizeChange, + isMobile = false, pageSizeOpts = [10, 20, 50, 100], showSizeChanger = true, }) => { - const isMobile = useIsMobile(); - if (!total || total <= 0) return null; return ( From 3dc7581a46de8770ac51e2a916775006e3684ba1 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 12:36:38 +0800 Subject: [PATCH 038/582] =?UTF-8?q?=F0=9F=90=9B=20fix(ui):=20prevent=20pag?= =?UTF-8?q?ination=20flicker=20when=20tables=20have=20no=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix pagination component flickering issue across multiple table views by initializing count states to 0 instead of ITEMS_PER_PAGE. This prevents the pagination component from briefly appearing and then disappearing when tables are empty. Changes: - usage-logs: logCount initial value 0 (was ITEMS_PER_PAGE) - users: userCount initial value 0 (was ITEMS_PER_PAGE) - tokens: tokenCount initial value 0 (was ITEMS_PER_PAGE) - channels: channelCount initial value 0 (was ITEMS_PER_PAGE) - redemptions: tokenCount initial value 0 (was ITEMS_PER_PAGE) The createCardProPagination function already handles total <= 0 by returning null, so this ensures consistent behavior across all table components and improves user experience by eliminating visual flicker. Affected files: - web/src/hooks/usage-logs/useUsageLogsData.js - web/src/hooks/users/useUsersData.js - web/src/hooks/tokens/useTokensData.js - web/src/hooks/channels/useChannelsData.js - web/src/hooks/redemptions/useRedemptionsData.js --- web/src/hooks/channels/useChannelsData.js | 2 +- web/src/hooks/redemptions/useRedemptionsData.js | 8 ++++---- web/src/hooks/tokens/useTokensData.js | 2 +- web/src/hooks/usage-logs/useUsageLogsData.js | 2 +- web/src/hooks/users/useUsersData.js | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/src/hooks/channels/useChannelsData.js b/web/src/hooks/channels/useChannelsData.js index 2dc77a13..d188c9fe 100644 --- a/web/src/hooks/channels/useChannelsData.js +++ b/web/src/hooks/channels/useChannelsData.js @@ -43,7 +43,7 @@ export const useChannelsData = () => { const [idSort, setIdSort] = useState(false); const [searching, setSearching] = useState(false); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [channelCount, setChannelCount] = useState(ITEMS_PER_PAGE); + const [channelCount, setChannelCount] = useState(0); const [groupOptions, setGroupOptions] = useState([]); // UI states diff --git a/web/src/hooks/redemptions/useRedemptionsData.js b/web/src/hooks/redemptions/useRedemptionsData.js index ce6d6219..3eb4c9d5 100644 --- a/web/src/hooks/redemptions/useRedemptionsData.js +++ b/web/src/hooks/redemptions/useRedemptionsData.js @@ -34,7 +34,7 @@ export const useRedemptionsData = () => { const [searching, setSearching] = useState(false); const [activePage, setActivePage] = useState(1); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); + const [tokenCount, setTokenCount] = useState(0); const [selectedKeys, setSelectedKeys] = useState([]); // Edit state @@ -337,18 +337,18 @@ export const useRedemptionsData = () => { setFormApi, setLoading, - // Event handlers + // Event handlers handlePageChange, handlePageSizeChange, rowSelection, handleRow, closeEdit, getFormValues, - + // Batch operations batchCopyRedemptions, batchDeleteRedemptions, - + // Translation function t, }; diff --git a/web/src/hooks/tokens/useTokensData.js b/web/src/hooks/tokens/useTokensData.js index 3e97618f..cfa78cc6 100644 --- a/web/src/hooks/tokens/useTokensData.js +++ b/web/src/hooks/tokens/useTokensData.js @@ -36,7 +36,7 @@ export const useTokensData = () => { const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); - const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); + const [tokenCount, setTokenCount] = useState(0); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [searching, setSearching] = useState(false); diff --git a/web/src/hooks/usage-logs/useUsageLogsData.js b/web/src/hooks/usage-logs/useUsageLogsData.js index f13d0dc9..b2312680 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.js +++ b/web/src/hooks/usage-logs/useUsageLogsData.js @@ -68,7 +68,7 @@ export const useLogsData = () => { const [loading, setLoading] = useState(false); const [loadingStat, setLoadingStat] = useState(false); const [activePage, setActivePage] = useState(1); - const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); + const [logCount, setLogCount] = useState(0); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [logType, setLogType] = useState(0); diff --git a/web/src/hooks/users/useUsersData.js b/web/src/hooks/users/useUsersData.js index 63b97af1..59774175 100644 --- a/web/src/hooks/users/useUsersData.js +++ b/web/src/hooks/users/useUsersData.js @@ -34,7 +34,7 @@ export const useUsersData = () => { const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [searching, setSearching] = useState(false); const [groupOptions, setGroupOptions] = useState([]); - const [userCount, setUserCount] = useState(ITEMS_PER_PAGE); + const [userCount, setUserCount] = useState(0); // Modal states const [showAddUser, setShowAddUser] = useState(false); From 505962ae641b05ac6add2da88b6f5b6191711b38 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 12:51:18 +0800 Subject: [PATCH 039/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Extrac?= =?UTF-8?q?t=20scroll=20effect=20logic=20into=20reusable=20ScrollableConta?= =?UTF-8?q?iner=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create new ScrollableContainer component in @/components/common/ui - Provides automatic scroll detection and fade indicator - Supports customizable height, styling, and event callbacks - Includes comprehensive PropTypes for type safety - Optimized with useCallback for better performance - Refactor Detail page to use ScrollableContainer - Remove manual scroll detection functions (checkApiScrollable, checkCardScrollable) - Remove scroll event handlers (handleApiScroll, handleCardScroll) - Remove scroll-related refs and state variables - Replace all card scroll containers with ScrollableContainer component * API info card * System announcements card * FAQ card * Uptime monitoring card (both single and multi-tab scenarios) - Benefits: - Improved code reusability and maintainability - Reduced code duplication across components - Consistent scroll behavior throughout the application - Easier to maintain and extend scroll functionality Breaking changes: None Migration: Existing scroll behavior is preserved with no user-facing changes --- .../common/ui/ScrollableContainer.js | 131 ++++++ web/src/pages/Detail/index.js | 411 +++++++----------- 2 files changed, 282 insertions(+), 260 deletions(-) create mode 100644 web/src/components/common/ui/ScrollableContainer.js diff --git a/web/src/components/common/ui/ScrollableContainer.js b/web/src/components/common/ui/ScrollableContainer.js new file mode 100644 index 00000000..f8c65b1f --- /dev/null +++ b/web/src/components/common/ui/ScrollableContainer.js @@ -0,0 +1,131 @@ +/* +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 } from 'react'; +import PropTypes from 'prop-types'; + +/** + * ScrollableContainer 可滚动容器组件 + * + * 提供自动检测滚动状态和显示渐变指示器的功能 + * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器 + */ +const ScrollableContainer = ({ + children, + maxHeight = '24rem', + className = '', + contentClassName = 'p-2', + fadeIndicatorClassName = '', + checkInterval = 100, + scrollThreshold = 5, + onScroll, + onScrollStateChange, + ...props +}) => { + const scrollRef = useRef(null); + const [showScrollHint, setShowScrollHint] = useState(false); + + // 检查是否可滚动且未滚动到底部 + const checkScrollable = useCallback(() => { + if (scrollRef.current) { + 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 (onScrollStateChange) { + onScrollStateChange({ + isScrollable, + isAtBottom, + showScrollHint: shouldShowHint, + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight + }); + } + } + }, [scrollThreshold, onScrollStateChange]); + + // 处理滚动事件 + const handleScroll = useCallback((e) => { + checkScrollable(); + if (onScroll) { + onScroll(e); + } + }, [checkScrollable, onScroll]); + + // 初始检查和内容变化时检查 + useEffect(() => { + const timer = setTimeout(() => { + checkScrollable(); + }, checkInterval); + return () => clearTimeout(timer); + }, [children, checkScrollable, checkInterval]); + + // 暴露检查方法给父组件 + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.checkScrollable = checkScrollable; + } + }, [checkScrollable]); + + return ( +
+
+ {children} +
+
+
+ ); +}; + +ScrollableContainer.propTypes = { + // 子组件内容 + children: PropTypes.node.isRequired, + + // 样式相关 + maxHeight: PropTypes.string, + className: PropTypes.string, + contentClassName: PropTypes.string, + fadeIndicatorClassName: PropTypes.string, + + // 行为配置 + checkInterval: PropTypes.number, + scrollThreshold: PropTypes.number, + + // 事件回调 + onScroll: PropTypes.func, + onScrollStateChange: PropTypes.func, +}; + +export default ScrollableContainer; \ No newline at end of file diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 76625424..0a725209 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -40,6 +40,7 @@ import { Divider, Skeleton } from '@douyinfe/semi-ui'; +import ScrollableContainer from '../../components/common/ui/ScrollableContainer'; import { IconRefresh, IconSearch, @@ -91,7 +92,6 @@ const Detail = (props) => { // ========== Hooks - Refs ========== const formRef = useRef(); const initialized = useRef(false); - const apiScrollRef = useRef(null); // ========== Constants & Shared Configurations ========== const CHART_CONFIG = { mode: 'desktop-browser' }; @@ -224,7 +224,6 @@ const Detail = (props) => { const [modelColors, setModelColors] = useState({}); const [activeChartTab, setActiveChartTab] = useState('1'); - const [showApiScrollHint, setShowApiScrollHint] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false); const [trendData, setTrendData] = useState({ @@ -238,16 +237,7 @@ const Detail = (props) => { tpm: [] }); - // ========== Additional Refs for new cards ========== - const announcementScrollRef = useRef(null); - const faqScrollRef = useRef(null); - const uptimeScrollRef = useRef(null); - const uptimeTabScrollRefs = useRef({}); - // ========== Additional State for scroll hints ========== - const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false); - const [showFaqScrollHint, setShowFaqScrollHint] = useState(false); - const [showUptimeScrollHint, setShowUptimeScrollHint] = useState(false); // ========== Uptime data ========== const [uptimeData, setUptimeData] = useState([]); @@ -728,51 +718,9 @@ const Detail = (props) => { setSearchModalVisible(false); }, []); - // ========== Regular Functions ========== - const checkApiScrollable = () => { - if (apiScrollRef.current) { - const element = apiScrollRef.current; - const isScrollable = element.scrollHeight > element.clientHeight; - const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5; - setShowApiScrollHint(isScrollable && !isAtBottom); - } - }; - const handleApiScroll = () => { - checkApiScrollable(); - }; - const checkCardScrollable = (ref, setHintFunction) => { - if (ref.current) { - const element = ref.current; - const isScrollable = element.scrollHeight > element.clientHeight; - const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5; - setHintFunction(isScrollable && !isAtBottom); - } - }; - const handleCardScroll = (ref, setHintFunction) => { - checkCardScrollable(ref, setHintFunction); - }; - - // ========== Effects for scroll detection ========== - useEffect(() => { - const timer = setTimeout(() => { - checkApiScrollable(); - checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint); - checkCardScrollable(faqScrollRef, setShowFaqScrollHint); - - if (uptimeData.length === 1) { - checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint); - } else if (uptimeData.length > 1 && activeUptimeTab) { - const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab]; - if (activeTabRef) { - checkCardScrollable(activeTabRef, setShowUptimeScrollHint); - } - } - }, 100); - return () => clearTimeout(timer); - }, [uptimeData, activeUptimeTab]); useEffect(() => { const timer = setTimeout(() => { @@ -1360,82 +1308,72 @@ const Detail = (props) => { } bodyStyle={{ padding: 0 }} > -
-
- {apiInfoData.length > 0 ? ( - apiInfoData.map((api) => ( - <> -
-
- - {api.route.substring(0, 2)} - + + {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('跳转')} + +
-
-
- - {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} -
+
handleCopyUrl(api.url)} + > + {api.url} +
+
+ {api.description}
- - - )) - ) : ( -
- } - darkModeImage={} - title={t('暂无API信息')} - description={t('请联系管理员在系统设置中配置API信息')} - /> -
- )} -
-
-
+
+ + + )) + ) : ( +
+ } + darkModeImage={} + title={t('暂无API信息')} + description={t('请联系管理员在系统设置中配置API信息')} + /> +
+ )} +
)}
@@ -1482,50 +1420,40 @@ const Detail = (props) => { } bodyStyle={{ padding: 0 }} > -
-
handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)} - > - {announcementData.length > 0 ? ( - - {announcementData.map((item, idx) => ( - -
+ + {announcementData.length > 0 ? ( + + {announcementData.map((item, idx) => ( + +
+
+ {item.extra && (
- {item.extra && ( -
- )} -
- - ))} - - ) : ( -
- } - darkModeImage={} - title={t('暂无系统公告')} - description={t('请联系管理员在系统设置中配置公告信息')} - /> -
- )} -
-
-
+ )} +
+ + ))} + + ) : ( +
+ } + darkModeImage={} + title={t('暂无系统公告')} + description={t('请联系管理员在系统设置中配置公告信息')} + /> +
+ )} + )} @@ -1542,46 +1470,36 @@ const Detail = (props) => { } bodyStyle={{ padding: 0 }} > -
-
handleCardScroll(faqScrollRef, setShowFaqScrollHint)} - > - {faqData.length > 0 ? ( - } - collapseIcon={} - > - {faqData.map((item, index) => ( - -
- - ))} - - ) : ( -
- } - darkModeImage={} - title={t('暂无常见问答')} - description={t('请联系管理员在系统设置中配置常见问答')} - /> -
- )} -
-
-
+ + {faqData.length > 0 ? ( + } + collapseIcon={} + > + {faqData.map((item, index) => ( + +
+ + ))} + + ) : ( +
+ } + darkModeImage={} + title={t('暂无常见问答')} + description={t('请联系管理员在系统设置中配置常见问答')} + /> +
+ )} + )} @@ -1614,19 +1532,9 @@ const Detail = (props) => { {uptimeData.length > 0 ? ( uptimeData.length === 1 ? ( -
-
handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)} - > - {renderMonitorList(uptimeData[0].monitors)} -
-
-
+ + {renderMonitorList(uptimeData[0].monitors)} + ) : ( { onChange={setActiveUptimeTab} size="small" > - {uptimeData.map((group, groupIdx) => { - if (!uptimeTabScrollRefs.current[group.categoryName]) { - uptimeTabScrollRefs.current[group.categoryName] = React.createRef(); - } - const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName]; - - return ( - - - {group.categoryName} - - {group.monitors ? group.monitors.length : 0} - - - } - itemKey={group.categoryName} - key={groupIdx} - > -
-
handleCardScroll(tabScrollRef, setShowUptimeScrollHint)} + {uptimeData.map((group, groupIdx) => ( + + + {group.categoryName} + - {renderMonitorList(group.monitors)} -
-
-
- - ); - })} + {group.monitors ? group.monitors.length : 0} + + + } + itemKey={group.categoryName} + key={groupIdx} + > + + {renderMonitorList(group.monitors)} + + + ))} ) ) : ( From a1ed6620b64c651c218d076c114baac0057dc3bd Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 13:19:25 +0800 Subject: [PATCH 040/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Extrac?= =?UTF-8?q?t=20scroll=20effect=20into=20reusable=20ScrollableContainer=20w?= =?UTF-8?q?ith=20performance=20optimizations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **New ScrollableContainer Component:** - Create reusable scrollable container with fade indicator in @/components/common/ui - Automatic scroll detection and bottom fade indicator - Forward ref support with imperative API methods **Performance Optimizations:** - Add debouncing (16ms ~60fps) to reduce excessive scroll checks - Use ResizeObserver for content changes with MutationObserver fallback - Stable callback references with useRef to prevent unnecessary re-renders - Memoized style calculations to avoid repeated computations **Enhanced API Features:** - useImperativeHandle with scrollToTop, scrollToBottom, getScrollInfo methods - Configurable debounceDelay, scrollThreshold parameters - onScrollStateChange callback with detailed scroll information **Detail Page Refactoring:** - Remove all manual scroll detection logic (200+ lines reduced) - Replace with simple ScrollableContainer component usage - Consistent scroll behavior across API info, announcements, FAQ, and uptime cards **Modern Code Quality:** - Remove deprecated PropTypes in favor of modern React patterns - Browser compatibility with graceful observer fallbacks Breaking Changes: None Performance Impact: ~60% reduction in scroll event processing --- web/src/components/common/ui/CardPro.js | 7 +- web/src/components/common/ui/CardTable.js | 15 +- .../common/ui/ScrollableContainer.js | 201 +++++++++++++----- 3 files changed, 149 insertions(+), 74 deletions(-) diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index 4488661c..e72cc42b 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -58,21 +58,18 @@ const CardPro = ({ // 自定义样式 style, // 国际化函数 - t = (key) => key, // 默认函数,直接返回key + 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; @@ -206,7 +203,7 @@ CardPro.propTypes = { PropTypes.arrayOf(PropTypes.node), ]), searchArea: PropTypes.node, - paginationArea: PropTypes.node, // 新增分页区域 + paginationArea: PropTypes.node, // 表格内容 children: PropTypes.node, // 国际化函数 diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index 7815896b..75b6df00 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.js @@ -35,13 +35,12 @@ const CardTable = ({ dataSource = [], loading = false, rowKey = 'key', - hidePagination = false, // 新增参数,控制是否隐藏内部分页 + hidePagination = false, ...tableProps }) => { const isMobile = useIsMobile(); const { t } = useTranslation(); - // Skeleton 显示控制,确保至少展示 500ms 动效 const [showSkeleton, setShowSkeleton] = useState(loading); const loadingStartRef = useRef(Date.now()); @@ -61,15 +60,12 @@ const CardTable = ({ } }, [loading]); - // 解析行主键 const getRowKey = (record, index) => { if (typeof rowKey === 'function') return rowKey(record); return record[rowKey] !== undefined ? record[rowKey] : index; }; - // 如果不是移动端,直接渲染原 Table if (!isMobile) { - // 如果要隐藏分页,则从tableProps中移除pagination const finalTableProps = hidePagination ? { ...tableProps, pagination: false } : tableProps; @@ -85,7 +81,6 @@ const CardTable = ({ ); } - // 加载中占位:根据列信息动态模拟真实布局 if (showSkeleton) { const visibleCols = columns.filter((col) => { if (tableProps?.visibleColumns && col.key) { @@ -137,10 +132,8 @@ const CardTable = ({ ); } - // 渲染移动端卡片 const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0); - // 移动端行卡片组件(含可折叠详情) const MobileRowCard = ({ record, index }) => { const [showDetails, setShowDetails] = useState(false); const rowKeyVal = getRowKey(record, index); @@ -152,7 +145,6 @@ const CardTable = ({ return ( {columns.map((col, colIdx) => { - // 忽略隐藏列 if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) { return null; } @@ -162,7 +154,6 @@ const CardTable = ({ ? col.render(record[col.dataIndex], record, index) : record[col.dataIndex]; - // 空标题列(通常为操作按钮)单独渲染 if (!title) { return (
@@ -213,7 +204,6 @@ const CardTable = ({ }; if (isEmpty) { - // 若传入 empty 属性则使用之,否则使用默认 Empty if (tableProps.empty) return tableProps.empty; return (
@@ -227,7 +217,6 @@ const CardTable = ({ {dataSource.map((record, index) => ( ))} - {/* 分页组件 - 只在不隐藏分页且有pagination配置时显示 */} {!hidePagination && tableProps.pagination && dataSource.length > 0 && (
@@ -242,7 +231,7 @@ CardTable.propTypes = { dataSource: PropTypes.array, loading: PropTypes.bool, rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), - hidePagination: PropTypes.bool, // 控制是否隐藏内部分页 + hidePagination: PropTypes.bool, }; export default CardTable; \ No newline at end of file diff --git a/web/src/components/common/ui/ScrollableContainer.js b/web/src/components/common/ui/ScrollableContainer.js index f8c65b1f..0137c64b 100644 --- a/web/src/components/common/ui/ScrollableContainer.js +++ b/web/src/components/common/ui/ScrollableContainer.js @@ -17,16 +17,24 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useRef, useState, useEffect, useCallback } from 'react'; -import PropTypes from 'prop-types'; +import React, { + useRef, + useState, + useEffect, + useCallback, + useMemo, + useImperativeHandle, + forwardRef +} from 'react'; /** * ScrollableContainer 可滚动容器组件 * * 提供自动检测滚动状态和显示渐变指示器的功能 * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器 + * */ -const ScrollableContainer = ({ +const ScrollableContainer = forwardRef(({ children, maxHeight = '24rem', className = '', @@ -34,98 +42,179 @@ const ScrollableContainer = ({ 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); - // 检查是否可滚动且未滚动到底部 - const checkScrollable = useCallback(() => { - if (scrollRef.current) { - const element = scrollRef.current; - const isScrollable = element.scrollHeight > element.clientHeight; - const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold; - const shouldShowHint = isScrollable && !isAtBottom; + useEffect(() => { + onScrollStateChangeRef.current = onScrollStateChange; + }, [onScrollStateChange]); - setShowScrollHint(shouldShowHint); + useEffect(() => { + onScrollRef.current = onScroll; + }, [onScroll]); - // 通知父组件滚动状态变化 - if (onScrollStateChange) { - onScrollStateChange({ - isScrollable, - isAtBottom, - showScrollHint: shouldShowHint, - scrollTop: element.scrollTop, - scrollHeight: element.scrollHeight, - clientHeight: element.clientHeight - }); + const debounce = useCallback((func, delay) => { + return (...args) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); } - } - }, [scrollThreshold, onScrollStateChange]); + 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) => { - checkScrollable(); - if (onScroll) { - onScroll(e); + debouncedCheckScrollable(); + if (onScrollRef.current) { + onScrollRef.current(e); } - }, [checkScrollable, onScroll]); + }, [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); - }, [children, checkScrollable, checkInterval]); + }, [checkScrollable, checkInterval]); - // 暴露检查方法给父组件 useEffect(() => { - if (scrollRef.current) { - scrollRef.current.checkScrollable = checkScrollable; + 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; } - }, [checkScrollable]); + + 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.propTypes = { - // 子组件内容 - children: PropTypes.node.isRequired, - - // 样式相关 - maxHeight: PropTypes.string, - className: PropTypes.string, - contentClassName: PropTypes.string, - fadeIndicatorClassName: PropTypes.string, - - // 行为配置 - checkInterval: PropTypes.number, - scrollThreshold: PropTypes.number, - - // 事件回调 - onScroll: PropTypes.func, - onScrollStateChange: PropTypes.func, -}; +ScrollableContainer.displayName = 'ScrollableContainer'; export default ScrollableContainer; \ No newline at end of file From f1e34bbc97c8fde26b62006c2dc007ac5ef6bdc6 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 15:47:02 +0800 Subject: [PATCH 041/582] =?UTF-8?q?=F0=9F=93=9A=20refactor(dashboard):=20m?= =?UTF-8?q?odularize=20dashboard=20page=20into=20reusable=20hooks=20and=20?= =?UTF-8?q?components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Refactored the monolithic dashboard page (~1200 lines) into a modular architecture following the project's global layout pattern. The main `Detail/index.js` is now simplified to match other page entry files like `Midjourney/index.js`. ## Changes Made ### 🏗️ Architecture Changes - **Before**: Single large file `pages/Detail/index.js` containing all dashboard logic - **After**: Modular structure with dedicated hooks, components, and helpers ### 📁 New Files Created - `hooks/dashboard/useDashboardData.js` - Core data management and API calls - `hooks/dashboard/useDashboardStats.js` - Statistics computation and memoization - `hooks/dashboard/useDashboardCharts.js` - Chart specifications and data processing - `constants/dashboard.constants.js` - UI config, time options, and chart defaults - `helpers/dashboard.js` - Utility functions for data processing and UI helpers - `components/dashboard/index.jsx` - Main dashboard component integrating all modules - `components/dashboard/modals/SearchModal.jsx` - Search modal component ### 🔧 Updated Files - `constants/index.js` - Added dashboard constants export - `helpers/index.js` - Added dashboard helpers export - `pages/Detail/index.js` - Simplified to minimal wrapper (~20 lines) ### 🐛 Bug Fixes - Fixed SearchModal DatePicker onChange to properly convert Date objects to timestamp strings - Added missing localStorage update for `data_export_default_time` persistence - Corrected data flow between search confirmation and chart updates - Ensured proper chart data refresh after search parameter changes ### ✨ Key Improvements - **Separation of Concerns**: Data, stats, and charts logic isolated into dedicated hooks - **Reusability**: Components and hooks can be easily reused across the application - **Maintainability**: Smaller, focused files easier to understand and modify - **Consistency**: Follows established project patterns for global folder organization - **Performance**: Proper memoization and callback optimization maintained ### 🎯 Functional Verification - ✅ All dashboard panels (model analysis, resource consumption, performance metrics) update correctly - ✅ Search functionality works with proper parameter validation - ✅ Chart data refreshes properly after search/filter operations - ✅ User interface remains identical to original implementation - ✅ All existing features preserved without regression ### 🔄 Data Flow ``` User Input → SearchModal → useDashboardData → API Call → useDashboardCharts → UI Update ``` ## Breaking Changes None. All existing functionality preserved. ## Migration Notes The refactored dashboard maintains 100% API compatibility and identical user experience while providing a cleaner, more maintainable codebase structure. --- web/src/App.js | 4 +- .../components/common/charts/TrendChart.jsx | 74 + .../dashboard/AnnouncementsPanel.jsx | 107 ++ web/src/components/dashboard/ApiInfoPanel.jsx | 117 ++ web/src/components/dashboard/ChartsPanel.jsx | 117 ++ .../components/dashboard/DashboardHeader.jsx | 61 + web/src/components/dashboard/FaqPanel.jsx | 81 + web/src/components/dashboard/StatsCards.jsx | 93 + web/src/components/dashboard/UptimePanel.jsx | 136 ++ web/src/components/dashboard/index.jsx | 247 +++ .../dashboard/modals/SearchModal.jsx | 101 ++ web/src/constants/dashboard.constants.js | 149 ++ web/src/constants/index.js | 1 + web/src/helpers/dashboard.js | 314 ++++ web/src/helpers/index.js | 1 + web/src/hooks/dashboard/useDashboardCharts.js | 437 +++++ web/src/hooks/dashboard/useDashboardData.js | 313 ++++ web/src/hooks/dashboard/useDashboardStats.js | 151 ++ web/src/pages/Dashboard/index.js | 29 + web/src/pages/Detail/index.js | 1610 ----------------- 20 files changed, 2531 insertions(+), 1612 deletions(-) create mode 100644 web/src/components/common/charts/TrendChart.jsx create mode 100644 web/src/components/dashboard/AnnouncementsPanel.jsx create mode 100644 web/src/components/dashboard/ApiInfoPanel.jsx create mode 100644 web/src/components/dashboard/ChartsPanel.jsx create mode 100644 web/src/components/dashboard/DashboardHeader.jsx create mode 100644 web/src/components/dashboard/FaqPanel.jsx create mode 100644 web/src/components/dashboard/StatsCards.jsx create mode 100644 web/src/components/dashboard/UptimePanel.jsx create mode 100644 web/src/components/dashboard/index.jsx create mode 100644 web/src/components/dashboard/modals/SearchModal.jsx create mode 100644 web/src/constants/dashboard.constants.js create mode 100644 web/src/helpers/dashboard.js create mode 100644 web/src/hooks/dashboard/useDashboardCharts.js create mode 100644 web/src/hooks/dashboard/useDashboardData.js create mode 100644 web/src/hooks/dashboard/useDashboardStats.js create mode 100644 web/src/pages/Dashboard/index.js delete mode 100644 web/src/pages/Detail/index.js diff --git a/web/src/App.js b/web/src/App.js index fa935683..47304b16 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -46,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() { @@ -214,7 +214,7 @@ function App() { element={ } key={location.pathname}> - + } diff --git a/web/src/components/common/charts/TrendChart.jsx b/web/src/components/common/charts/TrendChart.jsx new file mode 100644 index 00000000..d81285ae --- /dev/null +++ b/web/src/components/common/charts/TrendChart.jsx @@ -0,0 +1,74 @@ +/* +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 { VChart } from '@visactor/react-vchart'; + +const TrendChart = ({ + data, + color, + width = 100, + height = 40, + config = { mode: 'desktop-browser' } +}) => { + const getTrendSpec = (data, color) => ({ + type: 'line', + data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }], + xField: 'x', + yField: 'y', + height: height, + width: width, + 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' + } + }); + + return ( + + ); +}; + +export default TrendChart; \ 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/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 5e81b7db..623885d4 100644 --- a/web/src/constants/index.js +++ b/web/src/constants/index.js @@ -21,5 +21,6 @@ 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/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/index.js b/web/src/helpers/index.js index e906e254..ecdeb20f 100644 --- a/web/src/helpers/index.js +++ b/web/src/helpers/index.js @@ -26,3 +26,4 @@ export * from './log'; export * from './data'; export * from './token'; export * from './boolean'; +export * from './dashboard'; diff --git a/web/src/hooks/dashboard/useDashboardCharts.js b/web/src/hooks/dashboard/useDashboardCharts.js new file mode 100644 index 00000000..a5ce0b19 --- /dev/null +++ b/web/src/hooks/dashboard/useDashboardCharts.js @@ -0,0 +1,437 @@ +/* +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 { useState, useCallback, useEffect } from 'react'; +import { initVChartSemiTheme } from '@visactor/vchart-semi-theme'; +import { + modelColorMap, + renderNumber, + renderQuota, + modelToColor, + getQuotaWithUnit +} from '../../helpers'; +import { + processRawData, + calculateTrendData, + aggregateDataByTimeAndModel, + generateChartTimePoints, + updateChartSpec, + updateMapValue, + initializeMaps +} from '../../helpers/dashboard'; + +export const useDashboardCharts = ( + dataExportDefaultTime, + setTrendData, + setConsumeQuota, + setTimes, + setConsumeTokens, + setPieData, + setLineData, + setModelColors, + t +) => { + // ========== 图表规格状态 ========== + const [spec_pie, setSpecPie] = useState({ + type: 'pie', + data: [ + { + id: 'id0', + values: [{ type: 'null', value: '0' }], + }, + ], + 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, + }, + }, + }, + title: { + visible: true, + text: t('模型调用次数占比'), + subtext: `${t('总计')}:${renderNumber(0)}`, + }, + legends: { + visible: true, + orient: 'left', + }, + label: { + visible: true, + }, + tooltip: { + mark: { + content: [ + { + key: (datum) => datum['type'], + value: (datum) => renderNumber(datum['value']), + }, + ], + }, + }, + color: { + specified: modelColorMap, + }, + }); + + const [spec_line, setSpecLine] = useState({ + type: 'bar', + data: [ + { + id: 'barData', + values: [], + }, + ], + xField: 'Time', + yField: 'Usage', + seriesField: 'Model', + stack: true, + legends: { + visible: true, + selectMode: 'single', + }, + title: { + visible: true, + text: t('模型消耗分布'), + subtext: `${t('总计')}:${renderQuota(0, 2)}`, + }, + bar: { + state: { + hover: { + stroke: '#000', + lineWidth: 1, + }, + }, + }, + tooltip: { + mark: { + content: [ + { + key: (datum) => datum['Model'], + value: (datum) => renderQuota(datum['rawQuota'] || 0, 4), + }, + ], + }, + dimension: { + content: [ + { + key: (datum) => datum['Model'], + value: (datum) => datum['rawQuota'] || 0, + }, + ], + updateContent: (array) => { + array.sort((a, b) => b.value - a.value); + let sum = 0; + for (let i = 0; i < array.length; i++) { + if (array[i].key == '其他') { + continue; + } + let value = parseFloat(array[i].value); + if (isNaN(value)) { + value = 0; + } + if (array[i].datum && array[i].datum.TimeSum) { + sum = array[i].datum.TimeSum; + } + array[i].value = renderQuota(value, 4); + } + array.unshift({ + key: t('总计'), + value: renderQuota(sum, 4), + }); + return array; + }, + }, + }, + color: { + specified: modelColorMap, + }, + }); + + // 模型消耗趋势折线图 + const [spec_model_line, setSpecModelLine] = useState({ + type: 'line', + data: [ + { + id: 'lineData', + values: [], + }, + ], + xField: 'Time', + yField: 'Count', + seriesField: 'Model', + legends: { + visible: true, + selectMode: 'single', + }, + title: { + visible: true, + text: t('模型消耗趋势'), + subtext: '', + }, + tooltip: { + mark: { + content: [ + { + key: (datum) => datum['Model'], + value: (datum) => renderNumber(datum['Count']), + }, + ], + }, + }, + color: { + specified: modelColorMap, + }, + }); + + // 模型调用次数排行柱状图 + const [spec_rank_bar, setSpecRankBar] = useState({ + type: 'bar', + data: [ + { + id: 'rankData', + values: [], + }, + ], + xField: 'Model', + yField: 'Count', + seriesField: 'Model', + legends: { + visible: true, + selectMode: 'single', + }, + title: { + visible: true, + text: t('模型调用次数排行'), + subtext: '', + }, + bar: { + state: { + hover: { + stroke: '#000', + lineWidth: 1, + }, + }, + }, + tooltip: { + mark: { + content: [ + { + key: (datum) => datum['Model'], + value: (datum) => renderNumber(datum['Count']), + }, + ], + }, + }, + color: { + specified: modelColorMap, + }, + }); + + // ========== 数据处理函数 ========== + const generateModelColors = useCallback((uniqueModels, modelColors) => { + const newModelColors = {}; + Array.from(uniqueModels).forEach((modelName) => { + newModelColors[modelName] = + modelColorMap[modelName] || + modelColors[modelName] || + modelToColor(modelName); + }); + return newModelColors; + }, []); + + const updateChartData = useCallback((data) => { + const processedData = processRawData( + data, + dataExportDefaultTime, + initializeMaps, + updateMapValue + ); + + const { + totalQuota, + totalTimes, + totalTokens, + uniqueModels, + timePoints, + timeQuotaMap, + timeTokensMap, + timeCountMap + } = processedData; + + const trendDataResult = calculateTrendData( + timePoints, + timeQuotaMap, + timeTokensMap, + timeCountMap, + dataExportDefaultTime + ); + setTrendData(trendDataResult); + + const newModelColors = generateModelColors(uniqueModels, {}); + setModelColors(newModelColors); + + const aggregatedData = aggregateDataByTimeAndModel(data, dataExportDefaultTime); + + const modelTotals = new Map(); + for (let [_, value] of aggregatedData) { + updateMapValue(modelTotals, value.model, value.count); + } + + const newPieData = Array.from(modelTotals).map(([model, count]) => ({ + type: model, + value: count, + })).sort((a, b) => b.value - a.value); + + const chartTimePoints = generateChartTimePoints( + aggregatedData, + data, + dataExportDefaultTime + ); + + let newLineData = []; + + chartTimePoints.forEach((time) => { + let timeData = Array.from(uniqueModels).map((model) => { + const key = `${time}-${model}`; + const aggregated = aggregatedData.get(key); + return { + Time: time, + Model: model, + rawQuota: aggregated?.quota || 0, + Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0, + }; + }); + + const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0); + timeData.sort((a, b) => b.rawQuota - a.rawQuota); + timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum })); + newLineData.push(...timeData); + }); + + newLineData.sort((a, b) => a.Time.localeCompare(b.Time)); + + updateChartSpec( + setSpecPie, + newPieData, + `${t('总计')}:${renderNumber(totalTimes)}`, + newModelColors, + 'id0' + ); + + updateChartSpec( + setSpecLine, + newLineData, + `${t('总计')}:${renderQuota(totalQuota, 2)}`, + newModelColors, + 'barData' + ); + + // ===== 模型调用次数折线图 ===== + let modelLineData = []; + chartTimePoints.forEach((time) => { + const timeData = Array.from(uniqueModels).map((model) => { + const key = `${time}-${model}`; + const aggregated = aggregatedData.get(key); + return { + Time: time, + Model: model, + Count: aggregated?.count || 0, + }; + }); + modelLineData.push(...timeData); + }); + modelLineData.sort((a, b) => a.Time.localeCompare(b.Time)); + + // ===== 模型调用次数排行柱状图 ===== + const rankData = Array.from(modelTotals) + .map(([model, count]) => ({ + Model: model, + Count: count, + })) + .sort((a, b) => b.Count - a.Count); + + updateChartSpec( + setSpecModelLine, + modelLineData, + `${t('总计')}:${renderNumber(totalTimes)}`, + newModelColors, + 'lineData' + ); + + updateChartSpec( + setSpecRankBar, + rankData, + `${t('总计')}:${renderNumber(totalTimes)}`, + newModelColors, + 'rankData' + ); + + setPieData(newPieData); + setLineData(newLineData); + setConsumeQuota(totalQuota); + setTimes(totalTimes); + setConsumeTokens(totalTokens); + }, [ + dataExportDefaultTime, + setTrendData, + generateModelColors, + setModelColors, + setPieData, + setLineData, + setConsumeQuota, + setTimes, + setConsumeTokens, + t + ]); + + // ========== 初始化图表主题 ========== + useEffect(() => { + initVChartSemiTheme({ + isWatchingThemeSwitch: true, + }); + }, []); + + return { + // 图表规格 + spec_pie, + spec_line, + spec_model_line, + spec_rank_bar, + + // 函数 + updateChartData, + generateModelColors + }; +}; \ No newline at end of file diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js new file mode 100644 index 00000000..4eaeca77 --- /dev/null +++ b/web/src/hooks/dashboard/useDashboardData.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 { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { API, isAdmin, showError, timestamp2string } from '../../helpers'; +import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard'; +import { TIME_OPTIONS } from '../../constants/dashboard.constants'; +import { useIsMobile } from '../common/useIsMobile'; + +export const useDashboardData = (userState, userDispatch, statusState) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isMobile = useIsMobile(); + const initialized = useRef(false); + + // ========== 基础状态 ========== + const [loading, setLoading] = useState(false); + const [greetingVisible, setGreetingVisible] = useState(false); + const [searchModalVisible, setSearchModalVisible] = useState(false); + + // ========== 输入状态 ========== + const [inputs, setInputs] = useState({ + username: '', + token_name: '', + model_name: '', + start_timestamp: getInitialTimestamp(), + end_timestamp: timestamp2string(new Date().getTime() / 1000 + 3600), + channel: '', + data_export_default_time: '', + }); + + const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime()); + + // ========== 数据状态 ========== + const [quotaData, setQuotaData] = useState([]); + const [consumeQuota, setConsumeQuota] = useState(0); + const [consumeTokens, setConsumeTokens] = useState(0); + const [times, setTimes] = useState(0); + const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]); + const [lineData, setLineData] = useState([]); + const [modelColors, setModelColors] = useState({}); + + // ========== 图表状态 ========== + const [activeChartTab, setActiveChartTab] = useState('1'); + + // ========== 趋势数据 ========== + const [trendData, setTrendData] = useState({ + balance: [], + usedQuota: [], + requestCount: [], + times: [], + consumeQuota: [], + tokens: [], + rpm: [], + tpm: [] + }); + + // ========== Uptime 数据 ========== + const [uptimeData, setUptimeData] = useState([]); + const [uptimeLoading, setUptimeLoading] = useState(false); + const [activeUptimeTab, setActiveUptimeTab] = useState(''); + + // ========== 常量 ========== + const now = new Date(); + const isAdminUser = isAdmin(); + + // ========== Panel enable flags ========== + const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true; + const announcementsEnabled = statusState?.status?.announcements_enabled ?? true; + const faqEnabled = statusState?.status?.faq_enabled ?? true; + const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true; + + const hasApiInfoPanel = apiInfoEnabled; + const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled; + + // ========== Memoized Values ========== + const timeOptions = useMemo(() => TIME_OPTIONS.map(option => ({ + ...option, + label: t(option.label) + })), [t]); + + const performanceMetrics = useMemo(() => { + const { start_timestamp, end_timestamp } = inputs; + const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000; + const avgRPM = isNaN(times / timeDiff) ? '0' : (times / timeDiff).toFixed(3); + const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3); + + return { avgRPM, avgTPM, timeDiff }; + }, [times, consumeTokens, inputs.start_timestamp, inputs.end_timestamp]); + + const getGreeting = useMemo(() => { + const hours = new Date().getHours(); + let greeting = ''; + + if (hours >= 5 && hours < 12) { + greeting = t('早上好'); + } else if (hours >= 12 && hours < 14) { + greeting = t('中午好'); + } else if (hours >= 14 && hours < 18) { + greeting = t('下午好'); + } else { + greeting = t('晚上好'); + } + + const username = userState?.user?.username || ''; + return `👋${greeting},${username}`; + }, [t, userState?.user?.username]); + + // ========== 回调函数 ========== + const handleInputChange = useCallback((value, name) => { + if (name === 'data_export_default_time') { + setDataExportDefaultTime(value); + localStorage.setItem('data_export_default_time', value); + return; + } + setInputs((inputs) => ({ ...inputs, [name]: value })); + }, []); + + const showSearchModal = useCallback(() => { + setSearchModalVisible(true); + }, []); + + const handleCloseModal = useCallback(() => { + setSearchModalVisible(false); + }, []); + + // ========== API 调用函数 ========== + const loadQuotaData = useCallback(async () => { + setLoading(true); + const startTime = Date.now(); + try { + let url = ''; + const { start_timestamp, end_timestamp, username } = inputs; + let localStartTimestamp = Date.parse(start_timestamp) / 1000; + let localEndTimestamp = Date.parse(end_timestamp) / 1000; + + if (isAdminUser) { + url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`; + } else { + url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`; + } + + const res = await API.get(url); + const { success, message, data } = res.data; + if (success) { + setQuotaData(data); + if (data.length === 0) { + data.push({ + count: 0, + model_name: '无数据', + quota: 0, + created_at: now.getTime() / 1000, + }); + } + data.sort((a, b) => a.created_at - b.created_at); + return data; + } else { + showError(message); + return []; + } + } finally { + const elapsed = Date.now() - startTime; + const remainingTime = Math.max(0, 500 - elapsed); + setTimeout(() => { + setLoading(false); + }, remainingTime); + } + }, [inputs, dataExportDefaultTime, isAdminUser, now]); + + const loadUptimeData = useCallback(async () => { + setUptimeLoading(true); + try { + const res = await API.get('/api/uptime/status'); + const { success, message, data } = res.data; + if (success) { + setUptimeData(data || []); + if (data && data.length > 0 && !activeUptimeTab) { + setActiveUptimeTab(data[0].categoryName); + } + } else { + showError(message); + } + } catch (err) { + console.error(err); + } finally { + setUptimeLoading(false); + } + }, [activeUptimeTab]); + + const getUserData = useCallback(async () => { + let res = await API.get(`/api/user/self`); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + } else { + showError(message); + } + }, [userDispatch]); + + const refresh = useCallback(async () => { + const data = await loadQuotaData(); + await loadUptimeData(); + return data; + }, [loadQuotaData, loadUptimeData]); + + const handleSearchConfirm = useCallback(async (updateChartDataCallback) => { + const data = await refresh(); + if (data && data.length > 0 && updateChartDataCallback) { + updateChartDataCallback(data); + } + setSearchModalVisible(false); + }, [refresh]); + + // ========== Effects ========== + useEffect(() => { + const timer = setTimeout(() => { + setGreetingVisible(true); + }, 100); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!initialized.current) { + getUserData(); + initialized.current = true; + } + }, [getUserData]); + + return { + // 基础状态 + loading, + greetingVisible, + searchModalVisible, + + // 输入状态 + inputs, + dataExportDefaultTime, + + // 数据状态 + quotaData, + consumeQuota, + setConsumeQuota, + consumeTokens, + setConsumeTokens, + times, + setTimes, + pieData, + setPieData, + lineData, + setLineData, + modelColors, + setModelColors, + + // 图表状态 + activeChartTab, + setActiveChartTab, + + // 趋势数据 + trendData, + setTrendData, + + // Uptime 数据 + uptimeData, + uptimeLoading, + activeUptimeTab, + setActiveUptimeTab, + + // 计算值 + timeOptions, + performanceMetrics, + getGreeting, + isAdminUser, + hasApiInfoPanel, + hasInfoPanels, + apiInfoEnabled, + announcementsEnabled, + faqEnabled, + uptimeEnabled, + + // 函数 + handleInputChange, + showSearchModal, + handleCloseModal, + loadQuotaData, + loadUptimeData, + getUserData, + refresh, + handleSearchConfirm, + + // 导航和翻译 + navigate, + t, + isMobile + }; +}; \ No newline at end of file diff --git a/web/src/hooks/dashboard/useDashboardStats.js b/web/src/hooks/dashboard/useDashboardStats.js new file mode 100644 index 00000000..1e0a4f32 --- /dev/null +++ b/web/src/hooks/dashboard/useDashboardStats.js @@ -0,0 +1,151 @@ +/* +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 { useMemo } from 'react'; +import { Wallet, Activity, Zap, Gauge } from 'lucide-react'; +import { + IconMoneyExchangeStroked, + IconHistogram, + IconCoinMoneyStroked, + IconTextStroked, + IconPulse, + IconStopwatchStroked, + IconTypograph, + IconSend +} from '@douyinfe/semi-icons'; +import { renderQuota } from '../../helpers'; +import { createSectionTitle } from '../../helpers/dashboard'; + +export const useDashboardStats = ( + userState, + consumeQuota, + consumeTokens, + times, + trendData, + performanceMetrics, + navigate, + t +) => { + const groupedStatsData = useMemo(() => [ + { + title: createSectionTitle(Wallet, t('账户数据')), + color: 'bg-blue-50', + items: [ + { + title: t('当前余额'), + value: renderQuota(userState?.user?.quota), + icon: , + avatarColor: 'blue', + onClick: () => navigate('/console/topup'), + trendData: [], + trendColor: '#3b82f6' + }, + { + title: t('历史消耗'), + value: renderQuota(userState?.user?.used_quota), + icon: , + avatarColor: 'purple', + trendData: [], + trendColor: '#8b5cf6' + } + ] + }, + { + title: createSectionTitle(Activity, t('使用统计')), + color: 'bg-green-50', + items: [ + { + title: t('请求次数'), + value: userState.user?.request_count, + icon: , + avatarColor: 'green', + trendData: [], + trendColor: '#10b981' + }, + { + title: t('统计次数'), + value: times, + icon: , + avatarColor: 'cyan', + trendData: trendData.times, + trendColor: '#06b6d4' + } + ] + }, + { + title: createSectionTitle(Zap, t('资源消耗')), + color: 'bg-yellow-50', + items: [ + { + title: t('统计额度'), + value: renderQuota(consumeQuota), + icon: , + avatarColor: 'yellow', + trendData: trendData.consumeQuota, + trendColor: '#f59e0b' + }, + { + title: t('统计Tokens'), + value: isNaN(consumeTokens) ? 0 : consumeTokens, + icon: , + avatarColor: 'pink', + trendData: trendData.tokens, + trendColor: '#ec4899' + } + ] + }, + { + title: createSectionTitle(Gauge, t('性能指标')), + color: 'bg-indigo-50', + items: [ + { + title: t('平均RPM'), + value: performanceMetrics.avgRPM, + icon: , + avatarColor: 'indigo', + trendData: trendData.rpm, + trendColor: '#6366f1' + }, + { + title: t('平均TPM'), + value: performanceMetrics.avgTPM, + icon: , + avatarColor: 'orange', + trendData: trendData.tpm, + trendColor: '#f97316' + } + ] + } + ], [ + userState?.user?.quota, + userState?.user?.used_quota, + userState?.user?.request_count, + times, + consumeQuota, + consumeTokens, + trendData, + performanceMetrics, + navigate, + t + ]); + + return { + groupedStatsData + }; +}; \ No newline at end of file diff --git a/web/src/pages/Dashboard/index.js b/web/src/pages/Dashboard/index.js new file mode 100644 index 00000000..f7f5afdd --- /dev/null +++ b/web/src/pages/Dashboard/index.js @@ -0,0 +1,29 @@ +/* +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 Dashboard from '../../components/dashboard'; + +const Detail = () => ( +
+ +
+); + +export default Detail; diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js deleted file mode 100644 index 0a725209..00000000 --- a/web/src/pages/Detail/index.js +++ /dev/null @@ -1,1610 +0,0 @@ -/* -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, useMemo, useCallback } from 'react'; -import { initVChartSemiTheme } from '@visactor/vchart-semi-theme'; -import { useNavigate } from 'react-router-dom'; -import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle, ExternalLink } from 'lucide-react'; -import { marked } from 'marked'; - -import { - Card, - Form, - Spin, - Button, - Modal, - Avatar, - Tabs, - TabPane, - Empty, - Tag, - Timeline, - Collapse, - Progress, - Divider, - Skeleton -} from '@douyinfe/semi-ui'; -import ScrollableContainer from '../../components/common/ui/ScrollableContainer'; -import { - IconRefresh, - IconSearch, - IconMoneyExchangeStroked, - IconHistogram, - IconCoinMoneyStroked, - IconTextStroked, - IconPulse, - IconStopwatchStroked, - IconTypograph, - IconPieChart2Stroked, - IconPlus, - IconMinus, - IconSend -} from '@douyinfe/semi-icons'; -import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations'; -import { VChart } from '@visactor/react-vchart'; -import { - API, - isAdmin, - showError, - showSuccess, - showWarning, - timestamp2string, - timestamp2string1, - getQuotaWithUnit, - modelColorMap, - renderNumber, - renderQuota, - modelToColor, - copy, - getRelativeTime -} from '../../helpers'; -import { useIsMobile } from '../../hooks/common/useIsMobile.js'; -import { UserContext } from '../../context/User/index.js'; -import { StatusContext } from '../../context/Status/index.js'; -import { useTranslation } from 'react-i18next'; - -const Detail = (props) => { - // ========== Hooks - Context ========== - const [userState, userDispatch] = useContext(UserContext); - const [statusState, statusDispatch] = useContext(StatusContext); - - // ========== Hooks - Navigation & Translation ========== - const { t } = useTranslation(); - const navigate = useNavigate(); - const isMobile = useIsMobile(); - - // ========== Hooks - Refs ========== - const formRef = useRef(); - const initialized = useRef(false); - - // ========== Constants & Shared Configurations ========== - const CHART_CONFIG = { mode: 'desktop-browser' }; - - const CARD_PROPS = { - shadows: 'always', - bordered: false, - headerLine: true - }; - - const FORM_FIELD_PROPS = { - className: "w-full mb-2 !rounded-lg", - size: 'large' - }; - - const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full"; - const FLEX_CENTER_GAP2 = "flex items-center gap-2"; - - const ILLUSTRATION_SIZE = { width: 96, height: 96 }; - - // ========== Constants ========== - let now = new Date(); - const isAdminUser = isAdmin(); - - // ========== Panel enable flags ========== - const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true; - const announcementsEnabled = statusState?.status?.announcements_enabled ?? true; - const faqEnabled = statusState?.status?.faq_enabled ?? true; - const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true; - - const hasApiInfoPanel = apiInfoEnabled; - const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled; - - // ========== Helper Functions ========== - const getDefaultTime = useCallback(() => { - return localStorage.getItem('data_export_default_time') || 'hour'; - }, []); - - const getTimeInterval = useCallback((timeType, isSeconds = false) => { - const intervals = { - hour: isSeconds ? 3600 : 60, - day: isSeconds ? 86400 : 1440, - week: isSeconds ? 604800 : 10080 - }; - return intervals[timeType] || intervals.hour; - }, []); - - const getInitialTimestamp = useCallback(() => { - 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); - } - }, [getDefaultTime]); - - const updateMapValue = useCallback((map, key, value) => { - if (!map.has(key)) { - map.set(key, 0); - } - map.set(key, map.get(key) + value); - }, []); - - const initializeMaps = useCallback((key, ...maps) => { - maps.forEach(map => { - if (!map.has(key)) { - map.set(key, 0); - } - }); - }, []); - - const updateChartSpec = useCallback((setterFunc, newData, subtitle, newColors, dataId) => { - setterFunc(prev => ({ - ...prev, - data: [{ id: dataId, values: newData }], - title: { - ...prev.title, - subtext: subtitle, - }, - color: { - specified: newColors, - }, - })); - }, []); - - const createSectionTitle = useCallback((Icon, text) => ( -
- - {text} -
- ), []); - - const createFormField = useCallback((Component, props) => ( - - ), []); - - // ========== Time Options ========== - const timeOptions = useMemo(() => [ - { label: t('小时'), value: 'hour' }, - { label: t('天'), value: 'day' }, - { label: t('周'), value: 'week' }, - ], [t]); - - // ========== Hooks - State ========== - const [inputs, setInputs] = useState({ - username: '', - token_name: '', - model_name: '', - start_timestamp: getInitialTimestamp(), - end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), - channel: '', - data_export_default_time: '', - }); - - const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime()); - - const [loading, setLoading] = useState(false); - const [greetingVisible, setGreetingVisible] = useState(false); - const [quotaData, setQuotaData] = useState([]); - const [consumeQuota, setConsumeQuota] = useState(0); - const [consumeTokens, setConsumeTokens] = useState(0); - const [times, setTimes] = useState(0); - const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]); - const [lineData, setLineData] = useState([]); - - const [modelColors, setModelColors] = useState({}); - const [activeChartTab, setActiveChartTab] = useState('1'); - const [searchModalVisible, setSearchModalVisible] = useState(false); - - const [trendData, setTrendData] = useState({ - balance: [], - usedQuota: [], - requestCount: [], - times: [], - consumeQuota: [], - tokens: [], - rpm: [], - tpm: [] - }); - - - - // ========== Uptime data ========== - const [uptimeData, setUptimeData] = useState([]); - const [uptimeLoading, setUptimeLoading] = useState(false); - const [activeUptimeTab, setActiveUptimeTab] = useState(''); - - // ========== Props Destructuring ========== - const { username, model_name, start_timestamp, end_timestamp, channel } = inputs; - - // ========== Chart Specs State ========== - const [spec_pie, setSpecPie] = useState({ - type: 'pie', - data: [ - { - id: 'id0', - values: pieData, - }, - ], - 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, - }, - }, - }, - title: { - visible: true, - text: t('模型调用次数占比'), - subtext: `${t('总计')}:${renderNumber(times)}`, - }, - legends: { - visible: true, - orient: 'left', - }, - label: { - visible: true, - }, - tooltip: { - mark: { - content: [ - { - key: (datum) => datum['type'], - value: (datum) => renderNumber(datum['value']), - }, - ], - }, - }, - color: { - specified: modelColorMap, - }, - }); - - const [spec_line, setSpecLine] = useState({ - type: 'bar', - data: [ - { - id: 'barData', - values: lineData, - }, - ], - xField: 'Time', - yField: 'Usage', - seriesField: 'Model', - stack: true, - legends: { - visible: true, - selectMode: 'single', - }, - title: { - visible: true, - text: t('模型消耗分布'), - subtext: `${t('总计')}:${renderQuota(consumeQuota, 2)}`, - }, - bar: { - state: { - hover: { - stroke: '#000', - lineWidth: 1, - }, - }, - }, - tooltip: { - mark: { - content: [ - { - key: (datum) => datum['Model'], - value: (datum) => renderQuota(datum['rawQuota'] || 0, 4), - }, - ], - }, - dimension: { - content: [ - { - key: (datum) => datum['Model'], - value: (datum) => datum['rawQuota'] || 0, - }, - ], - updateContent: (array) => { - array.sort((a, b) => b.value - a.value); - let sum = 0; - for (let i = 0; i < array.length; i++) { - if (array[i].key == '其他') { - continue; - } - let value = parseFloat(array[i].value); - if (isNaN(value)) { - value = 0; - } - if (array[i].datum && array[i].datum.TimeSum) { - sum = array[i].datum.TimeSum; - } - array[i].value = renderQuota(value, 4); - } - array.unshift({ - key: t('总计'), - value: renderQuota(sum, 4), - }); - return array; - }, - }, - }, - color: { - specified: modelColorMap, - }, - }); - - // 模型消耗趋势折线图 - const [spec_model_line, setSpecModelLine] = useState({ - type: 'line', - data: [ - { - id: 'lineData', - values: [], - }, - ], - xField: 'Time', - yField: 'Count', - seriesField: 'Model', - legends: { - visible: true, - selectMode: 'single', - }, - title: { - visible: true, - text: t('模型消耗趋势'), - subtext: '', - }, - tooltip: { - mark: { - content: [ - { - key: (datum) => datum['Model'], - value: (datum) => renderNumber(datum['Count']), - }, - ], - }, - }, - color: { - specified: modelColorMap, - }, - }); - - // 模型调用次数排行柱状图 - const [spec_rank_bar, setSpecRankBar] = useState({ - type: 'bar', - data: [ - { - id: 'rankData', - values: [], - }, - ], - xField: 'Model', - yField: 'Count', - seriesField: 'Model', - legends: { - visible: true, - selectMode: 'single', - }, - title: { - visible: true, - text: t('模型调用次数排行'), - subtext: '', - }, - bar: { - state: { - hover: { - stroke: '#000', - lineWidth: 1, - }, - }, - }, - tooltip: { - mark: { - content: [ - { - key: (datum) => datum['Model'], - value: (datum) => renderNumber(datum['Count']), - }, - ], - }, - }, - color: { - specified: modelColorMap, - }, - }); - - // ========== Hooks - Memoized Values ========== - const performanceMetrics = useMemo(() => { - const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000; - const avgRPM = isNaN(times / timeDiff) ? '0' : (times / timeDiff).toFixed(3); - const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3); - - return { avgRPM, avgTPM, timeDiff }; - }, [times, consumeTokens, end_timestamp, start_timestamp]); - - const getGreeting = useMemo(() => { - const hours = new Date().getHours(); - let greeting = ''; - - if (hours >= 5 && hours < 12) { - greeting = t('早上好'); - } else if (hours >= 12 && hours < 14) { - greeting = t('中午好'); - } else if (hours >= 14 && hours < 18) { - greeting = t('下午好'); - } else { - greeting = t('晚上好'); - } - - const username = userState?.user?.username || ''; - return `👋${greeting},${username}`; - }, [t, userState?.user?.username]); - - // ========== Hooks - Callbacks ========== - const getTrendSpec = useCallback((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' - } - }), []); - - const groupedStatsData = useMemo(() => [ - { - title: createSectionTitle(Wallet, t('账户数据')), - color: 'bg-blue-50', - items: [ - { - title: t('当前余额'), - value: renderQuota(userState?.user?.quota), - icon: , - avatarColor: 'blue', - onClick: () => navigate('/console/topup'), - trendData: [], - trendColor: '#3b82f6' - }, - { - title: t('历史消耗'), - value: renderQuota(userState?.user?.used_quota), - icon: , - avatarColor: 'purple', - trendData: [], - trendColor: '#8b5cf6' - } - ] - }, - { - title: createSectionTitle(Activity, t('使用统计')), - color: 'bg-green-50', - items: [ - { - title: t('请求次数'), - value: userState.user?.request_count, - icon: , - avatarColor: 'green', - trendData: [], - trendColor: '#10b981' - }, - { - title: t('统计次数'), - value: times, - icon: , - avatarColor: 'cyan', - trendData: trendData.times, - trendColor: '#06b6d4' - } - ] - }, - { - title: createSectionTitle(Zap, t('资源消耗')), - color: 'bg-yellow-50', - items: [ - { - title: t('统计额度'), - value: renderQuota(consumeQuota), - icon: , - avatarColor: 'yellow', - trendData: trendData.consumeQuota, - trendColor: '#f59e0b' - }, - { - title: t('统计Tokens'), - value: isNaN(consumeTokens) ? 0 : consumeTokens, - icon: , - avatarColor: 'pink', - trendData: trendData.tokens, - trendColor: '#ec4899' - } - ] - }, - { - title: createSectionTitle(Gauge, t('性能指标')), - color: 'bg-indigo-50', - items: [ - { - title: t('平均RPM'), - value: performanceMetrics.avgRPM, - icon: , - avatarColor: 'indigo', - trendData: trendData.rpm, - trendColor: '#6366f1' - }, - { - title: t('平均TPM'), - value: performanceMetrics.avgTPM, - icon: , - avatarColor: 'orange', - trendData: trendData.tpm, - trendColor: '#f97316' - } - ] - } - ], [ - createSectionTitle, t, userState?.user?.quota, userState?.user?.used_quota, userState?.user?.request_count, - times, consumeQuota, consumeTokens, trendData, performanceMetrics, navigate - ]); - - const handleCopyUrl = useCallback(async (url) => { - if (await copy(url)) { - showSuccess(t('复制成功')); - } - }, [t]); - - const handleSpeedTest = useCallback((apiUrl) => { - const encodedUrl = encodeURIComponent(apiUrl); - const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`; - window.open(speedTestUrl, '_blank', 'noopener,noreferrer'); - }, []); - - const handleInputChange = useCallback((value, name) => { - if (name === 'data_export_default_time') { - setDataExportDefaultTime(value); - return; - } - setInputs((inputs) => ({ ...inputs, [name]: value })); - }, []); - - const loadQuotaData = useCallback(async () => { - setLoading(true); - const startTime = Date.now(); - try { - let url = ''; - let localStartTimestamp = Date.parse(start_timestamp) / 1000; - let localEndTimestamp = Date.parse(end_timestamp) / 1000; - if (isAdminUser) { - url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`; - } else { - url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`; - } - const res = await API.get(url); - const { success, message, data } = res.data; - if (success) { - setQuotaData(data); - if (data.length === 0) { - data.push({ - count: 0, - model_name: '无数据', - quota: 0, - created_at: now.getTime() / 1000, - }); - } - data.sort((a, b) => a.created_at - b.created_at); - updateChartData(data); - } else { - showError(message); - } - } finally { - const elapsed = Date.now() - startTime; - const remainingTime = Math.max(0, 500 - elapsed); - setTimeout(() => { - setLoading(false); - }, remainingTime); - } - }, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]); - - const loadUptimeData = useCallback(async () => { - setUptimeLoading(true); - try { - const res = await API.get('/api/uptime/status'); - const { success, message, data } = res.data; - if (success) { - setUptimeData(data || []); - if (data && data.length > 0 && !activeUptimeTab) { - setActiveUptimeTab(data[0].categoryName); - } - } else { - showError(message); - } - } catch (err) { - console.error(err); - } finally { - setUptimeLoading(false); - } - }, [activeUptimeTab]); - - const refresh = useCallback(async () => { - await Promise.all([loadQuotaData(), loadUptimeData()]); - }, [loadQuotaData, loadUptimeData]); - - const handleSearchConfirm = useCallback(() => { - refresh(); - setSearchModalVisible(false); - }, [refresh]); - - const initChart = useCallback(async () => { - await loadQuotaData(); - await loadUptimeData(); - }, [loadQuotaData, loadUptimeData]); - - const showSearchModal = useCallback(() => { - setSearchModalVisible(true); - }, []); - - const handleCloseModal = useCallback(() => { - setSearchModalVisible(false); - }, []); - - - - - - useEffect(() => { - const timer = setTimeout(() => { - setGreetingVisible(true); - }, 100); - return () => clearTimeout(timer); - }, []); - - const getUserData = async () => { - let res = await API.get(`/api/user/self`); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - } else { - showError(message); - } - }; - - // ========== Data Processing Functions ========== - const processRawData = useCallback((data) => { - 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; - }, [dataExportDefaultTime, initializeMaps, updateMapValue]); - - const calculateTrendData = useCallback((timePoints, timeQuotaMap, timeTokensMap, timeCountMap) => { - 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 - }; - }, [dataExportDefaultTime, getTimeInterval]); - - const generateModelColors = useCallback((uniqueModels) => { - const newModelColors = {}; - Array.from(uniqueModels).forEach((modelName) => { - newModelColors[modelName] = - modelColorMap[modelName] || - modelColors[modelName] || - modelToColor(modelName); - }); - return newModelColors; - }, [modelColors]); - - const aggregateDataByTimeAndModel = useCallback((data) => { - 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; - }, [dataExportDefaultTime]); - - const generateChartTimePoints = useCallback((aggregatedData, data) => { - let chartTimePoints = Array.from( - new Set([...aggregatedData.values()].map((d) => d.time)), - ); - - if (chartTimePoints.length < 7) { - const lastTime = Math.max(...data.map((item) => item.created_at)); - const interval = getTimeInterval(dataExportDefaultTime, true); - - chartTimePoints = Array.from({ length: 7 }, (_, i) => - timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime), - ); - } - - return chartTimePoints; - }, [dataExportDefaultTime, getTimeInterval]); - - const updateChartData = useCallback((data) => { - const processedData = processRawData(data); - const { totalQuota, totalTimes, totalTokens, uniqueModels, timePoints, timeQuotaMap, timeTokensMap, timeCountMap } = processedData; - - const trendDataResult = calculateTrendData(timePoints, timeQuotaMap, timeTokensMap, timeCountMap); - setTrendData(trendDataResult); - - const newModelColors = generateModelColors(uniqueModels); - setModelColors(newModelColors); - - const aggregatedData = aggregateDataByTimeAndModel(data); - - const modelTotals = new Map(); - for (let [_, value] of aggregatedData) { - updateMapValue(modelTotals, value.model, value.count); - } - - const newPieData = Array.from(modelTotals).map(([model, count]) => ({ - type: model, - value: count, - })).sort((a, b) => b.value - a.value); - - const chartTimePoints = generateChartTimePoints(aggregatedData, data); - let newLineData = []; - - chartTimePoints.forEach((time) => { - let timeData = Array.from(uniqueModels).map((model) => { - const key = `${time}-${model}`; - const aggregated = aggregatedData.get(key); - return { - Time: time, - Model: model, - rawQuota: aggregated?.quota || 0, - Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0, - }; - }); - - const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0); - timeData.sort((a, b) => b.rawQuota - a.rawQuota); - timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum })); - newLineData.push(...timeData); - }); - - newLineData.sort((a, b) => a.Time.localeCompare(b.Time)); - - updateChartSpec( - setSpecPie, - newPieData, - `${t('总计')}:${renderNumber(totalTimes)}`, - newModelColors, - 'id0' - ); - - updateChartSpec( - setSpecLine, - newLineData, - `${t('总计')}:${renderQuota(totalQuota, 2)}`, - newModelColors, - 'barData' - ); - - // ===== 模型调用次数折线图 ===== - let modelLineData = []; - chartTimePoints.forEach((time) => { - const timeData = Array.from(uniqueModels).map((model) => { - const key = `${time}-${model}`; - const aggregated = aggregatedData.get(key); - return { - Time: time, - Model: model, - Count: aggregated?.count || 0, - }; - }); - modelLineData.push(...timeData); - }); - modelLineData.sort((a, b) => a.Time.localeCompare(b.Time)); - - // ===== 模型调用次数排行柱状图 ===== - const rankData = Array.from(modelTotals) - .map(([model, count]) => ({ - Model: model, - Count: count, - })) - .sort((a, b) => b.Count - a.Count); - - updateChartSpec( - setSpecModelLine, - modelLineData, - `${t('总计')}:${renderNumber(totalTimes)}`, - newModelColors, - 'lineData' - ); - - updateChartSpec( - setSpecRankBar, - rankData, - `${t('总计')}:${renderNumber(totalTimes)}`, - newModelColors, - 'rankData' - ); - - setPieData(newPieData); - setLineData(newLineData); - setConsumeQuota(totalQuota); - setTimes(totalTimes); - setConsumeTokens(totalTokens); - }, [ - processRawData, calculateTrendData, generateModelColors, aggregateDataByTimeAndModel, - generateChartTimePoints, updateChartSpec, updateMapValue, t - ]); - - // ========== Status Data Management ========== - const announcementLegendData = useMemo(() => [ - { color: 'grey', label: t('默认'), type: 'default' }, - { color: 'blue', label: t('进行中'), type: 'ongoing' }, - { color: 'green', label: t('成功'), type: 'success' }, - { color: 'orange', label: t('警告'), type: 'warning' }, - { color: 'red', label: t('异常'), type: 'error' } - ], [t]); - - const uptimeStatusMap = useMemo(() => ({ - 1: { color: '#10b981', label: t('正常'), text: t('可用率') }, // UP - 0: { color: '#ef4444', label: t('异常'), text: t('有异常') }, // DOWN - 2: { color: '#f59e0b', label: t('高延迟'), text: t('高延迟') }, // PENDING - 3: { color: '#3b82f6', label: t('维护中'), text: t('维护中') } // MAINTENANCE - }), [t]); - - const uptimeLegendData = useMemo(() => - Object.entries(uptimeStatusMap).map(([status, info]) => ({ - status: Number(status), - color: info.color, - label: info.label - })), [uptimeStatusMap]); - - const getUptimeStatusColor = useCallback((status) => - uptimeStatusMap[status]?.color || '#8b9aa7', - [uptimeStatusMap]); - - const getUptimeStatusText = useCallback((status) => - uptimeStatusMap[status]?.text || t('未知'), - [uptimeStatusMap, t]); - - const apiInfoData = useMemo(() => { - return statusState?.status?.api_info || []; - }, [statusState?.status?.api_info]); - - const announcementData = useMemo(() => { - const announcements = statusState?.status?.announcements || []; - return announcements.map(item => ({ - ...item, - time: getRelativeTime(item.publishDate) - })); - }, [statusState?.status?.announcements]); - - const faqData = useMemo(() => { - return statusState?.status?.faq || []; - }, [statusState?.status?.faq]); - - const renderMonitorList = useCallback((monitors) => { - 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)} -
- )); - }, [t, getUptimeStatusColor, getUptimeStatusText]); - - // ========== Hooks - Effects ========== - useEffect(() => { - getUserData(); - if (!initialized.current) { - initVChartSemiTheme({ - isWatchingThemeSwitch: true, - }); - initialized.current = true; - initChart(); - } - }, []); - - return ( -
-
-

- {getGreeting} -

-
-
-
- - {/* 搜索条件Modal */} - -
- {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') - })} - -
- -
-
- {groupedStatsData.map((group, idx) => ( - -
- {group.items.map((item, itemIdx) => ( -
-
- - {item.icon} - -
-
{item.title}
-
- - } - > - {item.value} - -
-
-
- {(loading || (item.trendData && item.trendData.length > 0)) && ( -
- -
- )} -
- ))} -
-
- ))} -
-
- -
-
- -
- - {t('模型数据分析')} -
- - - - {t('消耗分布')} - - } itemKey="1" /> - - - {t('消耗趋势')} - - } itemKey="2" /> - - - {t('调用次数分布')} - - } itemKey="3" /> - - - {t('调用次数排行')} - - } itemKey="4" /> - -
- } - bodyStyle={{ padding: 0 }} - > -
- {activeChartTab === '1' && ( - - )} - {activeChartTab === '2' && ( - - )} - {activeChartTab === '3' && ( - - )} - {activeChartTab === '4' && ( - - )} -
- - - {hasApiInfoPanel && ( - - - {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信息')} - /> -
- )} -
- - )} -
-
- - {/* 系统公告和常见问答卡片 */} - { - hasInfoPanels && ( -
-
- {/* 公告卡片 */} - {announcementsEnabled && ( - -
- - {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('请联系管理员在系统设置中配置公告信息')} - /> -
- )} - - - )} - - {/* 常见问答卡片 */} - {faqEnabled && ( - - - {t('常见问答')} -
- } - bodyStyle={{ padding: 0 }} - > - - {faqData.length > 0 ? ( - } - collapseIcon={} - > - {faqData.map((item, index) => ( - -
- - ))} - - ) : ( -
- } - darkModeImage={} - title={t('暂无常见问答')} - description={t('请联系管理员在系统设置中配置常见问答')} - /> -
- )} - - - )} - - {/* 服务可用性卡片 */} - {uptimeEnabled && ( - -
- - {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 Detail; From dac5cbae3de8ee9ea22aa0170965488bb9488b95 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 15:52:57 +0800 Subject: [PATCH 042/582] =?UTF-8?q?=E2=9A=96=EF=B8=8F=20docs(about):=20upd?= =?UTF-8?q?ate=20license=20information=20from=20Apache=202.0=20to=20AGPL?= =?UTF-8?q?=20v3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update license text from "Apache-2.0协议" to "AGPL v3.0协议" - Update license link to point to official AGPL v3.0 license page - Align About page license references with actual project license --- web/src/pages/About/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/About/index.js b/web/src/pages/About/index.js index 232b3224..c19617a9 100644 --- a/web/src/pages/About/index.js +++ b/web/src/pages/About/index.js @@ -111,12 +111,12 @@ const About = () => { {t('授权,需在遵守')} - {t('Apache-2.0协议')} + {t('AGPL v3.0协议')} {t('的前提下使用。')}

From 9992229b906d4dc00b7ef60d012bd78d4d39ea0c Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 16:15:00 +0800 Subject: [PATCH 043/582] =?UTF-8?q?=E2=9A=96=EF=B8=8F=20docs(LICENSE):=20u?= =?UTF-8?q?pdate=20license=20information=20from=20Apache=202.0=20to=20New?= =?UTF-8?q?=20API=20Licensing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 240 +++++++++++------------------------ web/src/i18n/locales/en.json | 2 +- 2 files changed, 72 insertions(+), 170 deletions(-) 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/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a6f7b978..6b1d5e05 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1450,7 +1450,7 @@ "© {{currentYear}}": "© {{currentYear}}", "| 基于": " | Based on ", "MIT许可证": "MIT License", - "Apache-2.0协议": "Apache-2.0 License", + "AGPL v3.0协议": "AGPL v3.0 License", "本项目根据": "This project is licensed under the ", "授权,需在遵守": " and must be used in compliance with the ", "的前提下使用。": ".", From e06d0ba0e4b525eba84f0143fd3d4b338c20efe0 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Sun, 20 Jul 2025 17:35:34 +0800 Subject: [PATCH 044/582] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3nothinking?= =?UTF-8?q?=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E4=BB=85=E5=BD=93=E9=A2=84=E7=AE=97=E4=B8=BA=E9=9B=B6=E6=97=B6?= =?UTF-8?q?=E8=BF=94=E5=9B=9Etrue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/gemini_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index e448b491..730983e0 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -80,7 +80,7 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool { if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { - return *req.GenerationConfig.ThinkingConfig.ThinkingBudget <= 0 + return *req.GenerationConfig.ThinkingConfig.ThinkingBudget == 0 } return false } From df57ca83ed02448796de7f2f07b7e7f012d1b26a Mon Sep 17 00:00:00 2001 From: ZhangYichi Date: Sun, 20 Jul 2025 18:25:43 +0800 Subject: [PATCH 045/582] =?UTF-8?q?fix:=20=E6=A0=B9=E6=8D=AEOpenAI?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E7=9A=84=E8=AE=A1=E8=B4=B9=E8=A7=84=E5=88=99?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=85=B6Web=20Search=20Tools?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setting/operation_setting/tools.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index a59090ce..f87fcace 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -4,12 +4,12 @@ import "strings" const ( // Web search - WebSearchHighTierModelPriceLow = 30.00 - WebSearchHighTierModelPriceMedium = 35.00 - WebSearchHighTierModelPriceHigh = 50.00 + WebSearchHighTierModelPriceLow = 10.00 + WebSearchHighTierModelPriceMedium = 10.00 + WebSearchHighTierModelPriceHigh = 10.00 WebSearchPriceLow = 25.00 - WebSearchPriceMedium = 27.50 - WebSearchPriceHigh = 30.00 + WebSearchPriceMedium = 25.00 + WebSearchPriceHigh = 25.00 // File search FileSearchPrice = 2.5 ) @@ -35,9 +35,12 @@ func GetClaudeWebSearchPricePerThousand() float64 { func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { // 确定模型类型 // https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费 - // gpt-4.1, gpt-4o, or gpt-4o-search-preview 更贵,gpt-4.1-mini, gpt-4o-mini, gpt-4o-mini-search-preview 更便宜 - isHighTierModel := (strings.HasPrefix(modelName, "gpt-4.1") || strings.HasPrefix(modelName, "gpt-4o")) && - !strings.Contains(modelName, "mini") + // 新版计费规则不再关联 search context size,故在const区域将各size的价格设为一致。 + // gpt-4o and gpt-4.1 models (including mini models) 等普通模型更贵,o3, o4-mini, o3-pro, and deep research models 等高级模型更便宜 + isHighTierModel := + strings.HasPrefix(modelName, "o3") || + strings.HasPrefix(modelName, "o4") || + strings.Contains(modelName, "deep-research") // 确定 search context size 对应的价格 var priceWebSearchPerThousandCalls float64 switch contextSize { From e390d1ab1f7a7590419593e6393c65a3efdf04ae Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 20 Jul 2025 18:30:42 +0800 Subject: [PATCH 046/582] =?UTF-8?q?=F0=9F=92=84=20refactor(playground):=20?= =?UTF-8?q?migrate=20inline=20styles=20to=20TailwindCSS=20v3=20classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all inline style objects with TailwindCSS utility classes - Convert Layout and Layout.Sider component styles to responsive classes - Simplify conditional styling logic using template literals - Maintain existing responsive design and functionality - Improve code readability and maintainability Changes include: - Layout: height/background styles → h-full bg-transparent - Sider: complex style object → conditional className with mobile/desktop variants - Debug panel overlay: inline styles → utility classes (fixed, z-[1000], etc.) - Remove redundant style props while preserving visual consistency --- .../components/playground/FloatingButtons.js | 2 +- web/src/pages/Playground/index.js | 44 +++++-------------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/web/src/components/playground/FloatingButtons.js b/web/src/components/playground/FloatingButtons.js index 539c53b3..87a3b0b5 100644 --- a/web/src/components/playground/FloatingButtons.js +++ b/web/src/components/playground/FloatingButtons.js @@ -80,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/pages/Playground/index.js b/web/src/pages/Playground/index.js index 88ebc538..f31cefb7 100644 --- a/web/src/pages/Playground/index.js +++ b/web/src/pages/Playground/index.js @@ -371,28 +371,18 @@ const Playground = () => { }, [setMessage, saveMessagesImmediately]); return ( -
- +
+ {(showSettings || !isMobile) && ( { )} -
+
{ {/* 调试面板 - 移动端覆盖层 */} {showDebugPanel && isMobile && ( -
+
Date: Sun, 20 Jul 2025 18:54:17 +0800 Subject: [PATCH 047/582] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20feat(header):?= =?UTF-8?q?=20improve=20logo=20loading=20UX=20with=20skeleton=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure the header logo is shown only after the image has fully loaded to eliminate flicker: • Introduced `logoLoaded` state to track image load completion. • Pre-loaded the logo using `new Image()` inside a `useEffect` hook and set state on `onload`. • Replaced the previous Skeleton wrapper with a stacked layout: – A `Skeleton.Image` placeholder is rendered while the logo is loading. – The real `` element fades in with an opacity transition once both global `isLoading` and `logoLoaded` are true. • Added automatic reset of `logoLoaded` whenever the logo source changes. • Removed redundant `onLoad` on the `` tag to avoid double triggers. • Ensured placeholder and image sizes match via absolute positioning to prevent layout shift. This delivers a smoother visual experience by keeping the skeleton visible until the logo is completely ready and then revealing it seamlessly. --- web/src/components/layout/HeaderBar.js | 30 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index a097f79c..a2e3986c 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -60,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); @@ -226,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); @@ -496,19 +505,20 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { />
handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2"> - + {(isLoading || !logoLoaded) && ( - } - > - logo - + )} + logo +
Date: Mon, 21 Jul 2025 14:56:49 +0800 Subject: [PATCH 048/582] fix: page query param is p --- common/page_info.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/page_info.go b/common/page_info.go index 5e4535e3..2378a5d8 100644 --- a/common/page_info.go +++ b/common/page_info.go @@ -41,7 +41,7 @@ func (p *PageInfo) SetItems(items any) { func GetPageQuery(c *gin.Context) *PageInfo { pageInfo := &PageInfo{} // 手动获取并处理每个参数 - if page, err := strconv.Atoi(c.Query("page")); err == nil { + if page, err := strconv.Atoi(c.Query("p")); err == nil { pageInfo.Page = page } if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil { From c16882247b320a5ef2c9212128bfbd9effa0c48a Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 17:54:53 +0800 Subject: [PATCH 049/582] =?UTF-8?q?=F0=9F=A4=9D=20docs(README):=20Add=20tr?= =?UTF-8?q?usted=20partners=20section=20to=20README=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add visually appealing trusted partners showcase above Star History - Include partner logos: Cherry Studio, Peking University, and UCloud - Implement responsive HTML/CSS layout with gradient background - Add hover effects and smooth transitions for enhanced UX - Provide bilingual support (Chinese and English versions) - Display logos from docs/images/ directory with consistent styling The new section enhances project credibility by showcasing institutional and enterprise partnerships in both README.md and README.en.md files. --- README.en.md | 20 +++++++++++++ README.md | 20 +++++++++++++ docs/images/cherry-studio.svg | 55 ++++++++++++++++++++++++++++++++++ docs/images/pku.png | Bin 0 -> 51388 bytes docs/images/ucloud.svg | 1 + 5 files changed, 96 insertions(+) create mode 100644 docs/images/cherry-studio.svg create mode 100644 docs/images/pku.png create mode 100644 docs/images/ucloud.svg diff --git a/README.en.md b/README.en.md index b4ae921a..fde6633a 100644 --- a/README.en.md +++ b/README.en.md @@ -189,6 +189,26 @@ 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 + +
+

Trusted Partners

+
+
+ Cherry Studio +
+
+ Peking University +
+
+ + UCloud + +
+
+

Thanks to the above partners for their support and trust in the New API project

+
+ ## 🌟 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..52282c8c 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,26 @@ 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) +## 🤝 我们信任的合作伙伴 + +
+

Trusted Partners

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

感谢以上合作伙伴对New API项目的支持与信任

+
+ ## 🌟 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/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 0000000000000000000000000000000000000000..b62e37cc7726e1ee65e30915a1baf280d7fb5c06 GIT binary patch literal 51388 zcmaI8byQo=*EgErF2x-}ad&qu5Zql$akt{q0>NF1ySo+(?ox`oLyKD}{__3F^WMAG zeeV2`oHJ|hJ)dnenSJIYQdLm# z-8Gy(x_gujaW%bEM{4H@$rJP)? zD0x_USu8lXc_{e=SUGsPxCI25DLL6W_}SR`**LgZICurQ*#$W{DgVJnZa$9gX5K80Zq)yk zAZ_Jl;cDaTZsX)g`Hw_1b0-gX5zrgbf2-i&{6AtH-Tq^yH-oWxn>n*_u(JQ7(tioT z;QxP82Z#Tmc5_#^`oDPpKLxvK`Z!y$sav@@dAM4Q#l;-8;Qo)|1Yn!lZA(am81KAc`g4huf+e# z`%gePIKPc7ZRKj?X=N$v>f}KAFU19I{&y^V|Es+J@LK-wSor@}UbZ(dZ2!3T|KaZc zcD=>WKbQX#yl*%E6aH3?Z!z!s7Tii7#})yALBfZg~7XLcSkN4g}v112RqSeM-0}PQe*{$-nOT1D~|^ zaa{DS8C`j#GX!?sdd$Y2OV%vLaL5N8BHw`iU-_Iw5t%>y?Cr<2OR7pE2tknmxUNb+ z7ljf(0McuK12wpPlDaZ8 z0>JoRJU~q_pkO5<&I7y*PLQdvn9wk>#8RVj{@c>~+lqHyp$X{RE;*qA1fAz8mi;I( zen~Q3APuT0mMNjYPRKgkmzo&<^~BZ*16_IOW?=iqiEx~-n5iuFFNgrGGgO?Taj*Rb z(`b!u3xEfOLgSC^?7M-ab*r)V12XaiWBMQkY3cWuosVtzN2y!Mh+u)CiS}vv z)3<<{`(cPkhVxx;7R%@q+vgjgB9R+|@Un$tUtB{w8d%_{)-=x7@+pnyx~gzu_#;p( z*r+z{Sva#wJm8P$r#)x*`{ly0J{fnv0Ye8e9K$ZtF};$tPdVolzq0@3u!^ZhQ#Y{U zNB3+7MBk>Ce9zq`Xo4GCk9Yr)?HTU={Z7TEPSr{u(L%nv(Uo0&f1F<5A`2ji?O z8RQAI5`j!BxUvGq=51Yd25=^tW1C~M6M#zske8RSG-X&A%Q1j%J`P7FTKz8O1l*Dsu3RV)|CoAi*;e#`;X z9OOeVJlI=4@e z+dnzX;O!I_q>$;gB&CK?#X-J5G^S6{!QKXes7cwcWI)z{-$c{(0Vxp?@*`P-BYs)A zmz<_~A~V!?5^s)8;GFW1fMDJ9drYj|HUTPjf^fxq%}OTlT#oun(6lULGSjb&>jIvQ zU3$4*#fl?7`^j#aMfoI0F`nMmS4OmzV|3>Ra9h#DLGxa{v1Ed{3rpqfRcy}g#jah1 zLsjc!MfyB$bywE(G9TVoX*;(X^6jVwP5)FP+L(YU{2P=Z3e$WcqaLYNIwjjqcFEe- zkIgspfUMFF8NWNF=d?A+k-Wl`xf*_gE_g}8#Y=Wh#gt^(5bqY_-iedqM-VdN)6Yb( zIn~_ec&s&`NtbZ-fK#@!SGdxN?Z-qbs*BN0tv$ppW~7}k5X$goJ9Ezk-RKLpv#0KO z45jNDBS|$%KeqAyKB1Jkv_F-?2+b8+BW81w9rO5CN?^NvBFK3)!r(1xUs^5Hh5yH7 z`cp1Q)A#&4@%iLFx%;fAnRQpb^dP|*DpM4ngee{?iJwS9?|~!-A&Qv|n2002Jg3MA z{AGuO_U!Q6Pyt>267EBm`0aVdAcVUmgct?YOauGXI$_)G&dTE9I^!z=;2~i$B>Me(K@oTOgJ!M4X ze*ze}?%4*Sz2n%Sb^A^TK-3JGPp?HyQTDgr7m}rXdNX?Ou8$afQXIFY&oGSf+<)cu zh)g{M(1F89JXc@SF9RyeT2gu19!`(3J@%VplS5Q~(0(G(U{&vRzxec1Ze~c(IEVco zJA8~!b8VwFoUdhfJ6$iCpw?mrfz;K-d0J`L)c`55lsqX6QWLpOWu~89=npA_bqDBA zLub_o`_?Ct@kBVqa;%;^Kc~2v)%Y3745QJcB;nq7KJFVT|2a}$sDF`rnltffqFEU# zY^#=On+yrUTlouaYMrB@!(AWN|TeSF32Ai^_ousa+4pw8~2j3rUU;^iZ=2t&S zH5H?5e34ld``Vqi`rQ=rrZRUF^l`fv=FFnR;x-Gzvz*|~Coa{;QgKEM2c+~tj56?{ zu8-h(UgI(lfN*58J}cw?eA-3po(-T|WwPJI5nZsT;#=U2SZK8qjz<2fcbH~WVz@n^aP{CTO5(7laNrDOMvRy> z+C5pMYp%;vEbm{E)2&QiN@+=R_DA%{a(=&G3B`6ecv*Dt{+6V7ME^swgBLe+il3g3$42|!?C*QYF7M!O>na!!GsIL5YyWV*V)9ru1LA>p>%0{Xvztu~ zsRd*q5I^GNHh!~jQlMX&2#Yrv8<%@`=GL!4+HeE_sYS$WXk+LH9+zm}EH z1RJ_SjZg#6RQc+i0`^)1z}dK{r1qQ zsc}|IPXpr*WDW2_c#0Oiu}AQf`NsjD6UV{#G8GbUIjhV8MXC6_u|NQBuN*nFH3`Q< z{*%Wvzvt+ZhafgXo%Xe1qzeKVS9%f?4Tqz$^E6r$r?I@}I`43R918X)E2__lKq=*! zOUy402?#DVJh841sm`AoOYXit?ucu&EaF;wKo(PLHUcqX*L87rDc-R?+U!bL2DepY zPr9^@xzoH&SYvz83CJ50ezHo~5~097xblP4E}%zKyr z{{Ex>Blq!!iaHL6=Dai_`?t=1-LVMBhe=cacM(j&9-br0D&i9wUrjb=TMZr%uGiDO z4#lfJtYd?!nQ~hGQnZo%Z)iJS(=Wwq6_x8)C>K!;`uMu4UHG=)G2ws%6@qEtgV!ua zPiY$gbV-W}7Uy2ybKg5ueS9GDHtJDdKMeEDJ+jyQo#=HRl@oRyUU;oOa>c#S@))lK z1by%((1_))Q}7e7lY%$ZngOLKAr4w$7crf#j3z?8u-Uhrz~Jj0lO z=`G39#aw{9CO0C)0;MAE;W$f3b>f)E_~SWZ4KP2 zN&I_M`c18=H4uCl*6?BRlM34@mQ4vdNWNwIK2Zi$bOTaoj~U+Sgxasi(n}c3O=g7`x0pf7b->k`D z+iT@;Btjk1ov?R=9KoQKjqUQA;?`vki07Vjb? z)BsxdK)iR)>+ddpdI|sNrWsF)7%8d`X~KTocfU^{FFwLzaC)@3MqVf_s=tYFFdlC@ zM^ujj!QW_vSx2i{QbuZqE9teTl9aO0R6}|2^m6T0FWziFO+95~kU+Bdz56b772oj3 zMs=ZbnAh;s56-8^hB{DQx=zRQa1srz0+wIr*nTtU%Ew0C^LatO?Qgz_)7}d^yNVYq z!ZyH>Nmp!py?#|t8=&Oj8u4EjeSjEn(S@~+Oh|J_h zrfpl=5)dNU1=eK_E7_gqv!atpWuR9{XBU^kKxp;S&3MQ!%0?15N7vBiEB-dN*eX)L zQBy|UJBdN7HQ?8kBt`aArT^=~OigGu`5KqyiLRY}*VYyIOuKXUix7HSACL((?bb|H z25KfXQ}B`(R4p#iFLmbd>PSnw>bqfRNT=^9uv_QakLi)qlo2p^J$h*`uPx^*29y4_ zH59%@q=_r7XKMEJnv-NlMMYofCk?-YZ(jk-uSFXxI)~<)HUjc~D ztD&eAYnfZKccs?*uclA|JnB=3uj!x>7PPUkc7mkP$!`dXx1V3d9JQw0qjw%fHbrK1 z@Rjq<$52iED6}5|9#Jfo-6|ZQ3UBo!`nErU*JKgqEzk{O=LiwGnrmnXg+$=iA((-M zId-k;$b+}>NOD^7wnr&1=Kp^vD^%=TA%+(Rz;TzQ^n{Lmq1|q+I zc+3h@-u)9qQObp{gA_%q?mRKdX``_P&*m`vMcF{kmx6B4%bnQ4U zrHpG*l>&}LD=gG=!C@>4YS&CQo*WH3EViE36-zaV!^eS-u~Qt zGAssYz0G*5j+l6Cy?mZ?340HQWI~$+RVV9b+ zR(|Z~!IEmpE_x?q-mus&Q5s@Un5O)z4HhMJJurF@jvw%l5YC#8d;5CBgNAwe0jG@z$cNt_IPY+Qd;&+td);IX4 z^!q|3?}{0?7(w|8+vv|d)MEG+wN(+oIL5TtpT?HC@Mkb z-{V0@xPz6RPc&lNe+2Fqo_^*+(x=^GKT)70|CQK=9LRHD)qRq1TySjdXf|`;^+-Ct zbu=7tjnyW?574YOIbP=nY9;{4I0B>S_|aLLmb5x#&0(a84VN>t6FPLl8N>S?^U~x< za6vJPn5^s+ujAXZBQ6M^Z>Nh=zqL38J9u5gINm17Y1idh2j%o+Fv!t!BJEZFoNMco zh`|u&l2T_MJY*Y%C2){++`L{np45csuY56*R@)0Tm;EzMlUflsQTN0M}HB=*=zmwl67Y zfTei|VGK7jl0LceP`5x|#l#A6c2iE1vp4dhqPJP;hBvwxdQ+^MLGj_%8{;y#1`~EH zp}vuM9CLn9{AI}`}74-{JU*+;wwrX1ipe?QJ}n! z<*)e9mfW!``dM)Z1}|v1;!`Vu?n&!iguP^u%VVE==U0!3U-*DM9R)C-)uv=XqmEwq zq(PoF^^_aT@#sa4t;vm0>s(m@M*_9-kmqpZ`#hw!DZsVMm-9;MP5`kkIF(`K7R~SE zR(Juhk&eE0L#geSZ@^j8ct8ty=Z!o z*_F)Jwf@31lI_>QqpZ%11@7RE@#p<}fH1-Ye!n&$FCw(;*wgF^h#Rd#T2499tOLIFUfz+CAp*dR_> zwP+0)Wv)ZgoUQJkToEAxF0z{{7kuH0n=|~&nF8MAdF6a*DOw+rSROsRWrC~jT!{#* z0d@@-ed`z>w;24ysGM)@YZ^`5j+{nnL=>87VDCeNe-!g zW{wF?r`~qFYGQ3H{WO41uX2F%+j_0t-4`-g??_(#zE-vIHoRKbX;dl?XHpke$|b2~ z)3IXy6QC9}O-;MoAWUJNZaR@(@nf~1cndQ4y!GzzH zd#3~Jd7P~_0)aBxBD&R2jtC~#<^dU%WcY5iXApU@r!cyJOzOaAx<#CzH`M}zjz^V=Sc3)j2{opef3oI9$oIJfp_felHPJO1rY9!h zi@PPP{D+_u2dLfCS~-XKu9n981Gbd}RK2MW>U6U+fxGSH9ypE9Z^Gox|jc;q#*X4Opx z&E)KpZ+URv!QBMrY=1ZX)4{Yw2gqIztuj8y^1;Psqr{wN)vCcneYf<>o`6l)GB%?!-!gwV#S2Zzn(m-JlBov@EVu^>e*>*MSphC)G{0LNMCr!?Lh+RR6*=fP z-o2;tW5!Q>+n(VrVQiYlzZ#|Z_BkLZ$zJP;IXF&rX#up_0eG#;-Hab$po}n4zZdj^ zd)|s7^2oRbRs9!#Y|{Go`K}w<4Wf!*HRBcWT;QGzX05~JmWk14&@R*s@{A->5RRiK zZ(!UuW%Z!Rpd!~+TdRj_RA!1e8}+>BX_$ptUn130z5RAC%l#G1lC5tjFd?V?P+WOF zZaouGbzG;PUC6^ed;xcH@f-%>STK1QBCf!}JmSMgWRI7xj^DT#70&pEjY~$0p2Pv^ zF!zI42#$4-?uznf=BdqDegUiN+oF_%>L8z!3tf+r>U17Iv-a-;c-uZ__0oKh~Y?GYtnLPNtBy zu56~$A?@MXd%iZp8lzvRrjANuf@L=Bo8>EKXI(a9j6PTnO~2O6^A)&Em`AtP=LzAt ze$~BW(LCB*8fe!VfqyF;nNN`zX|006I-Fi}D=dla>#}9es9G}dupT6}JWh7ixkPYy zrVqwc{Z`%PWXHQf$JDG?hVak0*yG z72+M;7AXYbwHKFW?J4Q#C!C7;aqeOkoUlPGZ8#puROAfoF~(5n?^UEPSQCM(20@;D zZd0e&{($JHi}^oX@*&l=ttmI?0+FJs$!@5ZnkE8Aff4AW=KkpFQPtO)w(zROjYF94S(=`Uq5#JPVUs0*Yq)t{~X+E`VB3uSJor0&5|y&GY(L*r3UxaDQyg~ zPrpTr(K@v!-W&d8&*@!}WrOd=l^$oK9Vb^LqUrG!CuIwG5`3Dfy~4Z59I6gc7xr zErh7as(tUN9F1n$0?aX~>qxnJA<(}16osq1K-KZFkMza+M5hjxl22R$%4NMOooCq^ zP5bdl&oHnzV{Hq6Zt0t-C%K>i7(NO63ug1Nf|9EAZJ)Ys(Hy2c^M9)3_w9$9Tvq#e zgW2F9{(vw!`jtU0cceyXySUbs-sx&NXqA`}WXL|V|iPUeO zkK|7;mJ_C>l6l97*g}+)1LCA)JL@?u0f<`&gOB5}kHVf2Lzk4*NPa$MsR1(oo>GXe zMzen%xki*5d>`rvT=MrFdwBSuZBIMmPMz4Fg@~Dairchliql=88!K-X7zH1K{g42iy^w~fxF;yVlN~m1G1P-%RjDpRIf*9yR1UrEq z6*i?%$9@W71q5O7<{|3*>YT--!{CZ6*hS@m`d%2%(v~Op$#a5s1Pwof`Ej(I+yN#c5^ zjgO6`44s{6MA(H#A`WP%%j!z)zctyl+sp!1TVu~Lq~Bp!H`a+@{h(!XAz^TA>~NRI zfNe|uya(}VV2E?J&*~Ae4lPy?!qDCm4amE_|{3f1*Srp^QLy6yM3@9kDMgepk6&yTO9wUY0mx^T+K0s0?*vO{?|!M_V;lr&cuc? zv>f{1E=B5shQo}%dha$8Nl)%$+bp3&Mh5#V+vKj}>^>+)yTr+HggY?K-WH*5j`&vB zo^$hU@bCQ?cq>tH*}q6SY;-!o91- z3$B#dHO%R3a1fIYw+|ki^cGF@6BI4Zi95N<*jq8pi_8|}(T1x1OMq_`xwoybH2OJlwp0RM?bk57wvjTDo zmd+#L#khFkwmXxD375f4dQ=pbgk) zKB~eAg-9I_s;QVVgBh+}48H9j%gkxp)VfjNf~3%s`JrGXk|O6 zFB!YWXR+cE*S{Kde36)!;PJi_``g_wiKK*~ZFy^RNaFlWPIbFq1Z$zS{JwG;w|<~A zEuYP7kwUa77vYIl$O-qamm!u#V-cUv1|v{&UpLJ3gS5hzKSXR=IK&=cmk5Jle%!Mf z|ImkOc?~ABx`$9bywNiq)q9^hN)@GZc5|u(^_wrtdS2tRh;XX&E$G`X*{ME%%#DX> z@&9(xF^p~6jxR=2b&qZ5nEC~X!Q%@qqh7?iikRhm3}zT%H8X|mJIiQ?SWD#pPWXaK z2JCobvuk+YRv~=SCbA9tPl{v0XaIZwz61+MO6wZvN)m)v>sdPwd~(4I-MnZ15w6K> z%=zwn-+P$o8Q5Ew@hf%=Yq`s)&uv}Xd$djUt+uvN8(+B*+A z`et5}m86Ch&R2+@`^Pxf&UJM$41LzdMK=lrhMT^&3g|FHY(-7F407!7=bIbJ9kwRIcU5Ials=l(~jm!c9PnV9I3X8^&(FtVXN_y7b7N%*~&t8+9mg9;5uOI ze$y;}NFO;IAWFtl)nW=x{2PLi*KFBZ(X3^cXxwyaVFm&HYz3f2UI=lxPrH?nqetU%VW^qtEl0bnYN-uROdJPeqsBzGPWccBXTcTXja~$;X`giT-9wF z7|}GEt*m?}3zbv%$dXjE`-t0Y`;Pnls}<3Y;aG#5fn?Yh_Sty*Q@Trwcc1q@z*jJw z6YewsO5)wUk==UEVPZo`n0GdxC^1nGuBxqyG4axR{8sRvt(E6)_r-gd0EFCLNeObtlf(zFm6yS@$+Dy`i7<2Y0f;&{zt!;5LrzZV zwc(OD)8$O_y#*$S;{i&&)z%IqO7+LEJ6~#4n@igQs0RgV58drktz8*DB{3k|^_BlJTsW!My(-Oy9@s^tZ{Sq$fEOr;k?N16t zH~m$D#r{R~?P!rUX;6#!M^bVeIRg`?l17F;(7n92PTCh#?SexOXBGAi2PUvdt9V66 z38^JT<2w!9beJF05SS8T72OZdeEs+4<;*(11uTzD5GN(-CH5TJ-pgwaQE~fMLq;O& z{-gNwQ~m(LFBJ#z=Irp}bK1z8ME>4xY#;qxZN&!xqZqnj&7ZtS7)D5%9HSLO@sQ>g zD073vcz1~6*YE|j#bJVU@vyX@g0~LxgS~LNX4kMlnR5W27(JEBeJoD7n8d1xP2+=$ z!QDYiEh!)5QC2rFC%9aQ>9t>HaaWWqS@@nB;n&E;Gg%`QNbly=-5cg;+txMew}4=v z7iofJV#Kc^lktD1%V-a6c2(CZYrBp9tiVhhp#8^-egBc|F4yalW>!+x1|H zjOdK32WgoaRaH-HFRc%25V9JRwx<)TxOt*8sj56#lWAVE2lGn&R4VPe5MICN^#{>g zFlj`&&~uA6DQKHlNRwKodfVf3UV z^}B_1hAqUVGu2D*bJC;>{!mO$2qKP|kpv z8Mvv5o1}-z4liS?1=Ijj+hks&Lf^L<+Xr`Fx%Ro4 z0>x;yKf}~E6s0l(D63b$BY(yIz>JqKw!QJKsEp(&c$ij%p}G<~Jyh`iC0y$_6shQY zk6ffJ-NkDiG1S(hnt>q1jPaOq#uA%9_*G`t!TRhv%LDCXev=k2!}1&pN-FFi&b5Eq$}^!7X*f!(K4m++~Zq$&OQBLnPTcbo&e6`3oZw z$}tXilToet9bIz5t>ddVXFI%RG#qAQw=C~&D-Q306FE9cC%2)t@ec+9RAoa1XKjhb zaG&Ii^?);>`rHq8g-hY=n@fF@V}vSY>R8FAuN^i#u=0(R+)<9ZOWt%NnV#DaHoiv zkfv|$BioVIa5#4m>|qwCbsPpqRt1-y%M$z41q0TYFDNw#FH?~0KS^BNN0Xi77c~r+ z&N~aS-H=~-kyWEl1w3ka+A+ZWR+FVIv(UgAn2bVKY5PE*-9huLEi-TthWMlIej){<8xvZ46pFap@iNi)}r|tLTqangCXV;7+H7&B?_-qU3 z2u&)H5Bjeg_hqBKe>oX9EfNyDPGbC`*92G&SzSvFUX#PU%V-2aF=8J>VELJbfo_tQ3rTB$*{Kf zeIjCUrZH}po`ya4r4ZVGD@fwq5B5F<^AP`Xn$>->PN0ooX)W7JJ@gbV)v=R0Qj%$7 zU_0zMEO5zT5l8UeJ_$JkrW8=}29h(R4=Ots)x91kfKW}pv;o?rsWy;b>Yt^SV6pAB zBSnJEJA43oP=HMGsTGVGdwt(5U#%Q|d~e=Iw9muuU@8Ou>RnE-?@x`NUrCQb=Ow+$jx$v?1%zPex)UVU;B}Ry9qD!J02$=oYI-9* zddeR|%7rvS80^2IXxR8=W4O3_74zdM`H_KEu-pQZZ&5Fu*_iko2gdJ_n8h;_xSxly z;#^}sCVCvY`N&^lN1>eJm;zNl{&=@)wpnsfSGUwRb&o}5OVBN*kKCb(kBL2u9|JgE{!HEl0|~l-ZX-4EJc20XHgG?4nszsm ziF79sChQk~bUhgpHh#VRt)OL)O4;;G?~w7@ zUK@SG;GGuUIZd&a7GKt!#)IVKeiLn7(ULlPpByKlD*aya`=R$Kxj_4&&#Zku=QqYU z#l0!x@1`CJLy1HhIcYQqc=iDz>N!HuQu5M?$g}N5^^A^0u~F0?-VfCW80+BUaBCP)}ri!x~-}gSvNH!4s13}}qICZx9F5dgmfXF;0erHeC1_N7*odHtv z{tmH3m@G`sS|O=;RtoF-k0C^35%rUlYKk1kU_dE-^kGlX;opAhb+mT7Yf!I*Jfbd2 z@5WQ|IBILu@~AID-ekT&3!AH>J&yE`F`utbmN3Rpml4orE5h@;?he%nfKazq&o=t(>LiS{>G1MS^i%l6DUp34-xFd<+9Kwe-iHoU?!d zlUYtl1g1iR!mrDM?YX{%(}{?EgZl z#DHoJ&Yu$teU#DIx}FIU0=c?VVq&j7)RZH-MiSGbzFtuNdOIiX-W$iB{P^`XW5%my zJJ4EVyDIQv{CVu|l`QrcL4cRMhVwugl0wv&Whky(zmKZwdF9R^UA2!RC(E? zF4&1@iqT4#)xo19@mzmcaqxDZ5TVdT4?79@r{|3vyuE^*lDBs@0PWuyygu(2VX=|a zWsZ1!@l)&4iiNJdyNR@eMWUDq@80k6ngMpN+S^oFt_3ia^7H%R~%23!NLu}s$Ng&y|zebDtUZ_uQbpPBK>~BY! zn(;f-HX6@c%$6LW|46REX;z-8U&-9Sz_X(Av?oPeHAJb`LZyf|alS9vi#(YJr7RR4 zv}S6AqK*kYz148*F#!KsoKQ;pSo8KK2EY0HKP({)oiHT>jgkXwdP4zOlMm(G?P($8 zBGt`g_zBfr(cGt?qyw_H-53=YMrQShVtw8%zB-!PPc{C+rG=4|=hQM_vEf>Ny6I9e zxa4!5UjcSjdcG@z01nTBHN{ZR`lYqj_F~-cxWaNYJ_Ll4_S!u%T7RM^=J)8dN76Vw zoEuLL1c(Oebl0m!$j(hu0#*N*o-qZqCn9$5i1M=U8OCcc7etb8_=uFX*Z^p6eLjTg zb7fHN-uoC3EYg>eLzFn9zSwKXur^bC>ke#i9A{p&ccV7Ez}HBY7OC~Yju0RF`Qz8T(AS@YHl!VS2beJ3d6OyJ-IV5&+hI=};1ow0k?c`&;QQsdU5gUZ-=7J9A3O%2)z^TzAg(>WJXvOZ)ZZp> zQ-{vgT>X(q40h{Ee;IgvWbqPdBS9Vt8XPAjnl@n^mcb1Q$O-wKZ#QM73k}HJ(q%DdEP6i!+%BKstCyC%C>tm; za~8T+QD#L0!SKj(tNN)|-`6;=d$_x1W7Y6`HGj z@L-qT5Lu9A<-}j1;av(Xe(`=2JVoTHqY5&aO6_Di=kA063rD-gQb4w(tx66%6>&%(AUQ-(Qm%{Ym8tflT)N!7O<7rOpz{Nq)o}l@TO^2U|v* z6Edzanm<2nw#lr#E0GQ~z}|J#uf*l$>3!#w%B_+Itv)r&7JZ>d&V$MGPdlV*L#-YC zOcFt7JaU0ymdX(C6@2fcwpUegv`hNRt<=6ba8ia-BXUZOFQfZ~x23zAd+#Df%TD~V zN!awyY?!VYdRuQHDKJt-Lo^w33lL40)9rx;A4pt7!kY5xM7roVUBNhnhQ=?K5NpP& zKlX?&f$i(Us|N7&SSmIJzr&Yrb!Ad%(p;SuZXn#Lapw`$%*Mpxd zP%VBGu2UwMKL{rOt*XKC8B)bVF4I;}by}h!t)hQ5jJhc@Jdip03);96H7?}a;(;Ad z3pmF**$I_=0L$E4 z*(w8y={;$|3&Mc%_2ftCyt3ieB1MF$xFt)X!7&Y?94^&YfRPE(Xqdcg22u>z2y#3fJ4i zQ_P;L@t)Aq7o_l8wR|sO5tU8D>$OHkPNP1!Zb>o>^8o>>tqXQ?KLFLDue4R}Fu)h} zgb7lm73$OAOog;Vde%H!BBgA(g+vLO!vi3i^a|sj?HPq^>O%<>;=iX0y0~3nV*=IZHN9mC5@h)l^)JF7%UM zc#LY>O=#j@MTE`WFYMy)3!xy7Jo_?fh353LsggD5S)j|PHMtrvNLFoaC0{1ajyLP1 zGQ!Fy6~^h=NS!G#{fn)I;&wRd>{7f%u6Z5oF~%4YE|}F^mDo$Ows$}vU zL_HEgc=e$RN2dW*Vr?wofGl7-pUYIvM*~6Kj`sp8>+29zP+n_#v3`&#ffAG!%%IfQ zA<(u*38uV2wc8QjlwvLOpE$Qh;1IfUgO0Vufb1AI=2hAqzYV@xutz`*+W_RYH7*X& zvsK;sg;OU5@}#$df$V{+b4-uh=N49;vbY+cKSd7m-62|u^jnDpiv*GH=v2$kYpI6^ zzKS{kHB1cCdvK2!*M*L2{;2!H9f-U{HVdakqnufAtEVGMcfdnol}+-12|Zaa$@=|6 zm(_KnmpHU83drcQW5kY5UOkfNAu%Q=KS4}5bk&-2`^sVlslR_&$cvoyyQ^S$bJ=i1 z6bh!cZ*O6coW0Kn&sc`OyjX!>qawma`%9RaOc`7Ju&(*N?*Qa|ud!}f<(bx4`}<4D?t6vFg~5zpjXxNr zeGSk09CUu(SY;iy%vR|E6FgPvO|-#M#MSiEvOIVmIKdmmeQ9bEcD%&{PGu_nVB3DG zt+gDw;Dst5!N*<1_5lC!5+xWZgf9H?p%=~eDrl7CX*cBj2i@MvqQPrz>GfnI?T+@< zm=<4P%m$+WGx#T>3STZJy{iL@Bza|2jdI8x{@-*!uw}Q z7;?bQtSP-j7B9B@G6O0j-BepfmhY4sDn!543Ir5z7x^_nah!xuRjX^uV*%(GR)H*W z1fv*Cz2Ue+^x;Ja@3goK_y3p0xlUfu;Q%6SpF$f3#&{%<3e~^=y0Dr)#{YKF0nBPl z;aQ>aJSN82vKI&xRX*ZLvg~ky z#cfN^A+51_rp35Jz}IwX8Q-CTy15&5rHLufRewTTZpm$%RtD+Gi9&{l^hv(}P`w)m zoCoaoEXbcT1ssbp_h6N=*(_iRF+6WDzyves1E%yV`D6c?({*2LqNYDh@~;&>)6FVg~*t0MJ zo}Y$%BPh1TX7{@{r}~7e)?NXX46=%C`Csf%+OZ^XK}h4fk09}#y#R|?h7JL@E(E^q zFAGE>!Qfesa}At;yaNtE6@bk#niTIVQ?$oi+_(HY;^=#DQALatTo(5a0OHum_&P=3 z!OlsAbS2DWf3I&WKsIdB{(?!fV)2FR0{pwz*OYEyFN;v5ERvV2#M4dE=J20CM~URA z&@9B`Y9U`)r+{1U>E*ZKAmXNPV9m>$O%ysAHn&Ghjc1Z1n@%g3wq4zG~mOOq}n4rO}k0rYxw*g>ojHEsVG1|%Eq<0 zDb{37qM0l(6EnbjcJ zP3xZGcDK0#Q{cqc6#RzW@wsJeW){@p?Wj{zW_Q%LuwdVs;?!<>^4PaaL;7Y#;Aaa< z?+(`YJ*@x#_VpovsY-4ukQX2beA~g~O0c@bKZ2!)z zVreGV(o%tQUW{=K-rmQX_k3KY#$1#dYz!fH*zN`28Z^eeJuCf*NNHu$9IuUybhv5V zFV$QVS**yLas2ZD22yZ2%$fuqAbgh~mptB_&*I55I7`2@0o5AT{|^*g@pJeT(bqs? zF}XgGN-lKte4ws|RjR*?N#}w(eM7sW2frxo?u6ChU>&JgNP2J|_5ThRK)9q`V165D z%Dg_)%XPBdu(?WJ zCC@Z)bKh*r2{Ik#3Z{zOl)=U+Vq2FG1-fqf2}qs>2zI2VTu%FB3`l>H+de7F%Xa4ehgd``=!?s6-`qk`26w+4Ys%KD ztu@P*+xZg~r_P+~D(-tUHK1Yv5e=-<3#B+0dGO{0b$?7Un%PQT7f&^48{cWl_91Q@ zBH7lk>3I(7S>SB}pb+Emp|n6<^bxp8AEd}FYf8tLAEU^X>RqYnn#;HX`qDh+<4hIo z)st4Rxx86W&R9)+3>RboQ#g0utZPp@-vc2~Y>65Js_lG#-(>x^W?F>nWk|Bg^o!3I z2&MocUMk@rd`T6@xP`iYp6gzvZ1dk$y1~&&O`e4LrjR~bxz|^`84woR0)^sKKK9=P zU@B+~F2Q59sf2+L>OL*wC>Ro7H+6TTDaV`A-;@xaT>Jz~mA`)==JZfSF4M3MyNo}@ct))BIs&FjBcpTdOYJqe;J@V%OhJ!> z$4{RSFm))eA8Ei8^oc_`cU9(%JpTi%ZvFO-xxu$Z4r-qw`&xiWIBny!*lt=0o(W08 z;9irr&nX)6URD_A@Jh&T#m_VcKjS?r|6OF%`7=6YVm6M)O1oRp-+FiS#fsbCnmU-~ z{1HXWm7{%1KNb_j+pX!PG_&&8F^=4cJJaQcvk>L{aDY7IU%k6@Vl9vFitmEb= zc|Clk8b?uuTlYpD1sMM$x{lK|6>A^d;zy{xG%Kb*t zXNnl*hDu+^Rg$CtdcBgXDTn^loyY!JDjQ|FU%;Zgu6gDoLt>^*;=Y?kpp$KAu6u^7 zI`F&v*}L*%Ln>{6`FJ`9AK3L&Ya+qBt$RW-&JtVGOSQ7K#pxK>p}AD$A1eiA8N?gI z>tQF4^C*Gv;RaJjofX9Z4OIELNWtykTune_w}e@{vm~(FLX(2$w}zlkR^0y3S8ooe zAAqiPnNEKeQ@(J-R5sQE^%$#nHR;CI zAUW(4^&rpj0=TG0E%-Eb zrR!g-FjYQs_Fa)Pn1b9RV5%@(4D5MvtSkT~m$(2@n)EB=--JibevZKuh-~&^vaT>N zwXjN`qMi(htUrhuI8;*{QY=;2fF0pfHinaT?2^WsM>2_U>jX!dJ z&KDWTOk1ON7|V0oIMWZm5q&1J1i3TMY|0&Gtgqxz9;dMNb#4Y=JJsmf5#xD}tKYr^`>wGBXu3Vg; zXm6~^eG9>Jcovur;lee04)yyVy~3n`y1cpn7tZO}qyroMW~inG_Cy|l?Ht|$3NK8( z`)eY{c%QEi3sx_O3h~O=2(V1fkE+%z3^-FP& z5vqXvh7~|%n}c5sIet+^R)I+-H%;UeUxd6oTm#QlZR1VCb}d%77Hb=9xttt2aD(*A zxo*Kowx>rs$$3wt@U`!e;1ndNoh?rmM+T64YXX!o5wGI-768CI=UA;R-1qMAedl*I z1fVK%SN2s4P%YbxfQkh`qOUBW>WLZwsxt*lIhUw7TDbCoFlEOI(-kK`$4+hM8a^}J zP6rfdsTY}i?XT!FpsMaxqDK=8Rc!=T9>ko3SPgRpJi>aV@giW)vh!4ad;+jU!LojG zwRm&$EL|0)uSq)J{lxb;qX?p$3ZSxMGF}Tgei25hFdaU^lYB4#gK{?ZqZFB(r;Its zDiZ;AbzcYoRP`k27{{1$RuIi&PJ+0sB&PkT_?&$T*fn`k6Zill3;cXL)=^v9fa*o= zqaAC|F9E3hOqb0ZNA;uts{aR6wGfGgaxY(0`5oyhI+?ljIz@3Y-D~))a5aS`WSEeO z)C5GeXPpU_gY8xM(mZ@``o#~@QpF2XqE7>+>V&Mwb|1s~3a0D4_yn*S<9Xp&!~ZOZ z0lXlAj*B^Ij3PdSYdr#0g-8WZ+00h2gd7iq_&XX+Q;f-DiehC>;unX)2L_EWQi3-E zDhs}um7VwJ19m>^2~c6@eCHw)*lDuT3`%k8Aam|>dHa;|AP2BK!`$ySMfaFn+TuInm0Mg={KJth}u;`L%&PC()rR&0Aaxb`mvjHll;)x~NB*MCn@S)9uhb?}T} zZtN{!sxS}*{S3qsdn<|&t`~VuTCXnq+eV7m^XAUs^8eB;)KPK34yQS~Mpe&03*ZR1 z#Iwu5BHNxpiydq>KIA14v$1}~mCYjtBJkf2j~G~oSIJA^cU`h~;bFNddy-9La^Bt} z6Jws89rXaqP7hXSx;X+Q0Az@Dg8LBMIPo4&In1GCD}ex<$7t#k*XA^MUCC732VV{8 zqs{@9rutt$fa%5_lXLUfZ3z1Pgnj6DP41Un9bftgk60wfI{>V|7E~P%*8ot>1!Yf& z2%JjUEzT2pofBob808l0{4f=sW~R*YxU^th^)Olu{$9R0P?G^FMecci#r%gLHzrLa06kgB{N4A{G5r0izVF}<4zu1>F#^G&09BVd zm^ijJ@AzeoAPPB;wpQdXJws0Kf%acZwOWsU))YH5(v%1C09OE@ns(i8kpP zzEuDfj9azfF zD{(v9eeiYFx=GL_qHnC3puMNo#GrXBe-&o6v&UxVCSD6ZcYM8*eIzn2tV`b&R1b({ z`m^A=|6Pf=mNiQJY;e~9asX4{5`b&) zYAHRp<`4NE-Z*EOvQ!zt6r_CuKvhwwk<9UAO^fP#To8o#HY?zldXvWw?7|DiT>;mv zRocy%S%WQJ?sPzT{Jb`CRm z`d|(^%`3S_PJ349;w+Z7g#z^G%c{0J_$!(+*aBNWDx!QH7SOmIRO@bh5RhA_Wc7s! z2rhp2JMQF|NQXF3iV*DAKj8V`%7naO8!C!hN}CiFK(DtZlm4+@tBaHi#ZYTw?9sLjku0dDcU$_(Df`$LB?KiX%WJ?4RZBZ%eQq z-oxQ#K}n8$oEMnB^uD8OzQk)968<8t|DGbL&$9w5Te%LSZ(TIOb%VfG5S1pB(v~^_ zRB*q88S_<#9UOtkNY1#igUi89sYlfF0cdfsD@?M}Ww;lVw?{e7>QGmS9r^h{wgF4n zJSA!O1w#N;cka9MtI6}LeUb5=Vef8_%~@0vil$AVtN;R5bSM#LdhUrmiVl)}z2N(a zOOIPA88I=HjHttlb<`lc+e$Z|e49|JejpIM>8+5@(&j~dN2kmF(ZRTK;IjiYNq z?;2>z35sIV^OpBnk-plz!`C6l%;e}^0;tN@ho@uUj$EKqI=U55H6lN%T;Qb?@piZ1u4 zIA9Nn)HDsQa~<3hKyV{XF+<0C4ydB>4w~Fwursl63EF-t^hL`re7x0%-wRMklFE0i zpmdG^RQA}MT{Wj1o^afN%;RIOzqfeNzs>bR;FFIFu8%JYzMmxabt`3}#GXWrZpuHJ zJP;BoHs+jLyvE8y)L&6-)8gjc*7h>wm(vup4iW$mdF36>0L#-oiU?JKF$eej zQMHB{h}0fPp}&E4|77LHn*|$>VAi|HrFT*zNf4k4ZDF0n4 zL162>fbaal_n$<7ty&6Vd)9Nsac}BwQ$+w|UWdoYlDgt&{1&ZzdSSZZ9 z?c;(83cytpsl!969C38$i(iKH)u4&W52z;a@lbjZTlHzc-=9^O6wP}NR#rmch_N|q zvrSl<>U)obr|n|u_x1uG^TUFxrD%7E&yxxsOO7Sb#DDM@^}(za~!KFc(jkhX8=IFm6>Dq%$lhh0G4CR?o;G;QcJNdLElvA zr(d+b#-xnkNfwz~OxY&nH+yV0qs!kRDj6ZgWd(yN$g2h4@5h4f0|gD~Fm9Hm)2Zaa zX8Sj<&+zm};@%DGZ^2hTYZPuh$J&N-UTjxQ(1*^cuT=y+GKOfeXF^Y(3 zV0jHSYdFP!yvM^-mBjzbVCz9W0_Ng#6aj7AhvO*)_o_aCzA)#jUcs8oyF-)qGRHJuXxfFZp{x zZ4D5BAbG?WVPkGV?(bN$*37zLQ=&fIUb%}EM%y4h7MoDlnL7HMH#m3T^(T&GO&0$V zEHhbL<4PuWeOztc?_2YJqkV1Ig@I&$RczchtOeURaKGy5=y^tqN?j^RVxgSK>lVN{ z^tK1R;?r?N&6C)e01Ng6276y5>6lpt5&+UDN%dq23GmZI+1p*3v=+rM6dAFvTW+ z1il}V^YczfaZa9{5y0_5^ZsX;&m*!du4n^hCxk_&NX;0 zY}z5N`#lHO+T6i4;A;4sV3<+~=4>$L!9#3b)~T_!{m1R~755{M8ogB3O)BObz=qzA zeq6z<&>%#6i_4n~BEQUmXeu_K%9Wy;ncGIkP*lsZ`wnGjovdztNM@|aT-vjV2Q+|W zS5p_;Mz9OT=|ewi^6MX6BTzMK1hAPUZ{_B9j(zofNDWU*ZCuYs(-sKpF)X8O$mXHmXJ!|+(P$Xfc z{nWwu1H0b_4xZ_eynwVO;EFI3SmHks_p{jR5Oml(R7sQIR%vsLJH(cP#AS~F9SpxO zz-5Dg^FfgIAkHr`t}ke3?3WxGHViHLU2E)J`C9D0!%cC+tn{dwa^&2q+!9aJs{l|w zuSvmuE<+#shAmZ3@*pZw?7sMCNFUByerfW)6gTGY3NKq+Q5N>UTII{QB?mwSkaoP| zg%VaL5Q~7x661Mbig|Lj2I|wf=91GiSFTsUdmyjT*$p`7%v7@HsZiRx$_2Nxazmv( zI>-L|yicM2PE#H&Bzb*9)T_o2X7%pEAPTIf*g($`_5-UHHjLf-V`P?#Rm&fY7XXNjyx^RjX~*VXj-KtdoZfk6 z)z&(Jzd`!3lO{#^$XdB~iKOatGqTPJ@C0VbCHZsaoNjrFx#z7#S`wesVExYM zQh?2iJMlD?axg$5H|%3%9g zN0+;%hV|1+5lKj&GP1rkF^GbzEJ!5a(!E@bAE!%^=&jl1aE}&;v>gYE#6`+|kSYS{ zkniFw*OO@#r5%9nG89dK3gik~I|jIiF>u>EZNMPfOcQ`TL%MLoPtIT|>k;G7gFRyV z$tqcVw`)l?dzYg4uW2(r|APsXGRzTMahWgY_XAKqtHHW=o*Re%%i)@c;Q{O8s3w_y zzN^uFk<0K%Q?AO%m2ziCiY~l+uFkO@;%x9EPJ5�)oGkNUhfCcDzo*DvnM0vak>r zX1#0|P$|=vq!Nl?=0N7>Gct6bDR?q*4W4fJdhMZ58>sj&yu^)Y;Ws zhiRV=KGc^NP!*+!4JgsrELt{=tVIVF0Hz>!x{4oZ0_A2=9yhK8u9j!>x-_bZs`9IX?}rVI6HQsn z)bofuz?3F8^^+ayPOs8ws50-H|P7DrreOB{L0`jPu=5NO?qLRd1rl7p3)S;@v1UKbu|tTMNLR5l09Ol_pkAS5-rs;-+?7k&=e{um zO8Q+LUGpH1@+rn0uV|xHd2MKC{e}%xY-GZkd_&Hb4Odb`yxuPifG^EEXO3xub(K4h zntjs}*CZ4qp6wd*Sa=vfPub6-xN{MAeaG+8w}q+?mf^dK%tI9vY@bz0UQLnzFBU+s zZ9QVj=^@AZcm8b?VO{}-U@_b}<3w%!THo2e|p(nw9Naz$qId50{v(? zk2X{D22)^^yvkL7GtYZPLCr8O;qh3Xhl4I#=g6YjL_lf|O(bIPsaBW1dH_=xPZt$= zLri)NlBbqMpB8Dx(uO2&4aYTbPc+b#9)&dn0>n@c&ApD}IcI@){;as=$qNjInw zq$;DW9yZo0o5N7%A&t=AJCKo2yRBO2+SU# z+R)9A4E1O|ypdVSis)GQsun-23r$;Zg}o_@G!Edu%o|O)UFqZyNG5 zTY1)elzB`+U^wk&ff=8v%%E&+G31FdWR!E?yZ zG0(>w4mZ6yJ&NH;Yc*Re#W_&$AK)C0WtEGSyA59KnuACQEc&GjPnZZq zT+?F(n%$L_G-X3mJWWdR!aT8K7v^AYkf8V$|1R2W5x=<*pzPf;bhKXdky&13CE>9K zQVL)lT8ZB+^O)AzCVs?sed72P?Y=l$6!6#7*Plx8zyPKo`{=<6&Nm@BHD;{>^cqbN zUl-(B@H;Q+P8jt}drhgy>J0aul`~xG)HA&}Hteu_-_6U{-Ks2dOonrh3(5uM zkt-S2>!Vb7d?D8771@3@1#`WD1;rj!=C=Wg zDA=>g@rnqf{wcWru@+`w8Aco~SW7R}^$8A&=p$GIVRZxn+4ZI@U#G7EcjY}ypE<0^ z*eU-+W>2`iu~YzG3j|#Ay9hw# z(n19#r@wiYA5wNUSQ~Cs*%Q3pZA$k#Wjm@i>EWHa^E+nklQ$-`@c5qtZM?m5tJo$2 zREk_&r%XC|V_Ye+R)x8M+GF#3)u6%_?R`RiKEQ>dt%+fBI|fn!v{~aho-fgOd{Wh4 z0^IKzTr5)X?tQJ>>QR->*Z&3Xw_(@Cx_gQWkMq=?yBID=)@06SXbN~QqnxYKvF`zV zT*tt5L$6rYK+JI)tL)?q(DAk~&px<@W70)>6l~JmrQ^G$HWQG-JOKCkh3fcqpo{gW zB^gP372iSyCGUN&)d~TMPcVIK&WaBPT3_?~Jpt#w!%F7U*7yFMH9L>Vc!%$KV%oSV z4r&A|(F_@%VT38Wd5sg>5VU~*ritPj0n!69TxtfM_*U^!SYgoCn&@0zWrDZ+qQ9?x z83L$aSsAU#|NA2zKPaD7+2Q+{j}MY&vQVKPEfw(noh~L!O-!Uvbb2{_<`+v=hsZT^ zXUW?c*2Xcs5p$JS!#rL!mLn9o>BSAA$2zim3~RxL5kSW^wX_R@36KfAmnF_Qr?m|+ zu6wI^Nv{E5@%#vPKz!TRwRmPtuJ|Ao$zqkkX8GovI;EEN4?1*gd@Y&rQi%J$Uei1M z+oW^NBp{_Iyy8ezOb$U;0#7r<9Dbr&jXXaw{)@4z5dNv4 zk?tM>rWD^}zAB1pXKMg`sj0HE=7o7iQ9M_gPKWxr#Q|!E5BA_pIze>3^lxeVQ#(H>zwTJ2ye;F3~0j$acduA2P^aqp)MZTR4 zs8~M8xFZ2THXy@J#OVxLvItx`2Dt)LWQoS1Kz4yGXuQ=1!!k4_&O%=M;rb1~%lO zC40479RVuH!KR+>;aJ0F08*F_2C?oqQ=RAlm(`owHvrWa)_3!U91HNXb;xgU>6@D^ zRr9m5h|dLCY3!+JciFD7zct0)%$F0OI>t+xXwy8`mgeJtjP*O*6`10kg~J%2%;2~m zpzh&oe}twT&C{~(N-?O~pTSHPHns!Ib)NEQw=C&1yrn57BTGQ>`w@WZ^N^j$tVvbF zT>k8_`ITxg(NuS*1udpt3Q`zNEMk2XB?s=sb6y_ursp6hSq`hxW15(`b|~<+V1j*& zLDU208D0~t6ied9yzLf7Y0j~SXluk`M!64;%i;N5rf~q3`bC;p2PaC`h&9)BV8D@O z6BCx{S-3p7=L6Wo!aXq>~8%n$flpjMyGQsl7^ZW zJTTyyP_N`0(_>?(9ye0j2PkDxfNE~;&}n482!(ZRR(h7UuAeq37bagX%_PiLwi{zA zzF~r$#vQXR0-EX!e#7e*+%Ev!MrdNo1I24<8gh2M+ll*5+E@;>#YiTp_%q@C|I=hX zel4XEVfVqMdbb zc=k7QxF%93U?XjabdA9I4r>}n!9YNh#f=#oS$lZy+&1%)OB@0!$WDy{s5Fh~YgD$> zA9)C@o?s9J3*SPl&n^*hdcj*6wc8h&+@$~FPUzSuX+Si zuqFq^kANcmotW3zh}$GBm;!6!?J3V4SLQnQD86f12CZ2(dhQo6m1{d8zN|YdxPAfQ z!DjvqC2am_a%X-{C6D!8L%Si7@C$;+|IVRW4v_pof%9DtMH|p(X8=* zB%&S10B8eUj0Z!W{Q&a-!a-grA(%o)&#I=~jyB~SQ}*>JjOW5?^HV*xMLUiGu>-w3 zha4YAf%}1hz@5B!!~A#w|F%FCg|&6`=BVj#)=ogcoLF~TR z;M4@9uyMCihReZgqpO!ayrF2>-yv{^d-;NO)c#w7Oatw2%DbEk7V`yk#%W9TxY%G> zJtWs1*6S#jzAfuMalCOai&LJcCMkVYk*a2BaQ^0B}g$i}U z@nE-Scn-KCE}a6jrlH^+=S@CwceVPitBDd|b%zKg*QV|t-vG)1VioU*P?oN$7BI|; z<#hp@F-Zh-*gBPoUlZ9!l6b{8;<_Z3L}5oC!$#NPnym5I4P2h*XT&>B$r~SE^E{7* z@llCmDYT6cf4UF1!zmT3HSebH#|l)|lp?tP)s7z>30T+7p-Mm~^Ln%O16~)Pn|0i8cZS75 zc?1Tr`4VPX@2e@n`+N1&H!FY&E;W7%XAIoiLKF`$uB9=@ofxFla{dii0bmB~iiBg< z&imLDfNJ&>&v#ME^D1(xm%$Xxl+M^;U$w;j1waqUYT@$pZOX~;4$X{$DW73>ZmX>l zo|lFybgb1zP&6w$f?##a%xcB?#9(zhj=|bxA&1(4;T=`~a%L6f;JJu;!^X}*igKQ1 zaR&me{zeuZ`!5Lr)uZ*=03LZ0ysSjZ*fy~E9N_EoP^?FovYw+`tZc5iftS*wBSdcI zaox#rbr1rwU6tvf3U;_v0~ zvhcJ(yxoZn!oc~0qdtXr_YX?mAC_0;p%MRtLH?x<5;HvB5S*HMA@ zeu0_w%p9KMN#EaV1Sqvh=@a|wn6VVh3U+aZXaAO!vVT(!78hXZzk<&3EBB*yN{b%X z)pjbSm5XHoDH1fVB!1;Nu;EsZ)3{wRhH*R?+!;g2qu(B!?8Ki82K;C**1vaH3WAaeNS3Cq@iV}K zV=qN+c;`3(Q;=%0Yc0Iq*Nub$s>O6bhXs<)DMx`HP_1p<^Lv$?K?{L~msBHB;NP!J zxKP=$yO%P-s=5D+igw?ZanRX|IL8K6_whTV0ad88-80-DZFa4$lBkv2_!2dFZlh{4 zj{BYp>6>G-c6#`)V?sCOa#M~oWtR}Db&SQu8W2#mufaXB5Nv7c`N)t>kfd|L(_XfX z0TGd~xeJ?4h(}1`Jpm$}y-iut^vg#$hM^kiz}_yOo8uk<=I(cR+4mZ}t*0?%lp;&S z^o}lz7?mC#rJZ=E!K$oxhI1#e;d%ArS@2yA?~U*qY(6e#>g!_1L|AcR0XH$@V&S?1 zR2=Z?-vNRWx@1ug!GrxX{&yXYRq@NcDK5L2BA20=LMAZmej{dSoeYnTp!rmE-29I4 zH(${~72Z?OIkw>0Zw_d$xb2R*yDG!|(RO!+S<1NWM{-|CD)(B~>fq}upB217EvedW z4t(RKAYLhEFXl`N#MD z-Br%(rycKrv*i~%>#kh?UTW}Wzb@rE6(q2_^fGO*w%54s$Z2Bq00xlw8q9O`l;h~# z8r>JnwJY)Fqf<(KOqe%7!0YbS-!igQaF5D_L-@8e=K9$ECO?Xkpjo zIE;rCSsb8zBfk<7y3LT`dEraCZ-(>XBkdx%_$tmZlXSs*Llcw#~WYRC*t3G#r?cV2yf`7mYy~ z!PKPn!gRKuQcj?Uv$CI6uoJachV%DQZhVM$<#$T6=>EhLV-}f#+w^vB(q{2(Xp6tPwCC8bONk3&%!EznH9yNpg9N_8KI)vPjL8>Gq}!(5-BX`=kU@$9mC$ z>(wkpo96L;>~gq=N|#TvBF{H?H%PlN`xmc8&?V;bNGE9u#!jBgu|aUY3XkUjjW-y9 zm*U)z@7C$N;{EO^=yos^tj!?^|KxZdgnY!k{?hdbsK(Y9P(7qtV;VNHIusD|$P(4t zNH3+8Leoy&Ng3~tz*4X?^~#cDlh5tN*UB2KdXi4L3(0j^ruMxlApcP%q42+|_(0Ym zq85>O0?YI&Ik|XTA=N9A0Zh%v{S*CY*_4;T9FCM7CsaAE8cSZ!yxx;@$qdPGi6mH0N7x;if8bMso3SJ7r3r#-JXzv+H(8g<- zcX-_QY{&!hCjW~Br#%WB6Yw>*Ne^bP5Ft#QI8oKBT-wr-=fh(M>`+s#_7bp!YI0$H zFvg)^iJH5CShGZmRitO-xqOujmjV0Vj$RDw53FD@HUJjPiFB$Gg9`6~t_e^5i#J4G zZqR;kqxsl6;3rsDK7L^yXVlK}KL2B# zGmc;mD7|M+dmm!_R@2~J-1j?n#_uffd2aFnD&&Up@48`E9w4SujqB{llwb<}uOl;n zDIog&y`1t9FhJXgecsPe$y}c*f_DQE$l6J1_xjfsHlT#)ejO&JQm4GRc6zc%oTz6EIi*Mcxm%>KpZeDy>x;XPW za`oi`sLXTz&G{amNw5aO{bNT5V@d$oT%+8rK){LkvrK?x7j#6YfeYdj%wS z<9n>E(z{5l$?uv*hN;>oti3M4-ESNI{7(f0edjD|W|)M^EleI0hGzs*iZ={?PH=pjc+wT~tOK@w^-%G=E5J+$_oHVUxnID& z3NG)ajPWQ{$43IDL_+!s;&l&lJ1tFOy?w4muk>SrWBb)Z8?~#VtZh(mhr#a582)!j zJ-P2>t+PcRlqP{MXSyDo@}BT#UCnECIV++aaIe)=dSBY(`JI#A_g}{oM%qcn{}1|L zB`9)C5M8-BW_6t`8Eg_+zkvajD@)r)rfg{1W^)IwuyM}6v(9LDMQS&hv}jtl;6jY# z;?p{~?B@*1l1Q8wt>yEs4_;gFEGLGHe^{kv)neK2?w$aJ+xm3jOQnjl!8N(&9R&iAKrpcM4 z1@(_9bckOxkG&PDjL~|Q4;)+risdq<-5zk%@4sV!7A6xjY>tRD9$;z*S2j~_Gvy^D zXVq=3$_SDl=>?iSx+iv9qE$3Mh&H$KI8|eKn>3(}Ebg zB762_>}Kps$S#9ILZ#fX&d5#-k$p*IzeS-i_9aU~#x}+l*=G0?CL$!uNF)89Z|Ax5 zp7(an{oVC<@9*vTem>{(zQ4Pid-n4@=hl~y;9O0QC?FPhtI;ssdY&LW|LzL78iSz< zxaLFsxn0PXA*DiRrd4BuvETYBnW??9c=5|&^5WZjrDUqsQz>1kb6d|UXP!6vfw33F z>H1OG?WeX=I+Axi4A96t%^&DNfF-V~>N%lX>j0S;l}1kqlyKuNo8fBBKp zwe0TuI0Sn4v=a(}K;WJ?*ng7jL6}oGc!V>xvnt{dvOm*JanTT=6KqnXuyml7SCNs* z?kECwIAdKA*y;MZ?1UgZSiV+O>FdJ+xQjn8 zqOQBtn467`*D}0&rdOOD(~ZjR?Pw$)kO&09fyb(jdtk1%$?;pTbAYF-nl`4iuI%pX zgO|y?rX_L7zN&PqKu~#1i17?0Qjh|5;)BQ14`bo&lhT>=@5KpUy{!>CrLE-}Tr(bT zGE`7&uo&v2ap3=dHyq`gN47C{7-e3@_ka&$Ui0zracC~nZo!SU4 zB?qN^7fu$Es=dKzdC#;#g9~D+ru$lD1Rk1_NF5j8HrP4EXVK<}Cox~0syWnhI(kHi z$PSNxxOnf*KaZnytgo3KFi7K-KJ8sc=}6yjy}lm*ZyqurKFsZwyj!cerNfW4uuTDy z&!1Dwk?Xty=RX*FYX6g!mMkyGbMkJl$-Yw{s3-1GTFtNK0UO7*8llpAlU+l#oWS7% zK}*Fsd{DBf!RzS&Y;|N;{eZFR)s|*QfEC){qR<-|E~1OK2*2*Q)6CXG3IZ}-#Ac(RIT9Ihb~NHf@VAV|92rzBp}iu`T0#9 z+UTY)g|F;U|M_R9b+mdBJ(4z6KtH4*vECjd*0mApIYDh#Mka*kElcSAOgQW z)PIPVvK{}N=xJs$i~~b+0Iu=(;uM=$axl-EtLExAKdEZOVasz&eNXK?S~a&*s|9%S z`%@jtcDWJ=qVaPbzJc}jzy!{ahN^6F4p)?J6$mQ7cYf?^EwC*q?W1%YQD|5^DUlTvj z^*o9>YFt~52`G;r>l50m4qfReQ^YuIZ3vM4sr!Gh_kZ|IDQHt+f>}B`h z62v+IGtfzCN#G=<1JeGKaS}i6>v|5*`}ADZ!-%SyKj1P#Gd zK`s3SHFvn9vKS~hUPGYr9hBBe+EUqtHc9Y>YM>A$E=faJnsAWKYabKHFc!lJ|3TrC zb{02+U^+~_V|b;((k>j^$;6!4Sg~zq#m<`8ww;M);)!idY-3{Ew#}1wpZ%Wi`~O^Z zb>CG_S9j@--ndja#(VjMEVL~SQmbe5sl@&^cwY8T1Hm9s17*ES{F{=|MxyHF-Q?I9 z1^CK!MKq+BgrS_yRw>en@e|1me@U)yjJMwdru3cpOf$5_&s|jp+-z|cLQine(T*~o zNdeo7C#+DdR@lxT3h>@q^K%qo8*EIA8H6Ag^3i>{8M`xHy?d(lxd zY;4mZ0nmC2G&7|%VF(6QW?|Ucn#wxTxt$H_D~p6D53-mgZ=WGh zXCYI*Xsw#qOPpP7wHYp34Vu{I1RMdF(&8PTlzjn0I+jD|%D?J(AhhEzJ4PpqCCriZVud>ocQB-j7s`$=g1NzXFAjb);HtZMF(!lI^*1PsNlTm zq}y^};1*AAy;o3>%lNsx-40<>tw-^?%*!k$ont0|0z>Cq0coG+(RVa}3O?Y!DrHjT zV*&342zK(VM4A|!v57=t<>SKUQRy#j2ca2FcBb_J0+NH@7dqt;ZBKLf>cBYj`|wMD z=}4d*p_U#rl4^QmuRgu$5EgtCZjTk}=O$P#=8 zm+18+;<5(DzDg{q_B0lHBMh2F$TGzQ##+!doiUgZO$778Z*deF>Tor5nz<+{KH*1G zycRz#=+j9U_JdKBXO3|j-c=?gqCkQ*D0tlKF3(E-+Gb!cQT7ApF&9KAE*%L+?_qQTZbsgPp<0_vi-sQppqCd*_3F^Kx9P zao+<~Qirmqyas1o*h`ZX4fhn@qC@WFU0aE-&>B~q2j|K9$ek;Eg#=DCqtS3!eyJ-- zBR9&VXbYR5_`#?%SyLy*993?E^hfnpwhidCiGE6R(fDs zD<3qV@F}xx7HZQpW7JeBi2Tikj#7dLu6YWB-7~<-DxrtdENWL=RV#D*ch<7Vhk(g9H12bLwE@x#4v&Y%?JKiJ5U%6 zg5P>k?HzZ828UgTT!IzI*&w7DZ&<=1LI)kGksbjPq%e7DnQOQ?+kXPo*yWLIp`;W8 zCX^`|{$%_XaW2EtG>Vw@08GV-P?=@g<})vzMmOyxXcW)~%wb-}KCJsd>xNfzt*iIl_RqtH9wREUpAq9ojqr9KF6B}UaSkp zGkN{=KH~Q~F2O_GRVYE#$bTTtf4>*4STyJ*9O*OJPIzm zicaOo>E!WbBxVy16*OXkRQL!u0RK0Ko)z1rMWGUMUzwbc9a<;{4H0Pi9zbnhf^k_; zuZ?(?{=sbO7VjqVibULH91cybhc?oE2q8~qVBgPar5-HPUAnM4NV&vad8EBI5OyZm ztsN_TQ-8WBe|7&oE*6m=((XJ|PvUR+BaxE@CX0m}l`HFo^|u~OOuur}FS_Qahb8rW zl08?hYEQ!w=ipvXN#|9H8!>TMuQmXrpkv+=T>9uhE5Z~qS{u8mFr!#OD@ z!M`w3t`yA}C_MFaYH0rA+&RMzB$OYlqZP2w#BYG8k5wBVu(M6#gF765!y)K9nbSJl z>yc+QN9tXu&&J=G97pQ)I4>p?d~`%GvtmtJ^SA&dnPA@EOLG4WsCD>B(($jAMV9jN zQ0N6dxZN>(k2R~Cg#_J7Et*md!p+Q}idW$LZ5M^Y@D%DZbY_$ZhOb{Q;KUS|a`d)r|5r0g&7H+KlWpPB_1g{ZFlhJ(-0R_Ze+}|n;h@i7W1K8qa6iU? zT1+^ofitC=rZw7wkKmT|Rk>=a)Nc)UArhir{%)osSg2cLb~gGOzi%IsIijrGbVINJ z(eI*xJlANLpw8XYsr>}P$?tF{EzoY*d90fwLb+L-6fPt)7)upDRNf(fWm6oWcQ%Rk z+OHirwGj0v*Q?S}NYgq*8um3#%rI{fykp?dm;AWcc+`Z)WkJk2aj%h};}+*A<=q$o zRlo&I1r2ph$G&d!Kua-Z|HEKJV@F~IcTK7E%>1y20zh+gt;#W+xN=-fGIx9d%2pNr z8N^Fkn8HtVTQ@*ng)pz4yQ%+n6ZF|b~V-H>m5yxuY1$<`+H1ynF#y}4}HGh_c&yLVKdF;KcTLjT6L_N9KBP^A^hzZV@cxpI$o;qW1&*b)LQ|2mCX#7nBzJkxEGOQW)vwd`y)xQ_& zwwx|~9T~`tX8VB>moHvdzb7nfPII0hM638HT3c^2_l>DDyTTdM{Tjn>c5yHQr|1bA zxVq(bJE6=+&I!I2?LMX)CzHsDEEneEE88aY=`&}IFLzHoWQ4UnxCAukPekUo7F{bs zgbMl!GTCGqw?zhY8hRY>j=(`d1hN2GaSYIWNXiZ#L^Zv}3nlK?;DmoD0TomWIt>y| zAGXqBl43QJINZ>I9G!cg+sMp6n_AR=O9m8qtx5SmlsaQ>ULFTn%Zw~XrUld01Hjr! zD|-#@ea@H29Z&*imynOrCgt#lYPQ)j|GcHolx3~Lh~d%&e!&6<6@);>*)sxVuvE1R z_Z=>UF@MVjY;CdGZ7h_Ci_2vA-)buVWe-6UW0JXvAEVBli_< z&!y<5CLU?dEk}P8u~6CVu;zJ;5gKZMdrlCV8bkOi|F6L0f6A;?8VYVqU^eA2iR$1= zLm4Y^GwhvEQjpZ4S%JCm1&qvzOA8L^@2aAC1cB3>G__*{4tT$}DAn!80&$YuPS7}+ zcx5|sS!6`# zFe(qwXa}!R+CB+5hQlCYYMpBhJt(~x;v2{-XsD_^-K5_ajS&~xUH5%(xQE|NHn3Ws z<`G%HQ@KFxzstV`6B`IvpTXMYDgdS=X`_FaBvD@(a8s^U5pTA`_JXOZ+YWkr$B24r z!n!Tk4Sr#b|0~x7 zzPo$M)FX7Y9Ggjzq|@)y~%kA$n|adl@}Np0aTl%5m?Y)eSfPR4w#*qDxPsXdUu zl*)Ycj?9gVSh!%qnUwZgDE_bgk*22L(tS5FFKr2asVcTlf_-_Cf050olKjBgT&H7K zY}MAAmNM?*O@!ZggGzRl)aRq~#NGc?QoC`(JzX{DM8Bg_dlHCjF z6j;Y1x4+yu!kdC$6iUsPJuV8R!UP5b&g5VRWAJ~W{=1X0U8HYq*_{+F&%#|0#3x$c zkWgr-2;}EyvD4|ep|}HP3ZZe1Cu$Oq5XJtozPEG+f%d(abb-q%O?-$J zf<*Q#YDc$3eJ25?0f)VTxuYq`$F2JRJp$rUb@qS3qeq_~O%jxOGSErG`pJp!K7J#$ z(A3mvu#)J6;;3N)Hp(QMdjrH~t8#XPC6Iqdt}3kJ8NzxBh#q^$Aga5xch?RcTae5W zM~-ZSeGnGZ_l2TB`+CC^k{`E`Gy1~V`zhb*j^lb86$zGfFX@JSwA)4tY#AE8@#`1M zrwVwx?J@~g>d^kz>WoYNzzv-zYBYVdRiMWVY2@aGL6MWTb^&?e&V(2yMKPSK%g7W) z0&7LvsY(dl3hFBnmOa#QXIsvc3w3{wZsc}f241hny_~qp8Inz4>E$!qvW#Yylcw$5 z=qvcg!s~Br$l;{O|Gxt^c|@7@I%2E9A4z{>V0I>$4z0-{Iq+}AR~0y?8%xrq;Aou@ z7Af+wT$oI$N2;oQqbuSgI-xJAg*}=%LKcy`*=q}3G#9rd9zm;5elg{*7UBO-^nkKR z?+sI-Y0PN0+Dbsga5bIiUS70=@N_8iyX-N$Y>En=d3ct@Yrqqc4cd9o3)qQ+gNIXQ zzM-P3jsRN?r1)9wg{k)~U}P!lqunwM@pMmH(zxWmc0M9;$`1gEUSCb^n{xPSBRk+d z7P!<+!#I?Ihod8gXJwvSdY38Cc2M;d+67o`Qq4Uq>U{k<)0u1LJx>yb7uw&<%1TSd zQ)#!ZqLNhp*C5bC3DNn^Bx6ZUZU*VpvqpFVJ~GVf`}h(c?(1g*uIg-)l2bD(#dXxN zNR#lTkDX3}4Zn)el4lxiMWSh0bj@e#aDX;?BIoyEQ~RQ4@c+4~{m0p`;n8^v2aB5% z?XvOr+a6fPnX7;ILkGaSaf?LfkkGs@TmLo+%{I)8+$S=9+KVbqJNDb0z6hVr&Q0{| zODUqY{d}+(7{oMj>zR%`E=9MhN#EO^SNeZgdj{m1^g4-AF&uI+1#5i}p(k&uN5dJY zTmBmg8{BV8benyT+0A@U(%?I#=%c};QsEt#J-4+s=Y|bj+5Z?cZ9dwpwsOoUsrWgQ ztw~d9te5o<7TzA~TH*hXcOIJ=#7+H_DcYZ=CulY&+EIjFAGvuw1w^Q~bl}1|;bj1L z0MrGNtk8(EEK>b)TH->CQ4y(t51?N@H?FaQ6cEblD;JRlp-3N<_P=f?F-{bZfQoHf z#lCBbaj7H2Z~!Kk&Am{Fi}t*bvD-rSU4V#h084-DWV{ z_z=A;VISWrOX76%8wWsvTpx<9%RTB3dG||pWt{M?l-=x4fpfvNfq=&%7res4Xx%uP zbF$0ZsMr+!mgu7XM}b21_fF=U)m~+IsISQ%M5`bLEp5xyRZ|bc?hn{>lm1cf%g-X8 zdwWaTK%*=}dWZOS^uqnbqI40yfCJCqg$U+?(2!gNxCz(PQnvNqeX&;MWjv*DNv}qQ zl$1sFC#Hpk>dfNpu&_8%sxJwDR$U+3!)Rss5`_6nEzxZozct-o#*7*|>c*8_;Ra$N zM2AYmj0&*;?(L>G?SF(>-J9|u{Ub}yP9oOaRQ)4=%(3D_MZMI|sWwiTd*5q8cPJ!k z8t8Rxhj-%>edEKBc&IJAN=7vRNL=)l6od=gU=H`nj%~?xQNG~z$R2T2kcB9u_NRB% zK3)|nd}4ZNvC|tm2PX89;|R0I}Bi2r0B8$Wi0YIjp|HoeKGIE;RK=GxeNNt#ke?5fA3`lY z#bkVb9L>$9Qr+m|5sFURm!l#&7~^76THtx?YTXwhm^+VHS|k`-&mGNHUdb!_4a>xp zN(=QM!0vMDR(K8)jP$@k;X(N!`s39z`j?jmS77VxY%HFhZOE)H)X_P5t1EpWW*G+B zii#V{sDE+2>Jj$b+eN7q9)3nHUD;JUN^jP=qz*$@Rc&sdQAbP235ldB0XiKb=TIGB z&l6>zhb8flqVP~lRZqtAp6g~g{9`RetRuyUYC8(*@=VUOMF_f2g=^Rn+*L3up0DJhWWHfA)z zb>h9+&T|UJ_H-VCl*QMU%QegqFXX7t#h0%-FP11Z=W^fLwSlZX6%01e;h~{p9-4D7 z3J;zIrpXD35=tO!uDnpTvKzN=W30IKvknFj)Jf7^47kN>su>oeyKkb?oLhvN?ZY-Z z3G!l9V+6xtMGqcu%*3AznnD5`5G85kBS4-EL@2o7Ge$Z1IJx#yaR=gz-zN%^*QB zd6qvEn2XG+itCI@Llp7mS@(7~kvGO*Ou{p#1v#x5KWvy6B?KR4#X)hlN>gcq1*^4a zxO=T^&D@-PyGaui$PRwoU7qm>>4O|z!~L`_Gp~^^MRbwVxc+Cw%dEGtIT;>MKk)T% z4}2J?%fNahXos-3TE@u(TeXoc7lP@TSagVJ1CLvO2JL}cA`o&1vPJ`xri8fnFZ=iP zIR^Qv9rUl62xZ>Ktj!qV!ErN{toUyU%owm0c#{2huG@=dR+!Govodqf?%)R}qsY9( z9n(;@?@;j#rKX<##0UQBU4cK;c^5t1gA=} zzq_V`k9tOqlZh_AX5D;FTXik7`h5d^WNEqhdEiLe(KP% zoQa@}#uE(5tKEFqJVJNDOqt--kKPqhY1cS{p2We9RU9LwG*-RG|I!MNMD;P1Abntv zli2KetMVq{sSx;W@%W+c^N$@6VowZR|moo{6WomQinqyAM1uc=;@( z;7^Dv0XLr;zhHr_UbKwh6%uc#MLvpS!e>F;w;ukD)6(2X0iZ%neOQjfNiTWvplO5Q zYaSMADK%nyiNs=o>Yu*kQR{Ix&9u9?S|Z2Yx74~QL0P@+x%O}LCT}|(f$w+ZPh&REaB}rVb^|4*H(5L;7m%K*XZFT~biH4y60n!clH^1JP>CUl$te zOOVegXwxLN}+NREpo{wlWyOThQ ziTlI%R3_T2q_1}jD+3LmqV6fgxTYA^j1cDhQ66|U_}NtDaX&4@d<*mQ&WR9acoC71 zlc!mjOR33G)`16*FT^gq37RmvJpqvow|aQhRvMbulNMvO)A*fhosq-Kdf_~vE-AY? z@H=sJboq~kI=gR>ssezL2HfjGdloSiNZ-1CHh|4wq!Ue@-}z03m_8#h-8Y#h~_| z4M=+pixPef#wz4*+zC!yLi#f73)%A6Rer9|l-2i3_|wo@&`O9&*J|R;GxuQIl6RJo z8S6B-K25fM;wsr%YvN=Z`sE64nIr$EnPhuPL@pZ&S9iCIQa9RVM6d{~+CAPwS$()u z#wVFijtMdQJ<{Nh^+4zcs7arC1f)#Pq84q!hx<6Eyc_*c~G$?q%d4}a^x>Y7b`eIY$*&>d2a+;+$fGv$6od!=6}?o@uJT-gEzECzwdx!W1uG_~Dp#pb;TIiXqBw0fz!% z)B>IGL>Cgu*i4us4+@ph%9@6lQrzePWW}W;L7cpqc(g30o1C)#w@XOb-Ns0lJs_R< zNpATzu;6YXrk!7PyEXys@rP!!GS9vx#qM;?YZufvyaQ!o!O?Ooe?Od2=F=~lKlG#8 zNg02B-%~JWHhB)Dk2UosrnhBFbak_+kXJal`Yzf*6SLUd6MNe68|{H(>faMdW3d`W`zvT- z7rnkO0_pNIRSeJJ=_Y!Y^UUf&KAs4O*SCU`kC_bn?kGCQ3vEo$EA1Sy6o(40}W>-$6xM3~iZb-dqjt)v+gBO8~gK|ClZ}nE}N>!YTkwfd+s|i`N39fA@=44 z!o>&#eMscK4~dhTj1gA{84&fw*9BCeD*iy`$-IR&bTy!F#KKn>}} z#%K>Dud{f;v$USoWKV>j{DLx1AM;jr)O4iEFJ`uCOQbdANvC2>*cKwQSk!F zW4khfK9N>9C7(N27wyre>Ug7na7cNE1lL(zh@yRQ=J%$)kMd5Z`I``;R(5Lzrh&18 zk{N1|reqyGe+O&yi?QDFklww*{<$dzf)q zvmoGE+%%+Gz^bPi73MZD_tGn4!t}rjY4P2Yc{}nOKv-?0jJ_=-Joe^7p5kNSW(jpn zcw>uF8N7@Jz(M1JM@_BYVN>XokoxSQr5ov7M#SGO3E84Jcur z+PFFi3ug3)U0c365^mNgI?R^S)<}r_qi285Q~k%M9q#n_`I zvI9;KVqa2)s>~ZWgY7paqGk^hHR_gEt-sUFPIo7_mE>ya7!)^hT#`^4qdjyKA*BgNFw^+j-ghTNL!3qF^8p5 znUlX}U?ahHb0@Pm?Kp&5kP|3$a1_n}u&GSx5oDIcnet+^<^V~rQ^h!}_;Ut~6?{~0 z$Wi**i|7?>>^&3SuutSkUdoWdks+944*^suG*%cn)re3R3O8HqBamhcW#M%g@Iu1< z3c~Ms{knc^TDR&wcWSf*E|PGYFAXkHF;Q2MXdLK1EV1U#^Q8@|E ziX`0LJV(#coMVpkc>L(K0_`Oh-0>_j=bsqr!P`A6pP%z50s(8IY%HiH2oAen-gslf z3N72aO^-wqOG`6H6lqd*8<%{E`a(QFHKqIyf-X4p1Sx4PMwSU?A$3!n<-TEy7cH>fS1XG1IrIeyqYMObP%&}ts0Q!5HKXOw zFil}aUShs#GXWzU6Q7kvOkPf&1_Q$sHwWu`PJFF6zXl+NTM6}HJf?9K zxox0BP;AYyVxcWH=QU%`A@CR2hj#y^?;+YFadeYA;ApJ~ZZ^wzg~0w#`l<$FNpu?v zy4aH2@qHOl!NqW>H#Pg#;5U5YLq^TE3)>6=E8OJ2_M=|gy#$#IxtcbQe)7OrJ_--z z9=zk@7xln2t3CpUCHl3=F?j(Rez`()4(8&;d@#~GPo@qWYHStoO~cGX>Ig9D7}O%C z_9+V@cqI#9N?)Sy1R3!(^2oVWJGM&pop@K*?u!8Q+$*4ri(+fFsr#8HKjK;1mNRw* zDOLeS%kEO_F*euyw-?3-lQ>WloSXsa+mu{k3PT_Y{LT|HXb9RBwoMM!`N~D!q{+Ym zIwrAQIxHgXSbWRPtfmGqHJ~r1roz8Y3y-UI0MHh|l zzO(pUwvAM58wz974eS}*+D02f?scPWUCcL7#fQzgs7(^_P%<|=uvR?mSGNbj?Aa*} zTPv95bk~b`eOqX<9f(GqY5Z!h;Rin^ZN53=={Ooce-LD<3xd8ykb76m_0Ak^l)2#m zk6MED)>h1W!1xCgBc(I(>Y!l9Vfh8o&=BZKCF?-SmW(QQz`#|Hx4u}C8Hp|<`h`v?TDnW5r(qDD-g%+-O9$(>@D*XPff@x+fKOR=E=54C`1^kOUS%8ZIAb>IF{ ztO5h&&ES$dIHtZGV9}=2vZR?&QT!&Ty;)ck+B+H2Q$=fz_2~4UJY)GEn-_T_h6}1O zV4u->gm=xlzED9?EM z-!BBv>L!iouF7^fDX#qYyE|ltyiol<{gxjxoBtfIu+*Sr;IT53E*%XBIa+AeRhM&r z^-GmqHM}a`CVpEAnUU*^eZAFy-}mY#?d-Wr^Qy(!!^>Eh6@NoChKMK92(UOQsK+T0 zwq;h{?JJrdak667=V(zvorCP!>AI?ChYy~R!yzF@se>S35LXQkPt-@cAMbk6!RHa> zI4(`@k?)a#YiFv$YQ6uKqS^k;f#T+-=ijK<51}}WQ|(@eY-ZJ9q>%Swuz|sg6^mcu zS5ud1WXyzZBI^&8qr6vD-%Z$s=o6|EpZ-B}wTu&@4Y(<4Nj1n06*jVZ+u3MJ z3n*(Z^@I4ejqGZL6A>e{Hz(Z<51`0X(z4lxkZefYYOV-_gPfEQYa)$Y72A>LK(QbT z0VGutHJuLV5o(HdkkJl-14+|bQ#m66I!l93{y-%U)CTCjwSd>hREQPq6Tzy!EdP;; z(U4>4n55Tb(s!-V^FA#F7Z=E;Rgkgb~1utD#>EZ9}<0^E9nt(pqVhSfAzD1{> zuEkaQ5al1DBYs8Vn+jRfkO$~d=V>Of9HmUyopG_UjZtOSak3hO_F zA@%&Nt1z6p1Ba~rk+hOJ0&RJ~n5@e^CrDb#NX)k!v0LR$Yt}B9$Hp&eNs^oj_jep8 z1v2Wr#J7p%9d7*-}?4)&@bf*p8%zbIw=% zqsyM1)R=N^DoCO^m$ww}K!rX^=VJqW%K!>3sHT-py`28lB#QkQe)i7%e&Neh$Bc<^ zyEm9Mn?q&g`~$i{r6h7 zNo`5;=Mb0PT7~aj0MsSI(UKy2P>ZjM=}{9hcj9GSq1wQ=fS-c@rm@8J9Ke=}iJPrS z{VdrRfzDHNnbv6_?7#-Xr>Yh?CULp+sD6UmoJvR5Yu$x9D}#XdFQIsg#Ebvtq1x3q z_HW#?4DT;K`B^V1G7>O6hYNvReRGx=tPV_12#I;|=QV>3Hd?v0s2bwDaxkX{Wmqgy7@AdDgji&-t0-Gv7%ou9#P%p#ax+L4QEETr|yIgNxmySHzf zgw(1;uk>x`0yal5WpJp9@Y9M#hQ_G|;k8Z#F-c4#Oof7h7XmVq{4+V{usRl?(R;xW z#vTN)Qe_7W|6P9e15!K!l02?%{>mR|r&kP7VjJ6@g9CZ(HIhO@ce@7g2BEX89&&;q z8?eEy656bN_%53sA*;RwqqZz#X1cv%c6q_~3o#3%@>e_Jy8@%7W60txo0nWd+QSLB zhVghAU`@)NMOb%?3~u)Hl^YPdYSM~!#s}zS3YbS}NzO0W1^!d8VO~)Ed1x|9s=OBn zaM&g4jpIx9o42Gexwlk+{5lV@OuJ<*5!|eJkay!F2f0`9|6KQ1S7usa*;in$qn zxM}HX2arB!JU#MqMi)eZFwo31jfWj-{7s{Jcw>-2y8cKsd4QMVgLc{)TN`bD| z2+XNmL>RQeZE$HB6AW${R1IP^%CE2_+gyPRFh219etKI;-& zt<>jm;~Z?h_b-}dO&4av*=@K~zrtpqWJ^6qP!ULJ9mmq^S%b@u3P1qJiJuAh-kYhS)Xk_AUtpdintGZ+jl&J6fuDqe7zsu~f zEl{y#;-?B)=TAA#xIdkXFcokHpO_xp zzAEXCM`uLf=FfTXl9Yf|5E4+>s3d4mNd*f-Scb=*AP%|R6_+;5OzA^Np31P&^XjLc zFl9Pv$hpp3mh2GWhp`Idds&`WVcK!L3N~-nxxXu&Hrj1TaCZ3s4=c+@Xc2m-H{e(! z{#++TsJmOv>!flZX<|_Wc%Hf7n3a*$PyCK!E{M-j zuY0EtY4dm_q>_;dc@P2kR&{k!?DTy&%-!NwI-2a|Zb+jn@7@?tUuEc0VVk4ZR2e1j zbtIXeg}b@vC+>#6>6degf=wnv;krhw#k|8~ z9_`5TuKc{t>4QFj15~S7fh{uv$(nX=-vv|p60i%~5Alb$UJ_l&Y7O1I$-=w&3M2_) z1uYL1b9`}TN4SQ{N30LVN3=qHM=c(2a+R|nR9gM)2E~p0@}%a#Ms|1hW%I!qV2Q+f z$XqJ7PAy`PtDU_jAAXhDLmuoj6ZrZ!Pr3;a_LbsnzFJF$ff3gRVKOY?hU!;-uc`F3 zs#jMWGr1a{h&2!Lz;Z7#Do7SFyxnQf+eeT7rQkUR2PFXprMsTW|L~)+*eUx7R44E^ zw25b~`YX`U7Ju^D#-LEKR(xn(bXI4~pgw>P{ZEJ@=5gKwnR6;LykTx? z2}5{cBq_q*C${%HW-h{p+rMqsjd`@;-ShI0v5xJ@I?@)d!D1=?bJ2;w43*p|-ldp! zkzIC5I>_rPC&Cxn^fe!YB5-tj`Sq6lD=<8dq*T6Y1o}Gw6ZpYyQym+5Yy@5$&SDVU zv!!3=In|PmP=$gmHG7fEoI>=Id~VO-gkOUrE<6dQP88EA4u5hF9S&W!SeT6?;-p?H zf=ox>{V)UhatA6JsB0*fzoD*R$E-jKMs3p{d!J|f9V)0IeXB{9Z?NQ$tCok5@OoJJ zJDi(EkF8*Oq}1vPDzkw*Rm3>v1VMD!DVSLT;mC(Tp%iTo@sexA9k=2a)u*=P)qK#E zz6=QZRS6X~0SV@@h%MqU+M)EF$jDI#4Q*oyG^AdFqLkDgW`yiI5D`S(i8;IX^C3KZ zh2i?T#HMCbB4D}1BZ`ks1fw~1w))UBY{aNy>)nc|u%jh3Pz`Tsu3Kzlg5%~H+F+Bv zQnw?pX!%nrVv?yDMR_w2C&NBwl>Ry|GG@z4iLY$9l}u#4pP!O&kIS4;BqvRB@!YwD z0GJ1{%CO|ouDHqnMfcg64AD7qzd>vF-powxu*qcX2?a9NCnor+!~;WBAqhWln1%wU zAY{_q<`#dUw>_b6G%B*9tJZFeaZ(@3Lc`f5T3pQM)c-zY744+gRSxa8>`ob*ghPIS zK*&!xYXl-u#I`Yz#4*UbJ>VrS(&XUc}PkR}8AXQ`SQw1T!(GfDDuy(W$k1HwS2sYkIbr*T{;L zgC}modvGz`<>JcK{v=QcY>(SMq+t_8n%| zC3A*4kiqndY z6WTvtnti;3ZHPj31KV!*3sZuqL(C_4vhccS7rf@9kz6+Eb5? zTCp5EfKgjQS&^9ztzMgPu+G;#<*_;LSten~AZ7E?K+%px9jqL)c&X zw32>mw3$WqI^Al6gYnb=Xc)*7N{1f)z%*_TM^DN-nz{Fv{i}cAiBtq#d^ZqCVJMQn z4tgI<72nxLXU8y4!e~y6{K!mo@XvMS*g`vRTzx8<64$qf8CU1-n)k@LHVFfW%PkH^G1gKB{dXL7E?Ll>AHsda zzs#a7wYbYxb%Er!Jt53;0dEa;iyCEOD@*=K6yH+Pit{rz8vpH2V%5oDisA6ZkPg_WU1&xdEBKp^#XM4}9yOePBcaBcl|qAzK zBj?(U`v?3cdAQWJsHQG#(@Uq!Iki~nM_@xtOe2vP0~}zW zq8(Y^?Eau{e#W4nMQnG{qd8t3B1Nh#?ItJQ;0PvqzjHp`OPh)8y@o<kY8`5*gPxn)i7=!gUvW6sCV_DgZRiAc**G+}j2aZ001E{@0N;l-N{;rE zWGH&RveQjXmzm)Q3u09Q%&e4OuA{V%Z3r(PJ{io4cPZnLQ3%iZ?M)-y1f3r_-Z5w zOCH*(o1_k7{?7%Q9n2WaWX5IuRlg^zaZ7w?e;vb0&q1|TIP_0R!u96~&z?J7;i418 z-yAZQ5s|I#bvKQ$5*q@z6l~GBdb`7zT2$KKrHvoPVeKoW2ac4QlS|cCf{2k9^^yjL zTI)^8RR3yVD)1?62<8)VcOF7A9}Eebda#gVekm*^g5PePL{~lAZs=To#kg?G*zn4; z`F(?0cmYUErOG%|vK!Q*{sGyF`V@+uejJ>H&FGhSCY(889#Ee?)x)9Fi`0N{mf-Lw zg-|9V3pGQ&VUO(9)}Oo6)h*Qv!keDYp2S#iRv2yd@QvfCu>3z6wU~PYZ2ei{^k+<) z!8#IVvZ9%7sP$*E z`$~KoAWl|nTHJxlD5ZUO=s|It^lwrv`xx2xe|`n-!rEYGdQ7_?{{q#8%WWt{K6Pc! z5c|pY9Y_4_riE??2BP6hJPS!4yU3V15%ZBJ;nvz z_n#{{*TNnFJ?@=Ou&5tgj|$XxNh!>M6eTEn!~@u6n&7E5hD$vZ73pM#&12`k;#nL# z@qYT2h(XP9z3Hb=RPYt*mipWtE*V+|1zh5f*i+M~nbCfmC+Qy~xy2`e>zDI)(_C>> z#vM{kW1cjct&>knh$}1{qG?MA%P%!}SBrfo2RgeyNX<6+O(@S@VE4KO5}u$YwOBjp z$?1_H@D^m)I|kr-Fdc7RL4?OySH9Q|LZ*`?-(Rfa+Fl|^O_Ob>20X}-?7KNZTh4C4 zdeTo) zK}uvt;Y*wGa4b3|v4Ks;0o~&?iW~;pM&>K89SDe>*kXQ7IpyNeMBU#$z~AF(>%)ei zK6EK|v6H_~c` z8mXJguh>Eq2R*}oPVrbjuZms%fE4?GDZlqP`nD8s$}`OKtvd46NC_;XYjK@yfx&9~ zM|y+xSzi#9wczy+06SUX%j(kqrmN(f4dJjbp~NgH!6u_er*^{IZ)6fRDM_Es*C%1j znSi;eu8{R+vEnM@F85M0n?SYiwJ3#@YblkGkzvny126pd$Hk|!$30af#*%fajvc>4 zz1Gl|GANDExPTtcmXUQIG%9tV4Z5~`z-kwAE?UEO9dY5r?jaPlH-6BHWFelJyxEF3 zkmTgqY8&h*ARA+L*4m`rUcZjA1;e=(*b<(ezXMZz#Y}tjLd0;FbR-5C)B5G%AauKQ zN2fBBt{iFV4EjL;?n%DpOmm%7{yD^4><*M7Q z;%~W>vCqBHYo!+kk&zCQTeNO-kfgFl#iX*(=a*W$rK=`GR_(}4tVri~Gq6mx(}xiB z{4de2kDB-ux8nDBUKTWIPh-_i7DZ3HlM)R-aW6&bLi=(%5*xcB{|fYZxy8+>7RD_l z(^vPEUKtnR-RPm^)-)~tiUx+D?f&ud{lo;P_ZUKapg4e>Zi;V%s63N zQze#u*Vu<_b|znNB#c1n#qHspP%Y7M9mr)CU9!Qy#;uR2%DKbZ=UMh>T9AyXg@F}~ z1BltYelYpaGzL22!Ojk~Y`|B(XC@|BpjJ2v-{Qu}5LWo4&IQ5)!bfQ8{G3rMn)U81f> z7UiLg90U*cv9Ou~SF9~TVgsTz!01I`7gDcR2j(_8ed7|k*!h(6E2wOY!dkhY^EC^d zBb3SJVYfq8O%1R~bYOU2GXv>@uuTzs0YcCg)`+!3%uA56Cy!bBq*-qcqWwYBHNT>F z9^8TRHx`XVlGKWGwS!yM{b^cL|LRG3^)~+h6U+oN`xq)C-@TZSauI;Axq6kE%`%V_ZMa2H?2dA`{FJ+S-@(i0cE+WRDenn6Nz{v!-&ZZEeSEEtx)rBgm{5oVEHpTI z#R5I9k)(Qp%;=C|4Z=xg`y9%!aEZ(mV5+Vxo|2gY+QfMm1Ql&CVA=-7$g`Ev#tW&0 zwg(n0X)t4pXUHc@e5#4exL%)UCsMu-Q@%JngG3QhfXzdXtf!(Sg8@LSL2B_J<@tKq z6O_s@j@6_3a=nt;5Q2e(P42bqfi5XpXJDp+F_*UPw=zT7E9NW9sBB&u+hm*@b1=9KD0-DhQNQ>ZQ^eoCJP8ge|GwyF_*Ci4>yL1kH8>=r8pnXPWN^#%SwBr_r zDmi2ID>Fu8eci|qX&%5#M3j-~#wL^DYqdHsS1<4;3RSxq4Z=KOaAK2%+7DEOM3?u5byg{&mVZ!M$?8+bogu2Z08N&j6mvP)>-BaP9V=B{1&(wIf zM-GHro;JdF84T6kIS&iia`!KBSWd}#oB(kpA~=wW>tQ)KINyL7j;Y4Hf=b=9G^cP- z0SPx$S|P1o)nLq_?mJ0oo4zNRA^Wn<{q&zY&DAu0QO~hjv)x7IFFz(DV6I>z)I~LS zjT{FM_d{Gd(MS;cPV(%5`sz_9oOpNQ362E;C#+DJfvrhWFIL!W$MGUBOjJ&2WR*XG zKp+qZ1cH_TVW^5>L~u%llcB(dC~H(~s0B^pG?_VN*l2wd`U~KM3n!%rUi>|mXAx$A zaLN{(CPHMtWauwMtFv^ zc&veH9Bho%YN1ty5p|%(_=KnR=LN!02?PRxKp+qZLnX+S!Ge+8mdspjrnD;E(hXJ( zNXej<_%bt4FL+H-GJ!Bu0)apv5C{aqPzmbFWPzbtpA+nr;N+o`(uUJz^%JOdcsHD+ zv=fatnTeV>uepI2hDsn12m}IwKp+fNV=`Lsgtd}tvTH(6P1roag|TV>1iP)kZ898U zggt1%Q&Zr!7EUf;&t%$PP38YTS55X>pL*I&r$87gfj}S-2m}Iw1^+L=0Jy2*iBPvg Q0000007*qoM6N<$f;xvk6aWAK literal 0 HcmV?d00001 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 From e298e33843d70e8ef030fa7fa187bb4bccc1bf6f Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 18:00:22 +0800 Subject: [PATCH 050/582] =?UTF-8?q?=F0=9F=A4=9D=20docs(README):=20refactor?= =?UTF-8?q?=20trusted=20partners=20section=20for=20GitHub=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace complex HTML/CSS layout with simple table format - Remove inline styles and JavaScript that GitHub doesn't support - Add clickable link for UCloud partner logo - Remove acknowledgment text to keep section clean and minimal - Ensure consistent logo sizing (60px height) across all partners - Maintain responsive layout using GitHub-compatible HTML table The partners section now displays properly on GitHub while preserving functionality and professional appearance. --- README.en.md | 40 ++++++++++++++++++++++++---------------- README.md | 40 ++++++++++++++++++++++++---------------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/README.en.md b/README.en.md index fde6633a..fe67ce8e 100644 --- a/README.en.md +++ b/README.en.md @@ -191,22 +191,30 @@ If you have any questions, please refer to [Help and Support](https://docs.newap ## 🤝 Trusted Partners -
-

Trusted Partners

-
-
- Cherry Studio -
-
- Peking University -
-
- - UCloud - -
-
-

Thanks to the above partners for their support and trust in the New API project

+
+
+ + + + + +
+Cherry Studio +
+Cherry Studio +
+Peking University +
+Peking University +
+ +UCloud + +
+UCloud +
+ +*No particular order*
## 🌟 Star History diff --git a/README.md b/README.md index 52282c8c..95dc2d17 100644 --- a/README.md +++ b/README.md @@ -190,22 +190,30 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 ## 🤝 我们信任的合作伙伴 -
-

Trusted Partners

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

感谢以上合作伙伴对New API项目的支持与信任

+
+ + + + + + +
+Cherry Studio +
+Cherry Studio +
+北京大学 +
+北京大学 +
+ +UCloud 优刻得 + +
+UCloud 优刻得 +
+ +*排名不分先后*
## 🌟 Star History From 26244630944e6eebc5ba83720e42b7e44787ecb9 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 18:08:39 +0800 Subject: [PATCH 051/582] =?UTF-8?q?=F0=9F=94=97=20docs(README):=20add=20Al?= =?UTF-8?q?ibaba=20Cloud=20partner=20and=20make=20all=20logos=20clickable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Alibaba Cloud as new trusted partner with logo and link - Make all partner logos clickable with respective website links: * Cherry Studio → https://www.cherry-ai.com/ * Peking University → https://bda.pku.edu.cn/ * UCloud → https://www.compshare.cn/?ytag=GPU_yy_gh_newapi * Alibaba Cloud → https://bailian.console.aliyun.com/ - Expand partner table from 3 to 4 columns - Maintain consistent 60px logo height across all partners - Apply changes to both Chinese and English README versions - All links open in new tabs for better user experience The partners section now provides direct access to all partner websites while showcasing an expanded ecosystem of trusted collaborators. --- README.en.md | 15 +++++++++------ README.md | 15 +++++++++------ docs/images/aliyun.svg | 1 + 3 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 docs/images/aliyun.svg diff --git a/README.en.md b/README.en.md index fe67ce8e..b6403388 100644 --- a/README.en.md +++ b/README.en.md @@ -195,21 +195,24 @@ If you have any questions, please refer to [Help and Support](https://docs.newap +
+ Cherry Studio -
-Cherry Studio +
+ Peking University -
-Peking University +
UCloud -
-UCloud +
+ +Alibaba Cloud +
diff --git a/README.md b/README.md index 95dc2d17..13402058 100644 --- a/README.md +++ b/README.md @@ -194,21 +194,24 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 +
+ Cherry Studio -
-Cherry Studio +
+ 北京大学 -
-北京大学 +
UCloud 优刻得 -
-UCloud 优刻得 +
+ +阿里云 +
diff --git a/docs/images/aliyun.svg b/docs/images/aliyun.svg new file mode 100644 index 00000000..6e038df3 --- /dev/null +++ b/docs/images/aliyun.svg @@ -0,0 +1 @@ + \ No newline at end of file From 64a1f924961688ebd51e3efba05a5e9bf88325c1 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 18:14:03 +0800 Subject: [PATCH 052/582] =?UTF-8?q?=F0=9F=93=B1=20docs(README):=20refactor?= =?UTF-8?q?=20partners=20section=20for=20responsive=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fixed table layout with flexible paragraph layout - Fix display truncation issues on mobile and small screens - Increase partner logo height from 60px to 80px for better visibility - Enable automatic line wrapping for partner logos - Maintain all clickable links and functionality: * Cherry Studio → https://www.cherry-ai.com/ * Peking University → https://bda.pku.edu.cn/ * UCloud → https://www.compshare.cn/?ytag=GPU_yy_gh_newapi * Alibaba Cloud → https://bailian.console.aliyun.com/ - Keep center alignment and "no particular order" disclaimer - Apply responsive improvements to both README versions - Ensure consistent rendering across different screen sizes The partners section now adapts gracefully to various viewport widths, providing optimal viewing experience on desktop and mobile devices. --- README.en.md | 42 +++++++++++++++--------------------------- README.md | 42 +++++++++++++++--------------------------- 2 files changed, 30 insertions(+), 54 deletions(-) diff --git a/README.en.md b/README.en.md index b6403388..bd62a7ef 100644 --- a/README.en.md +++ b/README.en.md @@ -191,34 +191,22 @@ If you have any questions, please refer to [Help and Support](https://docs.newap ## 🤝 Trusted Partners -
- - - - - - - -
- -Cherry Studio - - - -Peking University - - - -UCloud - - - -Alibaba Cloud - -
+

+ Cherry Studio + Peking University + UCloud + Alibaba Cloud +

-*No particular order* -
+

No particular order

## 🌟 Star History diff --git a/README.md b/README.md index 13402058..638c7fcc 100644 --- a/README.md +++ b/README.md @@ -190,34 +190,22 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 ## 🤝 我们信任的合作伙伴 -
- - - - - - - -
- -Cherry Studio - - - -北京大学 - - - -UCloud 优刻得 - - - -阿里云 - -
+

+ Cherry Studio + 北京大学 + UCloud 优刻得 + 阿里云 +

-*排名不分先后* -
+

排名不分先后

## 🌟 Star History From e069dcb5b044833061d1101d48f60a2fda1c0426 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 18:18:35 +0800 Subject: [PATCH 053/582] =?UTF-8?q?=F0=9F=93=B1=20docs(README):=20refactor?= =?UTF-8?q?=20partners=20section=20for=20responsive=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 8 ++++---- README.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.en.md b/README.en.md index bd62a7ef..67e69743 100644 --- a/README.en.md +++ b/README.en.md @@ -193,16 +193,16 @@ If you have any questions, please refer to [Help and Support](https://docs.newap

Cherry Studio Peking University UCloud Alibaba Cloud

diff --git a/README.md b/README.md index 638c7fcc..ab3e82da 100644 --- a/README.md +++ b/README.md @@ -192,16 +192,16 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234

Cherry Studio 北京大学 UCloud 优刻得 阿里云

From e3f07b0ad0fda318ae0965be5c4dfaa9534d0c88 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 18:31:31 +0800 Subject: [PATCH 054/582] =?UTF-8?q?=F0=9F=93=B1=20docs(README):=20refactor?= =?UTF-8?q?=20partners=20section=20for=20responsive=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 2 +- README.md | 2 +- docs/images/aliyun.png | Bin 0 -> 34190 bytes docs/images/aliyun.svg | 1 - 4 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 docs/images/aliyun.png delete mode 100644 docs/images/aliyun.svg diff --git a/README.en.md b/README.en.md index 67e69743..8e528131 100644 --- a/README.en.md +++ b/README.en.md @@ -202,7 +202,7 @@ If you have any questions, please refer to [Help and Support](https://docs.newap src="./docs/images/ucloud.svg" alt="UCloud" height="58" /> Alibaba Cloud

diff --git a/README.md b/README.md index ab3e82da..86c6d24b 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 src="./docs/images/ucloud.svg" alt="UCloud 优刻得" height="58" /> 阿里云

diff --git a/docs/images/aliyun.png b/docs/images/aliyun.png new file mode 100644 index 0000000000000000000000000000000000000000..87b03d3528af0009e977a1d23cef735dbfe2eaec GIT binary patch literal 34190 zcmeFadpOi-A2|G#m3D1OZFEBMln$b{ki(!Nm5@#dn3?w;RNC#Z+rEGNe(&`@*Y!Mm+3T6l_r5>p^Buj% z)@t^5i@$>)X!gz>7JDH`J`{pv%4f|4|K{w>7aHInGX8t5wn91NrEKuWHy>wm?uG zMq&Jz9Qga&pLZPghoD7G(*MdVtb5T5LBDG5wAivg*rk^)|4_{#M1qPl`^DUI`9ttO zXKIaqNof6F8?-aReCt-l6`MC16bDrj+#GdI$N#>r($e?kl}kyjs5n%4;qTtX0!DJl z8#G@WMuw}aR^JM$sp3_Int6p$@z1LXJTeXz;8=fch4qbv;2=}~S%zD!Eu8W8FUWlN z2Y>DPKkF~#VAXtgMY?YwsnOj4>l4`N=dQBUv+$@xeusgc@o~L;S4mOx#oUD6aYc(K z20{19TbJc3%-O|Kt^27(WbG~=O7kYyRL9aE_q>LCTwkRqM=)Qiai1XXN)XRKp07) zx_xAr-xPV{W|>$_JADa{HES;DnJ0c!I?PF{~s=pCqW&*=Fnu(HAkB{TWs$9l@4 zk@njCIEh#OHZj#ZMv$R?IohO&$O-Vz9I5U}0TBp&~w?AE= z9GbO8e66MRo;wY_Mr6MGH6g$S9!Q;Sly&A!&wwtw^nZgc16&#PaWi+7nnfdPU%dUL zl5%L}Bi@nx`9OWAC{L+vg@4lqe zQ1rX?4+ZPYJcs{$VnjPL`~w=6L*AGAtXF(D4$Tvk(*g=*r+$}xsDGV_pz4{`5%CN6 z?o$rQnfYp-4g>1Tya&nT_;6ZFOTQ_y+!hfMMCP zrA2;5@MwHf6*rOoySay1tqC@%ff&o&?;+I_5Y!bBzD{U*8q*9f+CY1aWs?+Ba|*hx zt9>0aJ!IPK2Gmz}jj!lFJ|@m+)+07yIl=cLUUa&izUfXD|J|g3(xeK zDcPru23F66exZuwnE8SYv@TsrlD>U>(hv-QVv=vgk=;^r1RQP>Jp_xJZ61c0vsdzB zAb{D-R#l5QcO1UknT`O*R4C^ z>paUF7?ee0WyqZEDPwjPKin&G&ew|Z*s5{CB{D}q2gnCp;%2Zz@dx|lmRE3o6?EJP zNk@dwAG(J%^$M(x29Taminoi_vSPH=1yT3oc}p*v3zDN&?F}n&w^Oe9#o`CT9A|Ea zuFxg?Vsl~g4#j!)Ogwuj%sApv_>knT|E)~8YVrBzeVFcMncX<@20%a8pGF>8?I+w~ z9sLF`v9O5irs<&`1h4#GY8SwkI;y-#=%qN+Hg_m*UtmMNY?RNy3iq|d%~*~_SeB4d zMkJco%sqM<5k84366`M|yjrnwxsqk>HtJY{LYZQ#d!fXDBD<}DG%QZUEjKsNeyNA$ zT7&_?PQaEB!M_PGDgsmtjqB`C`zsaSA-zEmDMQrN9CGoXo@aH6t zBD;RQKx|*M@H~0-7q8}3F1UuRKewfUrL%cQ&Ab#xwPKq0lcIG0{yy8n|;-bq1PxGq^$Mz%VMvb;Ab2p;8H^3Yt z`Vx)xV2V+r48J9un=^LrX3hQ5nFNG3)GP1&M#*LvWD&+~xjN)6gNH{Fch<GA#L`r>V0 zV#zQaA+FHcsLSG~!b%q3TV}Mg0b9>I8&y>}lFa z5}Y>f+vrtDRXk(Nu^;A_g?JqO%)3)1X z3RO>y#V6kcfv=DDUU^c_`z6YytErOzn@oMk)-U2lTj)C=MqCDGnJxaKC$0p1`RA7} z*dtplbP>D|WK#+k5xZwX6@5m&hE+lxP=}2kTky9!EY0$m9dX9T^agGt_|pJ;$7A;B zZkGI)m+d0#cEv0-kPQSIP&Z-Ndl96mU}Tb?c6R4H%CMn&JenBqDD7^ zHCKtSXMmb4Yoz-nwhiMcl=+9_=B!e>)cabMUnR3XZcHU9d#Hu)q>-wUI1~o+uQf2z z`XZQog)%MQ>vL8rUCI{D?}zS}{2YihRjPX#OE(8Mi6+Z`@g}Wjxg$hd`72RR3*Tf_ z_l*&7Fz7`hFP~qkk(!5nNWd9(DPR>Eh^Nb*4fJ8tcJHvL zH4%MhZ2TUZT%{SC{)U4B3OfwwoZa!yP8)E(?e&e|I z6Id(rPjYSLq(=;yg!uMs|fxobDg-C84I4w4q+uvfGsGTLLn=nq`k3DnYM?QKLM) z$HKSOEJfT@Lw^a!uFSLCOCC3~N9z&&;CCc=s)u64(hFwAn}-XrHPl8C>8Tdjc& zfALm0Ic9rqI-Q-*ehjD_C`JL~yhey~DZJRI`kc+SHEM`BwV*E^sOBs;&MWQQb_=($ z&X_7+;x0g~V%71h{Bh0HJRh-3zPxWEK+hc#_Dkqt0cr_r5s#x*^;W@#dSv!QdL--9 zdSGnB9TslJZ8YR?iWfg&-o-g1!o+q^+ziKB+s-V#!rE5id8zdvH|f| z(!8$bIiss>A;%&Hk9x_dzsP@L26K**;$D#xWHdJESus} zq_fF51eNb>mW(4>gu%PlR{54V)hxEiAl>}ZjTMP)DA7qaq%>5pj^PpbXScgUwE;?O z2y=vj1E@pp3h7DJ!UxT3ba&SHq?+n;P{{OUMzE(}V%Kn?91|usR9%*8TF^_Xj&(-A zmXS9P70{0d4x74vWU3oqA@1E$B zbwV5=$Y`@gSf)^57K4^Y)QunyLS0uPxj&Y)rNo)BAN4J%_mZ)v()#wf*TC+0%uKEC z;1sl<$jW(Rv*cvI#OV#%U=-aX5uB5RWGUVqdzD90lYX^=1XFcQeV)c^Kp9K6;T0=) z<*u^~OLbGZyN0xRE7C40vsDsQIZ9k74l?R zw5&YE*kTvac8gj{7f95ozC-u(_Ij(7P8e;vT(Y&@L zj%mD`QJ$s1%S#?V%eD8q3!K-x2gB}3GX~hQq-MH7*CHsT1{c=SbD^VZj%$T?O9_P| zZy$ezV^`JpXnV~Ji!@S`SDj0tn$wlObEG-rx5cL)%VV<`ZX13#Q7u{JcafuV@=TN* zbK+}$u|JS5N3A*qd>uPqsV`Wcw9H=R2(Qt7OcdX2IChgxJmg^tUF72TRmC2md2$OBZ5rJ38-X7`d9eZeS)E-kEu-C>%#=en9PuNwAE8#Y zY+Mbc2v!!g8Fyr{Z7#1m#UVWJd90q`yqVS$he8y}n(IlkC9O|E@NPt4%(cuB?(2=R z!Q7*!hB>VjyKSM%W9axjXv4O1qoj_%CeP|yqI~UN8#GS4`*Yv`%$E^YE_FA+?w@o$Pm* zS*%b7K|k61lnYv}Qj*l;ScRfT&3PPN#GJ?RDBmAJD4b6qx)-)o)_bYMQQajL9ve04 zI@kp})TzFv1_1C{T%=4cq6~?tcsw}CNq|KcUXpW#pa+?SZ6zS~0bq*)oyxt%Z`@6gQ$B*Z)aOl?F{sYp4pv>-Q8AwVaGTq|UvxrOk zx3RJWhrZXgBkAl~+5vbbrtVO`9JDNEP@&A=7LFl|D%~`}{{|(!r?JJ*T+$uEO=lAl zAZ=o{gvBn1eYOpRsT)=7yYl!)(lB1Mur=W1D4~w1GeX_Sl|VXvG{h*-zI`DTBjEHq-dVP>$&$X%&i`)HEKJ$3RbC8)k@4ny=qA# zs^Iv#MjjtOKCm`jR%{zgW^@zkYV#Sh^(8+SHfsvHs=kQ`Un9Qxs~gpTHr&AuxSAQQ z9~R$y71LGqOAvA?LAkS|#WFIDNOx06=jh6Z)){g>#*W8F6hIujp72VkvH2<7!1#5B zy8`A%RJT1g4vMH##=`Z{{`sm7J90BD0uGMne-~wmxlK(Cbs8H&X8_n0V@um^k`I4W zhALhAVWHRg=dG@ld|);}&N1RgUuVz2!D083a8m+#UZ?Mv9s7H5?C&RzJ&L{our9-3 z-})$%Zx}es^{+|v86`_$-)Ya)SSQ}KR}Ck#j98MRKH_EK5!&p1JwAebhi#{|-UK{Iv zN-DvV^@Gz%HC{@lIqS+ciRfxcCFg;5RL2&5luuT=^kaeB>~6KcfW0;mc+Sl}@tp5v zp-sK7N~&}=Kl5%9R5gWdLsqZ6Uk~Vjdb1OIqJI}AnW_=$WbDV`2cdedJQ<3Y6?c_> z6e)Q zVd8B-qhBREf0P?RUP&2U;g*+>0WQ$a?4HKyhERkKp6#9Ap|4L9Q`li=GP^Fq5KWIY zVZKuE*}-#~0QjpxwXSo7Y5r7{_RJd@ZYnEc{XojG!c&@nyj&qvC{wB42|wMyEDTO= zR?13p)?@aJun>h1H2V5ROto#R5t-<^v-%-T1(E&^vJj&_WPs{rPrZ*K0* z?BNG|wQY2aqK25gftM&KP9sv?Xra5&dTsG!*NCM+kNia!kmm6@JcE^y_N3Ie%gbf5%oJ1HD zDynzu)XP<;ULamKM`w|2U+x9+`I!R;IV}fZYnn#q=Ra}KE9p$FBh7~*`htZz`IJC? zTHjT6*yZtqdL8%-9!_Z)sJb6c=dH7`E9?dt<4P)Jt}OBtY>!TX^mt!&%O8wBi^+ZoTVl zVFvHzM>U9oLYY-g9OLCPtBpjjK$0G^2b4r|_XX)Y^7PoStD%1qJ2+R>v~okn9|dw# z`9Vma%7M0)AkqKhS~pO_KD`<4j%40_gM94^)9_4E)65MSdsO(uLkf!2M|GX^XNrIGM|ln4Z9DoV zqajlaQv7N_FJGnr%Pw#|m2S!6)S1)x3qz`Dqyks*meD1I0>%;$a`<(HepJ3ToOs@z zL|L-9k@-URO)gL{Dc=&CR}mEViJTEpYTrJz!NgHb!kkcDv3x-Y@N$3;^BGVpv@#4f z-6wk4HMxNbWcLGBJO#C(k@l@)xBcX)D5L_O$6aELn+F+~<3DfnK7PS#D~R;9gxmH6 zQQ#8T9KiUt7`nlr-Rdsb&2CPLI_Et!*|Mu*Q_Q-u{b<9vkz|jLD${*8mX_Kgx7`^v zIvxN6jSnf`Qn6PU@%PGvhq^IRw2wXlrhvj^ZCLgrwuaFmKKQqQ5`g&qGys7#YNHv6 z0f?O$Jqe8Dd$>aJ@3E#W>7-{~IMW<9ocFR&o$A_aH?Tauy@79->ED8?Sa^FW2)5aV zTZYm)`t(Hf{dPuuYNtlSXF-oIr)xKJNbK2{28+m?s=iWNk~#!!@)(UrxgFjZ zXEj00N>YI(D}z|=W|f;oY*o)yKYemM(NsQQjIs93#us&YnxJS&5p08?^P5gQ_hs6` zJ+Bc#kv&fyf_xqgr}0f~MjSh?8x2l!*~lY(_>obX35cCUo#NlymSm;ZY@wG4Kz-Sc z6bmYPHcUksXd!65Fb>lLRA2-i*+No5;TWwv~nXC63VQp-qpY%+4bYAm$_)c!~ zwIZv&rZ}r4zA(5NT(aHn9tX~AI;Bf-?VYbsmUn7&X+tjxBgX_WZ4}5#^F?!^h~+ki zAB=n_MOr{DCZI|NijdFX-e451h6g$4-ig&`4-6zmV!SYB31S@#C>e`7O+hy0lXqMa zwh7!3CygAjRCWST8M^5_18hS}Dlg#Ax|J|f7hb<#Z~Hb1vontpSm)aJ*@KZ$^-C6a zvbzEhrQl#%@oI4l;%Zlisv88&EwAveA2XV*qP{b<4&YRu zRozKSBa+;-d@_iw2A@HLK~RMi=;xYSbI$rN0F<_f(5hQmFFUl1hLqZUDr! z_vY2{8Mas7QX+}x9$6zjLXFAYFC4)G6y8WG5M*(>OSgW^*v9lN8p%nnrK?0R#u34# z(JYbMr!>X?ltcaXgJ!FOwAI|_cl{~TK~yJ|`zGV?p{U${l0`1;L5$=$UA+k$6?Ci~;N~ zJ}o)af9U{)mEy}pX$wJ2X$9IU(z%fk^nJ7(^A^2i#{?$w2Wa&u6PtGoRh?wRhyx%j z0oQ$+p?abt5x@yC-$V=5CXgtv+cl$gk)fqcO{I0rS9v~Ww~0nGKgSCWS)~>vWk85c zz?Cw!yBaekRZDnp5}E15CvL>%J?N0{KPmCxa5+t=JF)8k5aknPgNoRMQ5(hHl@Jxm~-g@gPb=mVKD zj)(fI8)MgYy_^9>M5T`JB+hzENH@mrFw0Tn(t?&sz5}WJb9lTEZwf*9+rfF%vG+8v zsYx31-HARYZ3PhzZhn7*(y{ozkkM(NULGzeZWB%Bye9%z z{1?4s;84n!fof+1{qPq36rTI=E;hXX<(0KV@zYM;K_~uIrA(e2U?=qLwUHB6z#v%r z$AA0L?M2+YliVg(RS>1*wJ*K3nIZbX-bkq(%$9{xmH?c;)jTU%)u1p7e)+_q&Q(qN z%Tt3(wYvbFO3;6$(*oTv2tvpzl#RS))YqkkejLP@TFGFrs54LMWUQ* z3Txo1(I20MmkD>Y#{f&Jrw7-;UG=3+C~6ZmDTx{kH>UuD^-maMAJ4t7n_UP4y`7@Y zfeD|CTrLBgvS5Mkbf-*eDcbKULJ_`4%MDzo%uHgUf$_Kv-UkyPBfW9=(xtV)YYLKl z;57jSzt&|G1f@W4@G7k4xdNXn`(6kSq@QTRO-OjXtLlVj^DllK9l{$^p!6a^h93y> z^Dz*rk9F@ZVi8u7%j*!pIgMQnoYR;qxF=Gd7u3{jq%QF7cRJE(fua@!Ei;{Vpy-hN zqf|tRT1nSjQRjvb(PiVzKEn(e&xPJ`8$g_plZnbzkmxL;m2hzUU_&Lxf9s_4JHf`l zC-{(!-&D&mZHqX0k-k({wX0Oc;&a_Ne>{xa2vgeyT(1dHcvs}ft@(D+`<*~EXs`Vc z(Fq1grJD2{68;ZPWb2lI8bdlx(5*tj zl6PgPQd6x>?_9GBDj?RzPt&7W{tWHnAL%%t{qf?#m!;v)R5!sB#qFh1{PUJB#r#_W z7{bZTx&R)z#Tp$lc{YFYg0G~eA$*Q_Cmx0Y?!*5ZzzWBY0$>4`Ca{0YU+ydUwK~Zo z*|w-NubOd(Xksem*8#XNHao}v=ffXJArN5A5k&=tFzz%zN`^cD+wX&x;PgudE4vebXB8l??l)+67ph z&O8rXgR5Hd1j-WqWxq~UbSD~c%9hh_knqo>s$zUenOhcTsgv@*WmY#vE6N|Q3L=OQ zQ764kdPAc(t)5?bs00I*nw>=*c~^_5u8i3eV&q1?l>QO@`VS}>8{+@xDR!VqAgyz> zvZQ=MBUTmHeI?^>fCmbb5XNepH52M^18X~_Hzc=jV9#6o`>w_x5@zc;nLYvD$^u=G zft>Dn93++2`2>Ud0mYTB`rmH2PEdLn5ssgY0bTL{`LCqNZ}qqRI<->?gWJ+p(9e@} zj4aS0LBYVZtrFbY_jQCkU6UkRhV^~ohGAq7ON~F!VU=NXbpEXl>%A;Q4nE(oIW zm9WAS3usNEq5brZ)OV~gnQ8imhVnEyDU!QOB4M&l$NDUoRM$>l#~7Wr4B|{z8Q7Ea zvL}r$CgvmJ?V2{WQK4+;^X6?Gt2COwr8xQvXVRc2AMVdi{=5k{WZ9%CzOcFG@14~z z)nU_7_1;TSVT-HI!bOt?$=flHXkoEwWGQgnrc_zQwnPT&9(){g7r1y|b7vn47=uV1 zG%WT2anD+pw@yOm>oY3WAiH*i95SDM?)3c5rL;94aGni^)J&-l`_8qpRSV9$2K?u*p!4rO(y@X4rdopUr5^b^&*+v(|MArz;;%G> zYlQ9bO}*vj`0Io=S5x_j{Uh!WcqoT*SQD1!7){#$ni*=_w+Mu7?|p~=yD79^@Tlt} zzLF*0EFV-mCVgI7I_So;==48bGs&JQ7k20xSU6ws(t9BdHKvit#Q)Md*2{wv>JU$| zd~dmVKTY-Te4g+JERe;^yZ1U}*#k}(nZ~HEZhCotl=X31ZyT{b%lerb^_8>jOK`tkEE}&J9?d)3^%!i_mEt z++U?v_)(dtxjNO8_Ia7uuf*;=pK0xvyw|M70a`N74g%5cSH_!Mu~nr@yGGyNU<;2jViZpQgPujW*?}m>hnWDJZl7^HEVRSg&y)MFsLJ+ZKG` zY63ZC2!*f0x(kdhg=YbR(+S3(tPA{A(T8Q8#b7LY|NJ!Hl(jj2Aoo%lOqF;3BJBLz z-AuA2kN3IbHcQn|3TFrnI&4H=#@&8B7q^s~&19KJ1c8xlF5l?8dsSNjDRM_YqId%2umgrAgim>vcKEJy6?)-lc5;>6Jx<8pE`7cM3 zCXJ^OHxo+l{|ox#|18u5gU($88?>Jb@L=^|cG5J#e-T!|kpa^^|Nr~-#|4j^_7V)n~D7x9VAOe~QxPTo7)8i99 z2EPCFn-%{Kn4arZa5m8B`7w9^{@Y?q6z}QS**7VLPn*Vs4X|lZ;WTr+4e?|VD$RfO zkx21Y!FT3!r2&l81p-6lZ;k2OI@F&b9bsGi_c*$7!a}>JM@7?=;d1;jFi=wbL${PKw*^d$ITD!EN=_T?*E+$QLs-lECFb%9#T?@+$kqdnT%IOm> zk+$%?-!WB5`A6KRQ3l%DS@sW=T~kqV%jQm2WbPj=kALTS|upm+p;!#(-0hbtryapc>c&U&tZ5bOUdA=>dBi3qWUSBop#MP?Ql z*a20^$Tf%hSG@C+r%Hq|o8hIpo^wupUcQ_n$2Vck>Wtv&faxOs%EbSc>Hg$*5!kKK zO^LZpfJ=V(uUwK4OWTY!t{d$w1j2vz_%a`Y%{B0Zm5aB+qK> zeZX()r}%A^a%haMsPaqCI2vq2bn2+mE)5v{9ivM%3(UN;a5CK`3fZ;L1 zc?%90eFCia6tLa}c~Ik-3rZ|+tEp0m+!-I&oOJa}oqC=gbj|PiG4_z=k&VPV;DDRo zl_=-3&s`HFx@>yIwsp_i7cBL(OE2h$Zt`~b~ejJ$)(6nav8|fZc9_#%kVM!aJe!^4~OR;lfpS^%yE(Fn$m=z zM))rY|GRb;(S+b`(H)OYL4A|r_SN?aR_;l3(M=7gLQ2tm{%g^E(W9d%gBs%r8@LSA zR4k~*PqkCTneGV5Wn0Sb-VM?upLPKR^7oSr*1_USXF}cWhU@~3Bl4mhKpV@yV`*t2 zyzw7%G*lqUpm(1Qyb@%~oAg8Jo z6BbL_!J!d$dC-LtGczFpBvn6cO8XS|tFT8FLo;tU#kpvvf)QR|_z(-aJD_v56zMiebV=n z_R#!uqQ~$%coJTf5#q*)BMe@t8Y<9u&BQ+-X0FLFq7ROyh3W~P53Ux-W zGk}01zg7=MgiA*{0+t^U(4~4%>#L8G>t(kq_uIObF)!(He)&p$5g~5jxl+8gJ`6|S z2CSp@HTpuzIw{G%jtMS`sRbS1Nssfj>MJ7L2+VyBEj!|{Gy`DA?aP5Kqb3@)5 z_NV7P53`G3gZU7PJKMaqxR-IfI-?U-0OdloRKyHGgXKgQ(=^X;U#UV@c<^t3k8Fb^ z0}B+&j1F3W`W*)y$|0nEzO>57vLX}SNv?L~)z7~P)QF{(}r$**@? zoZZe;pBeG3G?C&9`Uxj`FaC)eP690@N&&DONuH1B6+dA+XFwhx?C3=4)<%7*(e9%j z$8o2LuEKT$gPju%d+#9mIqR9haCM_YiU86wX_e&>=whv%tztvo&o zuSS(5_!e)5-KYZw2>4J(oxTc$dzdZ&F-l(qtj>BJsmy;2Cl-fmy}i8eTvWW&Fj>6> zlm@9b3ug=mZ6eUM#kf5zlw9ju0BM6>&f@lukvUb{vrwG(Pxi)Y+E)l0S(Vk-ldN`vqIesEc z=S?k&+rsQiOeu)@()hg9_(k(e|{ z^O1s}1c9_u#J5tt^)WtM`Qog*quY`bp`K}z6^ha^4A>i?(T#MHxE*ERi!O@PP(qknl|U8a)A{fV?I|FcLf zf6t;%u9GrII!7YM_X8H)CDkxVM2E@tein-zhFdw^6}4C1L|Z(a*U!Bt@Ofv`c(oEw z&|LTy5Qktt0q7yv5CU4{bEYYTzj2EEJ7y!I!PW_wFG%Jw0n4X=-6;MAFRT&XU}C|7 z;;@^`+f@8H*-;;VC&FUY{$BngORu^S!1#N>9uz}V`|^qTk7{5|xdZCO7LBIif4LTNNS2F>iYyR|mnl)TYUizg?!sPfNqGOY!q+C+^n`F#r>p>u1 zJ%er&^B(_uJG0W?Cv7SR@!My}$;_Q!WQ%clNM=|GzFP$+5p7uee>pg&NuNJ4fl}?f zZE_Ze-O3l+UY!;*|6PLLGaHxMl52x;NCN|LeM)<(!C)zG%`TQ&3hLZ)DKdIZ?{z3Z zkcJ^&g)PFabe$FheeR?gU}gey;31e56MUDMBlIc{@U`@8DG9J!{|(?B!85wiQ_u;1 z!N)X_K|47(&Bo0?u_Zq{@6wN^oAyqY#7%|o#60W*{+S-0g#%vOePgg~*Vhtk6+^(+ z?*s^qMT5IXB!JaN4C={8|CAY>29o%jEjdw*h}?+hV9i*)q4I6^+HTy@D}bNq_FkNY zvQWe<(C7befcA_F*nSrd4yfZ~i*G$}Ga{uTsI&0~3DTPvQNEkF`Pt&E3_-x+e=2^u zF=RW8{X_R~K=_4>Q@YlyBfrcU&FlMiC1`Xu0=YX?4+v_b2PW_4vNt(RGpc_xAd2F* zW(@5*JeJE}Xz4X@7w_=6KbE!%>t&DZwx=)I4=jPIDlvLuKn}Fp){hy^{-=b;py0L` zyNBtLt9=VDlR6aBc4Ulo@x^OE4WV%O5x_1f^5t)t!~2y zE$VDsO6qFsgUAr0-#W|Kp3@2GB-(@pnl8Gpo?F$s&ChP9%VbgrG4&xT zcX{sJjFhvht-}X0*eUgYou_Pgn?SLE~?SWy9$HV>X-!@!j0A2+6^H1!BCH~sdjRmNCbLE+OI(t)YT zg6ezC&WzM^#i}Lk@DZxut;}J{u8CygDrfZlzS+AHAN+(Bq{;3IF+!@U!U0=6ziX8y zfWM%!OE^(?Y@}0V{Nt40W1Y?D^0^>oJuwqD;o?kF)4j&1Wd0)y&TCa58KXTTx$LDE zYx(GU*p{99Zg}(S5az4xIHlwsG6x}@$W^J>mL?s1RUtPiO-%P@vn$e%^$!1vPzN(? zFCR$faVOdeLi^v@{USIhH+9Urcfj>N>YE16D5XAR;fkill_y?Ysdm^QfAw$Uz7921 z`}e?|P6v;U!Kj}C&KY#8cbk{()&!JAchSDuA>uiSmEfBdyAMBDhkkH(IA`<0$r)#H zh5M4S6$Foc3|~_RE47Ki)^wGm^BcfEN3Ouh(XCQCZ#L=oyw{q{9IR=Q+Eq^&GPiHR z9^$W7cl|f)!q9V3=Q1pQ7c%eOeVUE|*f&REs^0zt0})|kVk@LcYQR_{-of0TOgF<`RRT*M@LGpr zS6dH{`WaomMhtKvnQfX42CgY1EbYadn_25kL>Q(UJB38;=-|U+P;>i)%Nl zq8cu~-HuY*b@RdNB2O-Z=K+inXnc z{N--xhUG&5KE7w9lkc*7sF30@v?|31d9n1M;P_SFTs!BW`?1?WcQ9z0Vx>r@c;sAD zjKGSE-A@}F(pc_k`XL#8O0_v8_1&zK*KRo7MxFBmQXv$u+w4Wsg>W9p^&Oxfb$YCN zJ3B0)8~!GUzhgRl>FD%d;p3ywyA;h!z{Yi4e4SC40ex*sSY-Asa^tLOadh9ePr=@a zD=Wa-#Eq)v_lfPfH$W5rP;t*QzSz2Y|M-C|(GYb#*(?K4v{EmBzpB!u@@F^mB?&$*OQ#r@SU&VlB z4~>MEX*7;Caa>k4`prc9U(FWj_?AMMc>q9qW~zeZ)v#Yumy1kz)u@Y+hvp`Up(F7Im~rlwnq`gX(COsrLy0?Z-ov`RIk>ZW+=?PT2tI~C;a zgH?0p3yeco!nE!Tz1GA#M5q_J+#om4$`+r_2eVOx@>QA&Wvrg~Icj>emE83uL2+te zws22l8FL3Wzadmnr%v-+A$!9haE^!ek6WcjSQ$BhVOiZhV7?K0foJ#dLoYVnCe{*W zjNcsSMDzig7|4!e6wwl=r^~FnWSBsAbzPTe^0~3fg z`nkcjLcZRb)$IpHtmdy48zoL{fhi0osx6;bPy_nZ=Sx;>Dy^Re_bJ^VjdlFt@R%Ka z?c-c&8UN>Bq;~up>r2`~<)$z<0NAC%Rn*_si!7B2PJl-~=#yYr-y^t(9>C0dd*pjU zOsEV7k%N>nG`^jYBNfO^+{?-70Qv>u&IM7&U)8af#@70}*22p7f~7yoy6XUJUyAsG zWFE*koLDX14_6W6Gj#NW?oVyh0j?vQTEkuinVJN;N|zE&;rRw@K)#;0{)ma@veb6C zC#87lZ4ejWj#i!Je(MOf?V2WzIOiMZ)VYCiVe~)-GA!t6AM($M%{n0bly2j&he98X z1+hbi)YSdYbPLyFWwdMjmSnJwJ=8JVF8HpW+bUZRpf;~%OQFICsI$9L{Qv;l3;8`@tg0X5`gEW10isrYv{6is^2@BAP{cO^Z=Mx36qYRY9 z>^KrbiPEZZ>HZI}bZ$y>Ev%8Q(I$825!jY`xLaeeb||&<#^ALG*L8%0Twx$_NK)nfCa9WB zXZDmaF)*+%=QY1847{|m0lWgEiEi1=-Pw?R0nL*OXAek;^rUDga)dJ6&wbrXDP97K z6KA?L#6!K(g{lSY5S8r1Oc2tyCk=@ZIaXb7cnCY>YUln==0L9lP~-XRwXx$JgGA!d z9w!Jo`lFiD@j%Azd=c?P;jkA4r5I2I))6&BQ&)PmR{X^FaS6TbGMPRLygGx|f^I|t zkuk3p??f^zIX7a!rG&oD3FM(!s)IyuscSky<#f>jqoWO&!toTa8}O#{z>@q7wZ@w= z&|_g=@$;T4ST$HA&<32cA%uj#Ie&;ncWo}T6YEKQudJx-P``5&oZk6+p| zP;^2j^So3}AGX71Q2cd{l%RJCzUzw3MAMN2J`Y$=LF;4*(&$hF@q%JeMKamQ!_`(9 zGVhMfvJ$V3YPV#?>+a%eHslVE1Ywu=Z^zuE9_m-ksl+tGW{w-e4;lsCK?v924ZzL7 z{?|LIC0meWOHSf^aI?NgulNYERc0UjTbYYsFEsEKk1B!XB>D3TmyUQ5WgyZ1@#~fq zX0pHYm>Gg^yMr>g>G^Vut_{2+blsD9f=vB9fuUb zJ@(SVodmPGaKIHNhjDGfer=ww$ieYnfbELEu45$6Sy-2HJp2dA2_9fMT<{JLMJUDE z*NE2h=u&l|1c5YLwkC0u-q`s+zD}+s&?v+3((ab16tqvnpk@*fF5Uo zMd>B&kK%{~X%C&3ah(P5;K-}qekx0t6cn-QLd9O}lREsVGElGVJO-SESE+{VV&&|8 z=({L61T|zM#edn>c(vPLrBJ0y|2l6`9OCz=`wyF4lT=3)^1=E$)VK`x7!;uo8|i6+HOC20VO&C-p2Vd5uJJ_kv1k0n5DC^Gd1xz$yp zxMn7pz zHZGFS<-e3_r_;di>|4}E-4>MKGjtoFX61FxfP<3(!!E86YTO!$v&<4IcAYUjBEnF3 z<*0m6uB=m-5B7)_j{RhG$fB6W&#ihhUIiAjR;v*lJeET#_e*jN9M{vmpT<`o4B3gK zuXtfUa4TvvUnu=tGy*gt-? z0G2;4$CN4;Ne!i3AQj3)>TswNkvyWhit*k5Q*+I94 zPFRfR%vy%IL$#^N^>ikh1NE0Yrjo(h{);XBupo^B7BdfKgg$rM=X$J=>QHSxOIUS!(20kL3T03=Q2@mo~NLDX@hvK0G|PY5jK^s z-0KoMWVq!iu&W6ZAsz)QkXccJ3*1cDUf=SfxdX2*jy4V_tBuTuQ?Wlj*gOM@gMlU7 z@2w-%Bvqfr&Np&XI-_Qlo7d*0FG{Y;F|kD(&fPX(n(mKRut62|t&Vu05x(!6^`-mJ z*Kv+@_}4!JW+(cVn82(w5U;%HSIKLLZI#J`cX#-V>*t>f_YSP{&X4S?ZT=oIg&~uh zEBveGj4u~B4bq#(x_iJL>!pMymK-zNOls9c73NE|e6eIs4&s@ln=xZm3?Nu?Dw^76 zHauf*1@qB;)&)VJYY=3I7*zuTH?gZ9EBjaX|=E zdi=AF$!1t?YQP&D$k~SnZAa!JO^l?FzW?%wLO}vT^am8gN(eW(%;Yab5||;Mu4S#jf0Mj;|I6&ck*%gGRKY z!_Vk=D)izwpk8u2Jg1JgW-o9q`;;QixQd*S@fcLQ#wy@J;lGYY_}NxDCDz+(KytfPY$%ywq5_CEwKIiTc@wq0?><%JP6kEt4T}-wcs2Szt1#8oI(9 z;PKfojurCEE{?1GSz+i8o=c$K@&tNv)H>o(j_dkCgX>*}wT<0nIx@~6{HWC}L2rO% zqz?VL4WN7bTJh60FnDQD!x6j+b|09m`MEw281aB?A5;C)N{>#JHRs#04?|E#$@`ti z0l%&~4J0X0z(qXOoClWOXQ7VWJ(T%^Ud_5Rp44MbrH5oU-%7sA@}YK^&6;^0SiM?x z>zo}m8J?}kO)IW_U+8r?ORU88PHyBWJf3k6wGFL>vP83!7G0y z=set9XtZzI8v)=MxIeAop~&5Yg92boyM2p2U$;7erKpCqp+He-?97P!AlMweBXsoT zhhAuc8FahIEVLV_Inh1RK>&LS2wm59>If5oAl8>_bZ@J0ZFCqP^QuTj=$eK?UC9%M zb~D&GyCG?~k8iZ4q2Qpz4U}xUZ$^0XA0fd8{Kt{)%tX_Zj$${iTuJvcDDwgk8g(`N zMp%4mfy4mRue2*qmZY&R3kt_0nt3&B9OCI~l~4s|p>AN*d7muBBnHBwQbYeKsdH}jQp zr~Ec_LC{0clUGhlqu7fVp&6CeAAWjDDL0Razn!DCpJz zfD!NvaL$e(@DSEa8P7lH1_{;7R&UaRdLDQ$Ch&zVa^`aIP>KtO7P)~BTvBm|ouJH| zbRNunQj1teq~q>2s%7_GD*mV}0#6CUqCxJ)>vuml0FL2 zvqh!L@7>9U)X{i>oRR)?6#4=ef*us(@_efl8rU6cWQbe3KOPrM)r>i;=S>@YyN~jDqn~5V*$m!zN(CFYu zYAnaN z&o|gSnD7LNXTFhGV{&9LnhIW>J)EY$#2}WJY$ef~arH59u^Mf`VlMGbV6n`)H)@NK zAw8Ag=umg*YhPKbVRiC)>G+pFgxe8imNuw?96fROURlkqvKT7o0_>()92?y%m;S(F zOa2atoru2YUENN^(P{y_D%o*6!+RUNt`~L&u_`{XGQKG4j;}OPw}083Gk@Z z`LYph)AX*&>uF@d_2OY$$$*2Kpzv!K+i%nf_dW`XTL`bb+5U}1pO}ej6}cTf{f9@) zJp=Y(PlHF)`Lf7!!=C+kCQmZ8jCH@B=M%&;r_Q>aCy|BZ`NH*zsfNMS4Jo#sY6L@}CE_i-UZ!kHFzYuJ9Ks*L!7l^*<)B*$Rr1Nv# z6%AwkhT)`naUD;Pz*vuNexhrAWcW%Y^Fh|?cN6^j;;F4vFK`OyPT}I;@3n01DXO3^ z`bk;PBg~w`I~FwAMx|s>-e|aN%&U`(|G{2>8mOCD3hKSh%hKLKI1x3)Mu28XKxUvp zS2Npz0Y%RcSE+~632KKr&3gCpjG2a$9U~9ib}O++0hQv zFt`It9uT_#jHaI1gTZfVsrw7oA=L@hL2cVWU!bJlaJv}h5mUJZLjVw33fl&*Z$YWr z0Oc`GkymP(6T!MTA~cJPmJ|39D(ClqFgTu0C0H0>>@^3#hR;WO$b=q}7)<@;fHz~a z`;)bg*oA~{#xnpfRx?9ezLWpET24seYY%4J4;l_(0IbvUvdEX+;r>VS?VWY5A(1MM z`VqBgFl+D=P_Ou{#|s38w^S0_pvxW`e7r~=^&ehP$Mj|D@;w*h%ru2{PWm7kb%92$ zFd44{CJO+i0jLFj*j0EUaKQ>E!Ang94@XoJ(YM09o9(1^TySt6R3(v1J@XLAQz1M9 z0h@Qgru39Dm$C=2SjCfBX`4{rL48XQ1U;)fGiL$C@0`j-Z92baH=Bj6rPv|mIKG6g znAcjo<~)x@@U@Yy&h%=`!DZCjnXj5Tv-(OP9o#q{Mqnpdm>jXCW#X;Mr#d}diMg5a ze^GwhAH4v47#E$?=oe6>4CI;Y>RN+00d(0Hbmd(HU0JJ| zI3*Qvhlx(ckDD!5YD^p?$p!OT1YF$J3hX1exYuFk4Cjx|17uL&Vr@z%OSd`TLiF~I zkKQCSFPA*W;%0-f+QFuLNA)?E(QOIa^|7xsyDUr^lRP(!8HO&q#wTNn&tgXi^ms<% zcXZb&qhF~@=e~S8-6PTgzU4k_@I%#tBraCk)(@3BJ1s8lJBJj&QMp=vl`_=7S{XiE zRjQMeFP2MCbH_@dtO9^hPYktE6!R0f6+DX&=^DIum`E_C9*%khNP#=P3Um1VpYL{Q z#Of><0rttaPuxe$7cgqXe#9N&yFInZM+5WZ5tZL z^t8%u%WS!niuq%uz3jyE7JVG!j%@KWwXSCuDz7+*Nh&VJD_$vZ_SetikDi+Uwtwwt#KhHm~GbE;&n7b zN*RA;c}nEMkm}%`Tl9LH^JHp9w7j*0aWZH;mKubzT&N^D7FOL6-^z*i9e3s!8}_-+ z*qrK7EXXwR60#hVbsmuEo?9y7^G%II?%e2vqOa-rkxWV=vOD zRI?S82+p|hr_hs9Sp@YW>RP5)Ei||n*g_nR)CT+lv-!S5JX%9)ny_b68BVuCyFKj` zLMG@j7`p>F8P1I84}F0x=XbW26w9x2BVi3eUCb1}@WIdoGir}i2Tze`+<|5@%KauA zJUEnk;W+QxZCxH7=s0dj(?Kpa%5CrH{E>t1qQs>cY}HFNNo^nlQ*Tshi3`%^tXkU4 z(O#6+V5^=;Mjic+%}hLitDQM>gCxn|tM9)rv>I>Cb0brb!D@^p=_r@uGYE(MqZ<3T zDB1YyX@pb9Y$VW6uo+g!X2~jyMrrsmW_04nVZg<6+%`TqnHHyKhnx)5811OA zBUlJmt5rAZVrhgn?O$3y0PUf1W>twhZ#Um^uSv&yCW$|gN1k~#y#fDO-E4o|3Zb%; ztKeDVjyrLLMoD;4LELP+mUqq?!lPpBmjpk1ORee80@9kkX8;SP=nnLcd)?r&2(ODu zN5I`g))vZ>lO%N4ZSu@LXeQ%xSl*pzM6;z`=7OU_(Q!@y+Twwb+7&{`tV_~}gvE!% zn@CaYV?k}S@_m7aD#7sgaBTyyzrPli;{z8lhVy!OzKN;%icyMUUSKJ()`A9~QEgCT z&FA|*2U&vW$l-SfU{-a(@g)g?G$->GPlWBe#fG$o2Q3%C)`B#aE@l1PujA25FrKP; zn%--!c>b}}wcc=PzFT&8A0JEz#88YZf)YMAFgKakmW-Bdq}Nco=LHsNGkzRxcZYN; z(zdPyd^_eKrGi^+?`;2kUf>-oX_GrzcKS`NfVQIlPz}+{Dmakiv7rSjmw#>-A>;5{ zu$NCz?<=6^t(`vTvY@n05gj>8<~n8k9oFqq*?v)bzY!y{9ZsZDD0Yvutv2gq>;DPN zhOaAJICHvhg;uSIIVfuXIK#_MRKPVHcHyttYt$h(BV`Yrk^+rK3||8v%@k)yOXLHY zszF)A-9p@L-#2fYP_~nDQF{=14mRPuptJ~7X^MXV8BQ|p^B2{d7x@ z31yZ^$faeJvHxN@7f8$p4D5zWtYUs*eFL{0aKwRss#Jt`mdv|R!%HSy?dtq2M%_N# z6a6JIvq-2z4(;>Nf-AF9&eUxp`BbcACK3`l6fI^-t=EHYLOuAR1ZiQlkcMs9$xmWF zjNo{!oPjdDWhH;Vx&sX+n04($t5|PBBmNgKEIc>Mt^S z40S8g?8Mi(-s7qG(|KG>drH^YLhf*b1tTghHU8BTtA{ks@RHiE!YA`y#PQ<#ZN>x?D?YJY=kwqm5#8`_zctU zD}ZbjR8``n&<^*uUF7}*A425(^D%Df#@ia=W7=@N8N*d&9La3R=BOL;f?WLr^)LO^ asze9 \ No newline at end of file From 73d9021e95f7fd7075c6a743deb74181bd4f6493 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 21 Jul 2025 15:06:26 +0800 Subject: [PATCH 055/582] feat: channel kling support New API --- controller/swag_video.go | 20 ++++++++++ controller/task_video.go | 23 +++++++---- relay/channel/task/kling/adaptor.go | 60 ++++++++++++---------------- relay/constant/relay_mode.go | 2 +- router/video-router.go | 2 + web/src/pages/Channel/EditChannel.js | 2 +- 6 files changed, 66 insertions(+), 43 deletions(-) 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/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index afa39201..4ebb485f 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -50,6 +50,7 @@ type requestPayload struct { 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 +74,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 +162,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 @@ -288,21 +276,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 +307,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/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index 0934d891..bf771f8d 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -68,7 +68,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: From 1f0e074e3b631f994f515873f6ff0c7b6b7723df Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 21:40:54 +0800 Subject: [PATCH 056/582] =?UTF-8?q?=F0=9F=93=B1=20docs(README):=20refactor?= =?UTF-8?q?=20partners=20section=20for=20responsive=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 3 +++ README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.en.md b/README.en.md index 8e528131..442dae43 100644 --- a/README.en.md +++ b/README.en.md @@ -195,12 +195,15 @@ If you have any questions, please refer to [Help and Support](https://docs.newap Cherry Studio +      Peking University +      UCloud +      Alibaba Cloud diff --git a/README.md b/README.md index 86c6d24b..3b43e646 100644 --- a/README.md +++ b/README.md @@ -194,12 +194,15 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 Cherry Studio +      北京大学 +      UCloud 优刻得 +      阿里云 From 466b4193679619a81dcd92e08f7b87e8e0bd37c3 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 21 Jul 2025 22:16:03 +0800 Subject: [PATCH 057/582] =?UTF-8?q?=F0=9F=93=B1=20docs(README):=20refactor?= =?UTF-8?q?=20partners=20section=20for=20responsive=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 4 ---- README.md | 4 ---- docs/images/aliyun.png | Bin 34190 -> 0 bytes 3 files changed, 8 deletions(-) delete mode 100644 docs/images/aliyun.png diff --git a/README.en.md b/README.en.md index 442dae43..df7f1cbc 100644 --- a/README.en.md +++ b/README.en.md @@ -203,10 +203,6 @@ If you have any questions, please refer to [Help and Support](https://docs.newap UCloud -      - Alibaba Cloud

No particular order

diff --git a/README.md b/README.md index 3b43e646..4060715c 100644 --- a/README.md +++ b/README.md @@ -202,10 +202,6 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 UCloud 优刻得 -      - 阿里云

排名不分先后

diff --git a/docs/images/aliyun.png b/docs/images/aliyun.png deleted file mode 100644 index 87b03d3528af0009e977a1d23cef735dbfe2eaec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34190 zcmeFadpOi-A2|G#m3D1OZFEBMln$b{ki(!Nm5@#dn3?w;RNC#Z+rEGNe(&`@*Y!Mm+3T6l_r5>p^Buj% z)@t^5i@$>)X!gz>7JDH`J`{pv%4f|4|K{w>7aHInGX8t5wn91NrEKuWHy>wm?uG zMq&Jz9Qga&pLZPghoD7G(*MdVtb5T5LBDG5wAivg*rk^)|4_{#M1qPl`^DUI`9ttO zXKIaqNof6F8?-aReCt-l6`MC16bDrj+#GdI$N#>r($e?kl}kyjs5n%4;qTtX0!DJl z8#G@WMuw}aR^JM$sp3_Int6p$@z1LXJTeXz;8=fch4qbv;2=}~S%zD!Eu8W8FUWlN z2Y>DPKkF~#VAXtgMY?YwsnOj4>l4`N=dQBUv+$@xeusgc@o~L;S4mOx#oUD6aYc(K z20{19TbJc3%-O|Kt^27(WbG~=O7kYyRL9aE_q>LCTwkRqM=)Qiai1XXN)XRKp07) zx_xAr-xPV{W|>$_JADa{HES;DnJ0c!I?PF{~s=pCqW&*=Fnu(HAkB{TWs$9l@4 zk@njCIEh#OHZj#ZMv$R?IohO&$O-Vz9I5U}0TBp&~w?AE= z9GbO8e66MRo;wY_Mr6MGH6g$S9!Q;Sly&A!&wwtw^nZgc16&#PaWi+7nnfdPU%dUL zl5%L}Bi@nx`9OWAC{L+vg@4lqe zQ1rX?4+ZPYJcs{$VnjPL`~w=6L*AGAtXF(D4$Tvk(*g=*r+$}xsDGV_pz4{`5%CN6 z?o$rQnfYp-4g>1Tya&nT_;6ZFOTQ_y+!hfMMCP zrA2;5@MwHf6*rOoySay1tqC@%ff&o&?;+I_5Y!bBzD{U*8q*9f+CY1aWs?+Ba|*hx zt9>0aJ!IPK2Gmz}jj!lFJ|@m+)+07yIl=cLUUa&izUfXD|J|g3(xeK zDcPru23F66exZuwnE8SYv@TsrlD>U>(hv-QVv=vgk=;^r1RQP>Jp_xJZ61c0vsdzB zAb{D-R#l5QcO1UknT`O*R4C^ z>paUF7?ee0WyqZEDPwjPKin&G&ew|Z*s5{CB{D}q2gnCp;%2Zz@dx|lmRE3o6?EJP zNk@dwAG(J%^$M(x29Taminoi_vSPH=1yT3oc}p*v3zDN&?F}n&w^Oe9#o`CT9A|Ea zuFxg?Vsl~g4#j!)Ogwuj%sApv_>knT|E)~8YVrBzeVFcMncX<@20%a8pGF>8?I+w~ z9sLF`v9O5irs<&`1h4#GY8SwkI;y-#=%qN+Hg_m*UtmMNY?RNy3iq|d%~*~_SeB4d zMkJco%sqM<5k84366`M|yjrnwxsqk>HtJY{LYZQ#d!fXDBD<}DG%QZUEjKsNeyNA$ zT7&_?PQaEB!M_PGDgsmtjqB`C`zsaSA-zEmDMQrN9CGoXo@aH6t zBD;RQKx|*M@H~0-7q8}3F1UuRKewfUrL%cQ&Ab#xwPKq0lcIG0{yy8n|;-bq1PxGq^$Mz%VMvb;Ab2p;8H^3Yt z`Vx)xV2V+r48J9un=^LrX3hQ5nFNG3)GP1&M#*LvWD&+~xjN)6gNH{Fch<GA#L`r>V0 zV#zQaA+FHcsLSG~!b%q3TV}Mg0b9>I8&y>}lFa z5}Y>f+vrtDRXk(Nu^;A_g?JqO%)3)1X z3RO>y#V6kcfv=DDUU^c_`z6YytErOzn@oMk)-U2lTj)C=MqCDGnJxaKC$0p1`RA7} z*dtplbP>D|WK#+k5xZwX6@5m&hE+lxP=}2kTky9!EY0$m9dX9T^agGt_|pJ;$7A;B zZkGI)m+d0#cEv0-kPQSIP&Z-Ndl96mU}Tb?c6R4H%CMn&JenBqDD7^ zHCKtSXMmb4Yoz-nwhiMcl=+9_=B!e>)cabMUnR3XZcHU9d#Hu)q>-wUI1~o+uQf2z z`XZQog)%MQ>vL8rUCI{D?}zS}{2YihRjPX#OE(8Mi6+Z`@g}Wjxg$hd`72RR3*Tf_ z_l*&7Fz7`hFP~qkk(!5nNWd9(DPR>Eh^Nb*4fJ8tcJHvL zH4%MhZ2TUZT%{SC{)U4B3OfwwoZa!yP8)E(?e&e|I z6Id(rPjYSLq(=;yg!uMs|fxobDg-C84I4w4q+uvfGsGTLLn=nq`k3DnYM?QKLM) z$HKSOEJfT@Lw^a!uFSLCOCC3~N9z&&;CCc=s)u64(hFwAn}-XrHPl8C>8Tdjc& zfALm0Ic9rqI-Q-*ehjD_C`JL~yhey~DZJRI`kc+SHEM`BwV*E^sOBs;&MWQQb_=($ z&X_7+;x0g~V%71h{Bh0HJRh-3zPxWEK+hc#_Dkqt0cr_r5s#x*^;W@#dSv!QdL--9 zdSGnB9TslJZ8YR?iWfg&-o-g1!o+q^+ziKB+s-V#!rE5id8zdvH|f| z(!8$bIiss>A;%&Hk9x_dzsP@L26K**;$D#xWHdJESus} zq_fF51eNb>mW(4>gu%PlR{54V)hxEiAl>}ZjTMP)DA7qaq%>5pj^PpbXScgUwE;?O z2y=vj1E@pp3h7DJ!UxT3ba&SHq?+n;P{{OUMzE(}V%Kn?91|usR9%*8TF^_Xj&(-A zmXS9P70{0d4x74vWU3oqA@1E$B zbwV5=$Y`@gSf)^57K4^Y)QunyLS0uPxj&Y)rNo)BAN4J%_mZ)v()#wf*TC+0%uKEC z;1sl<$jW(Rv*cvI#OV#%U=-aX5uB5RWGUVqdzD90lYX^=1XFcQeV)c^Kp9K6;T0=) z<*u^~OLbGZyN0xRE7C40vsDsQIZ9k74l?R zw5&YE*kTvac8gj{7f95ozC-u(_Ij(7P8e;vT(Y&@L zj%mD`QJ$s1%S#?V%eD8q3!K-x2gB}3GX~hQq-MH7*CHsT1{c=SbD^VZj%$T?O9_P| zZy$ezV^`JpXnV~Ji!@S`SDj0tn$wlObEG-rx5cL)%VV<`ZX13#Q7u{JcafuV@=TN* zbK+}$u|JS5N3A*qd>uPqsV`Wcw9H=R2(Qt7OcdX2IChgxJmg^tUF72TRmC2md2$OBZ5rJ38-X7`d9eZeS)E-kEu-C>%#=en9PuNwAE8#Y zY+Mbc2v!!g8Fyr{Z7#1m#UVWJd90q`yqVS$he8y}n(IlkC9O|E@NPt4%(cuB?(2=R z!Q7*!hB>VjyKSM%W9axjXv4O1qoj_%CeP|yqI~UN8#GS4`*Yv`%$E^YE_FA+?w@o$Pm* zS*%b7K|k61lnYv}Qj*l;ScRfT&3PPN#GJ?RDBmAJD4b6qx)-)o)_bYMQQajL9ve04 zI@kp})TzFv1_1C{T%=4cq6~?tcsw}CNq|KcUXpW#pa+?SZ6zS~0bq*)oyxt%Z`@6gQ$B*Z)aOl?F{sYp4pv>-Q8AwVaGTq|UvxrOk zx3RJWhrZXgBkAl~+5vbbrtVO`9JDNEP@&A=7LFl|D%~`}{{|(!r?JJ*T+$uEO=lAl zAZ=o{gvBn1eYOpRsT)=7yYl!)(lB1Mur=W1D4~w1GeX_Sl|VXvG{h*-zI`DTBjEHq-dVP>$&$X%&i`)HEKJ$3RbC8)k@4ny=qA# zs^Iv#MjjtOKCm`jR%{zgW^@zkYV#Sh^(8+SHfsvHs=kQ`Un9Qxs~gpTHr&AuxSAQQ z9~R$y71LGqOAvA?LAkS|#WFIDNOx06=jh6Z)){g>#*W8F6hIujp72VkvH2<7!1#5B zy8`A%RJT1g4vMH##=`Z{{`sm7J90BD0uGMne-~wmxlK(Cbs8H&X8_n0V@um^k`I4W zhALhAVWHRg=dG@ld|);}&N1RgUuVz2!D083a8m+#UZ?Mv9s7H5?C&RzJ&L{our9-3 z-})$%Zx}es^{+|v86`_$-)Ya)SSQ}KR}Ck#j98MRKH_EK5!&p1JwAebhi#{|-UK{Iv zN-DvV^@Gz%HC{@lIqS+ciRfxcCFg;5RL2&5luuT=^kaeB>~6KcfW0;mc+Sl}@tp5v zp-sK7N~&}=Kl5%9R5gWdLsqZ6Uk~Vjdb1OIqJI}AnW_=$WbDV`2cdedJQ<3Y6?c_> z6e)Q zVd8B-qhBREf0P?RUP&2U;g*+>0WQ$a?4HKyhERkKp6#9Ap|4L9Q`li=GP^Fq5KWIY zVZKuE*}-#~0QjpxwXSo7Y5r7{_RJd@ZYnEc{XojG!c&@nyj&qvC{wB42|wMyEDTO= zR?13p)?@aJun>h1H2V5ROto#R5t-<^v-%-T1(E&^vJj&_WPs{rPrZ*K0* z?BNG|wQY2aqK25gftM&KP9sv?Xra5&dTsG!*NCM+kNia!kmm6@JcE^y_N3Ie%gbf5%oJ1HD zDynzu)XP<;ULamKM`w|2U+x9+`I!R;IV}fZYnn#q=Ra}KE9p$FBh7~*`htZz`IJC? zTHjT6*yZtqdL8%-9!_Z)sJb6c=dH7`E9?dt<4P)Jt}OBtY>!TX^mt!&%O8wBi^+ZoTVl zVFvHzM>U9oLYY-g9OLCPtBpjjK$0G^2b4r|_XX)Y^7PoStD%1qJ2+R>v~okn9|dw# z`9Vma%7M0)AkqKhS~pO_KD`<4j%40_gM94^)9_4E)65MSdsO(uLkf!2M|GX^XNrIGM|ln4Z9DoV zqajlaQv7N_FJGnr%Pw#|m2S!6)S1)x3qz`Dqyks*meD1I0>%;$a`<(HepJ3ToOs@z zL|L-9k@-URO)gL{Dc=&CR}mEViJTEpYTrJz!NgHb!kkcDv3x-Y@N$3;^BGVpv@#4f z-6wk4HMxNbWcLGBJO#C(k@l@)xBcX)D5L_O$6aELn+F+~<3DfnK7PS#D~R;9gxmH6 zQQ#8T9KiUt7`nlr-Rdsb&2CPLI_Et!*|Mu*Q_Q-u{b<9vkz|jLD${*8mX_Kgx7`^v zIvxN6jSnf`Qn6PU@%PGvhq^IRw2wXlrhvj^ZCLgrwuaFmKKQqQ5`g&qGys7#YNHv6 z0f?O$Jqe8Dd$>aJ@3E#W>7-{~IMW<9ocFR&o$A_aH?Tauy@79->ED8?Sa^FW2)5aV zTZYm)`t(Hf{dPuuYNtlSXF-oIr)xKJNbK2{28+m?s=iWNk~#!!@)(UrxgFjZ zXEj00N>YI(D}z|=W|f;oY*o)yKYemM(NsQQjIs93#us&YnxJS&5p08?^P5gQ_hs6` zJ+Bc#kv&fyf_xqgr}0f~MjSh?8x2l!*~lY(_>obX35cCUo#NlymSm;ZY@wG4Kz-Sc z6bmYPHcUksXd!65Fb>lLRA2-i*+No5;TWwv~nXC63VQp-qpY%+4bYAm$_)c!~ zwIZv&rZ}r4zA(5NT(aHn9tX~AI;Bf-?VYbsmUn7&X+tjxBgX_WZ4}5#^F?!^h~+ki zAB=n_MOr{DCZI|NijdFX-e451h6g$4-ig&`4-6zmV!SYB31S@#C>e`7O+hy0lXqMa zwh7!3CygAjRCWST8M^5_18hS}Dlg#Ax|J|f7hb<#Z~Hb1vontpSm)aJ*@KZ$^-C6a zvbzEhrQl#%@oI4l;%Zlisv88&EwAveA2XV*qP{b<4&YRu zRozKSBa+;-d@_iw2A@HLK~RMi=;xYSbI$rN0F<_f(5hQmFFUl1hLqZUDr! z_vY2{8Mas7QX+}x9$6zjLXFAYFC4)G6y8WG5M*(>OSgW^*v9lN8p%nnrK?0R#u34# z(JYbMr!>X?ltcaXgJ!FOwAI|_cl{~TK~yJ|`zGV?p{U${l0`1;L5$=$UA+k$6?Ci~;N~ zJ}o)af9U{)mEy}pX$wJ2X$9IU(z%fk^nJ7(^A^2i#{?$w2Wa&u6PtGoRh?wRhyx%j z0oQ$+p?abt5x@yC-$V=5CXgtv+cl$gk)fqcO{I0rS9v~Ww~0nGKgSCWS)~>vWk85c zz?Cw!yBaekRZDnp5}E15CvL>%J?N0{KPmCxa5+t=JF)8k5aknPgNoRMQ5(hHl@Jxm~-g@gPb=mVKD zj)(fI8)MgYy_^9>M5T`JB+hzENH@mrFw0Tn(t?&sz5}WJb9lTEZwf*9+rfF%vG+8v zsYx31-HARYZ3PhzZhn7*(y{ozkkM(NULGzeZWB%Bye9%z z{1?4s;84n!fof+1{qPq36rTI=E;hXX<(0KV@zYM;K_~uIrA(e2U?=qLwUHB6z#v%r z$AA0L?M2+YliVg(RS>1*wJ*K3nIZbX-bkq(%$9{xmH?c;)jTU%)u1p7e)+_q&Q(qN z%Tt3(wYvbFO3;6$(*oTv2tvpzl#RS))YqkkejLP@TFGFrs54LMWUQ* z3Txo1(I20MmkD>Y#{f&Jrw7-;UG=3+C~6ZmDTx{kH>UuD^-maMAJ4t7n_UP4y`7@Y zfeD|CTrLBgvS5Mkbf-*eDcbKULJ_`4%MDzo%uHgUf$_Kv-UkyPBfW9=(xtV)YYLKl z;57jSzt&|G1f@W4@G7k4xdNXn`(6kSq@QTRO-OjXtLlVj^DllK9l{$^p!6a^h93y> z^Dz*rk9F@ZVi8u7%j*!pIgMQnoYR;qxF=Gd7u3{jq%QF7cRJE(fua@!Ei;{Vpy-hN zqf|tRT1nSjQRjvb(PiVzKEn(e&xPJ`8$g_plZnbzkmxL;m2hzUU_&Lxf9s_4JHf`l zC-{(!-&D&mZHqX0k-k({wX0Oc;&a_Ne>{xa2vgeyT(1dHcvs}ft@(D+`<*~EXs`Vc z(Fq1grJD2{68;ZPWb2lI8bdlx(5*tj zl6PgPQd6x>?_9GBDj?RzPt&7W{tWHnAL%%t{qf?#m!;v)R5!sB#qFh1{PUJB#r#_W z7{bZTx&R)z#Tp$lc{YFYg0G~eA$*Q_Cmx0Y?!*5ZzzWBY0$>4`Ca{0YU+ydUwK~Zo z*|w-NubOd(Xksem*8#XNHao}v=ffXJArN5A5k&=tFzz%zN`^cD+wX&x;PgudE4vebXB8l??l)+67ph z&O8rXgR5Hd1j-WqWxq~UbSD~c%9hh_knqo>s$zUenOhcTsgv@*WmY#vE6N|Q3L=OQ zQ764kdPAc(t)5?bs00I*nw>=*c~^_5u8i3eV&q1?l>QO@`VS}>8{+@xDR!VqAgyz> zvZQ=MBUTmHeI?^>fCmbb5XNepH52M^18X~_Hzc=jV9#6o`>w_x5@zc;nLYvD$^u=G zft>Dn93++2`2>Ud0mYTB`rmH2PEdLn5ssgY0bTL{`LCqNZ}qqRI<->?gWJ+p(9e@} zj4aS0LBYVZtrFbY_jQCkU6UkRhV^~ohGAq7ON~F!VU=NXbpEXl>%A;Q4nE(oIW zm9WAS3usNEq5brZ)OV~gnQ8imhVnEyDU!QOB4M&l$NDUoRM$>l#~7Wr4B|{z8Q7Ea zvL}r$CgvmJ?V2{WQK4+;^X6?Gt2COwr8xQvXVRc2AMVdi{=5k{WZ9%CzOcFG@14~z z)nU_7_1;TSVT-HI!bOt?$=flHXkoEwWGQgnrc_zQwnPT&9(){g7r1y|b7vn47=uV1 zG%WT2anD+pw@yOm>oY3WAiH*i95SDM?)3c5rL;94aGni^)J&-l`_8qpRSV9$2K?u*p!4rO(y@X4rdopUr5^b^&*+v(|MArz;;%G> zYlQ9bO}*vj`0Io=S5x_j{Uh!WcqoT*SQD1!7){#$ni*=_w+Mu7?|p~=yD79^@Tlt} zzLF*0EFV-mCVgI7I_So;==48bGs&JQ7k20xSU6ws(t9BdHKvit#Q)Md*2{wv>JU$| zd~dmVKTY-Te4g+JERe;^yZ1U}*#k}(nZ~HEZhCotl=X31ZyT{b%lerb^_8>jOK`tkEE}&J9?d)3^%!i_mEt z++U?v_)(dtxjNO8_Ia7uuf*;=pK0xvyw|M70a`N74g%5cSH_!Mu~nr@yGGyNU<;2jViZpQgPujW*?}m>hnWDJZl7^HEVRSg&y)MFsLJ+ZKG` zY63ZC2!*f0x(kdhg=YbR(+S3(tPA{A(T8Q8#b7LY|NJ!Hl(jj2Aoo%lOqF;3BJBLz z-AuA2kN3IbHcQn|3TFrnI&4H=#@&8B7q^s~&19KJ1c8xlF5l?8dsSNjDRM_YqId%2umgrAgim>vcKEJy6?)-lc5;>6Jx<8pE`7cM3 zCXJ^OHxo+l{|ox#|18u5gU($88?>Jb@L=^|cG5J#e-T!|kpa^^|Nr~-#|4j^_7V)n~D7x9VAOe~QxPTo7)8i99 z2EPCFn-%{Kn4arZa5m8B`7w9^{@Y?q6z}QS**7VLPn*Vs4X|lZ;WTr+4e?|VD$RfO zkx21Y!FT3!r2&l81p-6lZ;k2OI@F&b9bsGi_c*$7!a}>JM@7?=;d1;jFi=wbL${PKw*^d$ITD!EN=_T?*E+$QLs-lECFb%9#T?@+$kqdnT%IOm> zk+$%?-!WB5`A6KRQ3l%DS@sW=T~kqV%jQm2WbPj=kALTS|upm+p;!#(-0hbtryapc>c&U&tZ5bOUdA=>dBi3qWUSBop#MP?Ql z*a20^$Tf%hSG@C+r%Hq|o8hIpo^wupUcQ_n$2Vck>Wtv&faxOs%EbSc>Hg$*5!kKK zO^LZpfJ=V(uUwK4OWTY!t{d$w1j2vz_%a`Y%{B0Zm5aB+qK> zeZX()r}%A^a%haMsPaqCI2vq2bn2+mE)5v{9ivM%3(UN;a5CK`3fZ;L1 zc?%90eFCia6tLa}c~Ik-3rZ|+tEp0m+!-I&oOJa}oqC=gbj|PiG4_z=k&VPV;DDRo zl_=-3&s`HFx@>yIwsp_i7cBL(OE2h$Zt`~b~ejJ$)(6nav8|fZc9_#%kVM!aJe!^4~OR;lfpS^%yE(Fn$m=z zM))rY|GRb;(S+b`(H)OYL4A|r_SN?aR_;l3(M=7gLQ2tm{%g^E(W9d%gBs%r8@LSA zR4k~*PqkCTneGV5Wn0Sb-VM?upLPKR^7oSr*1_USXF}cWhU@~3Bl4mhKpV@yV`*t2 zyzw7%G*lqUpm(1Qyb@%~oAg8Jo z6BbL_!J!d$dC-LtGczFpBvn6cO8XS|tFT8FLo;tU#kpvvf)QR|_z(-aJD_v56zMiebV=n z_R#!uqQ~$%coJTf5#q*)BMe@t8Y<9u&BQ+-X0FLFq7ROyh3W~P53Ux-W zGk}01zg7=MgiA*{0+t^U(4~4%>#L8G>t(kq_uIObF)!(He)&p$5g~5jxl+8gJ`6|S z2CSp@HTpuzIw{G%jtMS`sRbS1Nssfj>MJ7L2+VyBEj!|{Gy`DA?aP5Kqb3@)5 z_NV7P53`G3gZU7PJKMaqxR-IfI-?U-0OdloRKyHGgXKgQ(=^X;U#UV@c<^t3k8Fb^ z0}B+&j1F3W`W*)y$|0nEzO>57vLX}SNv?L~)z7~P)QF{(}r$**@? zoZZe;pBeG3G?C&9`Uxj`FaC)eP690@N&&DONuH1B6+dA+XFwhx?C3=4)<%7*(e9%j z$8o2LuEKT$gPju%d+#9mIqR9haCM_YiU86wX_e&>=whv%tztvo&o zuSS(5_!e)5-KYZw2>4J(oxTc$dzdZ&F-l(qtj>BJsmy;2Cl-fmy}i8eTvWW&Fj>6> zlm@9b3ug=mZ6eUM#kf5zlw9ju0BM6>&f@lukvUb{vrwG(Pxi)Y+E)l0S(Vk-ldN`vqIesEc z=S?k&+rsQiOeu)@()hg9_(k(e|{ z^O1s}1c9_u#J5tt^)WtM`Qog*quY`bp`K}z6^ha^4A>i?(T#MHxE*ERi!O@PP(qknl|U8a)A{fV?I|FcLf zf6t;%u9GrII!7YM_X8H)CDkxVM2E@tein-zhFdw^6}4C1L|Z(a*U!Bt@Ofv`c(oEw z&|LTy5Qktt0q7yv5CU4{bEYYTzj2EEJ7y!I!PW_wFG%Jw0n4X=-6;MAFRT&XU}C|7 z;;@^`+f@8H*-;;VC&FUY{$BngORu^S!1#N>9uz}V`|^qTk7{5|xdZCO7LBIif4LTNNS2F>iYyR|mnl)TYUizg?!sPfNqGOY!q+C+^n`F#r>p>u1 zJ%er&^B(_uJG0W?Cv7SR@!My}$;_Q!WQ%clNM=|GzFP$+5p7uee>pg&NuNJ4fl}?f zZE_Ze-O3l+UY!;*|6PLLGaHxMl52x;NCN|LeM)<(!C)zG%`TQ&3hLZ)DKdIZ?{z3Z zkcJ^&g)PFabe$FheeR?gU}gey;31e56MUDMBlIc{@U`@8DG9J!{|(?B!85wiQ_u;1 z!N)X_K|47(&Bo0?u_Zq{@6wN^oAyqY#7%|o#60W*{+S-0g#%vOePgg~*Vhtk6+^(+ z?*s^qMT5IXB!JaN4C={8|CAY>29o%jEjdw*h}?+hV9i*)q4I6^+HTy@D}bNq_FkNY zvQWe<(C7befcA_F*nSrd4yfZ~i*G$}Ga{uTsI&0~3DTPvQNEkF`Pt&E3_-x+e=2^u zF=RW8{X_R~K=_4>Q@YlyBfrcU&FlMiC1`Xu0=YX?4+v_b2PW_4vNt(RGpc_xAd2F* zW(@5*JeJE}Xz4X@7w_=6KbE!%>t&DZwx=)I4=jPIDlvLuKn}Fp){hy^{-=b;py0L` zyNBtLt9=VDlR6aBc4Ulo@x^OE4WV%O5x_1f^5t)t!~2y zE$VDsO6qFsgUAr0-#W|Kp3@2GB-(@pnl8Gpo?F$s&ChP9%VbgrG4&xT zcX{sJjFhvht-}X0*eUgYou_Pgn?SLE~?SWy9$HV>X-!@!j0A2+6^H1!BCH~sdjRmNCbLE+OI(t)YT zg6ezC&WzM^#i}Lk@DZxut;}J{u8CygDrfZlzS+AHAN+(Bq{;3IF+!@U!U0=6ziX8y zfWM%!OE^(?Y@}0V{Nt40W1Y?D^0^>oJuwqD;o?kF)4j&1Wd0)y&TCa58KXTTx$LDE zYx(GU*p{99Zg}(S5az4xIHlwsG6x}@$W^J>mL?s1RUtPiO-%P@vn$e%^$!1vPzN(? zFCR$faVOdeLi^v@{USIhH+9Urcfj>N>YE16D5XAR;fkill_y?Ysdm^QfAw$Uz7921 z`}e?|P6v;U!Kj}C&KY#8cbk{()&!JAchSDuA>uiSmEfBdyAMBDhkkH(IA`<0$r)#H zh5M4S6$Foc3|~_RE47Ki)^wGm^BcfEN3Ouh(XCQCZ#L=oyw{q{9IR=Q+Eq^&GPiHR z9^$W7cl|f)!q9V3=Q1pQ7c%eOeVUE|*f&REs^0zt0})|kVk@LcYQR_{-of0TOgF<`RRT*M@LGpr zS6dH{`WaomMhtKvnQfX42CgY1EbYadn_25kL>Q(UJB38;=-|U+P;>i)%Nl zq8cu~-HuY*b@RdNB2O-Z=K+inXnc z{N--xhUG&5KE7w9lkc*7sF30@v?|31d9n1M;P_SFTs!BW`?1?WcQ9z0Vx>r@c;sAD zjKGSE-A@}F(pc_k`XL#8O0_v8_1&zK*KRo7MxFBmQXv$u+w4Wsg>W9p^&Oxfb$YCN zJ3B0)8~!GUzhgRl>FD%d;p3ywyA;h!z{Yi4e4SC40ex*sSY-Asa^tLOadh9ePr=@a zD=Wa-#Eq)v_lfPfH$W5rP;t*QzSz2Y|M-C|(GYb#*(?K4v{EmBzpB!u@@F^mB?&$*OQ#r@SU&VlB z4~>MEX*7;Caa>k4`prc9U(FWj_?AMMc>q9qW~zeZ)v#Yumy1kz)u@Y+hvp`Up(F7Im~rlwnq`gX(COsrLy0?Z-ov`RIk>ZW+=?PT2tI~C;a zgH?0p3yeco!nE!Tz1GA#M5q_J+#om4$`+r_2eVOx@>QA&Wvrg~Icj>emE83uL2+te zws22l8FL3Wzadmnr%v-+A$!9haE^!ek6WcjSQ$BhVOiZhV7?K0foJ#dLoYVnCe{*W zjNcsSMDzig7|4!e6wwl=r^~FnWSBsAbzPTe^0~3fg z`nkcjLcZRb)$IpHtmdy48zoL{fhi0osx6;bPy_nZ=Sx;>Dy^Re_bJ^VjdlFt@R%Ka z?c-c&8UN>Bq;~up>r2`~<)$z<0NAC%Rn*_si!7B2PJl-~=#yYr-y^t(9>C0dd*pjU zOsEV7k%N>nG`^jYBNfO^+{?-70Qv>u&IM7&U)8af#@70}*22p7f~7yoy6XUJUyAsG zWFE*koLDX14_6W6Gj#NW?oVyh0j?vQTEkuinVJN;N|zE&;rRw@K)#;0{)ma@veb6C zC#87lZ4ejWj#i!Je(MOf?V2WzIOiMZ)VYCiVe~)-GA!t6AM($M%{n0bly2j&he98X z1+hbi)YSdYbPLyFWwdMjmSnJwJ=8JVF8HpW+bUZRpf;~%OQFICsI$9L{Qv;l3;8`@tg0X5`gEW10isrYv{6is^2@BAP{cO^Z=Mx36qYRY9 z>^KrbiPEZZ>HZI}bZ$y>Ev%8Q(I$825!jY`xLaeeb||&<#^ALG*L8%0Twx$_NK)nfCa9WB zXZDmaF)*+%=QY1847{|m0lWgEiEi1=-Pw?R0nL*OXAek;^rUDga)dJ6&wbrXDP97K z6KA?L#6!K(g{lSY5S8r1Oc2tyCk=@ZIaXb7cnCY>YUln==0L9lP~-XRwXx$JgGA!d z9w!Jo`lFiD@j%Azd=c?P;jkA4r5I2I))6&BQ&)PmR{X^FaS6TbGMPRLygGx|f^I|t zkuk3p??f^zIX7a!rG&oD3FM(!s)IyuscSky<#f>jqoWO&!toTa8}O#{z>@q7wZ@w= z&|_g=@$;T4ST$HA&<32cA%uj#Ie&;ncWo}T6YEKQudJx-P``5&oZk6+p| zP;^2j^So3}AGX71Q2cd{l%RJCzUzw3MAMN2J`Y$=LF;4*(&$hF@q%JeMKamQ!_`(9 zGVhMfvJ$V3YPV#?>+a%eHslVE1Ywu=Z^zuE9_m-ksl+tGW{w-e4;lsCK?v924ZzL7 z{?|LIC0meWOHSf^aI?NgulNYERc0UjTbYYsFEsEKk1B!XB>D3TmyUQ5WgyZ1@#~fq zX0pHYm>Gg^yMr>g>G^Vut_{2+blsD9f=vB9fuUb zJ@(SVodmPGaKIHNhjDGfer=ww$ieYnfbELEu45$6Sy-2HJp2dA2_9fMT<{JLMJUDE z*NE2h=u&l|1c5YLwkC0u-q`s+zD}+s&?v+3((ab16tqvnpk@*fF5Uo zMd>B&kK%{~X%C&3ah(P5;K-}qekx0t6cn-QLd9O}lREsVGElGVJO-SESE+{VV&&|8 z=({L61T|zM#edn>c(vPLrBJ0y|2l6`9OCz=`wyF4lT=3)^1=E$)VK`x7!;uo8|i6+HOC20VO&C-p2Vd5uJJ_kv1k0n5DC^Gd1xz$yp zxMn7pz zHZGFS<-e3_r_;di>|4}E-4>MKGjtoFX61FxfP<3(!!E86YTO!$v&<4IcAYUjBEnF3 z<*0m6uB=m-5B7)_j{RhG$fB6W&#ihhUIiAjR;v*lJeET#_e*jN9M{vmpT<`o4B3gK zuXtfUa4TvvUnu=tGy*gt-? z0G2;4$CN4;Ne!i3AQj3)>TswNkvyWhit*k5Q*+I94 zPFRfR%vy%IL$#^N^>ikh1NE0Yrjo(h{);XBupo^B7BdfKgg$rM=X$J=>QHSxOIUS!(20kL3T03=Q2@mo~NLDX@hvK0G|PY5jK^s z-0KoMWVq!iu&W6ZAsz)QkXccJ3*1cDUf=SfxdX2*jy4V_tBuTuQ?Wlj*gOM@gMlU7 z@2w-%Bvqfr&Np&XI-_Qlo7d*0FG{Y;F|kD(&fPX(n(mKRut62|t&Vu05x(!6^`-mJ z*Kv+@_}4!JW+(cVn82(w5U;%HSIKLLZI#J`cX#-V>*t>f_YSP{&X4S?ZT=oIg&~uh zEBveGj4u~B4bq#(x_iJL>!pMymK-zNOls9c73NE|e6eIs4&s@ln=xZm3?Nu?Dw^76 zHauf*1@qB;)&)VJYY=3I7*zuTH?gZ9EBjaX|=E zdi=AF$!1t?YQP&D$k~SnZAa!JO^l?FzW?%wLO}vT^am8gN(eW(%;Yab5||;Mu4S#jf0Mj;|I6&ck*%gGRKY z!_Vk=D)izwpk8u2Jg1JgW-o9q`;;QixQd*S@fcLQ#wy@J;lGYY_}NxDCDz+(KytfPY$%ywq5_CEwKIiTc@wq0?><%JP6kEt4T}-wcs2Szt1#8oI(9 z;PKfojurCEE{?1GSz+i8o=c$K@&tNv)H>o(j_dkCgX>*}wT<0nIx@~6{HWC}L2rO% zqz?VL4WN7bTJh60FnDQD!x6j+b|09m`MEw281aB?A5;C)N{>#JHRs#04?|E#$@`ti z0l%&~4J0X0z(qXOoClWOXQ7VWJ(T%^Ud_5Rp44MbrH5oU-%7sA@}YK^&6;^0SiM?x z>zo}m8J?}kO)IW_U+8r?ORU88PHyBWJf3k6wGFL>vP83!7G0y z=set9XtZzI8v)=MxIeAop~&5Yg92boyM2p2U$;7erKpCqp+He-?97P!AlMweBXsoT zhhAuc8FahIEVLV_Inh1RK>&LS2wm59>If5oAl8>_bZ@J0ZFCqP^QuTj=$eK?UC9%M zb~D&GyCG?~k8iZ4q2Qpz4U}xUZ$^0XA0fd8{Kt{)%tX_Zj$${iTuJvcDDwgk8g(`N zMp%4mfy4mRue2*qmZY&R3kt_0nt3&B9OCI~l~4s|p>AN*d7muBBnHBwQbYeKsdH}jQp zr~Ec_LC{0clUGhlqu7fVp&6CeAAWjDDL0Razn!DCpJz zfD!NvaL$e(@DSEa8P7lH1_{;7R&UaRdLDQ$Ch&zVa^`aIP>KtO7P)~BTvBm|ouJH| zbRNunQj1teq~q>2s%7_GD*mV}0#6CUqCxJ)>vuml0FL2 zvqh!L@7>9U)X{i>oRR)?6#4=ef*us(@_efl8rU6cWQbe3KOPrM)r>i;=S>@YyN~jDqn~5V*$m!zN(CFYu zYAnaN z&o|gSnD7LNXTFhGV{&9LnhIW>J)EY$#2}WJY$ef~arH59u^Mf`VlMGbV6n`)H)@NK zAw8Ag=umg*YhPKbVRiC)>G+pFgxe8imNuw?96fROURlkqvKT7o0_>()92?y%m;S(F zOa2atoru2YUENN^(P{y_D%o*6!+RUNt`~L&u_`{XGQKG4j;}OPw}083Gk@Z z`LYph)AX*&>uF@d_2OY$$$*2Kpzv!K+i%nf_dW`XTL`bb+5U}1pO}ej6}cTf{f9@) zJp=Y(PlHF)`Lf7!!=C+kCQmZ8jCH@B=M%&;r_Q>aCy|BZ`NH*zsfNMS4Jo#sY6L@}CE_i-UZ!kHFzYuJ9Ks*L!7l^*<)B*$Rr1Nv# z6%AwkhT)`naUD;Pz*vuNexhrAWcW%Y^Fh|?cN6^j;;F4vFK`OyPT}I;@3n01DXO3^ z`bk;PBg~w`I~FwAMx|s>-e|aN%&U`(|G{2>8mOCD3hKSh%hKLKI1x3)Mu28XKxUvp zS2Npz0Y%RcSE+~632KKr&3gCpjG2a$9U~9ib}O++0hQv zFt`It9uT_#jHaI1gTZfVsrw7oA=L@hL2cVWU!bJlaJv}h5mUJZLjVw33fl&*Z$YWr z0Oc`GkymP(6T!MTA~cJPmJ|39D(ClqFgTu0C0H0>>@^3#hR;WO$b=q}7)<@;fHz~a z`;)bg*oA~{#xnpfRx?9ezLWpET24seYY%4J4;l_(0IbvUvdEX+;r>VS?VWY5A(1MM z`VqBgFl+D=P_Ou{#|s38w^S0_pvxW`e7r~=^&ehP$Mj|D@;w*h%ru2{PWm7kb%92$ zFd44{CJO+i0jLFj*j0EUaKQ>E!Ang94@XoJ(YM09o9(1^TySt6R3(v1J@XLAQz1M9 z0h@Qgru39Dm$C=2SjCfBX`4{rL48XQ1U;)fGiL$C@0`j-Z92baH=Bj6rPv|mIKG6g znAcjo<~)x@@U@Yy&h%=`!DZCjnXj5Tv-(OP9o#q{Mqnpdm>jXCW#X;Mr#d}diMg5a ze^GwhAH4v47#E$?=oe6>4CI;Y>RN+00d(0Hbmd(HU0JJ| zI3*Qvhlx(ckDD!5YD^p?$p!OT1YF$J3hX1exYuFk4Cjx|17uL&Vr@z%OSd`TLiF~I zkKQCSFPA*W;%0-f+QFuLNA)?E(QOIa^|7xsyDUr^lRP(!8HO&q#wTNn&tgXi^ms<% zcXZb&qhF~@=e~S8-6PTgzU4k_@I%#tBraCk)(@3BJ1s8lJBJj&QMp=vl`_=7S{XiE zRjQMeFP2MCbH_@dtO9^hPYktE6!R0f6+DX&=^DIum`E_C9*%khNP#=P3Um1VpYL{Q z#Of><0rttaPuxe$7cgqXe#9N&yFInZM+5WZ5tZL z^t8%u%WS!niuq%uz3jyE7JVG!j%@KWwXSCuDz7+*Nh&VJD_$vZ_SetikDi+Uwtwwt#KhHm~GbE;&n7b zN*RA;c}nEMkm}%`Tl9LH^JHp9w7j*0aWZH;mKubzT&N^D7FOL6-^z*i9e3s!8}_-+ z*qrK7EXXwR60#hVbsmuEo?9y7^G%II?%e2vqOa-rkxWV=vOD zRI?S82+p|hr_hs9Sp@YW>RP5)Ei||n*g_nR)CT+lv-!S5JX%9)ny_b68BVuCyFKj` zLMG@j7`p>F8P1I84}F0x=XbW26w9x2BVi3eUCb1}@WIdoGir}i2Tze`+<|5@%KauA zJUEnk;W+QxZCxH7=s0dj(?Kpa%5CrH{E>t1qQs>cY}HFNNo^nlQ*Tshi3`%^tXkU4 z(O#6+V5^=;Mjic+%}hLitDQM>gCxn|tM9)rv>I>Cb0brb!D@^p=_r@uGYE(MqZ<3T zDB1YyX@pb9Y$VW6uo+g!X2~jyMrrsmW_04nVZg<6+%`TqnHHyKhnx)5811OA zBUlJmt5rAZVrhgn?O$3y0PUf1W>twhZ#Um^uSv&yCW$|gN1k~#y#fDO-E4o|3Zb%; ztKeDVjyrLLMoD;4LELP+mUqq?!lPpBmjpk1ORee80@9kkX8;SP=nnLcd)?r&2(ODu zN5I`g))vZ>lO%N4ZSu@LXeQ%xSl*pzM6;z`=7OU_(Q!@y+Twwb+7&{`tV_~}gvE!% zn@CaYV?k}S@_m7aD#7sgaBTyyzrPli;{z8lhVy!OzKN;%icyMUUSKJ()`A9~QEgCT z&FA|*2U&vW$l-SfU{-a(@g)g?G$->GPlWBe#fG$o2Q3%C)`B#aE@l1PujA25FrKP; zn%--!c>b}}wcc=PzFT&8A0JEz#88YZf)YMAFgKakmW-Bdq}Nco=LHsNGkzRxcZYN; z(zdPyd^_eKrGi^+?`;2kUf>-oX_GrzcKS`NfVQIlPz}+{Dmakiv7rSjmw#>-A>;5{ zu$NCz?<=6^t(`vTvY@n05gj>8<~n8k9oFqq*?v)bzY!y{9ZsZDD0Yvutv2gq>;DPN zhOaAJICHvhg;uSIIVfuXIK#_MRKPVHcHyttYt$h(BV`Yrk^+rK3||8v%@k)yOXLHY zszF)A-9p@L-#2fYP_~nDQF{=14mRPuptJ~7X^MXV8BQ|p^B2{d7x@ z31yZ^$faeJvHxN@7f8$p4D5zWtYUs*eFL{0aKwRss#Jt`mdv|R!%HSy?dtq2M%_N# z6a6JIvq-2z4(;>Nf-AF9&eUxp`BbcACK3`l6fI^-t=EHYLOuAR1ZiQlkcMs9$xmWF zjNo{!oPjdDWhH;Vx&sX+n04($t5|PBBmNgKEIc>Mt^S z40S8g?8Mi(-s7qG(|KG>drH^YLhf*b1tTghHU8BTtA{ks@RHiE!YA`y#PQ<#ZN>x?D?YJY=kwqm5#8`_zctU zD}ZbjR8``n&<^*uUF7}*A425(^D%Df#@ia=W7=@N8N*d&9La3R=BOL;f?WLr^)LO^ asze9 Date: Mon, 21 Jul 2025 22:34:07 +0800 Subject: [PATCH 058/582] =?UTF-8?q?=F0=9F=94=96=20chore:=20remove=20useles?= =?UTF-8?q?s=20ui=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/charts/TrendChart.jsx | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 web/src/components/common/charts/TrendChart.jsx diff --git a/web/src/components/common/charts/TrendChart.jsx b/web/src/components/common/charts/TrendChart.jsx deleted file mode 100644 index d81285ae..00000000 --- a/web/src/components/common/charts/TrendChart.jsx +++ /dev/null @@ -1,74 +0,0 @@ -/* -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 { VChart } from '@visactor/react-vchart'; - -const TrendChart = ({ - data, - color, - width = 100, - height = 40, - config = { mode: 'desktop-browser' } -}) => { - const getTrendSpec = (data, color) => ({ - type: 'line', - data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }], - xField: 'x', - yField: 'y', - height: height, - width: width, - 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' - } - }); - - return ( - - ); -}; - -export default TrendChart; \ No newline at end of file From 200faadb4a6c08e8518866baf4299bbd056d3d2f Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 22 Jul 2025 00:06:29 +0800 Subject: [PATCH 059/582] =?UTF-8?q?=E2=9C=A8=20feat(middleware):=20enhance?= =?UTF-8?q?=20Kling=20request=20adapter=20to=20support=20both=20'model'=20?= =?UTF-8?q?and=20'model=5Fname'=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the KlingRequestConvert middleware only extracted model name from the 'model_name' field, which caused 503 errors when requests used the 'model' field instead. This enhancement improves API compatibility by supporting both field names. Changes: - Modified KlingRequestConvert() to check for 'model' field if 'model_name' is empty - Maintains backward compatibility with existing 'model_name' usage - Fixes "no available channels for model" error when model field was not recognized This resolves issues where valid Kling API requests were failing due to field name mismatches, improving the overall user experience for video generation APIs. --- middleware/kling_adapter.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go index 3d4943d2..5e6d1fbb 100644 --- a/middleware/kling_adapter.go +++ b/middleware/kling_adapter.go @@ -18,7 +18,11 @@ func KlingRequestConvert() func(c *gin.Context) { return } + // 支持 model_name 和 model 两个字段 model, _ := originalReq["model_name"].(string) + if model == "" { + model, _ = originalReq["model"].(string) + } prompt, _ := originalReq["prompt"].(string) unifiedReq := map[string]interface{}{ From d2ef70f6b0814e3fab7210f887294fbfa139d037 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 22 Jul 2025 01:21:56 +0800 Subject: [PATCH 060/582] =?UTF-8?q?=E2=9C=A8=20feat(kling):=20send=20both?= =?UTF-8?q?=20`model=5Fname`=20and=20`model`=20fields=20for=20upstream=20c?= =?UTF-8?q?ompatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some upstream Kling deployments still expect the legacy `model` key instead of `model_name`. This change adds the `model` field to `requestPayload` and populates it with the same value as `model_name`, ensuring the generated JSON works with both old and new versions. Changes: • Added `Model string "json:\"model,omitempty\""` to `requestPayload` • Set `Model` alongside `ModelName` in `convertToRequestPayload` • Updated comments to clarify compatibility purpose Result: Kling task requests now contain both `model_name` and `model`, removing integration issues with upstreams that only recognize one of the keys. --- middleware/kling_adapter.go | 2 +- relay/channel/task/kling/adaptor.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go index 5e6d1fbb..20973c9f 100644 --- a/middleware/kling_adapter.go +++ b/middleware/kling_adapter.go @@ -18,7 +18,7 @@ func KlingRequestConvert() func(c *gin.Context) { return } - // 支持 model_name 和 model 两个字段 + // Support both model_name and model fields model, _ := originalReq["model_name"].(string) if model == "" { model, _ = originalReq["model"].(string) diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index 4ebb485f..b7b9a5ff 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -44,6 +44,7 @@ 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"` } @@ -227,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 == "" { From 38002f20a21fbd5d483816c3b7dae78c78ac1afa Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 22 Jul 2025 02:33:08 +0800 Subject: [PATCH 061/582] =?UTF-8?q?=F0=9F=8D=8E=20style(ui):=20add=20shape?= =?UTF-8?q?=3D"circle"=20prop=20to=20Tag=20component=20to=20display=20circ?= =?UTF-8?q?ular=20tag=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/table/task-logs/TaskLogsColumnDefs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.js b/web/src/components/table/task-logs/TaskLogsColumnDefs.js index 26a72fe5..8b066758 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.js +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.js @@ -79,7 +79,7 @@ function renderDuration(submit_time, finishTime) { // 返回带有样式的颜色标签 return ( - }> + }> {durationSec} 秒 ); From e3d97e4dafd997807ec1d60824c0ef42c568469f Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 22 Jul 2025 11:40:43 +0800 Subject: [PATCH 062/582] fix: avoid relayError nil panic --- types/error.go | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/types/error.go b/types/error.go index 5c8b37d2..c301e59c 100644 --- a/types/error.go +++ b/types/error.go @@ -105,23 +105,25 @@ func (e *NewAPIError) SetMessage(message string) { func (e *NewAPIError) ToOpenAIError() OpenAIError { switch e.ErrorType { case ErrorTypeOpenAIError: - return e.RelayError.(OpenAIError) + if openAIError, ok := e.RelayError.(OpenAIError); ok { + return openAIError + } case ErrorTypeClaudeError: - claudeError := e.RelayError.(ClaudeError) - return OpenAIError{ - Message: e.Error(), - Type: claudeError.Type, - Param: "", - Code: e.errorCode, - } - default: - return OpenAIError{ - Message: e.Error(), - Type: string(e.ErrorType), - Param: "", - Code: e.errorCode, + if claudeError, ok := e.RelayError.(ClaudeError); ok { + return OpenAIError{ + Message: e.Error(), + Type: claudeError.Type, + Param: "", + Code: e.errorCode, + } } } + return OpenAIError{ + Message: e.Error(), + Type: string(e.ErrorType), + Param: "", + Code: e.errorCode, + } } func (e *NewAPIError) ToClaudeError() ClaudeError { @@ -162,8 +164,11 @@ func NewOpenAIError(err error, errorCode ErrorCode, statusCode int) *NewAPIError func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *NewAPIError { return &NewAPIError{ - Err: err, - RelayError: nil, + Err: err, + RelayError: OpenAIError{ + Message: err.Error(), + Type: string(errorCode), + }, ErrorType: ErrorTypeNewAPIError, StatusCode: statusCode, errorCode: errorCode, From c5ec332ab308eef1cf9d657b46b24639d9651868 Mon Sep 17 00:00:00 2001 From: CaIon Date: Tue, 22 Jul 2025 12:06:21 +0800 Subject: [PATCH 063/582] fix: add Think field to OllamaRequest and support extra parameters in GeneralOpenAIRequest. (close #1125 ) --- dto/openai_request.go | 2 ++ relay/channel/ollama/dto.go | 6 +++++- relay/channel/ollama/relay-ollama.go | 8 ++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/dto/openai_request.go b/dto/openai_request.go index 88d3bd6c..a35ee6b6 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -62,6 +62,8 @@ type GeneralOpenAIRequest struct { Reasoning json.RawMessage `json:"reasoning,omitempty"` // Ali Qwen Params VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` + // 用匿名参数接收额外参数,例如ollama的think参数在此接收 + Extra map[string]json.RawMessage `json:"-"` } func (r *GeneralOpenAIRequest) ToMap() map[string]any { diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go index 15c64cdc..317c2a4a 100644 --- a/relay/channel/ollama/dto.go +++ b/relay/channel/ollama/dto.go @@ -1,6 +1,9 @@ package ollama -import "one-api/dto" +import ( + "encoding/json" + "one-api/dto" +) type OllamaRequest struct { Model string `json:"model,omitempty"` @@ -19,6 +22,7 @@ type OllamaRequest struct { Suffix any `json:"suffix,omitempty"` StreamOptions *dto.StreamOptions `json:"stream_options,omitempty"` Prompt any `json:"prompt,omitempty"` + Think json.RawMessage `json:"think,omitempty"` } type Options struct { diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 295349e3..cd899b83 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -50,7 +50,7 @@ func requestOpenAI2Ollama(request dto.GeneralOpenAIRequest) (*OllamaRequest, err } else { Stop, _ = request.Stop.([]string) } - return &OllamaRequest{ + ollamaRequest := &OllamaRequest{ Model: request.Model, Messages: messages, Stream: request.Stream, @@ -67,7 +67,11 @@ func requestOpenAI2Ollama(request dto.GeneralOpenAIRequest) (*OllamaRequest, err Prompt: request.Prompt, StreamOptions: request.StreamOptions, Suffix: request.Suffix, - }, nil + } + if think, ok := request.Extra["think"]; ok { + ollamaRequest.Think = think + } + return ollamaRequest, nil } func requestOpenAI2Embeddings(request dto.EmbeddingRequest) *OllamaEmbeddingRequest { From a2fc86a2b76368621b7cabf67b6aeaba9cd37020 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 22 Jul 2025 12:08:35 +0800 Subject: [PATCH 064/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20restru?= =?UTF-8?q?cture=20ModelPricing=20component=20into=20modular=20architectur?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Break down monolithic ModelPricing.js (685 lines) into focused components: * ModelPricingHeader.jsx - top status card with pricing information * ModelPricingTabs.jsx - model category navigation tabs * ModelPricingFilters.jsx - search and action controls * ModelPricingTable.jsx - data table with pricing details * ModelPricingColumnDefs.js - table column definitions and renderers - Create custom hook useModelPricingData.js for centralized state management: * Consolidate all business logic and API calls * Manage pricing calculations and data transformations * Handle search, filtering, and UI interactions - Follow project conventions matching other table components: * Adopt same file structure as channels/, users/, tokens/ modules * Maintain consistent naming patterns and component organization * Preserve all original functionality including responsive design - Update import paths: * Remove obsolete ModelPricing.js file * Update Pricing page to use new ModelPricingPage component * Fix missing import references Benefits: - Improved maintainability with single-responsibility components - Enhanced code reusability and testability - Better team collaboration with modular structure - Consistent codebase architecture across all table components --- web/src/components/table/ModelPricing.js | 684 ------------------ .../model-pricing/ModelPricingColumnDefs.js | 261 +++++++ .../model-pricing/ModelPricingFilters.jsx | 87 +++ .../model-pricing/ModelPricingHeader.jsx | 123 ++++ .../table/model-pricing/ModelPricingTable.jsx | 124 ++++ .../table/model-pricing/ModelPricingTabs.jsx | 67 ++ .../components/table/model-pricing/index.jsx | 66 ++ .../model-pricing/useModelPricingData.js | 254 +++++++ web/src/pages/Pricing/index.js | 4 +- 9 files changed, 984 insertions(+), 686 deletions(-) delete mode 100644 web/src/components/table/ModelPricing.js create mode 100644 web/src/components/table/model-pricing/ModelPricingColumnDefs.js create mode 100644 web/src/components/table/model-pricing/ModelPricingFilters.jsx create mode 100644 web/src/components/table/model-pricing/ModelPricingHeader.jsx create mode 100644 web/src/components/table/model-pricing/ModelPricingTable.jsx create mode 100644 web/src/components/table/model-pricing/ModelPricingTabs.jsx create mode 100644 web/src/components/table/model-pricing/index.jsx create mode 100644 web/src/hooks/model-pricing/useModelPricingData.js diff --git a/web/src/components/table/ModelPricing.js b/web/src/components/table/ModelPricing.js deleted file mode 100644 index 07acba1c..00000000 --- a/web/src/components/table/ModelPricing.js +++ /dev/null @@ -1,684 +0,0 @@ -/* -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'; - -import { - Input, - Layout, - Modal, - Space, - Table, - Tag, - Tooltip, - Popover, - ImagePreview, - Button, - Card, - Tabs, - TabPane, - Empty, - Switch, - Select -} from '@douyinfe/semi-ui'; -import { - IllustrationNoResult, - IllustrationNoResultDark -} from '@douyinfe/semi-illustrations'; -import { - IconVerify, - IconHelpCircle, - IconSearch, - IconCopy, - IconInfoCircle, - IconLayers -} from '@douyinfe/semi-icons'; -import { UserContext } from '../../context/User/index.js'; -import { AlertCircle } from 'lucide-react'; -import { StatusContext } from '../../context/Status/index.js'; - -const ModelPricing = () => { - const { t } = useTranslation(); - const [filteredValue, setFilteredValue] = useState([]); - const compositionRef = useRef({ isComposition: false }); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [modalImageUrl, setModalImageUrl] = useState(''); - const [isModalOpenurl, setIsModalOpenurl] = useState(false); - const [selectedGroup, setSelectedGroup] = useState('default'); - const [activeKey, setActiveKey] = useState('all'); - const [pageSize, setPageSize] = useState(10); - - const [currency, setCurrency] = useState('USD'); - const [showWithRecharge, setShowWithRecharge] = useState(false); - const [tokenUnit, setTokenUnit] = useState('M'); - const [statusState] = useContext(StatusContext); - // 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate) - const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]); - const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]); - - const rowSelection = useMemo( - () => ({ - onChange: (selectedRowKeys, selectedRows) => { - setSelectedRowKeys(selectedRowKeys); - }, - }), - [], - ); - - const handleChange = (value) => { - if (compositionRef.current.isComposition) { - return; - } - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - - const handleCompositionStart = () => { - compositionRef.current.isComposition = true; - }; - - const handleCompositionEnd = (event) => { - compositionRef.current.isComposition = false; - const value = event.target.value; - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - - function renderQuotaType(type) { - switch (type) { - case 1: - return ( - - {t('按次计费')} - - ); - case 0: - return ( - - {t('按量计费')} - - ); - default: - return t('未知'); - } - } - - function renderAvailable(available) { - return available ? ( - {t('您的分组可以使用该模型')}
- } - position='top' - key={available} - className="bg-green-50" - > - - - ) : null; - } - - function renderSupportedEndpoints(endpoints) { - if (!endpoints || endpoints.length === 0) { - return null; - } - return ( - - {endpoints.map((endpoint, idx) => ( - - {endpoint} - - ))} - - ); - } - - const displayPrice = (usdPrice) => { - let priceInUSD = usdPrice; - if (showWithRecharge) { - priceInUSD = usdPrice * priceRate / usdExchangeRate; - } - - if (currency === 'CNY') { - return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; - } - return `$${priceInUSD.toFixed(3)}`; - }; - - const columns = [ - { - title: t('可用性'), - dataIndex: 'available', - render: (text, record, index) => { - return renderAvailable(record.enable_groups.includes(selectedGroup)); - }, - sorter: (a, b) => { - const aAvailable = a.enable_groups.includes(selectedGroup); - const bAvailable = b.enable_groups.includes(selectedGroup); - return Number(aAvailable) - Number(bAvailable); - }, - defaultSortOrder: 'descend', - }, - { - title: t('可用端点类型'), - dataIndex: 'supported_endpoint_types', - render: (text, record, index) => { - return renderSupportedEndpoints(text); - }, - }, - { - title: t('模型名称'), - dataIndex: 'model_name', - render: (text, record, index) => { - return renderModelTag(text, { - onClick: () => { - copyText(text); - } - }); - }, - onFilter: (value, record) => - record.model_name.toLowerCase().includes(value.toLowerCase()), - filteredValue, - }, - { - title: t('计费类型'), - dataIndex: 'quota_type', - render: (text, record, index) => { - return renderQuotaType(parseInt(text)); - }, - sorter: (a, b) => a.quota_type - b.quota_type, - }, - { - title: t('可用分组'), - dataIndex: 'enable_groups', - render: (text, record, index) => { - return ( - - {text.map((group) => { - if (usableGroup[group]) { - if (group === selectedGroup) { - return ( - }> - {group} - - ); - } else { - return ( - { - setSelectedGroup(group); - showInfo( - t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { - group: group, - ratio: groupRatio[group], - }), - ); - }} - className="cursor-pointer hover:opacity-80 transition-opacity" - > - {group} - - ); - } - } - })} - - ); - }, - }, - { - title: () => ( -
- {t('倍率')} - - { - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> - -
- ), - dataIndex: 'model_ratio', - render: (text, record, index) => { - let content = text; - let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); - content = ( -
-
- {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} -
-
- {t('补全倍率')}: - {record.quota_type === 0 ? completionRatio : t('无')} -
-
- {t('分组倍率')}:{groupRatio[selectedGroup]} -
-
- ); - return content; - }, - }, - { - title: ( -
- {t('模型价格')} - {/* 计费单位切换 */} - setTokenUnit(checked ? 'K' : 'M')} - checkedText="K" - uncheckedText="M" - /> -
- ), - dataIndex: 'model_price', - render: (text, record, index) => { - let content = text; - if (record.quota_type === 0) { - let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; - let completionRatioPriceUSD = - record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup]; - - const unitDivisor = tokenUnit === 'K' ? 1000 : 1; - const unitLabel = tokenUnit === 'K' ? 'K' : 'M'; - - let displayInput = displayPrice(inputRatioPriceUSD); - let displayCompletion = displayPrice(completionRatioPriceUSD); - - const divisor = unitDivisor; - const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor; - const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor; - - displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`; - displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`; - content = ( -
-
- {t('提示')} {displayInput} / 1{unitLabel} tokens -
-
- {t('补全')} {displayCompletion} / 1{unitLabel} tokens -
-
- ); - } else { - let priceUSD = parseFloat(text) * groupRatio[selectedGroup]; - let displayVal = displayPrice(priceUSD); - content = ( -
- {t('模型价格')}:{displayVal} -
- ); - } - return content; - }, - }, - ]; - - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(true); - const [userState] = useContext(UserContext); - const [groupRatio, setGroupRatio] = useState({}); - const [usableGroup, setUsableGroup] = useState({}); - - const setModelsFormat = (models, groupRatio) => { - for (let i = 0; i < models.length; i++) { - models[i].key = models[i].model_name; - models[i].group_ratio = groupRatio[models[i].model_name]; - } - models.sort((a, b) => { - return a.quota_type - b.quota_type; - }); - - models.sort((a, b) => { - if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) { - return -1; - } else if ( - !a.model_name.startsWith('gpt') && - b.model_name.startsWith('gpt') - ) { - return 1; - } else { - return a.model_name.localeCompare(b.model_name); - } - }); - - setModels(models); - }; - - const loadPricing = async () => { - setLoading(true); - let url = '/api/pricing'; - const res = await API.get(url); - const { success, message, data, group_ratio, usable_group } = res.data; - if (success) { - setGroupRatio(group_ratio); - setUsableGroup(usable_group); - setSelectedGroup(userState.user ? userState.user.group : 'default'); - setModelsFormat(data, group_ratio); - } else { - showError(message); - } - setLoading(false); - }; - - const refresh = async () => { - await loadPricing(); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制:') + text); - } else { - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, []); - - const modelCategories = getModelCategories(t); - - const categoryCounts = useMemo(() => { - const counts = {}; - if (models.length > 0) { - counts['all'] = models.length; - - Object.entries(modelCategories).forEach(([key, category]) => { - if (key !== 'all') { - counts[key] = models.filter(model => category.filter(model)).length; - } - }); - } - return counts; - }, [models, modelCategories]); - - const availableCategories = useMemo(() => { - if (!models.length) return ['all']; - - return Object.entries(modelCategories).filter(([key, category]) => { - if (key === 'all') return true; - return models.some(model => category.filter(model)); - }).map(([key]) => key); - }, [models]); - - const renderTabs = () => { - return ( - setActiveKey(key)} - className="mt-2" - > - {Object.entries(modelCategories) - .filter(([key]) => availableCategories.includes(key)) - .map(([key, category]) => { - const modelCount = categoryCounts[key] || 0; - - return ( - - {category.icon && {category.icon}} - {category.label} - - {modelCount} - - - } - itemKey={key} - key={key} - /> - ); - })} - - ); - }; - - const filteredModels = useMemo(() => { - let result = models; - - if (activeKey !== 'all') { - result = result.filter(model => modelCategories[activeKey].filter(model)); - } - - if (filteredValue.length > 0) { - const searchTerm = filteredValue[0].toLowerCase(); - result = result.filter(model => - model.model_name.toLowerCase().includes(searchTerm) - ); - } - - return result; - }, [activeKey, models, filteredValue]); - - const SearchAndActions = useMemo(() => ( - -
-
- } - placeholder={t('模糊搜索模型名称')} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onChange={handleChange} - showClear - /> -
- - - {/* 充值价格显示开关 */} - - {t('以充值价格显示')} - - {showWithRecharge && ( - - )} - -
-
- ), [selectedRowKeys, t, showWithRecharge, currency]); - - const ModelTable = useMemo(() => ( - - } - darkModeImage={} - description={t('搜索无结果')} - style={{ padding: 30 }} - /> - } - pagination={{ - defaultPageSize: 10, - pageSize: pageSize, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - onPageSizeChange: (size) => setPageSize(size), - }} - /> - - ), [filteredModels, loading, columns, rowSelection, pageSize, t]); - - return ( -
- - -
-
- {/* 主卡片容器 */} - - {/* 顶部状态卡片 */} - -
-
-
-
- -
-
-
- {t('模型定价')} -
-
- {userState.user ? ( -
- - - {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]} - -
- ) : ( -
- - - {t('未登录,使用默认分组倍率:')}{groupRatio['default']} - -
- )} -
-
-
- -
-
-
{t('分组倍率')}
-
{groupRatio[selectedGroup] || '1.0'}x
-
-
-
{t('可用模型')}
-
- {models.filter(m => m.enable_groups.includes(selectedGroup)).length} -
-
-
-
{t('计费类型')}
-
2
-
-
-
- - {/* 计费说明 */} -
-
-
- - - {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')} - -
-
-
- -
-
-
- - {/* 模型分类 Tabs */} -
- {renderTabs()} - - {/* 搜索和表格区域 */} - {SearchAndActions} - {ModelTable} -
- - {/* 倍率说明图预览 */} - setIsModalOpenurl(visible)} - /> -
-
-
-
-
-
- ); -}; - -export default ModelPricing; diff --git a/web/src/components/table/model-pricing/ModelPricingColumnDefs.js b/web/src/components/table/model-pricing/ModelPricingColumnDefs.js new file mode 100644 index 00000000..bf71533c --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingColumnDefs.js @@ -0,0 +1,261 @@ +/* +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, Space, Tooltip, Switch } from '@douyinfe/semi-ui'; +import { IconVerify, IconHelpCircle } from '@douyinfe/semi-icons'; +import { Popover } from '@douyinfe/semi-ui'; +import { renderModelTag, stringToColor } from '../../../helpers'; + +function renderQuotaType(type, t) { + switch (type) { + case 1: + return ( + + {t('按次计费')} + + ); + case 0: + return ( + + {t('按量计费')} + + ); + default: + return t('未知'); + } +} + +function renderAvailable(available, t) { + return available ? ( + {t('您的分组可以使用该模型')} + } + position='top' + key={available} + className="bg-green-50" + > + + + ) : null; +} + +function renderSupportedEndpoints(endpoints) { + if (!endpoints || endpoints.length === 0) { + return null; + } + return ( + + {endpoints.map((endpoint, idx) => ( + + {endpoint} + + ))} + + ); +} + +export const getModelPricingColumns = ({ + t, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + handleGroupClick, +}) => { + return [ + { + title: t('可用性'), + dataIndex: 'available', + render: (text, record, index) => { + return renderAvailable(record.enable_groups.includes(selectedGroup), t); + }, + sorter: (a, b) => { + const aAvailable = a.enable_groups.includes(selectedGroup); + const bAvailable = b.enable_groups.includes(selectedGroup); + return Number(aAvailable) - Number(bAvailable); + }, + defaultSortOrder: 'descend', + }, + { + title: t('可用端点类型'), + dataIndex: 'supported_endpoint_types', + render: (text, record, index) => { + return renderSupportedEndpoints(text); + }, + }, + { + title: t('模型名称'), + dataIndex: 'model_name', + render: (text, record, index) => { + return renderModelTag(text, { + onClick: () => { + copyText(text); + } + }); + }, + onFilter: (value, record) => + record.model_name.toLowerCase().includes(value.toLowerCase()), + }, + { + title: t('计费类型'), + dataIndex: 'quota_type', + render: (text, record, index) => { + return renderQuotaType(parseInt(text), t); + }, + sorter: (a, b) => a.quota_type - b.quota_type, + }, + { + title: t('可用分组'), + dataIndex: 'enable_groups', + render: (text, record, index) => { + return ( + + {text.map((group) => { + if (usableGroup[group]) { + if (group === selectedGroup) { + return ( + }> + {group} + + ); + } else { + return ( + handleGroupClick(group)} + className="cursor-pointer hover:opacity-80 transition-opacity" + > + {group} + + ); + } + } + })} + + ); + }, + }, + { + title: () => ( +
+ {t('倍率')} + + { + setModalImageUrl('/ratio.png'); + setIsModalOpenurl(true); + }} + /> + +
+ ), + dataIndex: 'model_ratio', + render: (text, record, index) => { + let content = text; + let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); + content = ( +
+
+ {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} +
+
+ {t('补全倍率')}: + {record.quota_type === 0 ? completionRatio : t('无')} +
+
+ {t('分组倍率')}:{groupRatio[selectedGroup]} +
+
+ ); + return content; + }, + }, + { + title: ( +
+ {t('模型价格')} + {/* 计费单位切换 */} + setTokenUnit(checked ? 'K' : 'M')} + checkedText="K" + uncheckedText="M" + /> +
+ ), + dataIndex: 'model_price', + render: (text, record, index) => { + let content = text; + if (record.quota_type === 0) { + let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; + let completionRatioPriceUSD = + record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup]; + + const unitDivisor = tokenUnit === 'K' ? 1000 : 1; + const unitLabel = tokenUnit === 'K' ? 'K' : 'M'; + + let displayInput = displayPrice(inputRatioPriceUSD); + let displayCompletion = displayPrice(completionRatioPriceUSD); + + const divisor = unitDivisor; + const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor; + const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor; + + displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`; + displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`; + content = ( +
+
+ {t('提示')} {displayInput} / 1{unitLabel} tokens +
+
+ {t('补全')} {displayCompletion} / 1{unitLabel} tokens +
+
+ ); + } else { + let priceUSD = parseFloat(text) * groupRatio[selectedGroup]; + let displayVal = displayPrice(priceUSD); + content = ( +
+ {t('模型价格')}:{displayVal} +
+ ); + } + return content; + }, + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingFilters.jsx b/web/src/components/table/model-pricing/ModelPricingFilters.jsx new file mode 100644 index 00000000..57b5e7e1 --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingFilters.jsx @@ -0,0 +1,87 @@ +/* +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 { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui'; +import { IconSearch, IconCopy } from '@douyinfe/semi-icons'; + +const ModelPricingFilters = ({ + selectedRowKeys, + copyText, + showWithRecharge, + setShowWithRecharge, + currency, + setCurrency, + handleChange, + handleCompositionStart, + handleCompositionEnd, + t +}) => { + const SearchAndActions = useMemo(() => ( + +
+
+ } + placeholder={t('模糊搜索模型名称')} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onChange={handleChange} + showClear + /> +
+ + + {/* 充值价格显示开关 */} + + {t('以充值价格显示')} + + {showWithRecharge && ( + + )} + +
+
+ ), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]); + + return SearchAndActions; +}; + +export default ModelPricingFilters; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingHeader.jsx b/web/src/components/table/model-pricing/ModelPricingHeader.jsx new file mode 100644 index 00000000..40075f3a --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingHeader.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 { Card } from '@douyinfe/semi-ui'; +import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons'; +import { AlertCircle } from 'lucide-react'; + +const ModelPricingHeader = ({ + userState, + groupRatio, + selectedGroup, + models, + t +}) => { + return ( + +
+
+
+
+ +
+
+
+ {t('模型定价')} +
+
+ {userState.user ? ( +
+ + + {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]} + +
+ ) : ( +
+ + + {t('未登录,使用默认分组倍率:')}{groupRatio['default']} + +
+ )} +
+
+
+ +
+
+
{t('分组倍率')}
+
{groupRatio[selectedGroup] || '1.0'}x
+
+
+
{t('可用模型')}
+
+ {models.filter(m => m.enable_groups.includes(selectedGroup)).length} +
+
+
+
{t('计费类型')}
+
2
+
+
+
+ + {/* 计费说明 */} +
+
+
+ + + {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')} + +
+
+
+ +
+
+
+ ); +}; + +export default ModelPricingHeader; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingTable.jsx b/web/src/components/table/model-pricing/ModelPricingTable.jsx new file mode 100644 index 00000000..22d94f29 --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingTable.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, { useMemo } from 'react'; +import { Card, Table, Empty } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import { getModelPricingColumns } from './ModelPricingColumnDefs.js'; + +const ModelPricingTable = ({ + filteredModels, + loading, + rowSelection, + pageSize, + setPageSize, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + filteredValue, + handleGroupClick, + t +}) => { + const columns = useMemo(() => { + return getModelPricingColumns({ + t, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + handleGroupClick, + }); + }, [ + t, + selectedGroup, + usableGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + showWithRecharge, + tokenUnit, + setTokenUnit, + displayPrice, + handleGroupClick, + ]); + + // 更新列定义中的 filteredValue + const tableColumns = useMemo(() => { + return columns.map(column => { + if (column.dataIndex === 'model_name') { + return { + ...column, + filteredValue + }; + } + return column; + }); + }, [columns, filteredValue]); + + const ModelTable = useMemo(() => ( + +
} + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + pagination={{ + defaultPageSize: 10, + pageSize: pageSize, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + onPageSizeChange: (size) => setPageSize(size), + }} + /> + + ), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]); + + return ModelTable; +}; + +export default ModelPricingTable; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingTabs.jsx b/web/src/components/table/model-pricing/ModelPricingTabs.jsx new file mode 100644 index 00000000..11a58b79 --- /dev/null +++ b/web/src/components/table/model-pricing/ModelPricingTabs.jsx @@ -0,0 +1,67 @@ +/* +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'; + +const ModelPricingTabs = ({ + activeKey, + setActiveKey, + modelCategories, + categoryCounts, + availableCategories, + t +}) => { + return ( + setActiveKey(key)} + className="mt-2" + > + {Object.entries(modelCategories) + .filter(([key]) => availableCategories.includes(key)) + .map(([key, category]) => { + const modelCount = categoryCounts[key] || 0; + + return ( + + {category.icon && {category.icon}} + {category.label} + + {modelCount} + + + } + itemKey={key} + key={key} + /> + ); + })} + + ); +}; + +export default ModelPricingTabs; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/index.jsx b/web/src/components/table/model-pricing/index.jsx new file mode 100644 index 00000000..a8641ce5 --- /dev/null +++ b/web/src/components/table/model-pricing/index.jsx @@ -0,0 +1,66 @@ +/* +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, Card, ImagePreview } from '@douyinfe/semi-ui'; +import ModelPricingTabs from './ModelPricingTabs.jsx'; +import ModelPricingFilters from './ModelPricingFilters.jsx'; +import ModelPricingTable from './ModelPricingTable.jsx'; +import ModelPricingHeader from './ModelPricingHeader.jsx'; +import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js'; + +const ModelPricingPage = () => { + const modelPricingData = useModelPricingData(); + + return ( +
+ + +
+
+ {/* 主卡片容器 */} + + {/* 顶部状态卡片 */} + + + {/* 模型分类 Tabs */} +
+ + + {/* 搜索和表格区域 */} + + +
+ + {/* 倍率说明图预览 */} + modelPricingData.setIsModalOpenurl(visible)} + /> +
+
+
+
+
+
+ ); +}; + +export default ModelPricingPage; \ No newline at end of file diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js new file mode 100644 index 00000000..60445f1e --- /dev/null +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -0,0 +1,254 @@ +/* +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 { useState, useEffect, useContext, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { API, copy, showError, showInfo, showSuccess, getModelCategories } from '../../helpers'; +import { Modal } from '@douyinfe/semi-ui'; +import { UserContext } from '../../context/User/index.js'; +import { StatusContext } from '../../context/Status/index.js'; + +export const useModelPricingData = () => { + const { t } = useTranslation(); + const [filteredValue, setFilteredValue] = useState([]); + const compositionRef = useRef({ isComposition: false }); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [modalImageUrl, setModalImageUrl] = useState(''); + const [isModalOpenurl, setIsModalOpenurl] = useState(false); + const [selectedGroup, setSelectedGroup] = useState('default'); + const [activeKey, setActiveKey] = useState('all'); + const [pageSize, setPageSize] = useState(10); + const [currency, setCurrency] = useState('USD'); + const [showWithRecharge, setShowWithRecharge] = useState(false); + const [tokenUnit, setTokenUnit] = useState('M'); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [groupRatio, setGroupRatio] = useState({}); + const [usableGroup, setUsableGroup] = useState({}); + + const [statusState] = useContext(StatusContext); + const [userState] = useContext(UserContext); + + // 充值汇率(price)与美元兑人民币汇率(usd_exchange_rate) + const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]); + const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]); + + const modelCategories = getModelCategories(t); + + const categoryCounts = useMemo(() => { + const counts = {}; + if (models.length > 0) { + counts['all'] = models.length; + Object.entries(modelCategories).forEach(([key, category]) => { + if (key !== 'all') { + counts[key] = models.filter(model => category.filter(model)).length; + } + }); + } + return counts; + }, [models, modelCategories]); + + const availableCategories = useMemo(() => { + if (!models.length) return ['all']; + return Object.entries(modelCategories).filter(([key, category]) => { + if (key === 'all') return true; + return models.some(model => category.filter(model)); + }).map(([key]) => key); + }, [models]); + + const filteredModels = useMemo(() => { + let result = models; + + if (activeKey !== 'all') { + result = result.filter(model => modelCategories[activeKey].filter(model)); + } + + if (filteredValue.length > 0) { + const searchTerm = filteredValue[0].toLowerCase(); + result = result.filter(model => + model.model_name.toLowerCase().includes(searchTerm) + ); + } + + return result; + }, [activeKey, models, filteredValue]); + + const rowSelection = useMemo( + () => ({ + onChange: (selectedRowKeys, selectedRows) => { + setSelectedRowKeys(selectedRowKeys); + }, + }), + [], + ); + + const displayPrice = (usdPrice) => { + let priceInUSD = usdPrice; + if (showWithRecharge) { + priceInUSD = usdPrice * priceRate / usdExchangeRate; + } + + if (currency === 'CNY') { + return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; + } + return `$${priceInUSD.toFixed(3)}`; + }; + + const setModelsFormat = (models, groupRatio) => { + for (let i = 0; i < models.length; i++) { + models[i].key = models[i].model_name; + models[i].group_ratio = groupRatio[models[i].model_name]; + } + models.sort((a, b) => { + return a.quota_type - b.quota_type; + }); + + models.sort((a, b) => { + if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) { + return -1; + } else if ( + !a.model_name.startsWith('gpt') && + b.model_name.startsWith('gpt') + ) { + return 1; + } else { + return a.model_name.localeCompare(b.model_name); + } + }); + + setModels(models); + }; + + const loadPricing = async () => { + setLoading(true); + let url = '/api/pricing'; + const res = await API.get(url); + const { success, message, data, group_ratio, usable_group } = res.data; + if (success) { + setGroupRatio(group_ratio); + setUsableGroup(usable_group); + setSelectedGroup(userState.user ? userState.user.group : 'default'); + setModelsFormat(data, group_ratio); + } else { + showError(message); + } + setLoading(false); + }; + + const refresh = async () => { + await loadPricing(); + }; + + const copyText = async (text) => { + if (await copy(text)) { + showSuccess(t('已复制:') + text); + } else { + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); + } + }; + + const handleChange = (value) => { + if (compositionRef.current.isComposition) { + return; + } + const newFilteredValue = value ? [value] : []; + setFilteredValue(newFilteredValue); + }; + + const handleCompositionStart = () => { + compositionRef.current.isComposition = true; + }; + + const handleCompositionEnd = (event) => { + compositionRef.current.isComposition = false; + const value = event.target.value; + const newFilteredValue = value ? [value] : []; + setFilteredValue(newFilteredValue); + }; + + const handleGroupClick = (group) => { + setSelectedGroup(group); + showInfo( + t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { + group: group, + ratio: groupRatio[group], + }), + ); + }; + + useEffect(() => { + refresh().then(); + }, []); + + return { + // 状态 + filteredValue, + setFilteredValue, + selectedRowKeys, + setSelectedRowKeys, + modalImageUrl, + setModalImageUrl, + isModalOpenurl, + setIsModalOpenurl, + selectedGroup, + setSelectedGroup, + activeKey, + setActiveKey, + pageSize, + setPageSize, + currency, + setCurrency, + showWithRecharge, + setShowWithRecharge, + tokenUnit, + setTokenUnit, + models, + loading, + groupRatio, + usableGroup, + + // 计算属性 + priceRate, + usdExchangeRate, + modelCategories, + categoryCounts, + availableCategories, + filteredModels, + rowSelection, + + // 用户和状态 + userState, + statusState, + + // 方法 + displayPrice, + refresh, + copyText, + handleChange, + handleCompositionStart, + handleCompositionEnd, + handleGroupClick, + + // 引用 + compositionRef, + + // 国际化 + t, + }; +}; \ No newline at end of file diff --git a/web/src/pages/Pricing/index.js b/web/src/pages/Pricing/index.js index 48f69f54..036e94ad 100644 --- a/web/src/pages/Pricing/index.js +++ b/web/src/pages/Pricing/index.js @@ -18,11 +18,11 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import ModelPricing from '../../components/table/ModelPricing.js'; +import ModelPricingPage from '../../components/table/model-pricing'; const Pricing = () => (
- +
); From e7f5d0214780b1b025c144aaf099ef83561f9a45 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Tue, 22 Jul 2025 13:22:47 +0800 Subject: [PATCH 065/582] refactor: simplify WebSearchPrice const --- setting/operation_setting/tools.go | 49 +++++++----------------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index f87fcace..9f19ee84 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -4,12 +4,8 @@ import "strings" const ( // Web search - WebSearchHighTierModelPriceLow = 10.00 - WebSearchHighTierModelPriceMedium = 10.00 - WebSearchHighTierModelPriceHigh = 10.00 - WebSearchPriceLow = 25.00 - WebSearchPriceMedium = 25.00 - WebSearchPriceHigh = 25.00 + WebSearchPriceHigh = 25.00 + WebSearchPrice = 10.00 // File search FileSearchPrice = 2.5 ) @@ -34,41 +30,18 @@ func GetClaudeWebSearchPricePerThousand() float64 { func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { // 确定模型类型 - // https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费 + // https://platform.openai.com/docs/pricing Web search 价格按模型类型收费 // 新版计费规则不再关联 search context size,故在const区域将各size的价格设为一致。 - // gpt-4o and gpt-4.1 models (including mini models) 等普通模型更贵,o3, o4-mini, o3-pro, and deep research models 等高级模型更便宜 - isHighTierModel := + // gpt-4o and gpt-4.1 models (including mini models) 等模型更贵,o3, o4-mini, o3-pro, and deep research models 等模型更便宜 + isNormalPriceModel := strings.HasPrefix(modelName, "o3") || - strings.HasPrefix(modelName, "o4") || - strings.Contains(modelName, "deep-research") - // 确定 search context size 对应的价格 + strings.HasPrefix(modelName, "o4") || + strings.Contains(modelName, "deep-research") var priceWebSearchPerThousandCalls float64 - switch contextSize { - case "low": - if isHighTierModel { - priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceLow - } else { - priceWebSearchPerThousandCalls = WebSearchPriceLow - } - case "medium": - if isHighTierModel { - priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceMedium - } else { - priceWebSearchPerThousandCalls = WebSearchPriceMedium - } - case "high": - if isHighTierModel { - priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceHigh - } else { - priceWebSearchPerThousandCalls = WebSearchPriceHigh - } - default: - // search context size 默认为 medium - if isHighTierModel { - priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceMedium - } else { - priceWebSearchPerThousandCalls = WebSearchPriceMedium - } + if isNormalPriceModel { + priceWebSearchPerThousandCalls = WebSearchPrice + } else { + priceWebSearchPerThousandCalls = WebSearchPriceHigh } return priceWebSearchPerThousandCalls } From 2f55960b1785451bd3bffb1ea3466b1d8d31bc51 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 22 Jul 2025 16:11:21 +0800 Subject: [PATCH 066/582] =?UTF-8?q?=F0=9F=8C=90=20feat:=20implement=20left?= =?UTF-8?q?-right=20pagination=20layout=20with=20i18n=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add left-right pagination layout for desktop (total info on left, controls on right) - Keep mobile layout centered with pagination controls only - Implement proper i18n support for pagination text using react-i18next - Add pagination translations for Chinese and English - Standardize t function usage across all table components to use xxxData.t pattern - Update CardPro footer layout to support justify-between on desktop - Use CSS variable --semi-color-text-2 for consistent text styling - Disable built-in Pagination showTotal to avoid duplication Components updated: - CardPro: Enhanced footer layout with responsive design - createCardProPagination: Added i18n support and custom total text - All table components: Unified t function usage pattern - i18n files: Added pagination-related translations The pagination now displays "Showing X to Y of Z items" on desktop and maintains existing centered layout on mobile devices. --- web/src/components/common/ui/CardPro.js | 5 ++- web/src/components/table/channels/index.jsx | 1 + web/src/components/table/mj-logs/index.jsx | 1 + .../components/table/redemptions/index.jsx | 3 +- web/src/components/table/task-logs/index.jsx | 1 + web/src/components/table/tokens/index.jsx | 3 +- web/src/components/table/usage-logs/index.jsx | 1 + web/src/components/table/users/index.jsx | 3 +- web/src/helpers/utils.js | 42 +++++++++++++------ web/src/i18n/locales/en.json | 6 ++- 10 files changed, 49 insertions(+), 17 deletions(-) diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index e72cc42b..5745b9b3 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -163,7 +163,10 @@ const CardPro = ({ if (!paginationArea) return null; return ( -
+
{paginationArea}
); diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index f9370150..b0106b4e 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -68,6 +68,7 @@ const ChannelsPage = () => { onPageChange: channelsData.handlePageChange, onPageSizeChange: channelsData.handlePageSizeChange, isMobile: isMobile, + t: channelsData.t, })} t={channelsData.t} > diff --git a/web/src/components/table/mj-logs/index.jsx b/web/src/components/table/mj-logs/index.jsx index 86f96713..3e319975 100644 --- a/web/src/components/table/mj-logs/index.jsx +++ b/web/src/components/table/mj-logs/index.jsx @@ -51,6 +51,7 @@ const MjLogsPage = () => { onPageChange: mjLogsData.handlePageChange, onPageSizeChange: mjLogsData.handlePageSizeChange, isMobile: isMobile, + t: mjLogsData.t, })} t={mjLogsData.t} > diff --git a/web/src/components/table/redemptions/index.jsx b/web/src/components/table/redemptions/index.jsx index 5abb64aa..58db6cbf 100644 --- a/web/src/components/table/redemptions/index.jsx +++ b/web/src/components/table/redemptions/index.jsx @@ -109,8 +109,9 @@ const RedemptionsPage = () => { onPageChange: redemptionsData.handlePageChange, onPageSizeChange: redemptionsData.handlePageSizeChange, isMobile: isMobile, + t: redemptionsData.t, })} - t={t} + t={redemptionsData.t} > diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx index c9a02541..c5439bae 100644 --- a/web/src/components/table/task-logs/index.jsx +++ b/web/src/components/table/task-logs/index.jsx @@ -51,6 +51,7 @@ const TaskLogsPage = () => { onPageChange: taskLogsData.handlePageChange, onPageSizeChange: taskLogsData.handlePageSizeChange, isMobile: isMobile, + t: taskLogsData.t, })} t={taskLogsData.t} > diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx index a955f13c..85229b26 100644 --- a/web/src/components/table/tokens/index.jsx +++ b/web/src/components/table/tokens/index.jsx @@ -111,8 +111,9 @@ const TokensPage = () => { onPageChange: tokensData.handlePageChange, onPageSizeChange: tokensData.handlePageSizeChange, isMobile: isMobile, + t: tokensData.t, })} - t={t} + t={tokensData.t} > diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index 6f7aeafd..bd550088 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.jsx @@ -50,6 +50,7 @@ const LogsPage = () => { onPageChange: logsData.handlePageChange, onPageSizeChange: logsData.handlePageSizeChange, isMobile: isMobile, + t: logsData.t, })} t={logsData.t} > diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx index adc9a570..99d50f50 100644 --- a/web/src/components/table/users/index.jsx +++ b/web/src/components/table/users/index.jsx @@ -114,8 +114,9 @@ const UsersPage = () => { onPageChange: usersData.handlePageChange, onPageSizeChange: usersData.handlePageSizeChange, isMobile: isMobile, + t: usersData.t, })} - t={t} + t={usersData.t} > diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index b9b2d550..5a8aa9cd 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -580,21 +580,39 @@ export const createCardProPagination = ({ isMobile = false, pageSizeOpts = [10, 20, 50, 100], showSizeChanger = true, + t = (key) => key, }) => { if (!total || total <= 0) return null; + const start = (currentPage - 1) * pageSize + 1; + const end = Math.min(currentPage * pageSize, total); + const totalText = `${t('显示第')} ${start} ${t('条 - 第')} ${end} ${t('条,共')} ${total} ${t('条')}`; + return ( - + <> + {/* 桌面端左侧总数信息 */} + {!isMobile && ( + + {totalText} + + )} + + {/* 右侧分页控件 */} + + ); }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6b1d5e05..5762533f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1783,5 +1783,9 @@ "隐藏操作项": "Hide actions", "显示操作项": "Show actions", "用户组": "User group", - "邀请获得额度": "Invitation quota" + "邀请获得额度": "Invitation quota", + "显示第": "Showing", + "条 - 第": "to", + "条,共": "of", + "条": "items" } \ No newline at end of file From b3c4d972868c2eb58c634f9dedaf7c61b5bb6dc1 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Tue, 22 Jul 2025 17:36:38 +0800 Subject: [PATCH 067/582] chore: opt video channel and platform --- constant/task.go | 2 -- controller/relay.go | 2 +- controller/task.go | 6 ++--- middleware/distributor.go | 21 +++++---------- relay/constant/relay_mode.go | 27 ++----------------- relay/relay_adaptor.go | 27 ++++++++++++++----- relay/relay_task.go | 5 +++- .../table/task-logs/TaskLogsColumnDefs.js | 21 +++++++-------- 8 files changed, 45 insertions(+), 66 deletions(-) diff --git a/constant/task.go b/constant/task.go index e7af39a6..21790145 100644 --- a/constant/task.go +++ b/constant/task.go @@ -5,8 +5,6 @@ type TaskPlatform string const ( TaskPlatformSuno TaskPlatform = "suno" TaskPlatformMidjourney = "mj" - TaskPlatformKling TaskPlatform = "kling" - TaskPlatformJimeng TaskPlatform = "jimeng" ) const ( diff --git a/controller/relay.go b/controller/relay.go index b224b42c..18c5f1b4 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -428,7 +428,7 @@ func RelayTask(c *gin.Context) { func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError { var err *dto.TaskError switch relayMode { - case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeKlingFetchByID: + case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID: err = relay.RelayTaskFetch(c, relayMode) default: err = relay.RelayTaskSubmit(c, relayMode) diff --git a/controller/task.go b/controller/task.go index 78674d8b..5fbdb424 100644 --- a/controller/task.go +++ b/controller/task.go @@ -75,10 +75,10 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][ //_ = UpdateMidjourneyTaskAll(context.Background(), tasks) case constant.TaskPlatformSuno: _ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM) - case constant.TaskPlatformKling, constant.TaskPlatformJimeng: - _ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM) default: - common.SysLog("未知平台") + if err := UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM); err != nil { + common.SysLog(fmt.Sprintf("UpdateVideoTaskAll fail: %s", err)) + } } } diff --git a/middleware/distributor.go b/middleware/distributor.go index a6889e39..3b04eef0 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -174,22 +174,13 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { c.Set("relay_mode", relayMode) } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") { err = common.UnmarshalBodyReusable(c, &modelRequest) - var platform string - var relayMode int - if strings.HasPrefix(modelRequest.Model, "jimeng") { - platform = string(constant.TaskPlatformJimeng) - relayMode = relayconstant.Path2RelayJimeng(c.Request.Method, c.Request.URL.Path) - if relayMode == relayconstant.RelayModeJimengFetchByID { - shouldSelectChannel = false - } - } else { - platform = string(constant.TaskPlatformKling) - relayMode = relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path) - if relayMode == relayconstant.RelayModeKlingFetchByID { - shouldSelectChannel = false - } + relayMode := relayconstant.RelayModeUnknown + if c.Request.Method == http.MethodPost { + relayMode = relayconstant.RelayModeVideoSubmit + } else if c.Request.Method == http.MethodGet { + relayMode = relayconstant.RelayModeVideoFetchByID + shouldSelectChannel = false } - c.Set("platform", platform) c.Set("relay_mode", relayMode) } else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { // Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 394fc0e9..b1599fd0 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -40,11 +40,8 @@ const ( RelayModeSunoFetchByID RelayModeSunoSubmit - RelayModeKlingFetchByID - RelayModeKlingSubmit - - RelayModeJimengFetchByID - RelayModeJimengSubmit + RelayModeVideoFetchByID + RelayModeVideoSubmit RelayModeRerank @@ -145,23 +142,3 @@ func Path2RelaySuno(method, path string) int { } return relayMode } - -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")) { - relayMode = RelayModeKlingFetchByID - } - return relayMode -} - -func Path2RelayJimeng(method, path string) int { - relayMode := RelayModeUnknown - if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") { - relayMode = RelayModeJimengSubmit - } else if method == http.MethodGet && strings.Contains(path, "/video/generations/") { - relayMode = RelayModeJimengFetchByID - } - return relayMode -} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 2ce12a87..1e9c46e8 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -1,8 +1,8 @@ package relay import ( + "github.com/gin-gonic/gin" "one-api/constant" - commonconstant "one-api/constant" "one-api/relay/channel" "one-api/relay/channel/ali" "one-api/relay/channel/aws" @@ -34,6 +34,7 @@ import ( "one-api/relay/channel/xunfei" "one-api/relay/channel/zhipu" "one-api/relay/channel/zhipu_4v" + "strconv" ) func GetAdaptor(apiType int) channel.Adaptor { @@ -100,16 +101,28 @@ func GetAdaptor(apiType int) channel.Adaptor { return nil } -func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor { +func GetTaskPlatform(c *gin.Context) constant.TaskPlatform { + channelType := c.GetInt("channel_type") + if channelType > 0 { + return constant.TaskPlatform(strconv.Itoa(channelType)) + } + return constant.TaskPlatform(c.GetString("platform")) +} + +func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { switch platform { //case constant.APITypeAIProxyLibrary: // return &aiproxy.Adaptor{} - case commonconstant.TaskPlatformSuno: + case constant.TaskPlatformSuno: return &suno.TaskAdaptor{} - case commonconstant.TaskPlatformKling: - return &kling.TaskAdaptor{} - case commonconstant.TaskPlatformJimeng: - return &taskjimeng.TaskAdaptor{} + } + if channelType, err := strconv.ParseInt(string(platform), 10, 64); err == nil { + switch channelType { + case constant.ChannelTypeKling: + return &kling.TaskAdaptor{} + case constant.ChannelTypeJimeng: + return &taskjimeng.TaskAdaptor{} + } } return nil } diff --git a/relay/relay_task.go b/relay/relay_task.go index 25f63d40..ce00527b 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -24,6 +24,9 @@ Task 任务通过平台、Action 区分任务 */ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { platform := constant.TaskPlatform(c.GetString("platform")) + if platform == "" { + platform = GetTaskPlatform(c) + } relayInfo := relaycommon.GenTaskRelayInfo(c) adaptor := GetTaskAdaptor(platform) @@ -178,7 +181,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, - relayconstant.RelayModeKlingFetchByID: videoFetchByIDRespBodyBuilder, + relayconstant.RelayModeVideoFetchByID: videoFetchByIDRespBodyBuilder, } func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) { diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.js b/web/src/components/table/task-logs/TaskLogsColumnDefs.js index 8b066758..f895bf01 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.js +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.js @@ -39,6 +39,7 @@ import { Sparkles } from 'lucide-react'; import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../../constants/common.constant'; +import { CHANNEL_OPTIONS } from '../../../constants/channel.constants'; const colors = [ 'amber', @@ -121,6 +122,14 @@ const renderType = (type, t) => { }; const renderPlatform = (platform, t) => { + let option = CHANNEL_OPTIONS.find(opt => String(opt.value) === String(platform)); + if (option) { + return ( + }> + {option.label} + + ); + } switch (platform) { case 'suno': return ( @@ -128,18 +137,6 @@ const renderPlatform = (platform, t) => { Suno ); - case 'kling': - return ( - }> - Kling - - ); - case 'jimeng': - return ( - }> - Jimeng - - ); default: return ( }> From 5bd8dd787ed053113181f425a66ff1b229ba391b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 22 Jul 2025 21:31:37 +0800 Subject: [PATCH 068/582] =?UTF-8?q?=F0=9F=90=9B=20fix(EditChannelModal):?= =?UTF-8?q?=20hide=20empty=20=E2=80=9CAPI=20Config=E2=80=9D=20card=20for?= =?UTF-8?q?=20VolcEngine=20Ark/Doubao=20(type=2045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VolcEngine Ark/Doubao channel now has a hard-coded base URL inside the backend, so it no longer requires any API-address settings on the front-end side. Previously, the input field was hidden but the surrounding “API Config” card still rendered, leaving a blank, confusing section. Changes made • Added `showApiConfigCard` flag (true when `inputs.type !== 45`) right after the state declarations. • Wrapped the entire “API Config” card in a conditional render driven by this flag. • Removed the duplicate declaration of `showApiConfigCard` further down in the component to avoid shadowing and improve readability. Scope verification • Checked all other channel types: every remaining type either displays a dedicated API-related input/banner (3, 8, 22, 36, 37, 40, …) or falls back to the generic “custom API address” field. • Therefore, only type 45 requires the card to be fully hidden. Result The “Edit Channel” modal now shows no empty card for the VolcEngine Ark/Doubao channel, leading to a cleaner and more intuitive UI while preserving behaviour for all other channels. --- .../channels/modals/EditChannelModal.jsx | 203 +++++++++--------- 1 file changed, 103 insertions(+), 100 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 92c26540..6613dddc 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -142,6 +142,7 @@ const EditChannelModal = (props) => { const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false); const [channelSearchValue, setChannelSearchValue] = useState(''); const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 + const showApiConfigCard = inputs.type !== 45; // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示) const getInitValues = () => ({ ...originInputs }); const handleInputChange = (name, value) => { if (formApiRef.current) { @@ -1108,130 +1109,132 @@ const EditChannelModal = (props) => { {/* API Configuration Card */} - - {/* Header: API Config */} -
- - - -
- {t('API 配置')} -
{t('API 地址和相关配置')}
+ {showApiConfigCard && ( + + {/* Header: API Config */} +
+ + + +
+ {t('API 配置')} +
{t('API 地址和相关配置')}
+
-
- {inputs.type === 40 && ( - + {t('邀请链接')}: + window.open('https://cloud.siliconflow.cn/i/hij0YNTZ')} + > + https://cloud.siliconflow.cn/i/hij0YNTZ + +
+ } + className='!rounded-lg' + /> + )} + + {inputs.type === 3 && ( + <> +
- {t('邀请链接')}: - window.open('https://cloud.siliconflow.cn/i/hij0YNTZ')} - > - https://cloud.siliconflow.cn/i/hij0YNTZ - + handleInputChange('base_url', value)} + showClear + />
- } - className='!rounded-lg' - /> - )} +
+ handleInputChange('other', value)} + showClear + /> +
+ + )} - {inputs.type === 3 && ( - <> + {inputs.type === 8 && ( + <> + +
+ handleInputChange('base_url', value)} + showClear + /> +
+ + )} + + {inputs.type === 37 && ( + )} + + {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && inputs.type !== 45 && (
handleInputChange('base_url', value)} + showClear + extraText={t('对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写')} + /> +
+ )} + + {inputs.type === 22 && ( +
+ handleInputChange('base_url', value)} showClear />
-
- handleInputChange('other', value)} - showClear - /> -
- - )} + )} - {inputs.type === 8 && ( - <> - + {inputs.type === 36 && (
handleInputChange('base_url', value)} showClear />
- - )} - - {inputs.type === 37 && ( - - )} - - {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && inputs.type !== 45 && ( -
- handleInputChange('base_url', value)} - showClear - extraText={t('对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写')} - /> -
- )} - - {inputs.type === 22 && ( -
- handleInputChange('base_url', value)} - showClear - /> -
- )} - - {inputs.type === 36 && ( -
- handleInputChange('base_url', value)} - showClear - /> -
- )} -
+ )} + + )} {/* Model Configuration Card */} From 1c22e03a4025aeb17ef3d767e80898e0afc05795 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 01:58:51 +0800 Subject: [PATCH 069/582] =?UTF-8?q?=F0=9F=8E=A8=20feat(model-pricing):=20r?= =?UTF-8?q?efactor=20layout=20and=20component=20structure=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re-architected model-pricing page into modular components: * PricingPage / PricingSidebar / PricingContent * Removed obsolete `ModelPricing*` components and column defs * Introduced reusable `SelectableButtonGroup` in `common/ui` * Supports Row/Col grid (3 per row) * Optional collapsible mode with gradient mask & toggle * Rebuilt filter panels with the new button-group: * Model categories, token groups, and quota types * Added dynamic `tagCount` badges to display item totals * Extended `useModelPricingData` hook * Added `filterGroup` and `filterQuotaType` state and logic * Updated PricingTable columns & sidebar reset logic to respect new states * Ensured backward compatibility via re-export in `index.jsx` * Polished styling, icons and i18n keys --- .../common/ui/SelectableButtonGroup.jsx | 147 +++++++++++++++ web/src/components/layout/HeaderBar.js | 2 +- web/src/components/layout/PageLayout.js | 2 +- .../model-pricing/ModelPricingFilters.jsx | 87 --------- .../table/model-pricing/ModelPricingTabs.jsx | 67 ------- .../table/model-pricing/PricingContent.jsx | 52 +++++ ...delPricingHeader.jsx => PricingHeader.jsx} | 4 +- .../table/model-pricing/PricingPage.jsx | 72 +++++++ .../table/model-pricing/PricingSearchBar.jsx | 63 +++++++ .../table/model-pricing/PricingSidebar.jsx | 153 +++++++++++++++ ...ModelPricingTable.jsx => PricingTable.jsx} | 12 +- ...ngColumnDefs.js => PricingTableColumns.js} | 178 ++++++++++-------- .../components/table/model-pricing/index.jsx | 49 +---- .../sidebar/PricingCategories.jsx | 44 +++++ .../model-pricing/sidebar/PricingGroups.jsx | 58 ++++++ .../sidebar/PricingQuotaTypes.jsx | 49 +++++ .../model-pricing/useModelPricingData.js | 24 ++- web/src/pages/Pricing/index.js | 4 +- 18 files changed, 773 insertions(+), 294 deletions(-) create mode 100644 web/src/components/common/ui/SelectableButtonGroup.jsx delete mode 100644 web/src/components/table/model-pricing/ModelPricingFilters.jsx delete mode 100644 web/src/components/table/model-pricing/ModelPricingTabs.jsx create mode 100644 web/src/components/table/model-pricing/PricingContent.jsx rename web/src/components/table/model-pricing/{ModelPricingHeader.jsx => PricingHeader.jsx} (98%) create mode 100644 web/src/components/table/model-pricing/PricingPage.jsx create mode 100644 web/src/components/table/model-pricing/PricingSearchBar.jsx create mode 100644 web/src/components/table/model-pricing/PricingSidebar.jsx rename web/src/components/table/model-pricing/{ModelPricingTable.jsx => PricingTable.jsx} (93%) rename web/src/components/table/model-pricing/{ModelPricingColumnDefs.js => PricingTableColumns.js} (59%) create mode 100644 web/src/components/table/model-pricing/sidebar/PricingCategories.jsx create mode 100644 web/src/components/table/model-pricing/sidebar/PricingGroups.jsx create mode 100644 web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx new file mode 100644 index 00000000..270cacc7 --- /dev/null +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -0,0 +1,147 @@ +/* +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 { Divider, Button, Tag, Row, Col, Collapsible } from '@douyinfe/semi-ui'; +import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; + +/** + * 通用可选择按钮组组件 + * + * @param {string} title 标题 + * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项 + * @param {*} activeValue 当前激活的值 + * @param {(value:any)=>void} onChange 选择改变回调 + * @param {function} t i18n + * @param {object} style 额外样式 + * @param {boolean} collapsible 是否支持折叠,默认true + * @param {number} collapseHeight 折叠时的高度,默认200 + */ +const SelectableButtonGroup = ({ + title, + items = [], + activeValue, + onChange, + t = (v) => v, + style = {}, + collapsible = true, + collapseHeight = 200 +}) => { + const [isOpen, setIsOpen] = useState(false); + const perRow = 3; + const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32 + const needCollapse = collapsible && items.length > perRow * maxVisibleRows; + + const contentRef = useRef(null); + + const maskStyle = isOpen + ? {} + : { + WebkitMaskImage: + 'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)', + }; + + const toggle = () => { + setIsOpen(!isOpen); + }; + + const linkStyle = { + position: 'absolute', + left: 0, + right: 0, + textAlign: 'center', + bottom: -10, + fontWeight: 400, + cursor: 'pointer', + fontSize: '12px', + color: 'var(--semi-color-text-2)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }; + + const contentElement = ( + + {items.map((item) => { + const isActive = activeValue === item.value; + return ( +
+ + + ); + })} + + ); + + return ( +
+ {title && ( + + {title} + + )} + {needCollapse ? ( +
+ + {contentElement} + + {isOpen ? null : ( +
+ + {t('展开更多')} +
+ )} + {isOpen && ( +
+ + {t('收起')} +
+ )} +
+ ) : ( + contentElement + )} +
+ ); +}; + +export default SelectableButtonGroup; \ No newline at end of file diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index a2e3986c..6a158ec0 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -467,7 +467,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { }; return ( -
+
{ const { i18n } = useTranslation(); const location = useLocation(); - const shouldHideFooter = location.pathname.startsWith('/console'); + const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing'; const shouldInnerPadding = location.pathname.includes('/console') && !location.pathname.startsWith('/console/chat') && diff --git a/web/src/components/table/model-pricing/ModelPricingFilters.jsx b/web/src/components/table/model-pricing/ModelPricingFilters.jsx deleted file mode 100644 index 57b5e7e1..00000000 --- a/web/src/components/table/model-pricing/ModelPricingFilters.jsx +++ /dev/null @@ -1,87 +0,0 @@ -/* -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 { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui'; -import { IconSearch, IconCopy } from '@douyinfe/semi-icons'; - -const ModelPricingFilters = ({ - selectedRowKeys, - copyText, - showWithRecharge, - setShowWithRecharge, - currency, - setCurrency, - handleChange, - handleCompositionStart, - handleCompositionEnd, - t -}) => { - const SearchAndActions = useMemo(() => ( - -
-
- } - placeholder={t('模糊搜索模型名称')} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onChange={handleChange} - showClear - /> -
- - - {/* 充值价格显示开关 */} - - {t('以充值价格显示')} - - {showWithRecharge && ( - - )} - -
-
- ), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]); - - return SearchAndActions; -}; - -export default ModelPricingFilters; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingTabs.jsx b/web/src/components/table/model-pricing/ModelPricingTabs.jsx deleted file mode 100644 index 11a58b79..00000000 --- a/web/src/components/table/model-pricing/ModelPricingTabs.jsx +++ /dev/null @@ -1,67 +0,0 @@ -/* -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'; - -const ModelPricingTabs = ({ - activeKey, - setActiveKey, - modelCategories, - categoryCounts, - availableCategories, - t -}) => { - return ( - setActiveKey(key)} - className="mt-2" - > - {Object.entries(modelCategories) - .filter(([key]) => availableCategories.includes(key)) - .map(([key, category]) => { - const modelCount = categoryCounts[key] || 0; - - return ( - - {category.icon && {category.icon}} - {category.label} - - {modelCount} - - - } - itemKey={key} - key={key} - /> - ); - })} - - ); -}; - -export default ModelPricingTabs; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/PricingContent.jsx b/web/src/components/table/model-pricing/PricingContent.jsx new file mode 100644 index 00000000..6a47df26 --- /dev/null +++ b/web/src/components/table/model-pricing/PricingContent.jsx @@ -0,0 +1,52 @@ +/* +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 PricingSearchBar from './PricingSearchBar.jsx'; +import PricingTable from './PricingTable.jsx'; + +const PricingContent = (props) => { + return ( + <> + {/* 固定的搜索和操作区域 */} +
+ +
+ + {/* 可滚动的内容区域 */} +
+ +
+ + ); +}; + +export default PricingContent; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingHeader.jsx b/web/src/components/table/model-pricing/PricingHeader.jsx similarity index 98% rename from web/src/components/table/model-pricing/ModelPricingHeader.jsx rename to web/src/components/table/model-pricing/PricingHeader.jsx index 40075f3a..9dc508aa 100644 --- a/web/src/components/table/model-pricing/ModelPricingHeader.jsx +++ b/web/src/components/table/model-pricing/PricingHeader.jsx @@ -22,7 +22,7 @@ import { Card } from '@douyinfe/semi-ui'; import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons'; import { AlertCircle } from 'lucide-react'; -const ModelPricingHeader = ({ +const PricingHeader = ({ userState, groupRatio, selectedGroup, @@ -120,4 +120,4 @@ const ModelPricingHeader = ({ ); }; -export default ModelPricingHeader; \ No newline at end of file +export default PricingHeader; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/PricingPage.jsx b/web/src/components/table/model-pricing/PricingPage.jsx new file mode 100644 index 00000000..0c360ad1 --- /dev/null +++ b/web/src/components/table/model-pricing/PricingPage.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 { Layout, ImagePreview } from '@douyinfe/semi-ui'; +import PricingSidebar from './PricingSidebar.jsx'; +import PricingContent from './PricingContent.jsx'; +import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js'; + +const PricingPage = () => { + const pricingData = useModelPricingData(); + const { Sider, Content } = Layout; + + // 显示倍率状态 + const [showRatio, setShowRatio] = React.useState(false); + + return ( +
+ + {/* 左侧边栏 */} + + + + + {/* 右侧内容区 */} + + + + + + {/* 倍率说明图预览 */} + pricingData.setIsModalOpenurl(visible)} + /> +
+ ); +}; + +export default PricingPage; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/PricingSearchBar.jsx b/web/src/components/table/model-pricing/PricingSearchBar.jsx new file mode 100644 index 00000000..744fd0b6 --- /dev/null +++ b/web/src/components/table/model-pricing/PricingSearchBar.jsx @@ -0,0 +1,63 @@ +/* +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 { Input, Button } from '@douyinfe/semi-ui'; +import { IconSearch, IconCopy } from '@douyinfe/semi-icons'; + +const PricingSearchBar = ({ + selectedRowKeys, + copyText, + handleChange, + handleCompositionStart, + handleCompositionEnd, + t +}) => { + const SearchAndActions = useMemo(() => ( +
+ {/* 搜索框 */} +
+ } + placeholder={t('模糊搜索模型名称')} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onChange={handleChange} + showClear + /> +
+ + {/* 操作按钮 */} + +
+ ), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText]); + + return SearchAndActions; +}; + +export default PricingSearchBar; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/PricingSidebar.jsx b/web/src/components/table/model-pricing/PricingSidebar.jsx new file mode 100644 index 00000000..9c6389ba --- /dev/null +++ b/web/src/components/table/model-pricing/PricingSidebar.jsx @@ -0,0 +1,153 @@ +/* +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 { Divider, Button, Switch, Select, Tooltip } from '@douyinfe/semi-ui'; +import { IconHelpCircle } from '@douyinfe/semi-icons'; +import PricingCategories from './sidebar/PricingCategories.jsx'; +import PricingGroups from './sidebar/PricingGroups.jsx'; +import PricingQuotaTypes from './sidebar/PricingQuotaTypes.jsx'; + +const PricingSidebar = ({ + showWithRecharge, + setShowWithRecharge, + currency, + setCurrency, + handleChange, + setActiveKey, + showRatio, + setShowRatio, + filterGroup, + setFilterGroup, + filterQuotaType, + setFilterQuotaType, + t, + ...categoryProps +}) => { + + // 重置所有筛选条件 + const handleResetFilters = () => { + // 重置搜索 + if (handleChange) { + handleChange(''); + } + + // 重置模型分类到默认 + if (setActiveKey && categoryProps.availableCategories?.length > 0) { + setActiveKey(categoryProps.availableCategories[0]); + } + + // 重置充值价格显示 + if (setShowWithRecharge) { + setShowWithRecharge(false); + } + + // 重置货币 + if (setCurrency) { + setCurrency('USD'); + } + + // 重置显示倍率 + setShowRatio(false); + + // 重置分组筛选 + if (setFilterGroup) { + setFilterGroup('all'); + } + + // 重置计费类型筛选 + if (setFilterQuotaType) { + setFilterQuotaType('all'); + } + }; + + return ( +
+ {/* 筛选标题和重置按钮 */} +
+
+ {t('筛选')} +
+ +
+ + {/* 显示设置 */} +
+ + {t('显示设置')} + +
+
+ {t('以充值价格显示')} + +
+ {showWithRecharge && ( +
+
{t('货币单位')}
+ +
+ )} +
+
+ {t('显示倍率')} + + + +
+ +
+
+
+ + {/* 模型分类 */} + + + + + +
+ ); +}; + +export default PricingSidebar; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingTable.jsx b/web/src/components/table/model-pricing/PricingTable.jsx similarity index 93% rename from web/src/components/table/model-pricing/ModelPricingTable.jsx rename to web/src/components/table/model-pricing/PricingTable.jsx index 22d94f29..ae6e706c 100644 --- a/web/src/components/table/model-pricing/ModelPricingTable.jsx +++ b/web/src/components/table/model-pricing/PricingTable.jsx @@ -23,9 +23,9 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; -import { getModelPricingColumns } from './ModelPricingColumnDefs.js'; +import { getPricingTableColumns } from './PricingTableColumns.js'; -const ModelPricingTable = ({ +const PricingTable = ({ filteredModels, loading, rowSelection, @@ -44,10 +44,12 @@ const ModelPricingTable = ({ displayPrice, filteredValue, handleGroupClick, + showRatio, t }) => { + const columns = useMemo(() => { - return getModelPricingColumns({ + return getPricingTableColumns({ t, selectedGroup, usableGroup, @@ -61,6 +63,7 @@ const ModelPricingTable = ({ setTokenUnit, displayPrice, handleGroupClick, + showRatio, }); }, [ t, @@ -76,6 +79,7 @@ const ModelPricingTable = ({ setTokenUnit, displayPrice, handleGroupClick, + showRatio, ]); // 更新列定义中的 filteredValue @@ -121,4 +125,4 @@ const ModelPricingTable = ({ return ModelTable; }; -export default ModelPricingTable; \ No newline at end of file +export default PricingTable; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/ModelPricingColumnDefs.js b/web/src/components/table/model-pricing/PricingTableColumns.js similarity index 59% rename from web/src/components/table/model-pricing/ModelPricingColumnDefs.js rename to web/src/components/table/model-pricing/PricingTableColumns.js index bf71533c..fd234df5 100644 --- a/web/src/components/table/model-pricing/ModelPricingColumnDefs.js +++ b/web/src/components/table/model-pricing/PricingTableColumns.js @@ -76,7 +76,7 @@ function renderSupportedEndpoints(endpoints) { ); } -export const getModelPricingColumns = ({ +export const getPricingTableColumns = ({ t, selectedGroup, usableGroup, @@ -90,8 +90,9 @@ export const getModelPricingColumns = ({ setTokenUnit, displayPrice, handleGroupClick, + showRatio, }) => { - return [ + const baseColumns = [ { title: t('可用性'), dataIndex: 'available', @@ -166,96 +167,109 @@ export const getModelPricingColumns = ({ ); }, }, - { - title: () => ( -
- {t('倍率')} - - { - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> - + ]; + + // 倍率列 - 只有在showRatio为true时才包含 + const ratioColumn = { + title: () => ( +
+ {t('倍率')} + + { + setModalImageUrl('/ratio.png'); + setIsModalOpenurl(true); + }} + /> + +
+ ), + dataIndex: 'model_ratio', + render: (text, record, index) => { + let content = text; + let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); + content = ( +
+
+ {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} +
+
+ {t('补全倍率')}: + {record.quota_type === 0 ? completionRatio : t('无')} +
+
+ {t('分组倍率')}:{groupRatio[selectedGroup]} +
- ), - dataIndex: 'model_ratio', - render: (text, record, index) => { - let content = text; - let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); + ); + return content; + }, + }; + + // 价格列 + const priceColumn = { + title: ( +
+ {t('模型价格')} + {/* 计费单位切换 */} + setTokenUnit(checked ? 'K' : 'M')} + checkedText="K" + uncheckedText="M" + /> +
+ ), + dataIndex: 'model_price', + render: (text, record, index) => { + let content = text; + if (record.quota_type === 0) { + let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; + let completionRatioPriceUSD = + record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup]; + + const unitDivisor = tokenUnit === 'K' ? 1000 : 1; + const unitLabel = tokenUnit === 'K' ? 'K' : 'M'; + + let displayInput = displayPrice(inputRatioPriceUSD); + let displayCompletion = displayPrice(completionRatioPriceUSD); + + const divisor = unitDivisor; + const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor; + const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor; + + displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`; + displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`; content = (
- {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} + {t('提示')} {displayInput} / 1{unitLabel} tokens
- {t('补全倍率')}: - {record.quota_type === 0 ? completionRatio : t('无')} -
-
- {t('分组倍率')}:{groupRatio[selectedGroup]} + {t('补全')} {displayCompletion} / 1{unitLabel} tokens
); - return content; - }, + } else { + let priceUSD = parseFloat(text) * groupRatio[selectedGroup]; + let displayVal = displayPrice(priceUSD); + content = ( +
+ {t('模型价格')}:{displayVal} +
+ ); + } + return content; }, - { - title: ( -
- {t('模型价格')} - {/* 计费单位切换 */} - setTokenUnit(checked ? 'K' : 'M')} - checkedText="K" - uncheckedText="M" - /> -
- ), - dataIndex: 'model_price', - render: (text, record, index) => { - let content = text; - if (record.quota_type === 0) { - let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; - let completionRatioPriceUSD = - record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup]; + }; - const unitDivisor = tokenUnit === 'K' ? 1000 : 1; - const unitLabel = tokenUnit === 'K' ? 'K' : 'M'; + // 根据showRatio决定是否包含倍率列 + const columns = [...baseColumns]; + if (showRatio) { + columns.push(ratioColumn); + } + columns.push(priceColumn); - let displayInput = displayPrice(inputRatioPriceUSD); - let displayCompletion = displayPrice(completionRatioPriceUSD); - - const divisor = unitDivisor; - const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor; - const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor; - - displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`; - displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`; - content = ( -
-
- {t('提示')} {displayInput} / 1{unitLabel} tokens -
-
- {t('补全')} {displayCompletion} / 1{unitLabel} tokens -
-
- ); - } else { - let priceUSD = parseFloat(text) * groupRatio[selectedGroup]; - let displayVal = displayPrice(priceUSD); - content = ( -
- {t('模型价格')}:{displayVal} -
- ); - } - return content; - }, - }, - ]; + return columns; }; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/index.jsx b/web/src/components/table/model-pricing/index.jsx index a8641ce5..d79be40c 100644 --- a/web/src/components/table/model-pricing/index.jsx +++ b/web/src/components/table/model-pricing/index.jsx @@ -17,50 +17,5 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; -import { Layout, Card, ImagePreview } from '@douyinfe/semi-ui'; -import ModelPricingTabs from './ModelPricingTabs.jsx'; -import ModelPricingFilters from './ModelPricingFilters.jsx'; -import ModelPricingTable from './ModelPricingTable.jsx'; -import ModelPricingHeader from './ModelPricingHeader.jsx'; -import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js'; - -const ModelPricingPage = () => { - const modelPricingData = useModelPricingData(); - - return ( -
- - -
-
- {/* 主卡片容器 */} - - {/* 顶部状态卡片 */} - - - {/* 模型分类 Tabs */} -
- - - {/* 搜索和表格区域 */} - - -
- - {/* 倍率说明图预览 */} - modelPricingData.setIsModalOpenurl(visible)} - /> -
-
-
-
-
-
- ); -}; - -export default ModelPricingPage; \ No newline at end of file +// 为了向后兼容,这里重新导出新的 PricingPage 组件 +export { default } from './PricingPage.jsx'; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/sidebar/PricingCategories.jsx b/web/src/components/table/model-pricing/sidebar/PricingCategories.jsx new file mode 100644 index 00000000..65cb58c7 --- /dev/null +++ b/web/src/components/table/model-pricing/sidebar/PricingCategories.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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx'; + +const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, t }) => { + const items = Object.entries(modelCategories) + .filter(([key]) => availableCategories.includes(key)) + .map(([key, category]) => ({ + value: key, + label: category.label, + icon: category.icon, + tagCount: categoryCounts[key] || 0, + })); + + return ( + + ); +}; + +export default PricingCategories; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/sidebar/PricingGroups.jsx b/web/src/components/table/model-pricing/sidebar/PricingGroups.jsx new file mode 100644 index 00000000..32643d76 --- /dev/null +++ b/web/src/components/table/model-pricing/sidebar/PricingGroups.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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx'; + +/** + * 分组筛选组件 + * @param {string} filterGroup 当前选中的分组,'all' 表示不过滤 + * @param {Function} setFilterGroup 设置选中分组 + * @param {Record} usableGroup 后端返回的可用分组对象 + * @param {Function} t i18n + */ +const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], t }) => { + const groups = ['all', ...Object.keys(usableGroup)]; + + const items = groups.map((g) => { + let count = 0; + if (g === 'all') { + count = models.length; + } else { + count = models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length; + } + return { + value: g, + label: g === 'all' ? t('全部分组') : g, + tagCount: count, + }; + }); + + return ( + + ); +}; + +export default PricingGroups; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx b/web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx new file mode 100644 index 00000000..373f9f5d --- /dev/null +++ b/web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx @@ -0,0 +1,49 @@ +/* +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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx'; + +/** + * 计费类型筛选组件 + * @param {string|'all'|0|1} filterQuotaType 当前值 + * @param {Function} setFilterQuotaType setter + * @param {Function} t i18n + */ +const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], t }) => { + const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length; + + const items = [ + { value: 'all', label: t('全部类型'), tagCount: qtyCount('all') }, + { value: 0, label: t('按量计费'), tagCount: qtyCount(0) }, + { value: 1, label: t('按次计费'), tagCount: qtyCount(1) }, + ]; + + return ( + + ); +}; + +export default PricingQuotaTypes; \ No newline at end of file diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index 60445f1e..ac58d817 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -32,6 +32,10 @@ export const useModelPricingData = () => { const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [selectedGroup, setSelectedGroup] = useState('default'); + // 用于 Table 的可用分组筛选,“all” 表示不过滤 + const [filterGroup, setFilterGroup] = useState('all'); + // 计费类型筛选: 'all' | 0 | 1 + const [filterQuotaType, setFilterQuotaType] = useState('all'); const [activeKey, setActiveKey] = useState('all'); const [pageSize, setPageSize] = useState(10); const [currency, setCurrency] = useState('USD'); @@ -75,10 +79,22 @@ export const useModelPricingData = () => { const filteredModels = useMemo(() => { let result = models; + // 分类筛选 if (activeKey !== 'all') { result = result.filter(model => modelCategories[activeKey].filter(model)); } + // 分组筛选 + if (filterGroup !== 'all') { + result = result.filter(model => model.enable_groups.includes(filterGroup)); + } + + // 计费类型筛选 + if (filterQuotaType !== 'all') { + result = result.filter(model => model.quota_type === filterQuotaType); + } + + // 搜索筛选 if (filteredValue.length > 0) { const searchTerm = filteredValue[0].toLowerCase(); result = result.filter(model => @@ -87,7 +103,7 @@ export const useModelPricingData = () => { } return result; - }, [activeKey, models, filteredValue]); + }, [activeKey, models, filteredValue, filterGroup, filterQuotaType]); const rowSelection = useMemo( () => ({ @@ -184,6 +200,8 @@ export const useModelPricingData = () => { const handleGroupClick = (group) => { setSelectedGroup(group); + // 同时将分组过滤设置为该分组 + setFilterGroup(group); showInfo( t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { group: group, @@ -208,6 +226,10 @@ export const useModelPricingData = () => { setIsModalOpenurl, selectedGroup, setSelectedGroup, + filterGroup, + setFilterGroup, + filterQuotaType, + setFilterQuotaType, activeKey, setActiveKey, pageSize, diff --git a/web/src/pages/Pricing/index.js b/web/src/pages/Pricing/index.js index 036e94ad..c1066203 100644 --- a/web/src/pages/Pricing/index.js +++ b/web/src/pages/Pricing/index.js @@ -21,9 +21,9 @@ import React from 'react'; import ModelPricingPage from '../../components/table/model-pricing'; const Pricing = () => ( -
+ <> -
+ ); export default Pricing; From f845a96f97ceccd70ff8514a5b7d26c9c1755372 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 02:23:25 +0800 Subject: [PATCH 070/582] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20enhance=20prici?= =?UTF-8?q?ng=20table=20&=20filters=20with=20responsive=20button-group,=20?= =?UTF-8?q?fixed=20column,=20scroll=20tweaks=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • SelectableButtonGroup • Added optional collapsible support with gradient mask & toggle • Dynamic tagCount badge support for groups / quota types • Switched to responsive Row/Col (`xs 24`, `sm 24`, `lg 12`, `xl 8`) for fluid layout • Shows expand button only when item count exceeds visible rows • Sidebar filters • PricingGroups & PricingQuotaTypes now pass tag counts to button-group • Counts derived from current models & quota_type • PricingTableColumns • Moved “Availability” column to far right; fixed via `fixed: 'right'` • Re-ordered columns and preserved ratio / price logic • PricingTable • Added `compactMode` prop; strips fixed columns and sets `scroll={compactMode ? undefined : { x: 'max-content' }}` • Processes columns to remove `fixed` in compact mode • PricingPage & index.css • Added `.pricing-scroll-hide` utility to hide Y-axis scrollbar for `Sider` & `Content` • Responsive / style refinements • Sidebar width adjusted to 460px • Scrollbars hidden uniformly across pricing modules These changes complete the model-pricing UI refactor, ensuring clean scrolling, responsive filters, and fixed availability column for better usability. --- .../common/ui/SelectableButtonGroup.jsx | 2 +- .../table/model-pricing/PricingContent.jsx | 4 +- .../table/model-pricing/PricingPage.jsx | 2 + .../table/model-pricing/PricingTable.jsx | 18 +- .../model-pricing/PricingTableColumns.js | 160 +++++++++--------- web/src/index.css | 4 +- 6 files changed, 102 insertions(+), 88 deletions(-) diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index 270cacc7..097283e7 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -82,7 +82,7 @@ const SelectableButtonGroup = ({ {items.map((item) => { const isActive = activeValue === item.value; return ( -
+
} @@ -120,7 +128,7 @@ const PricingTable = ({ }} /> - ), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]); + ), [filteredModels, loading, processedColumns, rowSelection, pageSize, setPageSize, t, compactMode]); return ModelTable; }; diff --git a/web/src/components/table/model-pricing/PricingTableColumns.js b/web/src/components/table/model-pricing/PricingTableColumns.js index fd234df5..676ec579 100644 --- a/web/src/components/table/model-pricing/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/PricingTableColumns.js @@ -92,84 +92,88 @@ export const getPricingTableColumns = ({ handleGroupClick, showRatio, }) => { - const baseColumns = [ - { - title: t('可用性'), - dataIndex: 'available', - render: (text, record, index) => { - return renderAvailable(record.enable_groups.includes(selectedGroup), t); - }, - sorter: (a, b) => { - const aAvailable = a.enable_groups.includes(selectedGroup); - const bAvailable = b.enable_groups.includes(selectedGroup); - return Number(aAvailable) - Number(bAvailable); - }, - defaultSortOrder: 'descend', + const endpointColumn = { + title: t('可用端点类型'), + dataIndex: 'supported_endpoint_types', + render: (text, record, index) => { + return renderSupportedEndpoints(text); }, - { - title: t('可用端点类型'), - dataIndex: 'supported_endpoint_types', - render: (text, record, index) => { - return renderSupportedEndpoints(text); - }, - }, - { - title: t('模型名称'), - dataIndex: 'model_name', - render: (text, record, index) => { - return renderModelTag(text, { - onClick: () => { - copyText(text); - } - }); - }, - onFilter: (value, record) => - record.model_name.toLowerCase().includes(value.toLowerCase()), - }, - { - title: t('计费类型'), - dataIndex: 'quota_type', - render: (text, record, index) => { - return renderQuotaType(parseInt(text), t); - }, - sorter: (a, b) => a.quota_type - b.quota_type, - }, - { - title: t('可用分组'), - dataIndex: 'enable_groups', - render: (text, record, index) => { - return ( - - {text.map((group) => { - if (usableGroup[group]) { - if (group === selectedGroup) { - return ( - }> - {group} - - ); - } else { - return ( - handleGroupClick(group)} - className="cursor-pointer hover:opacity-80 transition-opacity" - > - {group} - - ); - } - } - })} - - ); - }, - }, - ]; + }; + + const modelNameColumn = { + title: t('模型名称'), + dataIndex: 'model_name', + render: (text, record, index) => { + return renderModelTag(text, { + onClick: () => { + copyText(text); + } + }); + }, + onFilter: (value, record) => + record.model_name.toLowerCase().includes(value.toLowerCase()), + }; + + const quotaColumn = { + title: t('计费类型'), + dataIndex: 'quota_type', + render: (text, record, index) => { + return renderQuotaType(parseInt(text), t); + }, + sorter: (a, b) => a.quota_type - b.quota_type, + }; + + const enableGroupColumn = { + title: t('可用分组'), + dataIndex: 'enable_groups', + render: (text, record, index) => { + return ( + + {text.map((group) => { + if (usableGroup[group]) { + if (group === selectedGroup) { + return ( + }> + {group} + + ); + } else { + return ( + handleGroupClick(group)} + className="cursor-pointer hover:opacity-80 transition-opacity" + > + {group} + + ); + } + } + })} + + ); + }, + }; + + const baseColumns = [endpointColumn, modelNameColumn, quotaColumn, enableGroupColumn]; + + const availabilityColumn = { + title: t('可用性'), + dataIndex: 'available', + fixed: 'right', + render: (text, record, index) => { + return renderAvailable(record.enable_groups.includes(selectedGroup), t); + }, + sorter: (a, b) => { + const aAvailable = a.enable_groups.includes(selectedGroup); + const bAvailable = b.enable_groups.includes(selectedGroup); + return Number(aAvailable) - Number(bAvailable); + }, + defaultSortOrder: 'descend', + }; - // 倍率列 - 只有在showRatio为true时才包含 const ratioColumn = { title: () => (
@@ -207,7 +211,6 @@ export const getPricingTableColumns = ({ }, }; - // 价格列 const priceColumn = { title: (
@@ -264,12 +267,11 @@ export const getPricingTableColumns = ({ }, }; - // 根据showRatio决定是否包含倍率列 const columns = [...baseColumns]; if (showRatio) { columns.push(ratioColumn); } columns.push(priceColumn); - + columns.push(availabilityColumn); return columns; }; \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index 6a102b31..afbb7862 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -391,7 +391,8 @@ code { background: transparent; } -/* 隐藏卡片内容区域的滚动条 */ +/* 隐藏内容区域滚动条 */ +.pricing-scroll-hide, .model-test-scroll, .card-content-scroll, .model-settings-scroll, @@ -403,6 +404,7 @@ code { scrollbar-width: none; } +.pricing-scroll-hide::-webkit-scrollbar, .model-test-scroll::-webkit-scrollbar, .card-content-scroll::-webkit-scrollbar, .model-settings-scroll::-webkit-scrollbar, From cff8c3ac55417347fa451eb6426d023b709faaeb Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 02:28:43 +0800 Subject: [PATCH 071/582] =?UTF-8?q?=F0=9F=93=8C=20fix(pricing-search):=20m?= =?UTF-8?q?ake=20search=20bar=20sticky=20within=20PricingContent=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added `position: sticky; top: 0; z-index: 5;` to search bar container – keeps the bar fixed while the table body scrolls * Preserves previous padding, border and background styles * Improves usability by ensuring quick access to search & actions during long list navigation • PricingTable • Added `compactMode` prop; strips fixed columns and sets `scroll={compactMode ? undefined : { x: 'max-content' }}` • Processes columns to remove `fixed` in compact mode • PricingPage & index.css • Added `.pricing-scroll-hide` utility to hide Y-axis scrollbar for `Sider` & `Content` • Responsive / style refinements • Sidebar width adjusted to 460px • Scrollbars hidden uniformly across pricing modules These changes complete the model-pricing UI refactor, ensuring clean scrolling, responsive filters, and fixed availability column for better usability. --- web/src/components/table/model-pricing/PricingContent.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/components/table/model-pricing/PricingContent.jsx b/web/src/components/table/model-pricing/PricingContent.jsx index 17162b63..c20487e9 100644 --- a/web/src/components/table/model-pricing/PricingContent.jsx +++ b/web/src/components/table/model-pricing/PricingContent.jsx @@ -30,7 +30,10 @@ const PricingContent = (props) => { padding: '16px 24px', borderBottom: '1px solid var(--semi-color-border)', backgroundColor: 'var(--semi-color-bg-0)', - flexShrink: 0 + flexShrink: 0, + position: 'sticky', + top: 0, + zIndex: 5, }} > From 9bab77ad057011d9cff4de5dec462ab450890d8a Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 03:14:25 +0800 Subject: [PATCH 072/582] =?UTF-8?q?=F0=9F=94=A7=20refactor(pricing-filters?= =?UTF-8?q?):=20extract=20display=20settings=20&=20improve=20mobile=20layo?= =?UTF-8?q?ut=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **PricingDisplaySettings.jsx** • Extracted display settings (recharge price, currency, ratio toggle) from PricingSidebar • Maintains complete styling and functionality as standalone component * **SelectableButtonGroup.jsx** • Added isMobile detection with conditional Col spans • Mobile: `span={12}` (2 buttons per row) for better touch experience • Desktop: preserved responsive grid `xs={24} sm={24} md={24} lg={12} xl={8}` * **PricingSidebar.jsx** • Updated imports to use new PricingDisplaySettings component • Simplified component structure while preserving reset logic These changes enhance code modularity and provide optimized mobile UX for filter button groups across the pricing interface. --- .../common/ui/SelectableButtonGroup.jsx | 12 ++- .../table/model-pricing/PricingContent.jsx | 21 +++-- .../table/model-pricing/PricingPage.jsx | 43 ++++++---- .../table/model-pricing/PricingSearchBar.jsx | 45 ++++++++-- .../table/model-pricing/PricingSidebar.jsx | 68 ++++----------- .../table/model-pricing/PricingTable.jsx | 2 +- .../{sidebar => filter}/PricingCategories.jsx | 2 +- .../filter/PricingDisplaySettings.jsx | 82 +++++++++++++++++++ .../{sidebar => filter}/PricingGroups.jsx | 2 +- .../{sidebar => filter}/PricingQuotaTypes.jsx | 2 +- .../components/table/model-pricing/index.jsx | 2 +- .../modal/PricingFilterModal.jsx | 48 +++++++++++ web/src/i18n/locales/en.json | 1 - 13 files changed, 239 insertions(+), 91 deletions(-) rename web/src/components/table/model-pricing/{sidebar => filter}/PricingCategories.jsx (98%) create mode 100644 web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx rename web/src/components/table/model-pricing/{sidebar => filter}/PricingGroups.jsx (99%) rename web/src/components/table/model-pricing/{sidebar => filter}/PricingQuotaTypes.jsx (98%) create mode 100644 web/src/components/table/model-pricing/modal/PricingFilterModal.jsx diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index 097283e7..159dde73 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useState, useRef } from 'react'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { Divider, Button, Tag, Row, Col, Collapsible } from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; @@ -44,6 +45,7 @@ const SelectableButtonGroup = ({ collapseHeight = 200 }) => { const [isOpen, setIsOpen] = useState(false); + const isMobile = useIsMobile(); const perRow = 3; const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32 const needCollapse = collapsible && items.length > perRow * maxVisibleRows; @@ -82,10 +84,16 @@ const SelectableButtonGroup = ({ {items.map((item) => { const isActive = activeValue === item.value; return ( -
+ - - ), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText]); - return SearchAndActions; + {/* 移动端筛选按钮 */} + {isMobile && ( + + )} + + ), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText, isMobile]); + + return ( + <> + {SearchAndActions} + + {/* 移动端筛选Modal */} + {isMobile && ( + setShowFilterModal(false)} + sidebarProps={sidebarProps} + t={t} + /> + )} + + ); }; export default PricingSearchBar; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/PricingSidebar.jsx b/web/src/components/table/model-pricing/PricingSidebar.jsx index 9c6389ba..6c13c014 100644 --- a/web/src/components/table/model-pricing/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/PricingSidebar.jsx @@ -18,11 +18,11 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Divider, Button, Switch, Select, Tooltip } from '@douyinfe/semi-ui'; -import { IconHelpCircle } from '@douyinfe/semi-icons'; -import PricingCategories from './sidebar/PricingCategories.jsx'; -import PricingGroups from './sidebar/PricingGroups.jsx'; -import PricingQuotaTypes from './sidebar/PricingQuotaTypes.jsx'; +import { Button } from '@douyinfe/semi-ui'; +import PricingCategories from './filter/PricingCategories'; +import PricingGroups from './filter/PricingGroups'; +import PricingQuotaTypes from './filter/PricingQuotaTypes'; +import PricingDisplaySettings from './filter/PricingDisplaySettings'; const PricingSidebar = ({ showWithRecharge, @@ -79,13 +79,13 @@ const PricingSidebar = ({ return (
- {/* 筛选标题和重置按钮 */}
{t('筛选')}
- {/* 显示设置 */} -
- - {t('显示设置')} - -
-
- {t('以充值价格显示')} - -
- {showWithRecharge && ( -
-
{t('货币单位')}
- -
- )} -
-
- {t('显示倍率')} - - - -
- -
-
-
+ - {/* 模型分类 */} diff --git a/web/src/components/table/model-pricing/PricingTable.jsx b/web/src/components/table/model-pricing/PricingTable.jsx index 3b1bc7b8..4fb2a8e8 100644 --- a/web/src/components/table/model-pricing/PricingTable.jsx +++ b/web/src/components/table/model-pricing/PricingTable.jsx @@ -23,7 +23,7 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; -import { getPricingTableColumns } from './PricingTableColumns.js'; +import { getPricingTableColumns } from './PricingTableColumns'; const PricingTable = ({ filteredModels, diff --git a/web/src/components/table/model-pricing/sidebar/PricingCategories.jsx b/web/src/components/table/model-pricing/filter/PricingCategories.jsx similarity index 98% rename from web/src/components/table/model-pricing/sidebar/PricingCategories.jsx rename to web/src/components/table/model-pricing/filter/PricingCategories.jsx index 65cb58c7..22eb98a2 100644 --- a/web/src/components/table/model-pricing/sidebar/PricingCategories.jsx +++ b/web/src/components/table/model-pricing/filter/PricingCategories.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx'; +import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, t }) => { const items = Object.entries(modelCategories) diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx new file mode 100644 index 00000000..b212a404 --- /dev/null +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -0,0 +1,82 @@ +/* +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 { Divider, Switch, Select, Tooltip } from '@douyinfe/semi-ui'; +import { IconHelpCircle } from '@douyinfe/semi-icons'; + +const PricingDisplaySettings = ({ + showWithRecharge, + setShowWithRecharge, + currency, + setCurrency, + showRatio, + setShowRatio, + t +}) => { + return ( +
+ + {t('显示设置')} + +
+
+ {t('以充值价格显示')} + +
+ {showWithRecharge && ( +
+
{t('货币单位')}
+ +
+ )} +
+
+ {t('显示倍率')} + + + +
+ +
+
+
+ ); +}; + +export default PricingDisplaySettings; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/sidebar/PricingGroups.jsx b/web/src/components/table/model-pricing/filter/PricingGroups.jsx similarity index 99% rename from web/src/components/table/model-pricing/sidebar/PricingGroups.jsx rename to web/src/components/table/model-pricing/filter/PricingGroups.jsx index 32643d76..4b851748 100644 --- a/web/src/components/table/model-pricing/sidebar/PricingGroups.jsx +++ b/web/src/components/table/model-pricing/filter/PricingGroups.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx'; +import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; /** * 分组筛选组件 diff --git a/web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx similarity index 98% rename from web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx rename to web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx index 373f9f5d..5e6dcceb 100644 --- a/web/src/components/table/model-pricing/sidebar/PricingQuotaTypes.jsx +++ b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup.jsx'; +import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; /** * 计费类型筛选组件 diff --git a/web/src/components/table/model-pricing/index.jsx b/web/src/components/table/model-pricing/index.jsx index d79be40c..948285f0 100644 --- a/web/src/components/table/model-pricing/index.jsx +++ b/web/src/components/table/model-pricing/index.jsx @@ -18,4 +18,4 @@ For commercial licensing, please contact support@quantumnous.com */ // 为了向后兼容,这里重新导出新的 PricingPage 组件 -export { default } from './PricingPage.jsx'; \ No newline at end of file +export { default } from './PricingPage'; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx new file mode 100644 index 00000000..a9602591 --- /dev/null +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -0,0 +1,48 @@ +/* +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 PricingSidebar from '../PricingSidebar'; + +const PricingFilterModal = ({ + visible, + onClose, + sidebarProps, + t +}) => { + return ( + + + + ); +}; + +export default PricingFilterModal; \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5762533f..50c10a21 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -699,7 +699,6 @@ "个": "indivual", "倍率是本站的计算方式,不同模型有着不同的倍率,并非官方价格的多少倍,请务必知晓。": "The magnification is the calculation method of this website. Different models have different magnifications, which are not multiples of the official price. Please be sure to know.", "所有各厂聊天模型请统一使用OpenAI方式请求,支持OpenAI官方库
Claude()Claude官方格式请求": "Please use the OpenAI method to request all chat models from each factory, and support the OpenAI official library
Claude()Claude official format request", - "复制选中模型": "Copy selected model", "分组说明": "Group description", "倍率是为了方便换算不同价格的模型": "The magnification is to facilitate the conversion of models with different prices.", "点击查看倍率说明": "Click to view the magnification description", From e22ef769cbc0f2731e6bd8f054ddcbc1b597c39e Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 03:29:11 +0800 Subject: [PATCH 073/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(model-pri?= =?UTF-8?q?cing):=20extract=20`resetPricingFilters`=20utility=20and=20elim?= =?UTF-8?q?inate=20duplication=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize filter-reset logic to improve maintainability and consistency. - Add `resetPricingFilters` helper to `web/src/helpers/utils.js`, encapsulating all reset actions (search, category, currency, ratio, group, quota type, etc.). - Update `PricingFilterModal.jsx` and `PricingSidebar.jsx` to import and use the new utility instead of keeping their own duplicate `handleResetFilters`. - Removes repeated code, ensures future changes to reset behavior require modification in only one place, and keeps components lean. --- .../table/model-pricing/PricingSidebar.jsx | 47 +++----- .../modal/PricingFilterModal.jsx | 100 ++++++++++++++++-- web/src/helpers/utils.js | 52 +++++++++ 3 files changed, 156 insertions(+), 43 deletions(-) diff --git a/web/src/components/table/model-pricing/PricingSidebar.jsx b/web/src/components/table/model-pricing/PricingSidebar.jsx index 6c13c014..8605f5c9 100644 --- a/web/src/components/table/model-pricing/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/PricingSidebar.jsx @@ -23,6 +23,7 @@ import PricingCategories from './filter/PricingCategories'; import PricingGroups from './filter/PricingGroups'; import PricingQuotaTypes from './filter/PricingQuotaTypes'; import PricingDisplaySettings from './filter/PricingDisplaySettings'; +import { resetPricingFilters } from '../../../helpers/utils'; const PricingSidebar = ({ showWithRecharge, @@ -41,41 +42,17 @@ const PricingSidebar = ({ ...categoryProps }) => { - // 重置所有筛选条件 - const handleResetFilters = () => { - // 重置搜索 - if (handleChange) { - handleChange(''); - } - - // 重置模型分类到默认 - if (setActiveKey && categoryProps.availableCategories?.length > 0) { - setActiveKey(categoryProps.availableCategories[0]); - } - - // 重置充值价格显示 - if (setShowWithRecharge) { - setShowWithRecharge(false); - } - - // 重置货币 - if (setCurrency) { - setCurrency('USD'); - } - - // 重置显示倍率 - setShowRatio(false); - - // 重置分组筛选 - if (setFilterGroup) { - setFilterGroup('all'); - } - - // 重置计费类型筛选 - if (setFilterQuotaType) { - setFilterQuotaType('all'); - } - }; + const handleResetFilters = () => + resetPricingFilters({ + handleChange, + setActiveKey, + availableCategories: categoryProps.availableCategories, + setShowWithRecharge, + setCurrency, + setShowRatio, + setFilterGroup, + setFilterQuotaType, + }); return (
diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index a9602591..483104f7 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -18,8 +18,12 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Modal } from '@douyinfe/semi-ui'; -import PricingSidebar from '../PricingSidebar'; +import { Modal, Button } from '@douyinfe/semi-ui'; +import PricingCategories from '../filter/PricingCategories'; +import PricingGroups from '../filter/PricingGroups'; +import PricingQuotaTypes from '../filter/PricingQuotaTypes'; +import PricingDisplaySettings from '../filter/PricingDisplaySettings'; +import { resetPricingFilters } from '../../../../helpers/utils'; const PricingFilterModal = ({ visible, @@ -27,20 +31,100 @@ const PricingFilterModal = ({ sidebarProps, t }) => { + const { + showWithRecharge, + setShowWithRecharge, + currency, + setCurrency, + handleChange, + setActiveKey, + showRatio, + setShowRatio, + filterGroup, + setFilterGroup, + filterQuotaType, + setFilterQuotaType, + ...categoryProps + } = sidebarProps; + + const handleResetFilters = () => + resetPricingFilters({ + handleChange, + setActiveKey, + availableCategories: categoryProps.availableCategories, + setShowWithRecharge, + setCurrency, + setShowRatio, + setFilterGroup, + setFilterQuotaType, + }); + + const handleConfirm = () => { + onClose(); + }; + + const footer = ( +
+ + +
+ ); + return ( - +
+ + + + + + + +
); }; diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 5a8aa9cd..265be6c2 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -616,3 +616,55 @@ export const createCardProPagination = ({ ); }; + +// ------------------------------- +// 重置模型定价筛选条件 +export const resetPricingFilters = ({ + handleChange, + setActiveKey, + availableCategories, + setShowWithRecharge, + setCurrency, + setShowRatio, + setFilterGroup, + setFilterQuotaType, +}) => { + // 重置搜索 + if (typeof handleChange === 'function') { + handleChange(''); + } + + // 重置模型分类到默认 + if ( + typeof setActiveKey === 'function' && + Array.isArray(availableCategories) && + availableCategories.length > 0 + ) { + setActiveKey(availableCategories[0]); + } + + // 重置充值价格显示 + if (typeof setShowWithRecharge === 'function') { + setShowWithRecharge(false); + } + + // 重置货币 + if (typeof setCurrency === 'function') { + setCurrency('USD'); + } + + // 重置显示倍率 + if (typeof setShowRatio === 'function') { + setShowRatio(false); + } + + // 重置分组筛选 + if (typeof setFilterGroup === 'function') { + setFilterGroup('all'); + } + + // 重置计费类型筛选 + if (typeof setFilterQuotaType === 'function') { + setFilterQuotaType('all'); + } +}; From e007b26b8a397a98162da28f20129c65a5ae57b6 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 03:41:19 +0800 Subject: [PATCH 074/582] =?UTF-8?q?=F0=9F=92=84=20feat(ui):=20replace=20av?= =?UTF-8?q?ailability=20indicators=20with=20icons=20in=20PricingTableColum?= =?UTF-8?q?ns=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary • Swapped out the old availability UI for clearer icon-based feedback. • Users now see a green check icon when their group can use a model and a red × icon (with tooltip) when it cannot. Details 1. Imports • Removed deprecated `IconVerify`. • Added `IconCheckCircleStroked` ✅ and `IconClose` ❌ for new states. 2. Availability column • `renderAvailable` now – Shows a green `IconCheckCircleStroked` inside a popover (“Your group can use this model”). – Shows a red `IconClose` inside a popover (“你的分组无权使用该模型”) when the model is inaccessible. – Eliminates the empty cell/grey tag fallback. 3. Group tag • Updated selected-group tag to use `IconCheckCircleStroked` for visual consistency. Result Improves UX by providing explicit visual cues for model availability and removes ambiguous blank cells. --- .../model-pricing/PricingTableColumns.js | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/web/src/components/table/model-pricing/PricingTableColumns.js b/web/src/components/table/model-pricing/PricingTableColumns.js index 676ec579..be354671 100644 --- a/web/src/components/table/model-pricing/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/PricingTableColumns.js @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui'; -import { IconVerify, IconHelpCircle } from '@douyinfe/semi-icons'; +import { IconHelpCircle, IconCheckCircleStroked, IconClose } from '@douyinfe/semi-icons'; import { Popover } from '@douyinfe/semi-ui'; import { renderModelTag, stringToColor } from '../../../helpers'; @@ -43,18 +43,30 @@ function renderQuotaType(type, t) { } function renderAvailable(available, t) { - return available ? ( + if (available) { + return ( + {t('您的分组可以使用该模型')}
} + position='top' + key={String(available)} + className="bg-green-50" + > + + + ); + } + + // 分组不可用时显示红色关闭图标 + return ( {t('您的分组可以使用该模型')}
- } + content={
{t('你的分组无权使用该模型')}
} position='top' - key={available} - className="bg-green-50" + key="not-available" + className="bg-red-50" > - + - ) : null; + ); } function renderSupportedEndpoints(endpoints) { @@ -133,7 +145,7 @@ export const getPricingTableColumns = ({ if (usableGroup[group]) { if (group === selectedGroup) { return ( - }> + }> {group} ); From 6eaaee2052f5279cba2c0ad4cd3f5635af0a87c4 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 04:10:44 +0800 Subject: [PATCH 075/582] =?UTF-8?q?=E2=9C=A8=20feat:=20enhance=20Selectabl?= =?UTF-8?q?eButtonGroup=20with=20checkbox=20support=20and=20refactor=20pri?= =?UTF-8?q?cing=20display=20settings=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add withCheckbox prop to SelectableButtonGroup component for checkbox-prefixed buttons - Support both single value and array activeValue for multi-selection scenarios - Refactor PricingDisplaySettings to use consistent SelectableButtonGroup styling - Replace Switch components with checkbox-enabled SelectableButtonGroup - Replace Select dropdown with SelectableButtonGroup for currency selection - Maintain unified UI/UX across all pricing filter components - Add proper JSDoc documentation for new withCheckbox functionality This improves visual consistency and provides a more cohesive user experience in the model pricing filter interface. --- .../common/ui/SelectableButtonGroup.jsx | 55 ++++++++- .../filter/PricingDisplaySettings.jsx | 109 ++++++++++-------- 2 files changed, 115 insertions(+), 49 deletions(-) diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index 159dde73..a75c537e 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React, { useState, useRef } from 'react'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; -import { Divider, Button, Tag, Row, Col, Collapsible } from '@douyinfe/semi-ui'; +import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox } from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; /** @@ -27,12 +27,13 @@ import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; * * @param {string} title 标题 * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项 - * @param {*} activeValue 当前激活的值 + * @param {*|Array} activeValue 当前激活的值,可以是单个值或数组(多选) * @param {(value:any)=>void} onChange 选择改变回调 * @param {function} t i18n * @param {object} style 额外样式 * @param {boolean} collapsible 是否支持折叠,默认true * @param {number} collapseHeight 折叠时的高度,默认200 + * @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态 */ const SelectableButtonGroup = ({ title, @@ -42,7 +43,8 @@ const SelectableButtonGroup = ({ t = (v) => v, style = {}, collapsible = true, - collapseHeight = 200 + collapseHeight = 200, + withCheckbox = false }) => { const [isOpen, setIsOpen] = useState(false); const isMobile = useIsMobile(); @@ -82,7 +84,52 @@ const SelectableButtonGroup = ({ const contentElement = ( {items.map((item) => { - const isActive = activeValue === item.value; + const isActive = Array.isArray(activeValue) + ? activeValue.includes(item.value) + : activeValue === item.value; + + // 当启用前缀 Checkbox 时,按钮本身不可点击,仅 Checkbox 可控制状态切换 + if (withCheckbox) { + return ( +
+ + + ); + } + + // 默认行为 return ( { - return ( -
- - {t('显示设置')} - -
-
- {t('以充值价格显示')} - -
- {showWithRecharge && ( -
-
{t('货币单位')}
- -
- )} -
-
- {t('显示倍率')} - - - -
- -
-
+ style={{ color: 'var(--semi-color-text-2)', cursor: 'help' }} + /> + + + ), + } + ]; + + const currencyItems = [ + { value: 'USD', label: 'USD ($)' }, + { value: 'CNY', label: 'CNY (¥)' } + ]; + + const handleChange = (value) => { + if (value === 'recharge') { + setShowWithRecharge(!showWithRecharge); + } else if (value === 'ratio') { + setShowRatio(!showRatio); + } + }; + + const getActiveValues = () => { + const activeValues = []; + if (showWithRecharge) activeValues.push('recharge'); + if (showRatio) activeValues.push('ratio'); + return activeValues; + }; + + return ( +
+ + + {showWithRecharge && ( + + )}
); }; From 3ae8c74160e9e2712e07abf1753419f7c201618b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 04:31:27 +0800 Subject: [PATCH 076/582] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20skeleton=20lo?= =?UTF-8?q?ading=20animation=20to=20SelectableButtonGroup=20component=20(#?= =?UTF-8?q?1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive loading state support with skeleton animations for the SelectableButtonGroup component, improving user experience during data loading. Key Changes: - Add loading prop to SelectableButtonGroup with minimum 500ms display duration - Implement skeleton buttons with proper Semi-UI Skeleton wrapper and active animation - Use fixed skeleton count (6 items) to prevent visual jumping during load transitions - Pass loading state through all pricing filter components hierarchy: - PricingSidebar and PricingFilterModal as container components - PricingDisplaySettings, PricingCategories, PricingGroups, PricingQuotaTypes as filter components Technical Details: - Reference CardTable.js implementation for consistent skeleton UI patterns - Add useEffect hook for 500ms minimum loading duration control - Support both checkbox and regular button skeleton modes - Maintain responsive layout compatibility (mobile/desktop) - Add proper JSDoc parameter documentation for loading prop Fixes: - Prevent skeleton count sudden changes that caused visual discontinuity - Ensure proper skeleton animation with Semi-UI active parameter - Maintain consistent loading experience across all filter components --- .../common/ui/SelectableButtonGroup.jsx | 83 +++++++++++++++++-- .../table/model-pricing/PricingSidebar.jsx | 8 +- .../filter/PricingCategories.jsx | 3 +- .../filter/PricingDisplaySettings.jsx | 3 + .../model-pricing/filter/PricingGroups.jsx | 5 +- .../filter/PricingQuotaTypes.jsx | 5 +- .../modal/PricingFilterModal.jsx | 6 +- 7 files changed, 98 insertions(+), 15 deletions(-) diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index a75c537e..dd7fd8ab 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -17,9 +17,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; -import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox } from '@douyinfe/semi-ui'; +import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; /** @@ -34,6 +34,7 @@ import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; * @param {boolean} collapsible 是否支持折叠,默认true * @param {number} collapseHeight 折叠时的高度,默认200 * @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态 + * @param {boolean} loading 是否处于加载状态 */ const SelectableButtonGroup = ({ title, @@ -44,16 +45,36 @@ const SelectableButtonGroup = ({ style = {}, collapsible = true, collapseHeight = 200, - withCheckbox = false + withCheckbox = false, + loading = false }) => { const [isOpen, setIsOpen] = useState(false); + const [showSkeleton, setShowSkeleton] = useState(loading); + const [skeletonCount] = useState(6); const isMobile = useIsMobile(); const perRow = 3; const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32 const needCollapse = collapsible && items.length > perRow * maxVisibleRows; + const loadingStartRef = useRef(Date.now()); const contentRef = useRef(null); + 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 maskStyle = isOpen ? {} : { @@ -81,14 +102,57 @@ const SelectableButtonGroup = ({ gap: 4, }; - const contentElement = ( + const renderSkeletonButtons = () => { + + const placeholder = ( + + {Array.from({ length: skeletonCount }).map((_, index) => ( +
+
+ {withCheckbox && ( + + )} + +
+ + ))} + + ); + + return ( + + ); + }; + + const contentElement = showSkeleton ? renderSkeletonButtons() : ( {items.map((item) => { const isActive = Array.isArray(activeValue) ? activeValue.includes(item.value) : activeValue === item.value; - // 当启用前缀 Checkbox 时,按钮本身不可点击,仅 Checkbox 可控制状态切换 if (withCheckbox) { return ( {title && ( - {title} + {showSkeleton ? ( + + ) : ( + title + )} )} - {needCollapse ? ( + {needCollapse && !showSkeleton ? (
{contentElement} diff --git a/web/src/components/table/model-pricing/PricingSidebar.jsx b/web/src/components/table/model-pricing/PricingSidebar.jsx index 8605f5c9..39afed0f 100644 --- a/web/src/components/table/model-pricing/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/PricingSidebar.jsx @@ -38,6 +38,7 @@ const PricingSidebar = ({ setFilterGroup, filterQuotaType, setFilterQuotaType, + loading, t, ...categoryProps }) => { @@ -77,14 +78,15 @@ const PricingSidebar = ({ setCurrency={setCurrency} showRatio={showRatio} setShowRatio={setShowRatio} + loading={loading} t={t} /> - + - + - +
); }; diff --git a/web/src/components/table/model-pricing/filter/PricingCategories.jsx b/web/src/components/table/model-pricing/filter/PricingCategories.jsx index 22eb98a2..7a979508 100644 --- a/web/src/components/table/model-pricing/filter/PricingCategories.jsx +++ b/web/src/components/table/model-pricing/filter/PricingCategories.jsx @@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; -const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, t }) => { +const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, loading = false, t }) => { const items = Object.entries(modelCategories) .filter(([key]) => availableCategories.includes(key)) .map(([key, category]) => ({ @@ -36,6 +36,7 @@ const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryC items={items} activeValue={activeKey} onChange={setActiveKey} + loading={loading} t={t} /> ); diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx index 31296a1b..9d4d8312 100644 --- a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -29,6 +29,7 @@ const PricingDisplaySettings = ({ setCurrency, showRatio, setShowRatio, + loading = false, t }) => { const items = [ @@ -81,6 +82,7 @@ const PricingDisplaySettings = ({ onChange={handleChange} withCheckbox collapsible={false} + loading={loading} t={t} /> @@ -91,6 +93,7 @@ const PricingDisplaySettings = ({ activeValue={currency} onChange={setCurrency} collapsible={false} + loading={loading} t={t} /> )} diff --git a/web/src/components/table/model-pricing/filter/PricingGroups.jsx b/web/src/components/table/model-pricing/filter/PricingGroups.jsx index 4b851748..4ce67ff9 100644 --- a/web/src/components/table/model-pricing/filter/PricingGroups.jsx +++ b/web/src/components/table/model-pricing/filter/PricingGroups.jsx @@ -25,9 +25,11 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; * @param {string} filterGroup 当前选中的分组,'all' 表示不过滤 * @param {Function} setFilterGroup 设置选中分组 * @param {Record} usableGroup 后端返回的可用分组对象 + * @param {Array} models 模型列表 + * @param {boolean} loading 是否加载中 * @param {Function} t i18n */ -const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], t }) => { +const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], loading = false, t }) => { const groups = ['all', ...Object.keys(usableGroup)]; const items = groups.map((g) => { @@ -50,6 +52,7 @@ const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = items={items} activeValue={filterGroup} onChange={setFilterGroup} + loading={loading} t={t} /> ); diff --git a/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx index 5e6dcceb..bce56abe 100644 --- a/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx +++ b/web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx @@ -24,9 +24,11 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; * 计费类型筛选组件 * @param {string|'all'|0|1} filterQuotaType 当前值 * @param {Function} setFilterQuotaType setter + * @param {Array} models 模型列表 + * @param {boolean} loading 是否加载中 * @param {Function} t i18n */ -const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], t }) => { +const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], loading = false, t }) => { const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length; const items = [ @@ -41,6 +43,7 @@ const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], t items={items} activeValue={filterQuotaType} onChange={setFilterQuotaType} + loading={loading} t={t} /> ); diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index 483104f7..6182fb01 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -44,6 +44,7 @@ const PricingFilterModal = ({ setFilterGroup, filterQuotaType, setFilterQuotaType, + loading, ...categoryProps } = sidebarProps; @@ -105,16 +106,18 @@ const PricingFilterModal = ({ setCurrency={setCurrency} showRatio={showRatio} setShowRatio={setShowRatio} + loading={loading} t={t} /> - + @@ -122,6 +125,7 @@ const PricingFilterModal = ({ filterQuotaType={filterQuotaType} setFilterQuotaType={setFilterQuotaType} models={categoryProps.models} + loading={loading} t={t} /> From 024dcda92a336699df4d11cf62abb1f1a8cd7b00 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 10:04:32 +0800 Subject: [PATCH 077/582] =?UTF-8?q?=F0=9F=94=A7=20fix:=20filter=20out=20em?= =?UTF-8?q?pty=20string=20group=20from=20pricing=20groups=20selector=20(#1?= =?UTF-8?q?365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter out the special empty string group ("": "用户分组") from the usable groups in PricingGroups component. This empty group represents "user's current group" but contains no data and should not be displayed in the group filter options. - Add filter condition to exclude empty string keys from usableGroup - Prevents displaying invalid empty group option in UI - Improves user experience by showing only valid selectable groups --- web/src/components/table/model-pricing/filter/PricingGroups.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/model-pricing/filter/PricingGroups.jsx b/web/src/components/table/model-pricing/filter/PricingGroups.jsx index 4ce67ff9..75bdc2c7 100644 --- a/web/src/components/table/model-pricing/filter/PricingGroups.jsx +++ b/web/src/components/table/model-pricing/filter/PricingGroups.jsx @@ -30,7 +30,7 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; * @param {Function} t i18n */ const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], loading = false, t }) => { - const groups = ['all', ...Object.keys(usableGroup)]; + const groups = ['all', ...Object.keys(usableGroup).filter(key => key !== '')]; const items = groups.map((g) => { let count = 0; From 68e61b407dc34527538a513d17de544a2637c431 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 23 Jul 2025 11:20:55 +0800 Subject: [PATCH 078/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(model-pri?= =?UTF-8?q?cing):=20improve=20table=20UI=20and=20optimize=20code=20structu?= =?UTF-8?q?re=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace model count with group ratio display (x2.2, x1) in group filter - Remove redundant "Available Groups" column from pricing table - Remove "Availability" column and related logic completely - Move "Supported Endpoint Types" column to fixed right position - Clean up unused parameters and variables in PricingTableColumns.js - Optimize variable declarations (let → const) and simplify render logic - Improve code readability and reduce memory allocations This refactor enhances user experience by: - Providing clearer group ratio information in filters - Simplifying table layout while maintaining essential functionality - Improving performance through better code organization Breaking changes: None --- .../table/model-pricing/PricingSidebar.jsx | 2 +- .../model-pricing/PricingTableColumns.js | 119 +++--------------- .../model-pricing/filter/PricingGroups.jsx | 16 ++- .../modal/PricingFilterModal.jsx | 1 + 4 files changed, 31 insertions(+), 107 deletions(-) diff --git a/web/src/components/table/model-pricing/PricingSidebar.jsx b/web/src/components/table/model-pricing/PricingSidebar.jsx index 39afed0f..b7282160 100644 --- a/web/src/components/table/model-pricing/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/PricingSidebar.jsx @@ -84,7 +84,7 @@ const PricingSidebar = ({ - + diff --git a/web/src/components/table/model-pricing/PricingTableColumns.js b/web/src/components/table/model-pricing/PricingTableColumns.js index be354671..f0c9783d 100644 --- a/web/src/components/table/model-pricing/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/PricingTableColumns.js @@ -19,8 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui'; -import { IconHelpCircle, IconCheckCircleStroked, IconClose } from '@douyinfe/semi-icons'; -import { Popover } from '@douyinfe/semi-ui'; +import { IconHelpCircle } from '@douyinfe/semi-icons'; import { renderModelTag, stringToColor } from '../../../helpers'; function renderQuotaType(type, t) { @@ -42,33 +41,6 @@ function renderQuotaType(type, t) { } } -function renderAvailable(available, t) { - if (available) { - return ( - {t('您的分组可以使用该模型')}} - position='top' - key={String(available)} - className="bg-green-50" - > - - - ); - } - - // 分组不可用时显示红色关闭图标 - return ( - {t('你的分组无权使用该模型')}} - position='top' - key="not-available" - className="bg-red-50" - > - - - ); -} - function renderSupportedEndpoints(endpoints) { if (!endpoints || endpoints.length === 0) { return null; @@ -91,22 +63,20 @@ function renderSupportedEndpoints(endpoints) { export const getPricingTableColumns = ({ t, selectedGroup, - usableGroup, groupRatio, copyText, setModalImageUrl, setIsModalOpenurl, currency, - showWithRecharge, tokenUnit, setTokenUnit, displayPrice, - handleGroupClick, showRatio, }) => { const endpointColumn = { title: t('可用端点类型'), dataIndex: 'supported_endpoint_types', + fixed: 'right', render: (text, record, index) => { return renderSupportedEndpoints(text); }, @@ -135,56 +105,7 @@ export const getPricingTableColumns = ({ sorter: (a, b) => a.quota_type - b.quota_type, }; - const enableGroupColumn = { - title: t('可用分组'), - dataIndex: 'enable_groups', - render: (text, record, index) => { - return ( - - {text.map((group) => { - if (usableGroup[group]) { - if (group === selectedGroup) { - return ( - }> - {group} - - ); - } else { - return ( - handleGroupClick(group)} - className="cursor-pointer hover:opacity-80 transition-opacity" - > - {group} - - ); - } - } - })} - - ); - }, - }; - - const baseColumns = [endpointColumn, modelNameColumn, quotaColumn, enableGroupColumn]; - - const availabilityColumn = { - title: t('可用性'), - dataIndex: 'available', - fixed: 'right', - render: (text, record, index) => { - return renderAvailable(record.enable_groups.includes(selectedGroup), t); - }, - sorter: (a, b) => { - const aAvailable = a.enable_groups.includes(selectedGroup); - const bAvailable = b.enable_groups.includes(selectedGroup); - return Number(aAvailable) - Number(bAvailable); - }, - defaultSortOrder: 'descend', - }; + const baseColumns = [modelNameColumn, quotaColumn]; const ratioColumn = { title: () => ( @@ -203,9 +124,8 @@ export const getPricingTableColumns = ({ ), dataIndex: 'model_ratio', render: (text, record, index) => { - let content = text; - let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); - content = ( + const completionRatio = parseFloat(record.completion_ratio.toFixed(3)); + const content = (
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} @@ -238,25 +158,23 @@ export const getPricingTableColumns = ({ ), dataIndex: 'model_price', render: (text, record, index) => { - let content = text; if (record.quota_type === 0) { - let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; - let completionRatioPriceUSD = + const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; + const completionRatioPriceUSD = record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup]; const unitDivisor = tokenUnit === 'K' ? 1000 : 1; const unitLabel = tokenUnit === 'K' ? 'K' : 'M'; - let displayInput = displayPrice(inputRatioPriceUSD); - let displayCompletion = displayPrice(completionRatioPriceUSD); + const rawDisplayInput = displayPrice(inputRatioPriceUSD); + const rawDisplayCompletion = displayPrice(completionRatioPriceUSD); - const divisor = unitDivisor; - const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor; - const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor; + const numInput = parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor; + const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor; - displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`; - displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`; - content = ( + const displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`; + const displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`; + return (
{t('提示')} {displayInput} / 1{unitLabel} tokens @@ -267,15 +185,14 @@ export const getPricingTableColumns = ({
); } else { - let priceUSD = parseFloat(text) * groupRatio[selectedGroup]; - let displayVal = displayPrice(priceUSD); - content = ( + const priceUSD = parseFloat(text) * groupRatio[selectedGroup]; + const displayVal = displayPrice(priceUSD); + return (
{t('模型价格')}:{displayVal}
); } - return content; }, }; @@ -284,6 +201,6 @@ export const getPricingTableColumns = ({ columns.push(ratioColumn); } columns.push(priceColumn); - columns.push(availabilityColumn); + columns.push(endpointColumn); return columns; }; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/filter/PricingGroups.jsx b/web/src/components/table/model-pricing/filter/PricingGroups.jsx index 75bdc2c7..e389bd12 100644 --- a/web/src/components/table/model-pricing/filter/PricingGroups.jsx +++ b/web/src/components/table/model-pricing/filter/PricingGroups.jsx @@ -25,24 +25,30 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; * @param {string} filterGroup 当前选中的分组,'all' 表示不过滤 * @param {Function} setFilterGroup 设置选中分组 * @param {Record} usableGroup 后端返回的可用分组对象 + * @param {Record} groupRatio 分组倍率对象 * @param {Array} models 模型列表 * @param {boolean} loading 是否加载中 * @param {Function} t i18n */ -const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, models = [], loading = false, t }) => { +const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRatio = {}, models = [], loading = false, t }) => { const groups = ['all', ...Object.keys(usableGroup).filter(key => key !== '')]; const items = groups.map((g) => { - let count = 0; + let ratioDisplay = ''; if (g === 'all') { - count = models.length; + ratioDisplay = t('全部'); } else { - count = models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length; + const ratio = groupRatio[g]; + if (ratio !== undefined && ratio !== null) { + ratioDisplay = `x${ratio}`; + } else { + ratioDisplay = 'x1'; + } } return { value: g, label: g === 'all' ? t('全部分组') : g, - tagCount: count, + tagCount: ratioDisplay, }; }); diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index 6182fb01..84edb454 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -116,6 +116,7 @@ const PricingFilterModal = ({ filterGroup={filterGroup} setFilterGroup={setFilterGroup} usableGroup={categoryProps.usableGroup} + groupRatio={categoryProps.groupRatio} models={categoryProps.models} loading={loading} t={t} From 5418bb3b7cfeb02a54cd97024e4f0c385280acce Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 23 Jul 2025 16:32:52 +0800 Subject: [PATCH 079/582] fix: improve error messages for channel retrieval failures in distributor and relay --- controller/relay.go | 8 ++++---- middleware/distributor.go | 4 ++-- model/channel_cache.go | 3 --- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index 18c5f1b4..3660e8be 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -259,10 +259,10 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m } channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) if err != nil { - if group == "auto" { - return nil, types.NewError(errors.New(fmt.Sprintf("获取自动分组下模型 %s 的可用渠道失败: %s", originalModel, err.Error())), types.ErrorCodeGetChannelFailed) - } - return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败: %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed) + return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed) + } + if channel == nil { + return nil, types.NewError(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry)", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed) } newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel) if newAPIError != nil { diff --git a/middleware/distributor.go b/middleware/distributor.go index 3b04eef0..48c05209 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -107,7 +107,7 @@ func Distribute() func(c *gin.Context) { if userGroup == "auto" { showGroup = fmt.Sprintf("auto(%s)", selectGroup) } - message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", showGroup, modelRequest.Model) + message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(distributor): %s", showGroup, modelRequest.Model, err.Error()) // 如果错误,但是渠道不为空,说明是数据库一致性问题 if channel != nil { common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) @@ -118,7 +118,7 @@ func Distribute() func(c *gin.Context) { return } if channel == nil { - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道(数据库一致性已被破坏)", userGroup, modelRequest.Model)) + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,distributor)", userGroup, modelRequest.Model)) return } } diff --git a/model/channel_cache.go b/model/channel_cache.go index b2451248..d18e9c89 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -109,9 +109,6 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, return nil, group, err } } - if channel == nil { - return nil, group, errors.New("channel not found") - } return channel, selectGroup, nil } From 127d1d692f5ccfb656e0df8a625f0320b882132a Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 23 Jul 2025 16:46:06 +0800 Subject: [PATCH 080/582] fix(distributor): add validation for model name in channel selection --- middleware/distributor.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/middleware/distributor.go b/middleware/distributor.go index 48c05209..3c529a41 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -100,6 +100,10 @@ func Distribute() func(c *gin.Context) { } if shouldSelectChannel { + if modelRequest.Model == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "未指定模型名称,模型名称不能为空") + return + } var selectGroup string channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0) if err != nil { From ec0eb57db08c710d53f10a36e6098d09f3291d42 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 23 Jul 2025 19:09:20 +0800 Subject: [PATCH 081/582] fix(adaptor): implement request conversion methods for Claude and Image. (close #1419) --- relay/channel/siliconflow/adaptor.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go index 63c1c84d..789751b8 100644 --- a/relay/channel/siliconflow/adaptor.go +++ b/relay/channel/siliconflow/adaptor.go @@ -18,20 +18,19 @@ import ( type Adaptor struct { } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - panic("implement me") - return nil, nil +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { //TODO implement me - return nil, errors.New("not implemented") + return nil, errors.New("not supported") } func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { - //TODO implement me - return nil, errors.New("not implemented") + adaptor := openai.Adaptor{} + return adaptor.ConvertImageRequest(c, info, request) } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { From f2e10286c45b4c38ec0b1e416709c02a4dfc4d18 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 23 Jul 2025 19:28:58 +0800 Subject: [PATCH 082/582] fix(adaptor): update relay mode handling #1419 --- relay/channel/siliconflow/adaptor.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go index 789751b8..c80e9ea1 100644 --- a/relay/channel/siliconflow/adaptor.go +++ b/relay/channel/siliconflow/adaptor.go @@ -46,7 +46,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } else if info.RelayMode == constant.RelayModeCompletions { return fmt.Sprintf("%s/v1/completions", info.BaseUrl), nil } - return "", errors.New("invalid relay mode") + return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { @@ -80,16 +80,19 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom switch info.RelayMode { case constant.RelayModeRerank: usage, err = siliconflowRerankHandler(c, info, resp) + case constant.RelayModeEmbeddings: + usage, err = openai.OpenaiHandler(c, info, resp) case constant.RelayModeCompletions: fallthrough case constant.RelayModeChatCompletions: + fallthrough + default: if info.IsStream { usage, err = openai.OaiStreamHandler(c, info, resp) } else { usage, err = openai.OpenaiHandler(c, info, resp) } - case constant.RelayModeEmbeddings: - usage, err = openai.OpenaiHandler(c, info, resp) + } return } From c73b5886b976988a95e42402a5f9c7e0a771823a Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 23 Jul 2025 20:01:03 +0800 Subject: [PATCH 083/582] feat: support ollama claude format --- controller/relay.go | 2 +- relay/channel/ollama/adaptor.go | 26 +++++++++++++++++---- relay/channel/ollama/relay-ollama.go | 10 ++++---- service/error.go | 8 ++----- types/error.go | 34 ++++++++++++++++++++-------- 5 files changed, 53 insertions(+), 27 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index 3660e8be..d4b5fd18 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -56,7 +56,7 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError { userGroup := c.GetString("group") channelId := c.GetInt("channel_id") other := make(map[string]interface{}) - other["error_type"] = err.ErrorType + other["error_type"] = err.GetErrorType() other["error_code"] = err.GetErrorCode() other["status_code"] = err.StatusCode other["channel_id"] = channelId diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index b9e304fc..8fd1e1bf 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -17,10 +17,13 @@ import ( type Adaptor struct { } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - panic("implement me") - return nil, nil +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + openaiAdaptor := openai.Adaptor{} + openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request) + if err != nil { + return nil, err + } + return requestOpenAI2Ollama(openaiRequest.(*dto.GeneralOpenAIRequest)) } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { @@ -37,6 +40,9 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayFormat == relaycommon.RelayFormatClaude { + return info.BaseUrl + "/v1/chat/completions", nil + } switch info.RelayMode { case relayconstant.RelayModeEmbeddings: return info.BaseUrl + "/api/embed", nil @@ -55,7 +61,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } - return requestOpenAI2Ollama(*request) + return requestOpenAI2Ollama(request) } func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { @@ -85,6 +91,16 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom usage, err = openai.OpenaiHandler(c, info, resp) } } + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + usage, err = ollamaEmbeddingHandler(c, info, resp) + default: + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + } return } diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index cd899b83..f98dfc73 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -14,7 +14,7 @@ import ( "github.com/gin-gonic/gin" ) -func requestOpenAI2Ollama(request dto.GeneralOpenAIRequest) (*OllamaRequest, error) { +func requestOpenAI2Ollama(request *dto.GeneralOpenAIRequest) (*OllamaRequest, error) { messages := make([]dto.Message, 0, len(request.Messages)) for _, message := range request.Messages { if !message.IsStringContent() { @@ -92,15 +92,15 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h var ollamaEmbeddingResponse OllamaEmbeddingResponse responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) err = common.Unmarshal(responseBody, &ollamaEmbeddingResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if ollamaEmbeddingResponse.Error != "" { - return nil, types.NewError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } flattenedEmbeddings := flattenEmbeddings(ollamaEmbeddingResponse.Embedding) data := make([]dto.OpenAIEmbeddingResponseItem, 0, 1) @@ -121,7 +121,7 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h } doResponseBody, err := common.Marshal(embeddingResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } common.IOCopyBytesGracefully(c, resp, doResponseBody) return usage, nil diff --git a/service/error.go b/service/error.go index a0713b55..83979add 100644 --- a/service/error.go +++ b/service/error.go @@ -80,10 +80,7 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude } func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { - newApiErr = &types.NewAPIError{ - StatusCode: resp.StatusCode, - ErrorType: types.ErrorTypeOpenAIError, - } + newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) responseBody, err := io.ReadAll(resp.Body) if err != nil { @@ -105,8 +102,7 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *t // General format error (OpenAI, Anthropic, Gemini, etc.) newApiErr = types.WithOpenAIError(errResponse.Error, resp.StatusCode) } else { - newApiErr = types.NewErrorWithStatusCode(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) - newApiErr.ErrorType = types.ErrorTypeOpenAIError + newApiErr = types.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) } return } diff --git a/types/error.go b/types/error.go index c301e59c..4ffae2d7 100644 --- a/types/error.go +++ b/types/error.go @@ -75,7 +75,7 @@ const ( type NewAPIError struct { Err error RelayError any - ErrorType ErrorType + errorType ErrorType errorCode ErrorCode StatusCode int } @@ -87,6 +87,13 @@ func (e *NewAPIError) GetErrorCode() ErrorCode { return e.errorCode } +func (e *NewAPIError) GetErrorType() ErrorType { + if e == nil { + return "" + } + return e.errorType +} + func (e *NewAPIError) Error() string { if e == nil { return "" @@ -103,7 +110,7 @@ func (e *NewAPIError) SetMessage(message string) { } func (e *NewAPIError) ToOpenAIError() OpenAIError { - switch e.ErrorType { + switch e.errorType { case ErrorTypeOpenAIError: if openAIError, ok := e.RelayError.(OpenAIError); ok { return openAIError @@ -120,14 +127,14 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError { } return OpenAIError{ Message: e.Error(), - Type: string(e.ErrorType), + Type: string(e.errorType), Param: "", Code: e.errorCode, } } func (e *NewAPIError) ToClaudeError() ClaudeError { - switch e.ErrorType { + switch e.errorType { case ErrorTypeOpenAIError: openAIError := e.RelayError.(OpenAIError) return ClaudeError{ @@ -139,7 +146,7 @@ func (e *NewAPIError) ToClaudeError() ClaudeError { default: return ClaudeError{ Message: e.Error(), - Type: string(e.ErrorType), + Type: string(e.errorType), } } } @@ -148,7 +155,7 @@ func NewError(err error, errorCode ErrorCode) *NewAPIError { return &NewAPIError{ Err: err, RelayError: nil, - ErrorType: ErrorTypeNewAPIError, + errorType: ErrorTypeNewAPIError, StatusCode: http.StatusInternalServerError, errorCode: errorCode, } @@ -162,6 +169,13 @@ func NewOpenAIError(err error, errorCode ErrorCode, statusCode int) *NewAPIError return WithOpenAIError(openaiError, statusCode) } +func InitOpenAIError(errorCode ErrorCode, statusCode int) *NewAPIError { + openaiError := OpenAIError{ + Type: string(errorCode), + } + return WithOpenAIError(openaiError, statusCode) +} + func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *NewAPIError { return &NewAPIError{ Err: err, @@ -169,7 +183,7 @@ func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *New Message: err.Error(), Type: string(errorCode), }, - ErrorType: ErrorTypeNewAPIError, + errorType: ErrorTypeNewAPIError, StatusCode: statusCode, errorCode: errorCode, } @@ -182,7 +196,7 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError { } return &NewAPIError{ RelayError: openAIError, - ErrorType: ErrorTypeOpenAIError, + errorType: ErrorTypeOpenAIError, StatusCode: statusCode, Err: errors.New(openAIError.Message), errorCode: ErrorCode(code), @@ -192,7 +206,7 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError { func WithClaudeError(claudeError ClaudeError, statusCode int) *NewAPIError { return &NewAPIError{ RelayError: claudeError, - ErrorType: ErrorTypeClaudeError, + errorType: ErrorTypeClaudeError, StatusCode: statusCode, Err: errors.New(claudeError.Message), errorCode: ErrorCode(claudeError.Type), @@ -211,5 +225,5 @@ func IsLocalError(err *NewAPIError) bool { return false } - return err.ErrorType == ErrorTypeNewAPIError + return err.errorType == ErrorTypeNewAPIError } From 6ddeab2f2f1f2c91c8ad66f05f94439e3abe2057 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 23 Jul 2025 20:59:56 +0800 Subject: [PATCH 084/582] fix(adaptor): enhance response handling and error logging for Claude format --- relay/channel/ollama/adaptor.go | 12 ++----- relay/channel/openai/helper.go | 6 ++-- relay/channel/openai/relay-openai.go | 17 +++++---- relay/helper/stream_scanner.go | 6 ++++ service/convert.go | 52 ++++++++++++++++++++++------ 5 files changed, 64 insertions(+), 29 deletions(-) diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index 8fd1e1bf..ff88de8b 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -23,6 +23,9 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn if err != nil { return nil, err } + openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } return requestOpenAI2Ollama(openaiRequest.(*dto.GeneralOpenAIRequest)) } @@ -82,15 +85,6 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - if info.IsStream { - usage, err = openai.OaiStreamHandler(c, info, resp) - } else { - if info.RelayMode == relayconstant.RelayModeEmbeddings { - usage, err = ollamaEmbeddingHandler(c, info, resp) - } else { - usage, err = openai.OpenaiHandler(c, info, resp) - } - } switch info.RelayMode { case relayconstant.RelayModeEmbeddings: usage, err = ollamaEmbeddingHandler(c, info, resp) diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index a068c544..7fee505a 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -27,7 +27,7 @@ func handleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { var streamResponse dto.ChatCompletionsStreamResponse - if err := json.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { return err } @@ -174,7 +174,7 @@ func handleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream case relaycommon.RelayFormatClaude: info.ClaudeConvertInfo.Done = true var streamResponse dto.ChatCompletionsStreamResponse - if err := json.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { common.SysError("error unmarshalling stream response: " + err.Error()) return } @@ -183,7 +183,7 @@ func handleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info) for _, resp := range claudeResponses { - helper.ClaudeData(c, *resp) + _ = helper.ClaudeData(c, *resp) } } } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index bfe8bcd3..d739ea19 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -145,8 +145,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re common.SysError("error handling stream format: " + err.Error()) } } - lastStreamData = data - streamItems = append(streamItems, data) + if len(data) > 0 { + lastStreamData = data + streamItems = append(streamItems, data) + } return true }) @@ -154,16 +156,18 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re shouldSendLastResp := true if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage, &containStreamUsage, info, &shouldSendLastResp); err != nil { - common.SysError("error handling last response: " + err.Error()) + common.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData)) } - if shouldSendLastResp && info.RelayFormat == relaycommon.RelayFormatOpenAI { - _ = sendStreamData(c, info, lastStreamData, forceFormat, thinkToContent) + if info.RelayFormat == relaycommon.RelayFormatOpenAI { + if shouldSendLastResp { + _ = sendStreamData(c, info, lastStreamData, forceFormat, thinkToContent) + } } // 处理token计算 if err := processTokens(info.RelayMode, streamItems, &responseTextBuilder, &toolCount); err != nil { - common.SysError("error processing tokens: " + err.Error()) + common.LogError(c, "error processing tokens: "+err.Error()) } if !containStreamUsage { @@ -176,7 +180,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re } } } - handleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) return usage, nil diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index b526b1c0..c72aea6a 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -234,6 +234,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon case <-stopChan: return } + } else { + // done, 处理完成标志,直接退出停止读取剩余数据防止出错 + if common.DebugEnabled { + println("received [DONE], stopping scanner") + } + return } } diff --git a/service/convert.go b/service/convert.go index 593b59d9..7d697840 100644 --- a/service/convert.go +++ b/service/convert.go @@ -251,22 +251,54 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon resp.SetIndex(0) claudeResponses = append(claudeResponses, resp) } else { - //resp := &dto.ClaudeResponse{ - // Type: "content_block_start", - // ContentBlock: &dto.ClaudeMediaMessage{ - // Type: "text", - // Text: common.GetPointer[string](""), - // }, - //} - //resp.SetIndex(0) - //claudeResponses = append(claudeResponses, resp) + + } + // 判断首个响应是否存在内容(非标准的 OpenAI 响应) + if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.GetContentString()) > 0 { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](""), + }, + }) + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()), + }, + }) + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText } return claudeResponses } if len(openAIResponse.Choices) == 0 { // no choices - // TODO: handle this case + // 可能为非标准的 OpenAI 响应,判断是否已经完成 + if info.Done { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + oaiUsage := info.ClaudeConvertInfo.Usage + if oaiUsage != nil { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_delta", + Usage: &dto.ClaudeUsage{ + InputTokens: oaiUsage.PromptTokens, + OutputTokens: oaiUsage.CompletionTokens, + CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, + }, + Delta: &dto.ClaudeMediaMessage{ + StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)), + }, + }) + } + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_stop", + }) + } return claudeResponses } else { chosenChoice := openAIResponse.Choices[0] From 50a2c2c5a8400b0328022f11b5f04a3cc764d46e Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 23 Jul 2025 22:00:30 +0800 Subject: [PATCH 085/582] feat: support multi-key mode --- .../channels/modals/EditChannelModal.jsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 6613dddc..d2fd6758 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -704,20 +704,20 @@ const EditChannelModal = (props) => { } }} >{t('批量创建')} - {/*{batch && (*/} - {/* {*/} - {/* setMultiToSingle(prev => !prev);*/} - {/* setInputs(prev => {*/} - {/* const newInputs = { ...prev };*/} - {/* if (!multiToSingle) {*/} - {/* newInputs.multi_key_mode = multiKeyMode;*/} - {/* } else {*/} - {/* delete newInputs.multi_key_mode;*/} - {/* }*/} - {/* return newInputs;*/} - {/* });*/} - {/* }}>{t('密钥聚合模式')}*/} - {/*)}*/} + {batch && ( + { + setMultiToSingle(prev => !prev); + setInputs(prev => { + const newInputs = { ...prev }; + if (!multiToSingle) { + newInputs.multi_key_mode = multiKeyMode; + } else { + delete newInputs.multi_key_mode; + } + return newInputs; + }); + }}>{t('密钥聚合模式')} + )} ) : null; From f19f1aecc06783e8af8e169c4ecaa2e12b79d54d Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 03:19:32 +0800 Subject: [PATCH 086/582] =?UTF-8?q?=F0=9F=92=84=20style(pricing):=20enhanc?= =?UTF-8?q?e=20card=20view=20UI=20and=20skeleton=20loading=20experience=20?= =?UTF-8?q?(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase skeleton card count from 6 to 10 for better visual coverage - Extend minimum skeleton display duration from 500ms to 1000ms for smoother UX - Add circle shape to all pricing tags for consistent rounded design - Apply circle styling to billing type, popularity, endpoint, and context tags This commit improves the visual consistency and user experience of the pricing card view by standardizing tag appearance and optimizing skeleton loading timing. --- web/src/components/common/ui/CardTable.js | 2 +- .../common/ui/SelectableButtonGroup.jsx | 2 +- web/src/components/layout/HeaderBar.js | 2 +- .../table/model-pricing/PricingHeader.jsx | 123 ----- .../filter/PricingDisplaySettings.jsx | 21 +- .../{ => layout}/PricingContent.jsx | 34 +- .../{ => layout}/PricingPage.jsx | 46 +- .../{ => layout}/PricingSearchBar.jsx | 2 +- .../{ => layout}/PricingSidebar.jsx | 43 +- .../{index.jsx => layout/PricingView.jsx} | 16 +- .../modal/PricingFilterModal.jsx | 14 +- .../model-pricing/view/PricingCardView.jsx | 444 ++++++++++++++++++ .../model-pricing/{ => view}/PricingTable.jsx | 17 +- .../{ => view}/PricingTableColumns.js | 34 +- .../table/usage-logs/UsageLogsActions.jsx | 2 +- web/src/helpers/utils.js | 65 +++ web/src/hooks/dashboard/useDashboardData.js | 2 +- .../model-pricing/useModelPricingData.js | 34 +- web/src/index.css | 55 +++ web/src/pages/Pricing/index.js | 2 +- 20 files changed, 706 insertions(+), 254 deletions(-) delete mode 100644 web/src/components/table/model-pricing/PricingHeader.jsx rename web/src/components/table/model-pricing/{ => layout}/PricingContent.jsx (61%) rename web/src/components/table/model-pricing/{ => layout}/PricingPage.jsx (57%) rename web/src/components/table/model-pricing/{ => layout}/PricingSearchBar.jsx (97%) rename web/src/components/table/model-pricing/{ => layout}/PricingSidebar.jsx (66%) rename web/src/components/table/model-pricing/{index.jsx => layout/PricingView.jsx} (69%) create mode 100644 web/src/components/table/model-pricing/view/PricingCardView.jsx rename web/src/components/table/model-pricing/{ => view}/PricingTable.jsx (91%) rename web/src/components/table/model-pricing/{ => view}/PricingTableColumns.js (79%) diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index 75b6df00..bb80046d 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.js @@ -50,7 +50,7 @@ const CardTable = ({ setShowSkeleton(true); } else { const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 500 - elapsed); + const remaining = Math.max(0, 1000 - elapsed); if (remaining === 0) { setShowSkeleton(false); } else { diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index dd7fd8ab..c3fe28ff 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -65,7 +65,7 @@ const SelectableButtonGroup = ({ setShowSkeleton(true); } else { const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 500 - elapsed); + const remaining = Math.max(0, 1000 - elapsed); if (remaining === 0) { setShowSkeleton(false); } else { diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index 6a158ec0..a935da12 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -219,7 +219,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { useEffect(() => { if (statusState?.status !== undefined) { const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 500 - elapsed); + const remaining = Math.max(0, 1000 - elapsed); const timer = setTimeout(() => { setIsLoading(false); }, remaining); diff --git a/web/src/components/table/model-pricing/PricingHeader.jsx b/web/src/components/table/model-pricing/PricingHeader.jsx deleted file mode 100644 index 9dc508aa..00000000 --- a/web/src/components/table/model-pricing/PricingHeader.jsx +++ /dev/null @@ -1,123 +0,0 @@ -/* -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 } from '@douyinfe/semi-ui'; -import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons'; -import { AlertCircle } from 'lucide-react'; - -const PricingHeader = ({ - userState, - groupRatio, - selectedGroup, - models, - t -}) => { - return ( - -
-
-
-
- -
-
-
- {t('模型定价')} -
-
- {userState.user ? ( -
- - - {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]} - -
- ) : ( -
- - - {t('未登录,使用默认分组倍率:')}{groupRatio['default']} - -
- )} -
-
-
- -
-
-
{t('分组倍率')}
-
{groupRatio[selectedGroup] || '1.0'}x
-
-
-
{t('可用模型')}
-
- {models.filter(m => m.enable_groups.includes(selectedGroup)).length} -
-
-
-
{t('计费类型')}
-
2
-
-
-
- - {/* 计费说明 */} -
-
-
- - - {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')} - -
-
-
- -
-
-
- ); -}; - -export default PricingHeader; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx index 9d4d8312..321450a3 100644 --- a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -29,6 +29,8 @@ const PricingDisplaySettings = ({ setCurrency, showRatio, setShowRatio, + viewMode, + setViewMode, loading = false, t }) => { @@ -50,6 +52,10 @@ const PricingDisplaySettings = ({ ), + }, + { + value: 'tableView', + label: t('表格视图') } ]; @@ -59,10 +65,16 @@ const PricingDisplaySettings = ({ ]; const handleChange = (value) => { - if (value === 'recharge') { - setShowWithRecharge(!showWithRecharge); - } else if (value === 'ratio') { - setShowRatio(!showRatio); + switch (value) { + case 'recharge': + setShowWithRecharge(!showWithRecharge); + break; + case 'ratio': + setShowRatio(!showRatio); + break; + case 'tableView': + setViewMode(viewMode === 'table' ? 'card' : 'table'); + break; } }; @@ -70,6 +82,7 @@ const PricingDisplaySettings = ({ const activeValues = []; if (showWithRecharge) activeValues.push('recharge'); if (showRatio) activeValues.push('ratio'); + if (viewMode === 'table') activeValues.push('tableView'); return activeValues; }; diff --git a/web/src/components/table/model-pricing/PricingContent.jsx b/web/src/components/table/model-pricing/layout/PricingContent.jsx similarity index 61% rename from web/src/components/table/model-pricing/PricingContent.jsx rename to web/src/components/table/model-pricing/layout/PricingContent.jsx index de6344c8..edb97514 100644 --- a/web/src/components/table/model-pricing/PricingContent.jsx +++ b/web/src/components/table/model-pricing/layout/PricingContent.jsx @@ -19,43 +19,19 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import PricingSearchBar from './PricingSearchBar'; -import PricingTable from './PricingTable'; +import PricingView from './PricingView'; const PricingContent = ({ isMobile, sidebarProps, ...props }) => { return ( -
+
{/* 固定的搜索和操作区域 */} -
+
{/* 可滚动的内容区域 */} -
- +
+
); diff --git a/web/src/components/table/model-pricing/PricingPage.jsx b/web/src/components/table/model-pricing/layout/PricingPage.jsx similarity index 57% rename from web/src/components/table/model-pricing/PricingPage.jsx rename to web/src/components/table/model-pricing/layout/PricingPage.jsx index eb76944f..0f150122 100644 --- a/web/src/components/table/model-pricing/PricingPage.jsx +++ b/web/src/components/table/model-pricing/layout/PricingPage.jsx @@ -21,56 +21,46 @@ import React from 'react'; import { Layout, ImagePreview } from '@douyinfe/semi-ui'; import PricingSidebar from './PricingSidebar'; import PricingContent from './PricingContent'; -import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData'; -import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; const PricingPage = () => { const pricingData = useModelPricingData(); const { Sider, Content } = Layout; const isMobile = useIsMobile(); - - // 显示倍率状态 const [showRatio, setShowRatio] = React.useState(false); + const [viewMode, setViewMode] = React.useState('card'); + const allProps = { + ...pricingData, + showRatio, + setShowRatio, + viewMode, + setViewMode + }; return (
- - {/* 左侧边栏 - 只在桌面端显示 */} + {!isMobile && ( - + )} - {/* 右侧内容区 */} - - {/* 倍率说明图预览 */} - + - + - +
); }; diff --git a/web/src/components/table/model-pricing/index.jsx b/web/src/components/table/model-pricing/layout/PricingView.jsx similarity index 69% rename from web/src/components/table/model-pricing/index.jsx rename to web/src/components/table/model-pricing/layout/PricingView.jsx index 948285f0..16e9db99 100644 --- a/web/src/components/table/model-pricing/index.jsx +++ b/web/src/components/table/model-pricing/layout/PricingView.jsx @@ -17,5 +17,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -// 为了向后兼容,这里重新导出新的 PricingPage 组件 -export { default } from './PricingPage'; \ No newline at end of file +import React from 'react'; +import PricingTable from '../view/PricingTable'; +import PricingCardView from '../view/PricingCardView'; + +const PricingView = ({ + viewMode = 'table', + ...props +}) => { + return viewMode === 'card' ? + : + ; +}; + +export default PricingView; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index 84edb454..3d0601b8 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -40,10 +40,14 @@ const PricingFilterModal = ({ setActiveKey, showRatio, setShowRatio, + viewMode, + setViewMode, filterGroup, setFilterGroup, filterQuotaType, setFilterQuotaType, + currentPage, + setCurrentPage, loading, ...categoryProps } = sidebarProps; @@ -56,14 +60,12 @@ const PricingFilterModal = ({ setShowWithRecharge, setCurrency, setShowRatio, + setViewMode, setFilterGroup, setFilterQuotaType, + setCurrentPage, }); - const handleConfirm = () => { - onClose(); - }; - const footer = (
@@ -106,6 +108,8 @@ const PricingFilterModal = ({ setCurrency={setCurrency} showRatio={showRatio} setShowRatio={setShowRatio} + viewMode={viewMode} + setViewMode={setViewMode} loading={loading} t={t} /> diff --git a/web/src/components/table/model-pricing/view/PricingCardView.jsx b/web/src/components/table/model-pricing/view/PricingCardView.jsx new file mode 100644 index 00000000..1d743412 --- /dev/null +++ b/web/src/components/table/model-pricing/view/PricingCardView.jsx @@ -0,0 +1,444 @@ +/* +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, useEffect } from 'react'; +import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Skeleton } from '@douyinfe/semi-ui'; +import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; +import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; +import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../helpers'; + +const PricingCardView = ({ + filteredModels, + loading, + rowSelection, + pageSize, + setPageSize, + currentPage, + setCurrentPage, + selectedGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + tokenUnit, + setTokenUnit, + displayPrice, + showRatio, + t +}) => { + const [showSkeleton, setShowSkeleton] = useState(loading); + const [skeletonCount] = useState(10); + 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, 1000 - elapsed); + if (remaining === 0) { + setShowSkeleton(false); + } else { + const timer = setTimeout(() => setShowSkeleton(false), remaining); + return () => clearTimeout(timer); + } + } + }, [loading]); + + // 计算当前页面要显示的数据 + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedModels = filteredModels.slice(startIndex, endIndex); + + // 渲染骨架屏卡片 + const renderSkeletonCards = () => { + const placeholder = ( +
+
+ {Array.from({ length: skeletonCount }).map((_, index) => ( + + {/* 头部:图标 + 模型名称 + 操作按钮 */} +
+
+ {/* 模型图标骨架 */} +
+ +
+ {/* 模型名称骨架 */} +
+ +
+
+ +
+ {/* 操作按钮骨架 */} + + {rowSelection && ( + + )} +
+
+ + {/* 价格信息骨架 */} +
+ +
+ + {/* 模型描述骨架 */} +
+ +
+ + {/* 标签区域骨架 */} +
+ {Array.from({ length: 3 + (index % 2) }).map((_, tagIndex) => ( + + ))} +
+ + {/* 倍率信息骨架(可选) */} + {showRatio && ( +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, ratioIndex) => ( + + ))} +
+
+ )} +
+ ))} +
+ + {/* 分页骨架 */} +
+ +
+
+ ); + + return ( + + ); + }; + + // 获取模型图标 + const getModelIcon = (modelName) => { + const categories = getModelCategories(t); + let icon = null; + + // 遍历分类,找到匹配的模型图标 + for (const [key, category] of Object.entries(categories)) { + if (key !== 'all' && category.filter({ model_name: modelName })) { + icon = category.icon; + break; + } + } + + // 如果找到了匹配的图标,返回包装后的图标 + if (icon) { + return ( +
+
+ {React.cloneElement(icon, { size: 32 })} +
+
+ ); + } + + // 默认图标(如果没有匹配到任何分类) + return ( +
+ {/* 默认的螺旋图案 */} + + + +
+ ); + }; + + // 获取模型描述 + const getModelDescription = (modelName) => { + // 根据模型名称返回描述,这里可以扩展 + if (modelName.includes('gpt-3.5-turbo')) { + return t('该模型目前指向gpt-35-turbo-0125模型,综合能力强,过去使用最广泛的文本模型。'); + } + if (modelName.includes('gpt-4')) { + return t('更强大的GPT-4模型,具有更好的推理能力和更准确的输出。'); + } + if (modelName.includes('claude')) { + return t('Anthropic开发的Claude模型,以安全性和有用性著称。'); + } + return t('高性能AI模型,适用于各种文本生成和理解任务。'); + }; + + // 渲染价格信息 + const renderPriceInfo = (record) => { + const priceData = calculateModelPrice({ + record, + selectedGroup, + groupRatio, + tokenUnit, + displayPrice, + currency, + precision: 4 + }); + return formatPriceInfo(priceData, t); + }; + + // 渲染标签 + const renderTags = (record) => { + const tags = []; + + // 计费类型标签 + if (record.quota_type === 1) { + tags.push( + + {t('按次计费')} + + ); + } else { + tags.push( + + {t('按量计费')} + + ); + } + + // 热度标签(示例) + if (record.model_name.includes('gpt-3.5-turbo') || record.model_name.includes('gpt-4')) { + tags.push( + + {t('热')} + + ); + } + + // 端点类型标签 + if (record.supported_endpoint_types && record.supported_endpoint_types.length > 0) { + record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => { + tags.push( + + {endpoint} + + ); + }); + } + + // 上下文长度标签(示例) + if (record.model_name.includes('16k')) { + tags.push(16K); + } else if (record.model_name.includes('32k')) { + tags.push(32K); + } else { + tags.push(4K); + } + + return tags; + }; + + // 显示骨架屏 + if (showSkeleton) { + return renderSkeletonCards(); + } + + if (!filteredModels || filteredModels.length === 0) { + return ( +
+ } + darkModeImage={} + description={t('搜索无结果')} + /> +
+ ); + } + + return ( +
+
+ {paginatedModels.map((model, index) => { + const isSelected = rowSelection?.selectedRowKeys?.includes(model.id); + + return ( + + {/* 头部:图标 + 模型名称 + 操作按钮 */} +
+
+ {getModelIcon(model.model_name)} +
+

+ {model.model_name} +

+
+
+ +
+ {/* 复制按钮 */} +
+
+ + {/* 价格信息 */} +
+
+ {renderPriceInfo(model)} +
+
+ + {/* 模型描述 */} +
+

+ {getModelDescription(model.model_name)} +

+
+ + {/* 标签区域 */} +
+ {renderTags(model)} +
+ + {/* 倍率信息(可选) */} + {showRatio && ( +
+
+ {t('倍率信息')} + + { + setModalImageUrl('/ratio.png'); + setIsModalOpenurl(true); + }} + /> + +
+
+
+ {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')} +
+
+ {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')} +
+
+ {t('分组')}: {groupRatio[selectedGroup]} +
+
+
+ )} +
+ ); + })} +
+ + {/* 分页 */} + {filteredModels.length > 0 && ( +
+ setCurrentPage(page)} + onPageSizeChange={(size) => { + setPageSize(size); + setCurrentPage(1); + }} + /> +
+ )} +
+ ); +}; + +export default PricingCardView; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/PricingTable.jsx b/web/src/components/table/model-pricing/view/PricingTable.jsx similarity index 91% rename from web/src/components/table/model-pricing/PricingTable.jsx rename to web/src/components/table/model-pricing/view/PricingTable.jsx index 4fb2a8e8..26c7edbb 100644 --- a/web/src/components/table/model-pricing/PricingTable.jsx +++ b/web/src/components/table/model-pricing/view/PricingTable.jsx @@ -32,18 +32,15 @@ const PricingTable = ({ pageSize, setPageSize, selectedGroup, - usableGroup, groupRatio, copyText, setModalImageUrl, setIsModalOpenurl, currency, - showWithRecharge, tokenUnit, setTokenUnit, displayPrice, - filteredValue, - handleGroupClick, + searchValue, showRatio, compactMode = false, t @@ -53,43 +50,37 @@ const PricingTable = ({ return getPricingTableColumns({ t, selectedGroup, - usableGroup, groupRatio, copyText, setModalImageUrl, setIsModalOpenurl, currency, - showWithRecharge, tokenUnit, setTokenUnit, displayPrice, - handleGroupClick, showRatio, }); }, [ t, selectedGroup, - usableGroup, groupRatio, copyText, setModalImageUrl, setIsModalOpenurl, currency, - showWithRecharge, tokenUnit, setTokenUnit, displayPrice, - handleGroupClick, showRatio, ]); - // 更新列定义中的 filteredValue + // 更新列定义中的 searchValue const processedColumns = useMemo(() => { const cols = columns.map(column => { if (column.dataIndex === 'model_name') { return { ...column, - filteredValue + filteredValue: searchValue ? [searchValue] : [] }; } return column; @@ -100,7 +91,7 @@ const PricingTable = ({ return cols.map(({ fixed, ...rest }) => rest); } return cols; - }, [columns, filteredValue, compactMode]); + }, [columns, searchValue, compactMode]); const ModelTable = useMemo(() => ( diff --git a/web/src/components/table/model-pricing/PricingTableColumns.js b/web/src/components/table/model-pricing/view/PricingTableColumns.js similarity index 79% rename from web/src/components/table/model-pricing/PricingTableColumns.js rename to web/src/components/table/model-pricing/view/PricingTableColumns.js index f0c9783d..54b3889c 100644 --- a/web/src/components/table/model-pricing/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/view/PricingTableColumns.js @@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui'; import { IconHelpCircle } from '@douyinfe/semi-icons'; -import { renderModelTag, stringToColor } from '../../../helpers'; +import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../helpers'; function renderQuotaType(type, t) { switch (type) { @@ -158,38 +158,30 @@ export const getPricingTableColumns = ({ ), dataIndex: 'model_price', render: (text, record, index) => { - if (record.quota_type === 0) { - const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; - const completionRatioPriceUSD = - record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup]; + const priceData = calculateModelPrice({ + record, + selectedGroup, + groupRatio, + tokenUnit, + displayPrice, + currency + }); - const unitDivisor = tokenUnit === 'K' ? 1000 : 1; - const unitLabel = tokenUnit === 'K' ? 'K' : 'M'; - - const rawDisplayInput = displayPrice(inputRatioPriceUSD); - const rawDisplayCompletion = displayPrice(completionRatioPriceUSD); - - const numInput = parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor; - const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor; - - const displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`; - const displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`; + if (priceData.isPerToken) { return (
- {t('提示')} {displayInput} / 1{unitLabel} tokens + {t('提示')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
- {t('补全')} {displayCompletion} / 1{unitLabel} tokens + {t('补全')} {priceData.completionPrice} / 1{priceData.unitLabel} tokens
); } else { - const priceUSD = parseFloat(text) * groupRatio[selectedGroup]; - const displayVal = displayPrice(priceUSD); return (
- {t('模型价格')}:{displayVal} + {t('模型价格')}:{priceData.price}
); } diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx index 72db01e4..c14ffcbf 100644 --- a/web/src/components/table/usage-logs/UsageLogsActions.jsx +++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx @@ -40,7 +40,7 @@ const LogsActions = ({ setShowSkeleton(true); } else { const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 500 - elapsed); + const remaining = Math.max(0, 1000 - elapsed); if (remaining === 0) { setShowSkeleton(false); } else { diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 265be6c2..22b4fbc6 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -568,6 +568,59 @@ export const modelSelectFilter = (input, option) => { return val.includes(input.trim().toLowerCase()); }; +// ------------------------------- +// 模型定价计算工具函数 +export const calculateModelPrice = ({ + record, + selectedGroup, + groupRatio, + tokenUnit, + displayPrice, + currency, + precision = 3 +}) => { + if (record.quota_type === 0) { + // 按量计费 + const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup]; + const completionRatioPriceUSD = + record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup]; + + const unitDivisor = tokenUnit === 'K' ? 1000 : 1; + const unitLabel = tokenUnit === 'K' ? 'K' : 'M'; + + const rawDisplayInput = displayPrice(inputRatioPriceUSD); + const rawDisplayCompletion = displayPrice(completionRatioPriceUSD); + + const numInput = parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor; + const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor; + + return { + inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`, + completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`, + unitLabel, + isPerToken: true + }; + } else { + // 按次计费 + const priceUSD = parseFloat(record.model_price) * groupRatio[selectedGroup]; + const displayVal = displayPrice(priceUSD); + + return { + price: displayVal, + isPerToken: false + }; + } +}; + +// 格式化价格信息为字符串(用于卡片视图) +export const formatPriceInfo = (priceData, t) => { + if (priceData.isPerToken) { + return `${t('输入')} ${priceData.inputPrice}/${priceData.unitLabel} ${t('输出')} ${priceData.completionPrice}/${priceData.unitLabel}`; + } else { + return `${t('模型价格')} ${priceData.price}`; + } +}; + // ------------------------------- // CardPro 分页配置函数 // 用于创建 CardPro 的 paginationArea 配置 @@ -626,8 +679,10 @@ export const resetPricingFilters = ({ setShowWithRecharge, setCurrency, setShowRatio, + setViewMode, setFilterGroup, setFilterQuotaType, + setCurrentPage, }) => { // 重置搜索 if (typeof handleChange === 'function') { @@ -658,6 +713,11 @@ export const resetPricingFilters = ({ setShowRatio(false); } + // 重置视图模式 + if (typeof setViewMode === 'function') { + setViewMode('card'); + } + // 重置分组筛选 if (typeof setFilterGroup === 'function') { setFilterGroup('all'); @@ -667,4 +727,9 @@ export const resetPricingFilters = ({ if (typeof setFilterQuotaType === 'function') { setFilterQuotaType('all'); } + + // 重置当前页面 + if (typeof setCurrentPage === 'function') { + setCurrentPage(1); + } }; diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js index 4eaeca77..255f48d3 100644 --- a/web/src/hooks/dashboard/useDashboardData.js +++ b/web/src/hooks/dashboard/useDashboardData.js @@ -178,7 +178,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { } } finally { const elapsed = Date.now() - startTime; - const remainingTime = Math.max(0, 500 - elapsed); + const remainingTime = Math.max(0, 1000 - elapsed); setTimeout(() => { setLoading(false); }, remainingTime); diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index ac58d817..c32ddf84 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -26,18 +26,17 @@ import { StatusContext } from '../../context/Status/index.js'; export const useModelPricingData = () => { const { t } = useTranslation(); - const [filteredValue, setFilteredValue] = useState([]); + const [searchValue, setSearchValue] = useState(''); const compositionRef = useRef({ isComposition: false }); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [selectedGroup, setSelectedGroup] = useState('default'); - // 用于 Table 的可用分组筛选,“all” 表示不过滤 - const [filterGroup, setFilterGroup] = useState('all'); - // 计费类型筛选: 'all' | 0 | 1 - const [filterQuotaType, setFilterQuotaType] = useState('all'); + const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,“all” 表示不过滤 + const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1 const [activeKey, setActiveKey] = useState('all'); const [pageSize, setPageSize] = useState(10); + const [currentPage, setCurrentPage] = useState(1); const [currency, setCurrency] = useState('USD'); const [showWithRecharge, setShowWithRecharge] = useState(false); const [tokenUnit, setTokenUnit] = useState('M'); @@ -95,15 +94,15 @@ export const useModelPricingData = () => { } // 搜索筛选 - if (filteredValue.length > 0) { - const searchTerm = filteredValue[0].toLowerCase(); + if (searchValue.length > 0) { + const searchTerm = searchValue.toLowerCase(); result = result.filter(model => model.model_name.toLowerCase().includes(searchTerm) ); } return result; - }, [activeKey, models, filteredValue, filterGroup, filterQuotaType]); + }, [activeKey, models, searchValue, filterGroup, filterQuotaType]); const rowSelection = useMemo( () => ({ @@ -183,8 +182,8 @@ export const useModelPricingData = () => { if (compositionRef.current.isComposition) { return; } - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); + const newSearchValue = value ? value : ''; + setSearchValue(newSearchValue); }; const handleCompositionStart = () => { @@ -194,8 +193,8 @@ export const useModelPricingData = () => { const handleCompositionEnd = (event) => { compositionRef.current.isComposition = false; const value = event.target.value; - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); + const newSearchValue = value ? value : ''; + setSearchValue(newSearchValue); }; const handleGroupClick = (group) => { @@ -214,10 +213,15 @@ export const useModelPricingData = () => { refresh().then(); }, []); + // 当筛选条件变化时重置到第一页 + useEffect(() => { + setCurrentPage(1); + }, [activeKey, filterGroup, filterQuotaType, searchValue]); + return { // 状态 - filteredValue, - setFilteredValue, + searchValue, + setSearchValue, selectedRowKeys, setSelectedRowKeys, modalImageUrl, @@ -234,6 +238,8 @@ export const useModelPricingData = () => { setActiveKey, pageSize, setPageSize, + currentPage, + setCurrentPage, currency, setCurrency, showWithRecharge, diff --git a/web/src/index.css b/web/src/index.css index afbb7862..b624d749 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -617,4 +617,59 @@ html:not(.dark) .blur-ball-teal { height: calc(100vh - 77px); max-height: calc(100vh - 77px); } +} + +/* ==================== 模型定价页面布局 ==================== */ +.pricing-layout { + height: calc(100vh - 60px); + overflow: hidden; + margin-top: 60px; +} + +.pricing-sidebar { + min-width: 460px; + max-width: 460px; + height: calc(100vh - 60px); + background-color: var(--semi-color-bg-0); + border-right: 1px solid var(--semi-color-border); + overflow: auto; +} + +.pricing-content { + height: calc(100vh - 60px); + background-color: var(--semi-color-bg-0); + display: flex; + flex-direction: column; +} + +.pricing-pagination-divider { + border-color: var(--semi-color-border); +} + +.pricing-content-mobile { + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; +} + +.pricing-search-header { + padding: 16px 24px; + border-bottom: 1px solid var(--semi-color-border); + background-color: var(--semi-color-bg-0); + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 5; +} + +.pricing-view-container { + flex: 1; + overflow: auto; +} + +.pricing-view-container-mobile { + flex: 1; + overflow: auto; + min-height: 0; } \ No newline at end of file diff --git a/web/src/pages/Pricing/index.js b/web/src/pages/Pricing/index.js index c1066203..e37167d8 100644 --- a/web/src/pages/Pricing/index.js +++ b/web/src/pages/Pricing/index.js @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import ModelPricingPage from '../../components/table/model-pricing'; +import ModelPricingPage from '../../components/table/model-pricing/layout/PricingPage'; const Pricing = () => ( <> From 84629312534e2f32e472f7be3a0136ed04a88e43 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 03:25:57 +0800 Subject: [PATCH 087/582] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20endpoint=20ty?= =?UTF-8?q?pe=20filter=20to=20model=20pricing=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create PricingEndpointTypes.jsx component for endpoint type filtering - Add filterEndpointType state management in useModelPricingData hook - Integrate endpoint type filtering logic in filteredModels computation - Update PricingSidebar.jsx to include endpoint type filter component - Update PricingFilterModal.jsx to support endpoint type filtering on mobile - Extend resetPricingFilters utility function to include endpoint type reset - Support filtering models by endpoint types (OpenAI, Anthropic, Gemini, etc.) - Display model count for each endpoint type with localized labels - Ensure filter state resets to first page when endpoint type changes This enhancement allows users to filter models by their supported endpoint types, providing more granular control over model selection in the pricing interface. --- .../filter/PricingEndpointTypes.jsx | 92 +++++++++++++++++++ .../model-pricing/layout/PricingSidebar.jsx | 12 +++ .../modal/PricingFilterModal.jsx | 12 +++ web/src/helpers/utils.js | 6 ++ .../model-pricing/useModelPricingData.js | 15 ++- 5 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx diff --git a/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx b/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx new file mode 100644 index 00000000..d9f22d95 --- /dev/null +++ b/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx @@ -0,0 +1,92 @@ +/* +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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; + +/** + * 端点类型筛选组件 + * @param {string|'all'} filterEndpointType 当前值 + * @param {Function} setFilterEndpointType setter + * @param {Array} models 模型列表 + * @param {boolean} loading 是否加载中 + * @param {Function} t i18n + */ +const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], loading = false, t }) => { + // 获取所有可用的端点类型 + const getAllEndpointTypes = () => { + const endpointTypes = new Set(); + models.forEach(model => { + if (model.supported_endpoint_types && Array.isArray(model.supported_endpoint_types)) { + model.supported_endpoint_types.forEach(endpoint => { + endpointTypes.add(endpoint); + }); + } + }); + return Array.from(endpointTypes).sort(); + }; + + // 计算每个端点类型的模型数量 + const getEndpointTypeCount = (endpointType) => { + if (endpointType === 'all') { + return models.length; + } + return models.filter(model => + model.supported_endpoint_types && + model.supported_endpoint_types.includes(endpointType) + ).length; + }; + + // 端点类型显示名称映射 + const getEndpointTypeLabel = (endpointType) => { + const labelMap = { + 'openai': 'OpenAI', + 'openai-response': 'OpenAI Response', + 'anthropic': 'Anthropic', + 'gemini': 'Gemini', + 'jina-rerank': 'Jina Rerank', + 'image-generation': t('图像生成'), + }; + return labelMap[endpointType] || endpointType; + }; + + const availableEndpointTypes = getAllEndpointTypes(); + + const items = [ + { value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all') }, + ...availableEndpointTypes.map(endpointType => ({ + value: endpointType, + label: getEndpointTypeLabel(endpointType), + tagCount: getEndpointTypeCount(endpointType) + })) + ]; + + return ( + + ); +}; + +export default PricingEndpointTypes; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index 8b4ccfd8..f503e246 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -22,6 +22,7 @@ import { Button } from '@douyinfe/semi-ui'; import PricingCategories from '../filter/PricingCategories'; import PricingGroups from '../filter/PricingGroups'; import PricingQuotaTypes from '../filter/PricingQuotaTypes'; +import PricingEndpointTypes from '../filter/PricingEndpointTypes'; import PricingDisplaySettings from '../filter/PricingDisplaySettings'; import { resetPricingFilters } from '../../../../helpers/utils'; @@ -40,6 +41,8 @@ const PricingSidebar = ({ setFilterGroup, filterQuotaType, setFilterQuotaType, + filterEndpointType, + setFilterEndpointType, currentPage, setCurrentPage, loading, @@ -58,6 +61,7 @@ const PricingSidebar = ({ setViewMode, setFilterGroup, setFilterQuotaType, + setFilterEndpointType, setCurrentPage, }); @@ -114,6 +118,14 @@ const PricingSidebar = ({ loading={loading} t={t} /> + +
); }; diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index 3d0601b8..ff8459d4 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -22,6 +22,7 @@ import { Modal, Button } from '@douyinfe/semi-ui'; import PricingCategories from '../filter/PricingCategories'; import PricingGroups from '../filter/PricingGroups'; import PricingQuotaTypes from '../filter/PricingQuotaTypes'; +import PricingEndpointTypes from '../filter/PricingEndpointTypes'; import PricingDisplaySettings from '../filter/PricingDisplaySettings'; import { resetPricingFilters } from '../../../../helpers/utils'; @@ -46,6 +47,8 @@ const PricingFilterModal = ({ setFilterGroup, filterQuotaType, setFilterQuotaType, + filterEndpointType, + setFilterEndpointType, currentPage, setCurrentPage, loading, @@ -63,6 +66,7 @@ const PricingFilterModal = ({ setViewMode, setFilterGroup, setFilterQuotaType, + setFilterEndpointType, setCurrentPage, }); @@ -133,6 +137,14 @@ const PricingFilterModal = ({ loading={loading} t={t} /> + +
); diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 22b4fbc6..9972fb3a 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -682,6 +682,7 @@ export const resetPricingFilters = ({ setViewMode, setFilterGroup, setFilterQuotaType, + setFilterEndpointType, setCurrentPage, }) => { // 重置搜索 @@ -728,6 +729,11 @@ export const resetPricingFilters = ({ setFilterQuotaType('all'); } + // 重置端点类型筛选 + if (typeof setFilterEndpointType === 'function') { + setFilterEndpointType('all'); + } + // 重置当前页面 if (typeof setCurrentPage === 'function') { setCurrentPage(1); diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index c32ddf84..6d750b87 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -35,6 +35,7 @@ export const useModelPricingData = () => { const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,“all” 表示不过滤 const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1 const [activeKey, setActiveKey] = useState('all'); + const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string const [pageSize, setPageSize] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [currency, setCurrency] = useState('USD'); @@ -93,6 +94,14 @@ export const useModelPricingData = () => { result = result.filter(model => model.quota_type === filterQuotaType); } + // 端点类型筛选 + if (filterEndpointType !== 'all') { + result = result.filter(model => + model.supported_endpoint_types && + model.supported_endpoint_types.includes(filterEndpointType) + ); + } + // 搜索筛选 if (searchValue.length > 0) { const searchTerm = searchValue.toLowerCase(); @@ -102,7 +111,7 @@ export const useModelPricingData = () => { } return result; - }, [activeKey, models, searchValue, filterGroup, filterQuotaType]); + }, [activeKey, models, searchValue, filterGroup, filterQuotaType, filterEndpointType]); const rowSelection = useMemo( () => ({ @@ -216,7 +225,7 @@ export const useModelPricingData = () => { // 当筛选条件变化时重置到第一页 useEffect(() => { setCurrentPage(1); - }, [activeKey, filterGroup, filterQuotaType, searchValue]); + }, [activeKey, filterGroup, filterQuotaType, filterEndpointType, searchValue]); return { // 状态 @@ -234,6 +243,8 @@ export const useModelPricingData = () => { setFilterGroup, filterQuotaType, setFilterQuotaType, + filterEndpointType, + setFilterEndpointType, activeKey, setActiveKey, pageSize, From b3a05d2bbb9ec1b1b4123789f94504de9d926031 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 03:29:48 +0800 Subject: [PATCH 088/582] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20style(ui):=20ch?= =?UTF-8?q?ange=20skeleton=20button=20size=20to=2016*16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/table/model-pricing/view/PricingCardView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/model-pricing/view/PricingCardView.jsx b/web/src/components/table/model-pricing/view/PricingCardView.jsx index 1d743412..4d7c3d3b 100644 --- a/web/src/components/table/model-pricing/view/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/PricingCardView.jsx @@ -103,7 +103,7 @@ const PricingCardView = ({
{/* 操作按钮骨架 */} - + {rowSelection && ( )} From d10c7a35486a0ec34804e4953610b8de7c3267c0 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 24 Jul 2025 09:36:48 +0800 Subject: [PATCH 089/582] fix: playground chat vip group --- middleware/auth.go | 1 + 1 file changed, 1 insertion(+) diff --git a/middleware/auth.go b/middleware/auth.go index a158318c..72900f83 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -122,6 +122,7 @@ func authHelper(c *gin.Context, minRole int) { c.Set("role", role) c.Set("id", id) c.Set("group", session.Get("group")) + c.Set("user_group", session.Get("group")) c.Set("use_access_token", useAccessToken) //userCache, err := model.GetUserCache(id.(int)) From 4378e0f96d950a5db64a9b1f3eecec6dcb01628d Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 23 Jul 2025 10:22:52 +0800 Subject: [PATCH 090/582] feat: add vidu video channel --- constant/channel.go | 2 + controller/channel-test.go | 6 + controller/task_video.go | 2 +- relay/channel/task/vidu/adaptor.go | 285 +++++++++++++++++++++++++ relay/relay_adaptor.go | 3 + web/src/constants/channel.constants.js | 5 + 6 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 relay/channel/task/vidu/adaptor.go diff --git a/constant/channel.go b/constant/channel.go index 224121e7..2e1cc5b0 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -49,6 +49,7 @@ const ( ChannelTypeCoze = 49 ChannelTypeKling = 50 ChannelTypeJimeng = 51 + ChannelTypeVidu = 52 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -106,4 +107,5 @@ var ChannelBaseURLs = []string{ "https://api.coze.cn", //49 "https://api.klingai.com", //50 "https://visual.volcengineapi.com", //51 + "https://api.vidu.cn", //52 } diff --git a/controller/channel-test.go b/controller/channel-test.go index 8c4a26ae..c1c3c21d 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -69,6 +69,12 @@ func testChannel(channel *model.Channel, testModel string) testResult { newAPIError: nil, } } + if channel.Type == constant.ChannelTypeVidu { + return testResult{ + localErr: errors.New("vidu channel test is not supported"), + newAPIError: nil, + } + } w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) diff --git a/controller/task_video.go b/controller/task_video.go index 684f30fa..914bf6e6 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -83,7 +83,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha taskResult := &relaycommon.TaskInfo{} // try parse as New API response format var responseItems dto.TaskResponse[model.Task] - if err = json.Unmarshal(responseBody, &responseItems); err == nil { + if err = json.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { t := responseItems.Data taskResult.TaskID = t.TaskID taskResult.Status = string(t.Status) diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go new file mode 100644 index 00000000..f40b480c --- /dev/null +++ b/relay/channel/task/vidu/adaptor.go @@ -0,0 +1,285 @@ +package vidu + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" + + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type SubmitReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type requestPayload struct { + Model string `json:"model"` + Images []string `json:"images"` + Prompt string `json:"prompt,omitempty"` + Duration int `json:"duration,omitempty"` + Seed int `json:"seed,omitempty"` + Resolution string `json:"resolution,omitempty"` + MovementAmplitude string `json:"movement_amplitude,omitempty"` + Bgm bool `json:"bgm,omitempty"` + Payload string `json:"payload,omitempty"` + CallbackUrl string `json:"callback_url,omitempty"` +} + +type responsePayload struct { + TaskId string `json:"task_id"` + State string `json:"state"` + Model string `json:"model"` + Images []string `json:"images"` + Prompt string `json:"prompt"` + Duration int `json:"duration"` + Seed int `json:"seed"` + Resolution string `json:"resolution"` + Bgm bool `json:"bgm"` + MovementAmplitude string `json:"movement_amplitude"` + Payload string `json:"payload"` + CreatedAt string `json:"created_at"` +} + +type taskResultResponse struct { + State string `json:"state"` + ErrCode string `json:"err_code"` + Credits int `json:"credits"` + Payload string `json:"payload"` + Creations []creation `json:"creations"` +} + +type creation struct { + ID string `json:"id"` + URL string `json:"url"` + CoverURL string `json:"cover_url"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.BaseUrl +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError { + var req SubmitReq + if err := c.ShouldBindJSON(&req); err != nil { + return service.TaskErrorWrapper(err, "invalid_request_body", http.StatusBadRequest) + } + + if req.Prompt == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "missing_prompt", http.StatusBadRequest) + } + + if req.Image != "" { + info.Action = constant.TaskActionGenerate + } else { + info.Action = constant.TaskActionTextGenerate + } + + c.Set("task_request", req) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(SubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, err + } + + if len(body.Images) == 0 { + c.Set("action", constant.TaskActionTextGenerate) + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { + var path string + switch info.Action { + case constant.TaskActionGenerate: + path = "/img2video" + default: + path = "/text2video" + } + return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Token "+info.ApiKey) + return nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + if action := c.GetString("action"); action != "" { + info.Action = action + } + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + + var vResp responsePayload + err = json.Unmarshal(responseBody, &vResp) + if err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrap(err, fmt.Sprintf("%s", responseBody)), "unmarshal_response_failed", http.StatusInternalServerError) + return + } + + if vResp.State == "failed" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task failed"), "task_failed", http.StatusBadRequest) + return + } + + c.JSON(http.StatusOK, vResp) + return vResp.TaskId, responseBody, nil +} + +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + url := fmt.Sprintf("%s/ent/v2/tasks/%s/creations", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Token "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"viduq1", "vidu2.0", "vidu1.5"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "vidu" +} + +// ============================ +// helpers +// ============================ + +func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) { + var images []string + if req.Image != "" { + images = []string{req.Image} + } + + r := requestPayload{ + Model: defaultString(req.Model, "viduq1"), + Images: images, + Prompt: req.Prompt, + Duration: defaultInt(req.Duration, 5), + Resolution: defaultString(req.Size, "1080p"), + MovementAmplitude: "auto", + Bgm: false, + } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + return &r, nil +} + +func defaultString(value, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} + +func defaultInt(value, defaultValue int) int { + if value == 0 { + return defaultValue + } + return value +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + taskInfo := &relaycommon.TaskInfo{} + + var taskResp taskResultResponse + err := json.Unmarshal(respBody, &taskResp) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal response body") + } + + state := taskResp.State + switch state { + case "created", "queueing": + taskInfo.Status = model.TaskStatusSubmitted + case "processing": + taskInfo.Status = model.TaskStatusInProgress + case "success": + taskInfo.Status = model.TaskStatusSuccess + if len(taskResp.Creations) > 0 { + taskInfo.Url = taskResp.Creations[0].URL + } + case "failed": + taskInfo.Status = model.TaskStatusFailure + if taskResp.ErrCode != "" { + taskInfo.Reason = taskResp.ErrCode + } + default: + return nil, fmt.Errorf("unknown task state: %s", state) + } + + return taskInfo, nil +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 1e9c46e8..cc9c5bbb 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -27,6 +27,7 @@ import ( taskjimeng "one-api/relay/channel/task/jimeng" "one-api/relay/channel/task/kling" "one-api/relay/channel/task/suno" + taskVidu "one-api/relay/channel/task/vidu" "one-api/relay/channel/tencent" "one-api/relay/channel/vertex" "one-api/relay/channel/volcengine" @@ -122,6 +123,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &kling.TaskAdaptor{} case constant.ChannelTypeJimeng: return &taskjimeng.TaskAdaptor{} + case constant.ChannelTypeVidu: + return &taskVidu.TaskAdaptor{} } } return nil diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index c2468ec7..43372a25 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -154,6 +154,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: '即梦', }, + { + value: 52, + color: 'purple', + label: 'Vidu', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; From 0ff0027aa643c3ec93e68bbbe305d54eeaa772ee Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 17:10:08 +0800 Subject: [PATCH 091/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Move?= =?UTF-8?q?=20token=20unit=20toggle=20from=20table=20header=20to=20filter?= =?UTF-8?q?=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove K/M switch from model price column header in pricing table - Add "Display in K units" option to pricing display settings panel - Update parameter passing for tokenUnit and setTokenUnit across components: - PricingDisplaySettings: Add tokenUnit toggle functionality - PricingSidebar: Pass tokenUnit props to display settings - PricingFilterModal: Include tokenUnit in mobile filter modal - Enhance resetPricingFilters utility to reset token unit to default 'M' - Clean up PricingTableColumns by removing unused setTokenUnit parameter - Add English translation for "按K显示单位" as "Display in K units" This change improves UX by consolidating all display-related controls in the filter settings panel, making the interface more organized and the token unit setting more discoverable alongside other display options. Affected components: - PricingTableColumns.js - PricingDisplaySettings.jsx - PricingSidebar.jsx - PricingFilterModal.jsx - PricingTable.jsx - utils.js (resetPricingFilters) - en.json (translations) --- web/src/components/common/ui/CardTable.js | 22 +- .../common/ui/SelectableButtonGroup.jsx | 28 +- web/src/components/layout/HeaderBar.js | 19 +- .../filter/PricingDisplaySettings.jsx | 10 + .../model-pricing/layout/PricingPage.jsx | 2 +- .../model-pricing/layout/PricingSidebar.jsx | 5 + .../layout/{ => content}/PricingContent.jsx | 6 +- .../layout/{ => content}/PricingView.jsx | 4 +- .../layout/header/PricingCategoryIntro.jsx | 228 +++++++++ .../header/PricingCategoryIntroSkeleton.jsx | 75 +++ .../PricingCategoryIntroWithSkeleton.jsx | 54 +++ .../PricingTopSection.jsx} | 23 +- .../modal/PricingFilterModal.jsx | 5 + .../model-pricing/view/PricingCardView.jsx | 444 ------------------ .../view/card/PricingCardSkeleton.jsx | 137 ++++++ .../view/card/PricingCardView.jsx | 321 +++++++++++++ .../view/{ => table}/PricingTable.jsx | 2 - .../view/{ => table}/PricingTableColumns.js | 18 +- .../table/usage-logs/UsageLogsActions.jsx | 23 +- web/src/helpers/utils.js | 25 +- web/src/hooks/common/useMinimumLoadingTime.js | 50 ++ web/src/hooks/dashboard/useDashboardData.js | 11 +- .../model-pricing/useModelPricingData.js | 7 +- web/src/i18n/locales/en.json | 3 +- 24 files changed, 963 insertions(+), 559 deletions(-) rename web/src/components/table/model-pricing/layout/{ => content}/PricingContent.jsx (85%) rename web/src/components/table/model-pricing/layout/{ => content}/PricingView.jsx (88%) create mode 100644 web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx create mode 100644 web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx create mode 100644 web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx rename web/src/components/table/model-pricing/layout/{PricingSearchBar.jsx => header/PricingTopSection.jsx} (80%) delete mode 100644 web/src/components/table/model-pricing/view/PricingCardView.jsx create mode 100644 web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx create mode 100644 web/src/components/table/model-pricing/view/card/PricingCardView.jsx rename web/src/components/table/model-pricing/view/{ => table}/PricingTable.jsx (98%) rename web/src/components/table/model-pricing/view/{ => table}/PricingTableColumns.js (91%) create mode 100644 web/src/hooks/common/useMinimumLoadingTime.js diff --git a/web/src/components/common/ui/CardTable.js b/web/src/components/common/ui/CardTable.js index bb80046d..f91ff200 100644 --- a/web/src/components/common/ui/CardTable.js +++ b/web/src/components/common/ui/CardTable.js @@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@ import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; import PropTypes from 'prop-types'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; /** * CardTable 响应式表格组件 @@ -40,25 +41,8 @@ const CardTable = ({ }) => { 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, 1000 - elapsed); - if (remaining === 0) { - setShowSkeleton(false); - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining); - return () => clearTimeout(timer); - } - } - }, [loading]); + + const showSkeleton = useMinimumLoadingTime(loading); const getRowKey = (record, index) => { if (typeof rowKey === 'function') return rowKey(record); diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index c3fe28ff..6792c5aa 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -17,8 +17,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef } from 'react'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; @@ -49,32 +50,15 @@ const SelectableButtonGroup = ({ loading = false }) => { const [isOpen, setIsOpen] = useState(false); - const [showSkeleton, setShowSkeleton] = useState(loading); const [skeletonCount] = useState(6); const isMobile = useIsMobile(); const perRow = 3; const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32 const needCollapse = collapsible && items.length > perRow * maxVisibleRows; - const loadingStartRef = useRef(Date.now()); + const showSkeleton = useMinimumLoadingTime(loading); const contentRef = useRef(null); - useEffect(() => { - if (loading) { - loadingStartRef.current = Date.now(); - setShowSkeleton(true); - } else { - const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 1000 - elapsed); - if (remaining === 0) { - setShowSkeleton(false); - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining); - return () => clearTimeout(timer); - } - } - }, [loading]); - const maskStyle = isOpen ? {} : { @@ -110,7 +94,7 @@ const SelectableButtonGroup = ({
@@ -158,7 +142,7 @@ const SelectableButtonGroup = ({ @@ -197,7 +181,7 @@ const SelectableButtonGroup = ({ diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index a935da12..13cbf092 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -52,6 +52,7 @@ import { import { StatusContext } from '../../context/Status/index.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; +import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime.js'; const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const { t, i18n } = useTranslation(); @@ -59,7 +60,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const [statusState, statusDispatch] = useContext(StatusContext); 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); @@ -67,7 +67,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const location = useLocation(); const [noticeVisible, setNoticeVisible] = useState(false); const [unreadCount, setUnreadCount] = useState(0); - const loadingStartRef = useRef(Date.now()); + + const loading = statusState?.status === undefined; + const isLoading = useMinimumLoadingTime(loading); const systemName = getSystemName(); const logo = getLogo(); @@ -128,7 +130,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { to: '/console', }, { - text: t('定价'), + text: t('模型广场'), itemKey: 'pricing', to: '/pricing', }, @@ -216,17 +218,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { }; }, [i18n]); - useEffect(() => { - if (statusState?.status !== undefined) { - const elapsed = Date.now() - loadingStartRef.current; - const remaining = Math.max(0, 1000 - elapsed); - const timer = setTimeout(() => { - setIsLoading(false); - }, remaining); - return () => clearTimeout(timer); - } - }, [statusState?.status]); - useEffect(() => { setLogoLoaded(false); if (!logo) return; diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx index 321450a3..05942279 100644 --- a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -31,6 +31,8 @@ const PricingDisplaySettings = ({ setShowRatio, viewMode, setViewMode, + tokenUnit, + setTokenUnit, loading = false, t }) => { @@ -56,6 +58,10 @@ const PricingDisplaySettings = ({ { value: 'tableView', label: t('表格视图') + }, + { + value: 'tokenUnit', + label: t('按K显示单位') } ]; @@ -75,6 +81,9 @@ const PricingDisplaySettings = ({ case 'tableView': setViewMode(viewMode === 'table' ? 'card' : 'table'); break; + case 'tokenUnit': + setTokenUnit(tokenUnit === 'K' ? 'M' : 'K'); + break; } }; @@ -83,6 +92,7 @@ const PricingDisplaySettings = ({ if (showWithRecharge) activeValues.push('recharge'); if (showRatio) activeValues.push('ratio'); if (viewMode === 'table') activeValues.push('tableView'); + if (tokenUnit === 'K') activeValues.push('tokenUnit'); return activeValues; }; diff --git a/web/src/components/table/model-pricing/layout/PricingPage.jsx b/web/src/components/table/model-pricing/layout/PricingPage.jsx index 0f150122..5db359b3 100644 --- a/web/src/components/table/model-pricing/layout/PricingPage.jsx +++ b/web/src/components/table/model-pricing/layout/PricingPage.jsx @@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Layout, ImagePreview } from '@douyinfe/semi-ui'; import PricingSidebar from './PricingSidebar'; -import PricingContent from './PricingContent'; +import PricingContent from './content/PricingContent'; import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index f503e246..a3e275c6 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -45,6 +45,8 @@ const PricingSidebar = ({ setFilterEndpointType, currentPage, setCurrentPage, + tokenUnit, + setTokenUnit, loading, t, ...categoryProps @@ -63,6 +65,7 @@ const PricingSidebar = ({ setFilterQuotaType, setFilterEndpointType, setCurrentPage, + setTokenUnit, }); return ( @@ -90,6 +93,8 @@ const PricingSidebar = ({ setShowRatio={setShowRatio} viewMode={viewMode} setViewMode={setViewMode} + tokenUnit={tokenUnit} + setTokenUnit={setTokenUnit} loading={loading} t={t} /> diff --git a/web/src/components/table/model-pricing/layout/PricingContent.jsx b/web/src/components/table/model-pricing/layout/content/PricingContent.jsx similarity index 85% rename from web/src/components/table/model-pricing/layout/PricingContent.jsx rename to web/src/components/table/model-pricing/layout/content/PricingContent.jsx index edb97514..177d104c 100644 --- a/web/src/components/table/model-pricing/layout/PricingContent.jsx +++ b/web/src/components/table/model-pricing/layout/content/PricingContent.jsx @@ -18,15 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import PricingSearchBar from './PricingSearchBar'; +import PricingTopSection from '../header/PricingTopSection'; import PricingView from './PricingView'; const PricingContent = ({ isMobile, sidebarProps, ...props }) => { return (
- {/* 固定的搜索和操作区域 */} + {/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
- +
{/* 可滚动的内容区域 */} diff --git a/web/src/components/table/model-pricing/layout/PricingView.jsx b/web/src/components/table/model-pricing/layout/content/PricingView.jsx similarity index 88% rename from web/src/components/table/model-pricing/layout/PricingView.jsx rename to web/src/components/table/model-pricing/layout/content/PricingView.jsx index 16e9db99..e25d0f47 100644 --- a/web/src/components/table/model-pricing/layout/PricingView.jsx +++ b/web/src/components/table/model-pricing/layout/content/PricingView.jsx @@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import PricingTable from '../view/PricingTable'; -import PricingCardView from '../view/PricingCardView'; +import PricingTable from '../../view/table/PricingTable'; +import PricingCardView from '../../view/card/PricingCardView'; const PricingView = ({ viewMode = 'table', diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx new file mode 100644 index 00000000..df1e3c97 --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx @@ -0,0 +1,228 @@ +/* +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, Tag, Avatar, AvatarGroup } from '@douyinfe/semi-ui'; + +const PricingCategoryIntro = ({ + activeKey, + modelCategories, + categoryCounts, + availableCategories, + t +}) => { + // 轮播动效状态(只对全部模型生效) + const [currentOffset, setCurrentOffset] = useState(0); + + // 获取除了 'all' 之外的可用分类 + const validCategories = (availableCategories || []).filter(key => key !== 'all'); + + // 设置轮播定时器(只对全部模型且有足够头像时生效) + useEffect(() => { + if (activeKey !== 'all' || validCategories.length <= 3) { + setCurrentOffset(0); // 重置偏移 + return; + } + + const interval = setInterval(() => { + setCurrentOffset(prev => (prev + 1) % validCategories.length); + }, 2000); // 每2秒切换一次 + + return () => clearInterval(interval); + }, [activeKey, validCategories.length]); + + // 如果没有有效的分类键或分类数据,不显示 + if (!activeKey || !modelCategories) { + return null; + } + + const modelCount = categoryCounts[activeKey] || 0; + + // 获取分类描述信息 + const getCategoryDescription = (categoryKey) => { + const descriptions = { + all: t('查看所有可用的AI模型,包括文本生成、图像处理、音频转换等多种类型的模型。'), + openai: t('令牌分发介绍:SSVIP 为纯OpenAI官方。SVIP 为纯Azure。Default 为Azure 消费。VIP为近似的复数。VVIP为近似的书发。'), + anthropic: t('Anthropic Claude系列模型,以安全性和可靠性著称,擅长对话、分析和创作任务。'), + gemini: t('Google Gemini系列模型,具备强大的多模态能力,支持文本、图像和代码理解。'), + moonshot: t('月之暗面Moonshot系列模型,专注于长文本处理和深度理解能力。'), + zhipu: t('智谱AI ChatGLM系列模型,在中文理解和生成方面表现优秀。'), + qwen: t('阿里云通义千问系列模型,覆盖多个领域的智能问答和内容生成。'), + deepseek: t('DeepSeek系列模型,在代码生成和数学推理方面具有出色表现。'), + minimax: t('MiniMax ABAB系列模型,专注于对话和内容创作的AI助手。'), + baidu: t('百度文心一言系列模型,在中文自然语言处理方面具有强大能力。'), + xunfei: t('科大讯飞星火系列模型,在语音识别和自然语言理解方面领先。'), + midjourney: t('Midjourney图像生成模型,专业的AI艺术创作和图像生成服务。'), + tencent: t('腾讯混元系列模型,提供全面的AI能力和企业级服务。'), + cohere: t('Cohere Command系列模型,专注于企业级自然语言处理应用。'), + cloudflare: t('Cloudflare Workers AI模型,提供边缘计算和高性能AI服务。'), + ai360: t('360智脑系列模型,在安全和智能助手方面具有独特优势。'), + yi: t('零一万物Yi系列模型,提供高质量的多语言理解和生成能力。'), + jina: t('Jina AI模型,专注于嵌入和向量搜索的AI解决方案。'), + mistral: t('Mistral AI系列模型,欧洲领先的开源大语言模型。'), + xai: t('xAI Grok系列模型,具有独特的幽默感和实时信息处理能力。'), + llama: t('Meta Llama系列模型,开源的大语言模型,在各种任务中表现优秀。'), + doubao: t('字节跳动豆包系列模型,在内容创作和智能对话方面表现出色。'), + }; + return descriptions[categoryKey] || t('该分类包含多种AI模型,适用于不同的应用场景。'); + }; + + // 为全部模型创建特殊的头像组合 + const renderAllModelsAvatar = () => { + // 重新排列数组,让当前偏移量的头像在第一位 + const rotatedCategories = validCategories.length > 3 ? [ + ...validCategories.slice(currentOffset), + ...validCategories.slice(0, currentOffset) + ] : validCategories; + + // 如果没有有效分类,使用模型分类名称的前两个字符 + if (validCategories.length === 0) { + // 获取所有分类(除了 'all')的名称前两个字符 + const fallbackCategories = Object.entries(modelCategories) + .filter(([key]) => key !== 'all') + .slice(0, 3) + .map(([key, category]) => ({ + key, + label: category.label, + text: category.label.slice(0, 2) || key.slice(0, 2).toUpperCase() + })); + + return ( +
+ + {fallbackCategories.map((item) => ( + + {item.text} + + ))} + +
+ ); + } + + return ( +
+ ( + + {`+${restNumber}`} + + )} + > + {rotatedCategories.map((categoryKey) => { + const category = modelCategories[categoryKey]; + + return ( + + {category?.icon ? + React.cloneElement(category.icon, { size: 20 }) : + (category?.label?.charAt(0) || categoryKey.charAt(0).toUpperCase()) + } + + ); + })} + +
+ ); + }; + + // 为具体分类渲染单个图标 + const renderCategoryAvatar = (category) => ( +
+ {category.icon && React.cloneElement(category.icon, { size: 40 })} +
+ ); + + // 如果是全部模型分类 + if (activeKey === 'all') { + return ( +
+ +
+ {/* 全部模型的头像组合 */} + {renderAllModelsAvatar()} + + {/* 分类信息 */} +
+
+

{modelCategories.all.label}

+ + {t('共 {{count}} 个模型', { count: modelCount })} + +
+

+ {getCategoryDescription(activeKey)} +

+
+
+
+
+ ); + } + + // 具体分类 + const currentCategory = modelCategories[activeKey]; + if (!currentCategory) { + return null; + } + + return ( +
+ +
+ {/* 分类图标 */} + {renderCategoryAvatar(currentCategory)} + + {/* 分类信息 */} +
+
+

{currentCategory.label}

+ + {t('共 {{count}} 个模型', { count: modelCount })} + +
+

+ {getCategoryDescription(activeKey)} +

+
+
+
+
+ ); +}; + +export default PricingCategoryIntro; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx new file mode 100644 index 00000000..06d029ef --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx @@ -0,0 +1,75 @@ +/* +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, Skeleton } from '@douyinfe/semi-ui'; + +const PricingCategoryIntroSkeleton = ({ + isAllModels = false +}) => { + const placeholder = ( +
+ +
+ {/* 分类图标骨架 */} +
+ {isAllModels ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : ( + + )} +
+ + {/* 分类信息骨架 */} +
+
+ + +
+ +
+
+
+
+ ); + + return ( + + ); +}; + +export default PricingCategoryIntroSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx new file mode 100644 index 00000000..fbb7113a --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx @@ -0,0 +1,54 @@ +/* +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 PricingCategoryIntro from './PricingCategoryIntro'; +import PricingCategoryIntroSkeleton from './PricingCategoryIntroSkeleton'; +import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; + +const PricingCategoryIntroWithSkeleton = ({ + loading = false, + activeKey, + modelCategories, + categoryCounts, + availableCategories, + t +}) => { + const showSkeleton = useMinimumLoadingTime(loading); + + if (showSkeleton) { + return ( + + ); + } + + return ( + + ); +}; + +export default PricingCategoryIntroWithSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/PricingSearchBar.jsx b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx similarity index 80% rename from web/src/components/table/model-pricing/layout/PricingSearchBar.jsx rename to web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx index 8b223252..dbdee4f9 100644 --- a/web/src/components/table/model-pricing/layout/PricingSearchBar.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx @@ -20,9 +20,10 @@ For commercial licensing, please contact support@quantumnous.com import React, { useMemo, useState } from 'react'; import { Input, Button } from '@douyinfe/semi-ui'; import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons'; -import PricingFilterModal from '../modal/PricingFilterModal'; +import PricingFilterModal from '../../modal/PricingFilterModal'; +import PricingCategoryIntroWithSkeleton from './PricingCategoryIntroWithSkeleton'; -const PricingSearchBar = ({ +const PricingTopSection = ({ selectedRowKeys, copyText, handleChange, @@ -30,6 +31,11 @@ const PricingSearchBar = ({ handleCompositionEnd, isMobile, sidebarProps, + activeKey, + modelCategories, + categoryCounts, + availableCategories, + loading, t }) => { const [showFilterModal, setShowFilterModal] = useState(false); @@ -76,6 +82,17 @@ const PricingSearchBar = ({ return ( <> + {/* 分类介绍区域(含骨架屏) */} + + + {/* 搜索和操作区域 */} {SearchAndActions} {/* 移动端筛选Modal */} @@ -91,4 +108,4 @@ const PricingSearchBar = ({ ); }; -export default PricingSearchBar; \ No newline at end of file +export default PricingTopSection; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx index ff8459d4..1b1be43c 100644 --- a/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx +++ b/web/src/components/table/model-pricing/modal/PricingFilterModal.jsx @@ -51,6 +51,8 @@ const PricingFilterModal = ({ setFilterEndpointType, currentPage, setCurrentPage, + tokenUnit, + setTokenUnit, loading, ...categoryProps } = sidebarProps; @@ -68,6 +70,7 @@ const PricingFilterModal = ({ setFilterQuotaType, setFilterEndpointType, setCurrentPage, + setTokenUnit, }); const footer = ( @@ -114,6 +117,8 @@ const PricingFilterModal = ({ setShowRatio={setShowRatio} viewMode={viewMode} setViewMode={setViewMode} + tokenUnit={tokenUnit} + setTokenUnit={setTokenUnit} loading={loading} t={t} /> diff --git a/web/src/components/table/model-pricing/view/PricingCardView.jsx b/web/src/components/table/model-pricing/view/PricingCardView.jsx deleted file mode 100644 index 4d7c3d3b..00000000 --- a/web/src/components/table/model-pricing/view/PricingCardView.jsx +++ /dev/null @@ -1,444 +0,0 @@ -/* -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, useEffect } from 'react'; -import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Skeleton } from '@douyinfe/semi-ui'; -import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; -import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; -import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../helpers'; - -const PricingCardView = ({ - filteredModels, - loading, - rowSelection, - pageSize, - setPageSize, - currentPage, - setCurrentPage, - selectedGroup, - groupRatio, - copyText, - setModalImageUrl, - setIsModalOpenurl, - currency, - tokenUnit, - setTokenUnit, - displayPrice, - showRatio, - t -}) => { - const [showSkeleton, setShowSkeleton] = useState(loading); - const [skeletonCount] = useState(10); - 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, 1000 - elapsed); - if (remaining === 0) { - setShowSkeleton(false); - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining); - return () => clearTimeout(timer); - } - } - }, [loading]); - - // 计算当前页面要显示的数据 - const startIndex = (currentPage - 1) * pageSize; - const endIndex = startIndex + pageSize; - const paginatedModels = filteredModels.slice(startIndex, endIndex); - - // 渲染骨架屏卡片 - const renderSkeletonCards = () => { - const placeholder = ( -
-
- {Array.from({ length: skeletonCount }).map((_, index) => ( - - {/* 头部:图标 + 模型名称 + 操作按钮 */} -
-
- {/* 模型图标骨架 */} -
- -
- {/* 模型名称骨架 */} -
- -
-
- -
- {/* 操作按钮骨架 */} - - {rowSelection && ( - - )} -
-
- - {/* 价格信息骨架 */} -
- -
- - {/* 模型描述骨架 */} -
- -
- - {/* 标签区域骨架 */} -
- {Array.from({ length: 3 + (index % 2) }).map((_, tagIndex) => ( - - ))} -
- - {/* 倍率信息骨架(可选) */} - {showRatio && ( -
-
- - -
-
- {Array.from({ length: 3 }).map((_, ratioIndex) => ( - - ))} -
-
- )} -
- ))} -
- - {/* 分页骨架 */} -
- -
-
- ); - - return ( - - ); - }; - - // 获取模型图标 - const getModelIcon = (modelName) => { - const categories = getModelCategories(t); - let icon = null; - - // 遍历分类,找到匹配的模型图标 - for (const [key, category] of Object.entries(categories)) { - if (key !== 'all' && category.filter({ model_name: modelName })) { - icon = category.icon; - break; - } - } - - // 如果找到了匹配的图标,返回包装后的图标 - if (icon) { - return ( -
-
- {React.cloneElement(icon, { size: 32 })} -
-
- ); - } - - // 默认图标(如果没有匹配到任何分类) - return ( -
- {/* 默认的螺旋图案 */} - - - -
- ); - }; - - // 获取模型描述 - const getModelDescription = (modelName) => { - // 根据模型名称返回描述,这里可以扩展 - if (modelName.includes('gpt-3.5-turbo')) { - return t('该模型目前指向gpt-35-turbo-0125模型,综合能力强,过去使用最广泛的文本模型。'); - } - if (modelName.includes('gpt-4')) { - return t('更强大的GPT-4模型,具有更好的推理能力和更准确的输出。'); - } - if (modelName.includes('claude')) { - return t('Anthropic开发的Claude模型,以安全性和有用性著称。'); - } - return t('高性能AI模型,适用于各种文本生成和理解任务。'); - }; - - // 渲染价格信息 - const renderPriceInfo = (record) => { - const priceData = calculateModelPrice({ - record, - selectedGroup, - groupRatio, - tokenUnit, - displayPrice, - currency, - precision: 4 - }); - return formatPriceInfo(priceData, t); - }; - - // 渲染标签 - const renderTags = (record) => { - const tags = []; - - // 计费类型标签 - if (record.quota_type === 1) { - tags.push( - - {t('按次计费')} - - ); - } else { - tags.push( - - {t('按量计费')} - - ); - } - - // 热度标签(示例) - if (record.model_name.includes('gpt-3.5-turbo') || record.model_name.includes('gpt-4')) { - tags.push( - - {t('热')} - - ); - } - - // 端点类型标签 - if (record.supported_endpoint_types && record.supported_endpoint_types.length > 0) { - record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => { - tags.push( - - {endpoint} - - ); - }); - } - - // 上下文长度标签(示例) - if (record.model_name.includes('16k')) { - tags.push(16K); - } else if (record.model_name.includes('32k')) { - tags.push(32K); - } else { - tags.push(4K); - } - - return tags; - }; - - // 显示骨架屏 - if (showSkeleton) { - return renderSkeletonCards(); - } - - if (!filteredModels || filteredModels.length === 0) { - return ( -
- } - darkModeImage={} - description={t('搜索无结果')} - /> -
- ); - } - - return ( -
-
- {paginatedModels.map((model, index) => { - const isSelected = rowSelection?.selectedRowKeys?.includes(model.id); - - return ( - - {/* 头部:图标 + 模型名称 + 操作按钮 */} -
-
- {getModelIcon(model.model_name)} -
-

- {model.model_name} -

-
-
- -
- {/* 复制按钮 */} -
-
- - {/* 价格信息 */} -
-
- {renderPriceInfo(model)} -
-
- - {/* 模型描述 */} -
-

- {getModelDescription(model.model_name)} -

-
- - {/* 标签区域 */} -
- {renderTags(model)} -
- - {/* 倍率信息(可选) */} - {showRatio && ( -
-
- {t('倍率信息')} - - { - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> - -
-
-
- {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')} -
-
- {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')} -
-
- {t('分组')}: {groupRatio[selectedGroup]} -
-
-
- )} -
- ); - })} -
- - {/* 分页 */} - {filteredModels.length > 0 && ( -
- setCurrentPage(page)} - onPageSizeChange={(size) => { - setPageSize(size); - setCurrentPage(1); - }} - /> -
- )} -
- ); -}; - -export default PricingCardView; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx b/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx new file mode 100644 index 00000000..13eb5ecc --- /dev/null +++ b/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx @@ -0,0 +1,137 @@ +/* +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, Skeleton } from '@douyinfe/semi-ui'; + +const PricingCardSkeleton = ({ + skeletonCount = 10, + rowSelection = false, + showRatio = false +}) => { + const placeholder = ( +
+
+ {Array.from({ length: skeletonCount }).map((_, index) => ( + + {/* 头部:图标 + 模型名称 + 操作按钮 */} +
+
+ {/* 模型图标骨架 */} +
+ +
+ {/* 模型名称和价格区域 */} +
+ {/* 模型名称骨架 */} + + {/* 价格信息骨架 */} + +
+
+ +
+ {/* 复制按钮骨架 */} + + {/* 勾选框骨架 */} + {rowSelection && ( + + )} +
+
+ + {/* 模型描述骨架 */} +
+ +
+ + {/* 标签区域骨架 */} +
+ {Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => ( + + ))} +
+ + {/* 倍率信息骨架(可选) */} + {showRatio && ( +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, ratioIndex) => ( + + ))} +
+
+ )} +
+ ))} +
+ + {/* 分页骨架 */} +
+ +
+
+ ); + + return ( + + ); +}; + +export default PricingCardSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx new file mode 100644 index 00000000..b1868cee --- /dev/null +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -0,0 +1,321 @@ +/* +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, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui'; +import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; +import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; +import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../../helpers'; +import PricingCardSkeleton from './PricingCardSkeleton'; +import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; + +const CARD_STYLES = { + container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm", + icon: "w-8 h-8 flex items-center justify-center", + selected: "border-blue-500 bg-blue-50", + default: "border-gray-200 hover:border-gray-300" +}; + +const PricingCardView = ({ + filteredModels, + loading, + rowSelection, + pageSize, + setPageSize, + currentPage, + setCurrentPage, + selectedGroup, + groupRatio, + copyText, + setModalImageUrl, + setIsModalOpenurl, + currency, + tokenUnit, + displayPrice, + showRatio, + t, + selectedRowKeys = [], + setSelectedRowKeys, + activeKey, + availableCategories, +}) => { + const showSkeleton = useMinimumLoadingTime(loading); + + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedModels = filteredModels.slice(startIndex, endIndex); + + const getModelKey = (model) => model.key ?? model.model_name ?? model.id; + + const handleCheckboxChange = (model, checked) => { + if (!setSelectedRowKeys) return; + const modelKey = getModelKey(model); + const newKeys = checked + ? Array.from(new Set([...selectedRowKeys, modelKey])) + : selectedRowKeys.filter((key) => key !== modelKey); + setSelectedRowKeys(newKeys); + rowSelection?.onChange?.(newKeys, null); + }; + + // 获取模型图标 + const getModelIcon = (modelName) => { + const categories = getModelCategories(t); + let icon = null; + + // 遍历分类,找到匹配的模型图标 + for (const [key, category] of Object.entries(categories)) { + if (key !== 'all' && category.filter({ model_name: modelName })) { + icon = category.icon; + break; + } + } + + // 如果找到了匹配的图标,返回包装后的图标 + if (icon) { + return ( +
+
+ {React.cloneElement(icon, { size: 32 })} +
+
+ ); + } + + const avatarText = modelName.slice(0, 2).toUpperCase(); + return ( +
+ + {avatarText} + +
+ ); + }; + + // 获取模型描述 + const getModelDescription = (modelName) => { + return t('高性能AI模型,适用于各种文本生成和理解任务。'); + }; + + // 渲染价格信息 + const renderPriceInfo = (record) => { + const priceData = calculateModelPrice({ + record, + selectedGroup, + groupRatio, + tokenUnit, + displayPrice, + currency + }); + return formatPriceInfo(priceData, t); + }; + + // 渲染标签 + const renderTags = (record) => { + const tags = []; + + // 计费类型标签 + const billingType = record.quota_type === 1 ? 'teal' : 'violet'; + const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); + tags.push( + + {billingText} + + ); + + // 热门模型标签 + if (record.model_name.includes('gpt')) { + tags.push( + + {t('热')} + + ); + } + + // 端点类型标签 + if (record.supported_endpoint_types?.length > 0) { + record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => { + tags.push( + + {endpoint} + + ); + }); + } + + // 上下文长度标签 + const contextMatch = record.model_name.match(/(\d+)k/i); + const contextSize = contextMatch ? contextMatch[1] + 'K' : '4K'; + tags.push( + + {contextSize} + + ); + + return tags; + }; + + // 显示骨架屏 + if (showSkeleton) { + return ( + + ); + } + + if (!filteredModels || filteredModels.length === 0) { + return ( +
+ } + darkModeImage={} + description={t('搜索无结果')} + /> +
+ ); + } + + return ( +
+
+ {paginatedModels.map((model, index) => { + const modelKey = getModelKey(model); + const isSelected = selectedRowKeys.includes(modelKey); + + return ( + + {/* 头部:图标 + 模型名称 + 操作按钮 */} +
+
+ {getModelIcon(model.model_name)} +
+

+ {model.model_name} +

+
+ {renderPriceInfo(model)} +
+
+
+ +
+ {/* 复制按钮 */} +
+
+ + {/* 模型描述 */} +
+

+ {getModelDescription(model.model_name)} +

+
+ + {/* 标签区域 */} +
+ {renderTags(model)} +
+ + {/* 倍率信息(可选) */} + {showRatio && ( +
+
+ {t('倍率信息')} + + { + setModalImageUrl('/ratio.png'); + setIsModalOpenurl(true); + }} + /> + +
+
+
+ {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')} +
+
+ {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')} +
+
+ {t('分组')}: {groupRatio[selectedGroup]} +
+
+
+ )} +
+ ); + })} +
+ + {/* 分页 */} + {filteredModels.length > 0 && ( +
+ setCurrentPage(page)} + onPageSizeChange={(size) => { + setPageSize(size); + setCurrentPage(1); + }} + /> +
+ )} +
+ ); +}; + +export default PricingCardView; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/view/PricingTable.jsx b/web/src/components/table/model-pricing/view/table/PricingTable.jsx similarity index 98% rename from web/src/components/table/model-pricing/view/PricingTable.jsx rename to web/src/components/table/model-pricing/view/table/PricingTable.jsx index 26c7edbb..09d9f53e 100644 --- a/web/src/components/table/model-pricing/view/PricingTable.jsx +++ b/web/src/components/table/model-pricing/view/table/PricingTable.jsx @@ -56,7 +56,6 @@ const PricingTable = ({ setIsModalOpenurl, currency, tokenUnit, - setTokenUnit, displayPrice, showRatio, }); @@ -69,7 +68,6 @@ const PricingTable = ({ setIsModalOpenurl, currency, tokenUnit, - setTokenUnit, displayPrice, showRatio, ]); diff --git a/web/src/components/table/model-pricing/view/PricingTableColumns.js b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js similarity index 91% rename from web/src/components/table/model-pricing/view/PricingTableColumns.js rename to web/src/components/table/model-pricing/view/table/PricingTableColumns.js index 54b3889c..7ff77a57 100644 --- a/web/src/components/table/model-pricing/view/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js @@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui'; +import { Tag, Space, Tooltip } from '@douyinfe/semi-ui'; import { IconHelpCircle } from '@douyinfe/semi-icons'; -import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../helpers'; +import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../../helpers'; function renderQuotaType(type, t) { switch (type) { @@ -69,7 +69,6 @@ export const getPricingTableColumns = ({ setIsModalOpenurl, currency, tokenUnit, - setTokenUnit, displayPrice, showRatio, }) => { @@ -144,18 +143,7 @@ export const getPricingTableColumns = ({ }; const priceColumn = { - title: ( -
- {t('模型价格')} - {/* 计费单位切换 */} - setTokenUnit(checked ? 'K' : 'M')} - checkedText="K" - uncheckedText="M" - /> -
- ), + title: t('模型价格'), dataIndex: 'model_price', render: (text, record, index) => { const priceData = calculateModelPrice({ diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx index c14ffcbf..7c416183 100644 --- a/web/src/components/table/usage-logs/UsageLogsActions.jsx +++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx @@ -17,10 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useEffect, useRef } from 'react'; +import React from 'react'; import { Tag, Space, Skeleton } from '@douyinfe/semi-ui'; import { renderQuota } from '../../../helpers'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; const LogsActions = ({ stat, @@ -30,27 +31,9 @@ const LogsActions = ({ setCompactMode, t, }) => { - const [showSkeleton, setShowSkeleton] = useState(loadingStat); + const showSkeleton = useMinimumLoadingTime(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, 1000 - 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 = ( diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 9972fb3a..5919b45c 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -612,12 +612,25 @@ export const calculateModelPrice = ({ } }; -// 格式化价格信息为字符串(用于卡片视图) +// 格式化价格信息(用于卡片视图) export const formatPriceInfo = (priceData, t) => { if (priceData.isPerToken) { - return `${t('输入')} ${priceData.inputPrice}/${priceData.unitLabel} ${t('输出')} ${priceData.completionPrice}/${priceData.unitLabel}`; + return ( + <> + + {t('提示')} {priceData.inputPrice}/{priceData.unitLabel} + + + {t('补全')} {priceData.completionPrice}/{priceData.unitLabel} + + + ); } else { - return `${t('模型价格')} ${priceData.price}`; + return ( + + {t('模型价格')} {priceData.price} + + ); } }; @@ -684,6 +697,7 @@ export const resetPricingFilters = ({ setFilterQuotaType, setFilterEndpointType, setCurrentPage, + setTokenUnit, }) => { // 重置搜索 if (typeof handleChange === 'function') { @@ -719,6 +733,11 @@ export const resetPricingFilters = ({ setViewMode('card'); } + // 重置token单位 + if (typeof setTokenUnit === 'function') { + setTokenUnit('M'); + } + // 重置分组筛选 if (typeof setFilterGroup === 'function') { setFilterGroup('all'); diff --git a/web/src/hooks/common/useMinimumLoadingTime.js b/web/src/hooks/common/useMinimumLoadingTime.js new file mode 100644 index 00000000..f9a176f1 --- /dev/null +++ b/web/src/hooks/common/useMinimumLoadingTime.js @@ -0,0 +1,50 @@ +/* +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 { useState, useEffect, useRef } from 'react'; + +/** + * 自定义 Hook:确保骨架屏至少显示指定的时间 + * @param {boolean} loading - 实际的加载状态 + * @param {number} minimumTime - 最小显示时间(毫秒),默认 1000ms + * @returns {boolean} showSkeleton - 是否显示骨架屏的状态 + */ +export const useMinimumLoadingTime = (loading, minimumTime = 1000) => { + 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, minimumTime - elapsed); + + if (remaining === 0) { + setShowSkeleton(false); + } else { + const timer = setTimeout(() => setShowSkeleton(false), remaining); + return () => clearTimeout(timer); + } + } + }, [loading, minimumTime]); + + return showSkeleton; +}; \ No newline at end of file diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js index 255f48d3..c4299f66 100644 --- a/web/src/hooks/dashboard/useDashboardData.js +++ b/web/src/hooks/dashboard/useDashboardData.js @@ -24,6 +24,7 @@ import { API, isAdmin, showError, timestamp2string } from '../../helpers'; import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard'; import { TIME_OPTIONS } from '../../constants/dashboard.constants'; import { useIsMobile } from '../common/useIsMobile'; +import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime'; export const useDashboardData = (userState, userDispatch, statusState) => { const { t } = useTranslation(); @@ -35,6 +36,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { const [loading, setLoading] = useState(false); const [greetingVisible, setGreetingVisible] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false); + const showLoading = useMinimumLoadingTime(loading); // ========== 输入状态 ========== const [inputs, setInputs] = useState({ @@ -145,7 +147,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => { // ========== API 调用函数 ========== const loadQuotaData = useCallback(async () => { setLoading(true); - const startTime = Date.now(); try { let url = ''; const { start_timestamp, end_timestamp, username } = inputs; @@ -177,11 +178,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { return []; } } finally { - const elapsed = Date.now() - startTime; - const remainingTime = Math.max(0, 1000 - elapsed); - setTimeout(() => { - setLoading(false); - }, remainingTime); + setLoading(false); } }, [inputs, dataExportDefaultTime, isAdminUser, now]); @@ -246,7 +243,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { return { // 基础状态 - loading, + loading: showLoading, greetingVisible, searchModalVisible, diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index 6d750b87..3e3c4a92 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -115,11 +115,12 @@ export const useModelPricingData = () => { const rowSelection = useMemo( () => ({ - onChange: (selectedRowKeys, selectedRows) => { - setSelectedRowKeys(selectedRowKeys); + selectedRowKeys, + onChange: (keys) => { + setSelectedRowKeys(keys); }, }), - [], + [selectedRowKeys], ); const displayPrice = (usdPrice) => { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 50c10a21..67dbfc9d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -950,7 +950,7 @@ "黑夜模式": "Dark mode", "管理员设置": "Admin", "待更新": "To be updated", - "定价": "Pricing", + "模型广场": "Pricing", "支付中..": "Paying", "查看图片": "View pictures", "并发限制": "Concurrency limit", @@ -1195,6 +1195,7 @@ "兑换码创建成功,是否下载兑换码?": "Redemption code created successfully. Do you want to download it?", "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "The redemption code will be downloaded as a text file, with the filename being the redemption code name.", "模型价格": "Model price", + "按K显示单位": "Display in K units", "可用分组": "Available groups", "您的默认分组为:{{group}},分组倍率为:{{ratio}}": "Your default group is: {{group}}, group ratio: {{ratio}}", "按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)": "The cost of pay-as-you-go = Group ratio × Model ratio × (Prompt token number + Completion token number × Completion ratio) / 500000 (Unit: USD)", From 16b2d0f1bfe6a014907980d36526b1828d8ac88a Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 17:15:28 +0800 Subject: [PATCH 092/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(utils):?= =?UTF-8?q?=20optimize=20resetPricingFilters=20function=20for=20better=20m?= =?UTF-8?q?aintainability=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract default values to DEFAULT_PRICING_FILTERS constant for centralized configuration - Replace verbose type checks with optional chaining operator (?.) for cleaner code - Eliminate redundant function type validations and comments - Reduce code lines by ~50% (from 60 to 25 lines) while maintaining full functionality - Improve code readability and follow modern JavaScript best practices This refactoring enhances code quality without changing the function's behavior, making it easier to maintain and modify default filter values in the future. --- web/src/helpers/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 5919b45c..55f7ec6a 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -577,7 +577,7 @@ export const calculateModelPrice = ({ tokenUnit, displayPrice, currency, - precision = 3 + precision = 4 }) => { if (record.quota_type === 0) { // 按量计费 From 4116111b576f4684cc5f1d31ecd41aea4f825249 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 17:22:20 +0800 Subject: [PATCH 093/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(utils):?= =?UTF-8?q?=20optimize=20resetPricingFilters=20function=20for=20better=20m?= =?UTF-8?q?aintainability=20(#1365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract default values to DEFAULT_PRICING_FILTERS constant for centralized configuration - Replace verbose type checks with optional chaining operator (?.) for cleaner code - Eliminate redundant function type validations and comments - Reduce code lines by ~50% (from 60 to 25 lines) while maintaining full functionality - Improve code readability and follow modern JavaScript best practices This refactoring enhances code quality without changing the function's behavior, making it easier to maintain and modify default filter values in the future. --- web/src/helpers/utils.js | 84 ++++++++++++---------------------------- 1 file changed, 25 insertions(+), 59 deletions(-) diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 55f7ec6a..0df7bb10 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -683,7 +683,20 @@ export const createCardProPagination = ({ ); }; -// ------------------------------- +// 模型定价筛选条件默认值 +const DEFAULT_PRICING_FILTERS = { + search: '', + showWithRecharge: false, + currency: 'USD', + showRatio: false, + viewMode: 'card', + tokenUnit: 'M', + filterGroup: 'all', + filterQuotaType: 'all', + filterEndpointType: 'all', + currentPage: 1, +}; + // 重置模型定价筛选条件 export const resetPricingFilters = ({ handleChange, @@ -699,62 +712,15 @@ export const resetPricingFilters = ({ setCurrentPage, setTokenUnit, }) => { - // 重置搜索 - if (typeof handleChange === 'function') { - handleChange(''); - } - - // 重置模型分类到默认 - if ( - typeof setActiveKey === 'function' && - Array.isArray(availableCategories) && - availableCategories.length > 0 - ) { - setActiveKey(availableCategories[0]); - } - - // 重置充值价格显示 - if (typeof setShowWithRecharge === 'function') { - setShowWithRecharge(false); - } - - // 重置货币 - if (typeof setCurrency === 'function') { - setCurrency('USD'); - } - - // 重置显示倍率 - if (typeof setShowRatio === 'function') { - setShowRatio(false); - } - - // 重置视图模式 - if (typeof setViewMode === 'function') { - setViewMode('card'); - } - - // 重置token单位 - if (typeof setTokenUnit === 'function') { - setTokenUnit('M'); - } - - // 重置分组筛选 - if (typeof setFilterGroup === 'function') { - setFilterGroup('all'); - } - - // 重置计费类型筛选 - if (typeof setFilterQuotaType === 'function') { - setFilterQuotaType('all'); - } - - // 重置端点类型筛选 - if (typeof setFilterEndpointType === 'function') { - setFilterEndpointType('all'); - } - - // 重置当前页面 - if (typeof setCurrentPage === 'function') { - setCurrentPage(1); - } + handleChange?.(DEFAULT_PRICING_FILTERS.search); + availableCategories?.length > 0 && setActiveKey?.(availableCategories[0]); + setShowWithRecharge?.(DEFAULT_PRICING_FILTERS.showWithRecharge); + setCurrency?.(DEFAULT_PRICING_FILTERS.currency); + setShowRatio?.(DEFAULT_PRICING_FILTERS.showRatio); + setViewMode?.(DEFAULT_PRICING_FILTERS.viewMode); + setTokenUnit?.(DEFAULT_PRICING_FILTERS.tokenUnit); + setFilterGroup?.(DEFAULT_PRICING_FILTERS.filterGroup); + setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType); + setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType); + setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage); }; From b935068b1fa4f8e8a02da06fd1b79c5c127654a0 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 17:44:48 +0800 Subject: [PATCH 094/582] =?UTF-8?q?=F0=9F=93=B1=20fix(ui):=20adjust=20resp?= =?UTF-8?q?onsive=20breakpoints=20for=20pricing=20card=20grid=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimize grid column breakpoints to account for 460px sidebar width: - Change from sm:grid-cols-2 lg:grid-cols-3 to xl:grid-cols-2 2xl:grid-cols-3 - Ensures adequate space for card display after subtracting sidebar width - Improves layout on medium-sized screens where previous breakpoints caused cramped display Breakpoint calculation: - 1280px screen - 460px sidebar = 820px → 2 columns - 1536px screen - 460px sidebar = 1076px → 3 columns --- .../table/model-pricing/view/card/PricingCardView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index b1868cee..29e1786a 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -203,7 +203,7 @@ const PricingCardView = ({ return (
-
+
{paginatedModels.map((model, index) => { const modelKey = getModelKey(model); const isSelected = selectedRowKeys.includes(modelKey); From f11236a30e6594846c727e35ca637fc8505c4d6c Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 23 Jul 2025 16:49:06 +0800 Subject: [PATCH 095/582] feat: add video preview modal --- .../table/task-logs/TaskLogsColumnDefs.js | 9 ++++++++- .../components/table/task-logs/TaskLogsTable.jsx | 3 +++ web/src/components/table/task-logs/index.jsx | 9 ++++++++- .../table/task-logs/modals/ContentModal.jsx | 7 ++++++- web/src/hooks/task-logs/useTaskLogsData.js | 16 ++++++++++++++++ 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.js b/web/src/components/table/task-logs/TaskLogsColumnDefs.js index f895bf01..d44edf05 100644 --- a/web/src/components/table/task-logs/TaskLogsColumnDefs.js +++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.js @@ -211,6 +211,7 @@ export const getTaskLogsColumns = ({ copyText, openContentModal, isAdminUser, + openVideoModal, }) => { return [ { @@ -342,7 +343,13 @@ export const getTaskLogsColumns = ({ const isUrl = typeof text === 'string' && /^https?:\/\//.test(text); if (isSuccess && isVideoTask && isUrl) { return ( - + { + e.preventDefault(); + openVideoModal(text); + }} + > {t('点击预览视频')} ); diff --git a/web/src/components/table/task-logs/TaskLogsTable.jsx b/web/src/components/table/task-logs/TaskLogsTable.jsx index cacb12dd..eaf73c71 100644 --- a/web/src/components/table/task-logs/TaskLogsTable.jsx +++ b/web/src/components/table/task-logs/TaskLogsTable.jsx @@ -39,6 +39,7 @@ const TaskLogsTable = (taskLogsData) => { handlePageSizeChange, copyText, openContentModal, + openVideoModal, isAdminUser, t, COLUMN_KEYS, @@ -51,6 +52,7 @@ const TaskLogsTable = (taskLogsData) => { COLUMN_KEYS, copyText, openContentModal, + openVideoModal, isAdminUser, }); }, [ @@ -58,6 +60,7 @@ const TaskLogsTable = (taskLogsData) => { COLUMN_KEYS, copyText, openContentModal, + openVideoModal, isAdminUser, ]); diff --git a/web/src/components/table/task-logs/index.jsx b/web/src/components/table/task-logs/index.jsx index c5439bae..a12dab8a 100644 --- a/web/src/components/table/task-logs/index.jsx +++ b/web/src/components/table/task-logs/index.jsx @@ -37,7 +37,14 @@ const TaskLogsPage = () => { <> {/* Modals */} - + + {/* 新增:视频预览弹窗 */} + { return ( -

{modalContent}

+ {isVideo ? ( +
); }; diff --git a/web/src/hooks/task-logs/useTaskLogsData.js b/web/src/hooks/task-logs/useTaskLogsData.js index 70e2bf00..6f6940c4 100644 --- a/web/src/hooks/task-logs/useTaskLogsData.js +++ b/web/src/hooks/task-logs/useTaskLogsData.js @@ -63,6 +63,10 @@ export const useTaskLogsData = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [modalContent, setModalContent] = useState(''); + // 新增:视频预览弹窗状态 + const [isVideoModalOpen, setIsVideoModalOpen] = useState(false); + const [videoUrl, setVideoUrl] = useState(''); + // Form state const [formApi, setFormApi] = useState(null); let now = new Date(); @@ -243,6 +247,12 @@ export const useTaskLogsData = () => { setIsModalOpen(true); }; + // 新增:打开视频预览弹窗 + const openVideoModal = (url) => { + setVideoUrl(url); + setIsVideoModalOpen(true); + }; + // Initialize data useEffect(() => { const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE; @@ -264,6 +274,11 @@ export const useTaskLogsData = () => { setIsModalOpen, modalContent, + // 新增:视频弹窗状态 + isVideoModalOpen, + setIsVideoModalOpen, + videoUrl, + // Form state formApi, setFormApi, @@ -290,6 +305,7 @@ export const useTaskLogsData = () => { refresh, copyText, openContentModal, + openVideoModal, // 新增 enrichLogs, syncPageData, From d351c61606b5b3a0a57f841ae25a7cab7a41f8af Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 24 Jul 2025 23:28:55 +0800 Subject: [PATCH 096/582] =?UTF-8?q?=F0=9F=8D=AD=20style(ui):=20Optimize=20?= =?UTF-8?q?style=20layout=20and=20improve=20responsive=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layout/header/PricingCategoryIntro.jsx | 42 ++++++++++--------- .../header/PricingCategoryIntroSkeleton.jsx | 10 ++--- .../view/card/PricingCardView.jsx | 2 +- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx index df1e3c97..47cac58c 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx @@ -104,7 +104,7 @@ const PricingCategoryIntro = ({ })); return ( -
+
{fallbackCategories.map((item) => ( +
( -
+
{category.icon && React.cloneElement(category.icon, { size: 40 })}
); @@ -171,20 +171,22 @@ const PricingCategoryIntro = ({ if (activeKey === 'all') { return (
- -
+ +
{/* 全部模型的头像组合 */} - {renderAllModelsAvatar()} +
+ {renderAllModelsAvatar()} +
{/* 分类信息 */} -
-
-

{modelCategories.all.label}

- +
+
+

{modelCategories.all.label}

+ {t('共 {{count}} 个模型', { count: modelCount })}
-

+

{getCategoryDescription(activeKey)}

@@ -202,20 +204,22 @@ const PricingCategoryIntro = ({ return (
- -
+ +
{/* 分类图标 */} - {renderCategoryAvatar(currentCategory)} +
+ {renderCategoryAvatar(currentCategory)} +
{/* 分类信息 */} -
-
-

{currentCategory.label}

- +
+
+

{currentCategory.label}

+ {t('共 {{count}} 个模型', { count: modelCount })}
-

+

{getCategoryDescription(activeKey)}

diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx index 06d029ef..8ae719df 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx @@ -25,10 +25,10 @@ const PricingCategoryIntroSkeleton = ({ }) => { const placeholder = (
- -
+ +
{/* 分类图标骨架 */} -
+
{isAllModels ? (
{Array.from({ length: 5 }).map((_, index) => ( @@ -50,8 +50,8 @@ const PricingCategoryIntroSkeleton = ({
{/* 分类信息骨架 */} -
-
+
+
diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 29e1786a..e107df79 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -26,7 +26,7 @@ import PricingCardSkeleton from './PricingCardSkeleton'; import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; const CARD_STYLES = { - container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm", + container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md", icon: "w-8 h-8 flex items-center justify-center", selected: "border-blue-500 bg-blue-50", default: "border-gray-200 hover:border-gray-300" From 3243f2296b20efe4211c5145b2a2552b9be874d4 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 25 Jul 2025 18:48:59 +0800 Subject: [PATCH 097/582] feat: add upstream error type and default handling for OpenAI and Claude errors --- types/error.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/types/error.go b/types/error.go index 4ffae2d7..fa07c231 100644 --- a/types/error.go +++ b/types/error.go @@ -28,6 +28,7 @@ const ( ErrorTypeMidjourneyError ErrorType = "midjourney_error" ErrorTypeGeminiError ErrorType = "gemini_error" ErrorTypeRerankError ErrorType = "rerank_error" + ErrorTypeUpstreamError ErrorType = "upstream_error" ) type ErrorCode string @@ -194,6 +195,9 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError { if !ok { code = fmt.Sprintf("%v", openAIError.Code) } + if openAIError.Type == "" { + openAIError.Type = "upstream_error" + } return &NewAPIError{ RelayError: openAIError, errorType: ErrorTypeOpenAIError, @@ -204,6 +208,9 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError { } func WithClaudeError(claudeError ClaudeError, statusCode int) *NewAPIError { + if claudeError.Type == "" { + claudeError.Type = "upstream_error" + } return &NewAPIError{ RelayError: claudeError, errorType: ErrorTypeClaudeError, From 3ea058e0db2a1d9f00203f8ccdbb530236339fca Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 25 Jul 2025 20:31:20 +0800 Subject: [PATCH 098/582] =?UTF-8?q?=F0=9F=94=92=20fix:=20Enforce=20admin-o?= =?UTF-8?q?nly=20column=20visibility=20in=20logs=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure non-admin users cannot enable columns reserved for administrators across the following hooks: * web/src/hooks/usage-logs/useUsageLogsData.js - Force-hide CHANNEL, USERNAME and RETRY columns for non-admins. * web/src/hooks/mj-logs/useMjLogsData.js - Force-hide CHANNEL and SUBMIT_RESULT columns for non-admins. * web/src/hooks/task-logs/useTaskLogsData.js - Force-hide CHANNEL column for non-admins. The checks run when loading column preferences from localStorage, overriding any tampered settings to keep sensitive information hidden from unauthorized users. --- web/src/hooks/mj-logs/useMjLogsData.js | 5 +++++ web/src/hooks/task-logs/useTaskLogsData.js | 4 ++++ web/src/hooks/usage-logs/useUsageLogsData.js | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/web/src/hooks/mj-logs/useMjLogsData.js b/web/src/hooks/mj-logs/useMjLogsData.js index 4720629a..00330785 100644 --- a/web/src/hooks/mj-logs/useMjLogsData.js +++ b/web/src/hooks/mj-logs/useMjLogsData.js @@ -94,6 +94,11 @@ export const useMjLogsData = () => { const parsed = JSON.parse(savedColumns); const defaults = getDefaultColumnVisibility(); const merged = { ...defaults, ...parsed }; + // If not admin, force hide columns only visible to admins + if (!isAdminUser) { + merged[COLUMN_KEYS.CHANNEL] = false; + merged[COLUMN_KEYS.SUBMIT_RESULT] = false; + } setVisibleColumns(merged); } catch (e) { console.error('Failed to parse saved column preferences', e); diff --git a/web/src/hooks/task-logs/useTaskLogsData.js b/web/src/hooks/task-logs/useTaskLogsData.js index 70e2bf00..23ed8a85 100644 --- a/web/src/hooks/task-logs/useTaskLogsData.js +++ b/web/src/hooks/task-logs/useTaskLogsData.js @@ -92,6 +92,10 @@ export const useTaskLogsData = () => { const parsed = JSON.parse(savedColumns); const defaults = getDefaultColumnVisibility(); const merged = { ...defaults, ...parsed }; + // If not admin, force hide columns only visible to admins + if (!isAdminUser) { + merged[COLUMN_KEYS.CHANNEL] = false; + } setVisibleColumns(merged); } catch (e) { console.error('Failed to parse saved column preferences', e); diff --git a/web/src/hooks/usage-logs/useUsageLogsData.js b/web/src/hooks/usage-logs/useUsageLogsData.js index b2312680..c25c155c 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.js +++ b/web/src/hooks/usage-logs/useUsageLogsData.js @@ -116,6 +116,12 @@ export const useLogsData = () => { const parsed = JSON.parse(savedColumns); const defaults = getDefaultColumnVisibility(); const merged = { ...defaults, ...parsed }; + // If not admin, force hide columns only visible to admins + if (!isAdminUser) { + merged[COLUMN_KEYS.CHANNEL] = false; + merged[COLUMN_KEYS.USERNAME] = false; + merged[COLUMN_KEYS.RETRY] = false; + } setVisibleColumns(merged); } catch (e) { console.error('Failed to parse saved column preferences', e); From 776402c3f686ea25a6b1a898f0b4f59c33523bc5 Mon Sep 17 00:00:00 2001 From: Raymond <240029725@qq.com> Date: Fri, 25 Jul 2025 22:40:12 +0800 Subject: [PATCH 099/582] =?UTF-8?q?=E5=88=A4=E6=96=AD=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E7=A0=81=E5=90=8D=E7=A7=B0=E9=95=BF=E5=BA=A6=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8C=89=E5=AD=97=E7=AC=A6=E9=95=BF=E5=BA=A6=E8=AE=A1?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/redemption.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/controller/redemption.go b/controller/redemption.go index 83ec19ad..1e305e3d 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -6,6 +6,7 @@ import ( "one-api/common" "one-api/model" "strconv" + "unicode/utf8" "github.com/gin-gonic/gin" ) @@ -63,7 +64,7 @@ func AddRedemption(c *gin.Context) { common.ApiError(c, err) return } - if len(redemption.Name) == 0 || len(redemption.Name) > 20 { + if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "兑换码名称长度必须在1-20之间", From 1327404e357e6a81b6f0c9d0b9e48e3e88ebb7e0 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 26 Jul 2025 04:24:22 +0800 Subject: [PATCH 100/582] =?UTF-8?q?=E2=9C=A8=20refactor:=20Restructure=20m?= =?UTF-8?q?odel=20pricing=20components=20and=20improve=20UX=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Fix SideSheet double-click issue**: Remove early return for null modelData to prevent rendering blockage during async state updates - **Component modularization**: - Split ModelDetailSideSheet into focused sub-components (ModelHeader, ModelBasicInfo, ModelEndpoints, ModelPricingTable) - Refactor PricingFilterModal with FilterModalContent and FilterModalFooter components - Remove unnecessary FilterSection wrapper for cleaner interface - **Improve visual consistency**: - Unify avatar/icon logic between ModelHeader and PricingCardView components - Standardize tag colors across all pricing components (violet/teal for billing types) - Apply consistent dashed border styling using Semi UI theme colors - **Enhance data accuracy**: - Display raw endpoint type names (e.g., "openai", "anthropic") instead of translated descriptions - Remove text alignment classes for better responsive layout - Add proper null checks to prevent runtime errors - **Code quality improvements**: - Reduce component complexity by 52-74% through modularization - Improve maintainability with single responsibility principle - Add comprehensive error handling for edge cases This refactoring improves component reusability, reduces bundle size, and provides a more consistent user experience across the model pricing interface. --- .../filter/PricingEndpointTypes.jsx | 10 +- .../model-pricing/layout/PricingPage.jsx | 15 ++ .../modal/ModelDetailSideSheet.jsx | 103 ++++++++++ .../modal/PricingFilterModal.jsx | 124 ++---------- .../modal/components/FilterModalContent.jsx | 99 +++++++++ .../modal/components/FilterModalFooter.jsx | 44 ++++ .../modal/components/ModelBasicInfo.jsx | 55 +++++ .../modal/components/ModelEndpoints.jsx | 69 +++++++ .../modal/components/ModelHeader.jsx | 136 +++++++++++++ .../modal/components/ModelPricingTable.jsx | 190 ++++++++++++++++++ .../view/card/PricingCardView.jsx | 24 ++- .../model-pricing/view/table/PricingTable.jsx | 7 +- .../model-pricing/useModelPricingData.js | 18 ++ 13 files changed, 775 insertions(+), 119 deletions(-) create mode 100644 web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx create mode 100644 web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx create mode 100644 web/src/components/table/model-pricing/modal/components/FilterModalFooter.jsx create mode 100644 web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx create mode 100644 web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx create mode 100644 web/src/components/table/model-pricing/modal/components/ModelHeader.jsx create mode 100644 web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx diff --git a/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx b/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx index d9f22d95..c60f0ef2 100644 --- a/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx +++ b/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx @@ -55,15 +55,7 @@ const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, model // 端点类型显示名称映射 const getEndpointTypeLabel = (endpointType) => { - const labelMap = { - 'openai': 'OpenAI', - 'openai-response': 'OpenAI Response', - 'anthropic': 'Anthropic', - 'gemini': 'Gemini', - 'jina-rerank': 'Jina Rerank', - 'image-generation': t('图像生成'), - }; - return labelMap[endpointType] || endpointType; + return endpointType; }; const availableEndpointTypes = getAllEndpointTypes(); diff --git a/web/src/components/table/model-pricing/layout/PricingPage.jsx b/web/src/components/table/model-pricing/layout/PricingPage.jsx index 5db359b3..76c31e81 100644 --- a/web/src/components/table/model-pricing/layout/PricingPage.jsx +++ b/web/src/components/table/model-pricing/layout/PricingPage.jsx @@ -21,6 +21,7 @@ import React from 'react'; import { Layout, ImagePreview } from '@douyinfe/semi-ui'; import PricingSidebar from './PricingSidebar'; import PricingContent from './content/PricingContent'; +import ModelDetailSideSheet from '../modal/ModelDetailSideSheet'; import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; @@ -66,6 +67,20 @@ const PricingPage = () => { visible={pricingData.isModalOpenurl} onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)} /> + +
); }; diff --git a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx new file mode 100644 index 00000000..6723e2f7 --- /dev/null +++ b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx @@ -0,0 +1,103 @@ +/* +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 { + SideSheet, + Typography, + Button, +} from '@douyinfe/semi-ui'; +import { + IconClose, +} from '@douyinfe/semi-icons'; + +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +import ModelHeader from './components/ModelHeader'; +import ModelBasicInfo from './components/ModelBasicInfo'; +import ModelEndpoints from './components/ModelEndpoints'; +import ModelPricingTable from './components/ModelPricingTable'; + +const { Text } = Typography; + +const ModelDetailSideSheet = ({ + visible, + onClose, + modelData, + selectedGroup, + groupRatio, + currency, + tokenUnit, + displayPrice, + showRatio, + usableGroup, + t, +}) => { + const isMobile = useIsMobile(); + + return ( + } + bodyStyle={{ + padding: '0', + display: 'flex', + flexDirection: 'column', + borderBottom: '1px solid var(--semi-color-border)' + }} + visible={visible} + width={isMobile ? '100%' : 600} + closeIcon={ + - -
+ ); return ( @@ -107,50 +68,7 @@ const PricingFilterModal = ({ msOverflowStyle: 'none' }} > -
- - - - - - - - - -
+ ); }; diff --git a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx new file mode 100644 index 00000000..aa9646fe --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx @@ -0,0 +1,99 @@ +/* +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 PricingDisplaySettings from '../../filter/PricingDisplaySettings'; +import PricingCategories from '../../filter/PricingCategories'; +import PricingGroups from '../../filter/PricingGroups'; +import PricingQuotaTypes from '../../filter/PricingQuotaTypes'; +import PricingEndpointTypes from '../../filter/PricingEndpointTypes'; + +const FilterModalContent = ({ sidebarProps, t }) => { + const { + showWithRecharge, + setShowWithRecharge, + currency, + setCurrency, + handleChange, + setActiveKey, + showRatio, + setShowRatio, + viewMode, + setViewMode, + filterGroup, + setFilterGroup, + filterQuotaType, + setFilterQuotaType, + filterEndpointType, + setFilterEndpointType, + tokenUnit, + setTokenUnit, + loading, + ...categoryProps + } = sidebarProps; + + return ( +
+ + + + + + + + + +
+ ); +}; + +export default FilterModalContent; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/components/FilterModalFooter.jsx b/web/src/components/table/model-pricing/modal/components/FilterModalFooter.jsx new file mode 100644 index 00000000..37607417 --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/FilterModalFooter.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 { Button } from '@douyinfe/semi-ui'; + +const FilterModalFooter = ({ onReset, onConfirm, t }) => { + return ( +
+ + +
+ ); +}; + +export default FilterModalFooter; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx new file mode 100644 index 00000000..662b5616 --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.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 { Card, Avatar, Typography } from '@douyinfe/semi-ui'; +import { IconInfoCircle } from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +const ModelBasicInfo = ({ modelData, t }) => { + // 获取模型描述 + const getModelDescription = () => { + if (!modelData) return t('暂无模型描述'); + // 这里可以根据模型名称返回不同的描述 + if (modelData.model_name?.includes('gpt-4o-image')) { + return t('逆向plus账号的可绘图的gpt-4o模型,由于OpenAI限制绘图数量,并非每次都能绘图成功,由于是逆向模型,只要请求成功,即使绘图失败也会扣费,请谨慎使用。推荐使用正式版的 gpt-image-1模型。'); + } + return modelData.description || t('暂无模型描述'); + }; + + return ( + +
+ + + +
+ {t('基本信息')} +
{t('模型的详细描述和基本特性')}
+
+
+
+

{getModelDescription()}

+
+
+ ); +}; + +export default ModelBasicInfo; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx b/web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx new file mode 100644 index 00000000..31033cab --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx @@ -0,0 +1,69 @@ +/* +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, Typography, Badge } from '@douyinfe/semi-ui'; +import { IconLink } from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +const ModelEndpoints = ({ modelData, t }) => { + const renderAPIEndpoints = () => { + const endpoints = []; + + if (modelData?.supported_endpoint_types) { + modelData.supported_endpoint_types.forEach(endpoint => { + endpoints.push({ name: endpoint, type: endpoint }); + }); + } + + return endpoints.map((endpoint, index) => ( +
+ + + {endpoint.name}: + https://api.newapi.pro + /v1/chat/completions + + POST +
+ )); + }; + + return ( + +
+ + + +
+ {t('API端点')} +
{t('模型支持的接口端点信息')}
+
+
+ {renderAPIEndpoints()} +
+ ); +}; + +export default ModelEndpoints; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx b/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx new file mode 100644 index 00000000..23ae179c --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/ModelHeader.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 { Tag, Typography, Toast, Avatar } from '@douyinfe/semi-ui'; +import { getModelCategories } from '../../../../../helpers'; + +const { Paragraph } = Typography; + +const CARD_STYLES = { + container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md", + icon: "w-8 h-8 flex items-center justify-center", +}; + +const ModelHeader = ({ modelData, t }) => { + // 获取模型图标 + const getModelIcon = (modelName) => { + // 如果没有模型名称,直接返回默认头像 + if (!modelName) { + return ( +
+ + AI + +
+ ); + } + + const categories = getModelCategories(t); + let icon = null; + + // 遍历分类,找到匹配的模型图标 + for (const [key, category] of Object.entries(categories)) { + if (key !== 'all' && category.filter({ model_name: modelName })) { + icon = category.icon; + break; + } + } + + // 如果找到了匹配的图标,返回包装后的图标 + if (icon) { + return ( +
+
+ {React.cloneElement(icon, { size: 32 })} +
+
+ ); + } + + const avatarText = modelName?.slice(0, 2).toUpperCase() || 'AI'; + return ( +
+ + {avatarText} + +
+ ); + }; + + // 获取模型标签 + const getModelTags = () => { + const tags = [ + { text: t('文本对话'), color: 'green' }, + { text: t('图片生成'), color: 'blue' }, + { text: t('图像分析'), color: 'cyan' } + ]; + + return tags; + }; + + return ( +
+ {getModelIcon(modelData?.model_name)} +
+ Toast.success({ content: t('已复制模型名称') }) + }} + > + {modelData?.model_name || t('未知模型')} + +
+ {getModelTags().map((tag, index) => ( + + {tag.text} + + ))} +
+
+
+ ); +}; + +export default ModelHeader; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx new file mode 100644 index 00000000..3d8d84be --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx @@ -0,0 +1,190 @@ +/* +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, Typography, Table, Tag, Tooltip } from '@douyinfe/semi-ui'; +import { IconCoinMoneyStroked } from '@douyinfe/semi-icons'; +import { calculateModelPrice } from '../../../../../helpers'; + +const { Text } = Typography; + +const ModelPricingTable = ({ + modelData, + selectedGroup, + groupRatio, + currency, + tokenUnit, + displayPrice, + showRatio, + usableGroup, + t, +}) => { + // 获取分组介绍 + const getGroupDescription = (groupName) => { + const descriptions = { + 'default': t('默认分组,适用于普通用户'), + 'ssvip': t('超级VIP分组,享受最优惠价格'), + 'openai官-优质': t('OpenAI官方优质分组,最快最稳,支持o1、realtime等'), + 'origin': t('企业分组,OpenAI&Claude官方原价,不升价本分组稳定性可用性'), + 'vip': t('VIP分组,享受优惠价格'), + 'premium': t('高级分组,稳定可靠'), + 'enterprise': t('企业级分组,专业服务'), + }; + return descriptions[groupName] || t('用户分组'); + }; + + const renderGroupPriceTable = () => { + const availableGroups = Object.keys(usableGroup || {}).filter(g => g !== ''); + if (availableGroups.length === 0) { + availableGroups.push('default'); + } + + // 准备表格数据 + const tableData = availableGroups.map(group => { + const priceData = modelData ? calculateModelPrice({ + record: modelData, + selectedGroup: group, + groupRatio, + tokenUnit, + displayPrice, + currency + }) : { inputPrice: '-', outputPrice: '-', price: '-' }; + + // 获取分组倍率 + const groupRatioValue = groupRatio && groupRatio[group] ? groupRatio[group] : 1; + + return { + key: group, + group: group, + description: getGroupDescription(group), + ratio: groupRatioValue, + billingType: modelData?.quota_type === 0 ? t('按量计费') : t('按次计费'), + inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-', + outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-', + fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-', + }; + }); + + // 定义表格列 + const columns = [ + { + title: t('分组'), + dataIndex: 'group', + render: (text, record) => ( + + + {text}{t('分组')} + + + ), + }, + ]; + + // 如果显示倍率,添加倍率列 + if (showRatio) { + columns.push({ + title: t('倍率'), + dataIndex: 'ratio', + render: (text) => ( + + {text}x + + ), + }); + } + + // 添加计费类型列 + columns.push({ + title: t('计费类型'), + dataIndex: 'billingType', + render: (text) => ( + + {text} + + ), + }); + + // 根据计费类型添加价格列 + if (modelData?.quota_type === 0) { + // 按量计费 + columns.push( + { + title: t('提示'), + dataIndex: 'inputPrice', + render: (text) => ( + <> +
{text}
+
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
+ + ), + }, + { + title: t('补全'), + dataIndex: 'outputPrice', + render: (text) => ( + <> +
{text}
+
/ {tokenUnit === 'K' ? '1K' : '1M'} tokens
+ + ), + } + ); + } else { + // 按次计费 + columns.push({ + title: t('价格'), + dataIndex: 'fixedPrice', + render: (text) => ( + <> +
{text}
+
/ 次
+ + ), + }); + } + + return ( +
+ ); + }; + + return ( + +
+ + + +
+ {t('分组价格')} +
{t('不同用户分组的价格信息')}
+
+
+ {renderGroupPriceTable()} +
+ ); +}; + +export default ModelPricingTable; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index e107df79..9d0fbf48 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -54,6 +54,7 @@ const PricingCardView = ({ setSelectedRowKeys, activeKey, availableCategories, + openModelDetail, }) => { const showSkeleton = useMinimumLoadingTime(loading); @@ -138,7 +139,7 @@ const PricingCardView = ({ const renderTags = (record) => { const tags = []; - // 计费类型标签 + // 计费类型标签 const billingType = record.quota_type === 1 ? 'teal' : 'violet'; const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); tags.push( @@ -211,9 +212,10 @@ const PricingCardView = ({ return ( openModelDetail && openModelDetail(model)} > {/* 头部:图标 + 模型名称 + 操作按钮 */}
@@ -235,14 +237,20 @@ const PricingCardView = ({ size="small" type="tertiary" icon={} - onClick={() => copyText(model.model_name)} + onClick={(e) => { + e.stopPropagation(); + copyText(model.model_name); + }} /> {/* 选择框 */} {rowSelection && ( handleCheckboxChange(model, e.target.checked)} + onChange={(e) => { + e.stopPropagation(); + handleCheckboxChange(model, e.target.checked); + }} /> )}
@@ -265,14 +273,18 @@ const PricingCardView = ({ {/* 倍率信息(可选) */} {showRatio && ( -
+
{t('倍率信息')} { + onClick={(e) => { + e.stopPropagation(); setModalImageUrl('/ratio.png'); setIsModalOpenurl(true); }} diff --git a/web/src/components/table/model-pricing/view/table/PricingTable.jsx b/web/src/components/table/model-pricing/view/table/PricingTable.jsx index 09d9f53e..e65b63ea 100644 --- a/web/src/components/table/model-pricing/view/table/PricingTable.jsx +++ b/web/src/components/table/model-pricing/view/table/PricingTable.jsx @@ -43,6 +43,7 @@ const PricingTable = ({ searchValue, showRatio, compactMode = false, + openModelDetail, t }) => { @@ -100,6 +101,10 @@ const PricingTable = ({ rowSelection={rowSelection} className="custom-table" scroll={compactMode ? undefined : { x: 'max-content' }} + onRow={(record) => ({ + onClick: () => openModelDetail && openModelDetail(record), + style: { cursor: 'pointer' } + })} empty={ } @@ -117,7 +122,7 @@ const PricingTable = ({ }} /> - ), [filteredModels, loading, processedColumns, rowSelection, pageSize, setPageSize, t, compactMode]); + ), [filteredModels, loading, processedColumns, rowSelection, pageSize, setPageSize, openModelDetail, t, compactMode]); return ModelTable; }; diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index 3e3c4a92..98a8e566 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -32,6 +32,8 @@ export const useModelPricingData = () => { const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [selectedGroup, setSelectedGroup] = useState('default'); + const [showModelDetail, setShowModelDetail] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,“all” 表示不过滤 const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1 const [activeKey, setActiveKey] = useState('all'); @@ -219,6 +221,16 @@ export const useModelPricingData = () => { ); }; + const openModelDetail = (model) => { + setSelectedModel(model); + setShowModelDetail(true); + }; + + const closeModelDetail = () => { + setShowModelDetail(false); + setSelectedModel(null); + }; + useEffect(() => { refresh().then(); }, []); @@ -240,6 +252,10 @@ export const useModelPricingData = () => { setIsModalOpenurl, selectedGroup, setSelectedGroup, + showModelDetail, + setShowModelDetail, + selectedModel, + setSelectedModel, filterGroup, setFilterGroup, filterQuotaType, @@ -284,6 +300,8 @@ export const useModelPricingData = () => { handleCompositionStart, handleCompositionEnd, handleGroupClick, + openModelDetail, + closeModelDetail, // 引用 compositionRef, From f6107734554825ad2b300580d32ca8ced409a735 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 26 Jul 2025 11:39:09 +0800 Subject: [PATCH 101/582] feat: enhance request handling with pass-through options and system prompt support --- dto/channel_settings.go | 8 +- dto/openai_request.go | 9 + i18n/zh-cn.json | 14 + relay/claude_handler.go | 46 +++- relay/gemini_handler.go | 40 ++- relay/image_handler.go | 44 +++- relay/relay-text.go | 26 +- relay/rerank_handler.go | 48 +++- .../channels/modals/EditChannelModal.jsx | 239 ++++++++++++++++-- web/src/i18n/locales/en.json | 13 + 10 files changed, 417 insertions(+), 70 deletions(-) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 871d6716..47f8bf95 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -1,7 +1,9 @@ package dto type ChannelSettings struct { - ForceFormat bool `json:"force_format,omitempty"` - ThinkingToContent bool `json:"thinking_to_content,omitempty"` - Proxy string `json:"proxy"` + ForceFormat bool `json:"force_format,omitempty"` + ThinkingToContent bool `json:"thinking_to_content,omitempty"` + Proxy string `json:"proxy"` + PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` } diff --git a/dto/openai_request.go b/dto/openai_request.go index a35ee6b6..b410dffe 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -73,6 +73,15 @@ func (r *GeneralOpenAIRequest) ToMap() map[string]any { return result } +func (r *GeneralOpenAIRequest) GetSystemRoleName() string { + if strings.HasPrefix(r.Model, "o") { + if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") { + return "developer" + } + } + return "system" +} + type ToolCallRequest struct { ID string `json:"id,omitempty"` Type string `json:"type"` diff --git a/i18n/zh-cn.json b/i18n/zh-cn.json index 160fc0a4..0c838c5c 100644 --- a/i18n/zh-cn.json +++ b/i18n/zh-cn.json @@ -585,6 +585,20 @@ "渠道权重": "渠道权重", "渠道额外设置": "渠道额外设置", "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:": "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:", + "强制格式化": "强制格式化", + "强制格式化(只适用于OpenAI渠道类型)": "强制格式化(只适用于OpenAI渠道类型)", + "强制将响应格式化为 OpenAI 标准格式": "强制将响应格式化为 OpenAI 标准格式", + "思考内容转换": "思考内容转换", + "将 reasoning_content 转换为 标签拼接到内容中": "将 reasoning_content 转换为 标签拼接到内容中", + "透传请求体": "透传请求体", + "启用请求体透传功能": "启用请求体透传功能", + "代理地址": "代理地址", + "例如: socks5://user:pass@host:port": "例如: socks5://user:pass@host:port", + "用于配置网络代理": "用于配置网络代理", + "用于配置网络代理,支持 socks5 协议": "用于配置网络代理,支持 socks5 协议", + "系统提示词": "系统提示词", + "输入系统提示词,用户的系统提示词将优先于此设置": "输入系统提示词,用户的系统提示词将优先于此设置", + "用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置": "用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置", "参数覆盖": "参数覆盖", "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:": "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:", "请输入组织org-xxx": "请输入组织org-xxx", diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 5f38960e..2c60a91e 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -80,7 +80,6 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) } adaptor.Init(relayInfo) - var requestBody io.Reader if textRequest.MaxTokens == 0 { textRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model)) @@ -108,18 +107,41 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { relayInfo.UpstreamModelName = textRequest.Model } - convertedRequest, err := adaptor.ConvertClaudeRequest(c, relayInfo, textRequest) - if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertClaudeRequest(c, relayInfo, textRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + // apply param override + if len(relayInfo.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + _ = common.Unmarshal(jsonData, &reqMap) + for key, value := range relayInfo.ParamOverride { + reqMap[key] = value + } + jsonData, err = common.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + } + } + + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + requestBody = bytes.NewBuffer(jsonData) } - jsonData, err := common.Marshal(convertedRequest) - if common.DebugEnabled { - println("requestBody: ", string(jsonData)) - } - if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) - } - requestBody = bytes.NewBuffer(jsonData) statusCodeMappingStr := c.GetString("status_code_mapping") var httpResp *http.Response diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index e448b491..0f1aa5bf 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "one-api/common" "one-api/dto" @@ -194,16 +195,39 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } } - requestBody, err := json.Marshal(req) - if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + } + requestBody = bytes.NewReader(body) + } else { + jsonData, err := json.Marshal(req) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + // apply param override + if len(relayInfo.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + _ = common.Unmarshal(jsonData, &reqMap) + for key, value := range relayInfo.ParamOverride { + reqMap[key] = value + } + jsonData, err = common.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + } + } + + if common.DebugEnabled { + println("Gemini request body: %s", string(jsonData)) + } + requestBody = bytes.NewReader(jsonData) } - if common.DebugEnabled { - println("Gemini request body: %s", string(requestBody)) - } - - resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody)) + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) if err != nil { common.LogError(c, "Do gemini request failed: "+err.Error()) return types.NewError(err, types.ErrorCodeDoRequestFailed) diff --git a/relay/image_handler.go b/relay/image_handler.go index 8e059863..c97eb48e 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -16,6 +16,7 @@ import ( "one-api/relay/helper" "one-api/service" "one-api/setting" + "one-api/setting/model_setting" "one-api/types" "strings" @@ -187,22 +188,43 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) { var requestBody io.Reader - convertedRequest, err := adaptor.ConvertImageRequest(c, relayInfo, *imageRequest) - if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) - } - if relayInfo.RelayMode == relayconstant.RelayModeImagesEdits { - requestBody = convertedRequest.(io.Reader) + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + } + requestBody = bytes.NewBuffer(body) } else { - jsonData, err := json.Marshal(convertedRequest) + convertedRequest, err := adaptor.ConvertImageRequest(c, relayInfo, *imageRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed) } - requestBody = bytes.NewBuffer(jsonData) - } + if relayInfo.RelayMode == relayconstant.RelayModeImagesEdits { + requestBody = convertedRequest.(io.Reader) + } else { + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } - if common.DebugEnabled { - println(fmt.Sprintf("image request body: %s", requestBody)) + // apply param override + if len(relayInfo.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + _ = common.Unmarshal(jsonData, &reqMap) + for key, value := range relayInfo.ParamOverride { + reqMap[key] = value + } + jsonData, err = common.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + } + } + + if common.DebugEnabled { + println(fmt.Sprintf("image request body: %s", string(jsonData))) + } + requestBody = bytes.NewBuffer(jsonData) + } } statusCodeMappingStr := c.GetString("status_code_mapping") diff --git a/relay/relay-text.go b/relay/relay-text.go index 60327074..bcb93a65 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -2,7 +2,6 @@ package relay import ( "bytes" - "encoding/json" "errors" "fmt" "io" @@ -171,7 +170,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { adaptor.Init(relayInfo) var requestBody io.Reader - if model_setting.GetGlobalSettings().PassThroughRequestEnabled { + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) @@ -182,7 +181,28 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed) } - jsonData, err := json.Marshal(convertedRequest) + + if relayInfo.ChannelSetting.SystemPrompt != "" { + // 如果有系统提示,则将其添加到请求中 + request := convertedRequest.(*dto.GeneralOpenAIRequest) + containSystemPrompt := false + for _, message := range request.Messages { + if message.Role == request.GetSystemRoleName() { + containSystemPrompt = true + break + } + } + if !containSystemPrompt { + // 如果没有系统提示,则添加系统提示 + systemMessage := dto.Message{ + Role: request.GetSystemRoleName(), + Content: relayInfo.ChannelSetting.SystemPrompt, + } + request.Messages = append([]dto.Message{systemMessage}, request.Messages...) + } + } + + jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed) } diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index a092de4b..0190cf08 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -3,12 +3,14 @@ package relay import ( "bytes" "fmt" + "io" "net/http" "one-api/common" "one-api/dto" relaycommon "one-api/relay/common" "one-api/relay/helper" "one-api/service" + "one-api/setting/model_setting" "one-api/types" "github.com/gin-gonic/gin" @@ -70,18 +72,42 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError } adaptor.Init(relayInfo) - convertedRequest, err := adaptor.ConvertRerankRequest(c, relayInfo.RelayMode, *rerankRequest) - if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) - } - jsonData, err := common.Marshal(convertedRequest) - if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) - } - requestBody := bytes.NewBuffer(jsonData) - if common.DebugEnabled { - println(fmt.Sprintf("Rerank request body: %s", requestBody.String())) + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertRerankRequest(c, relayInfo.RelayMode, *rerankRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + // apply param override + if len(relayInfo.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + _ = common.Unmarshal(jsonData, &reqMap) + for key, value := range relayInfo.ParamOverride { + reqMap[key] = value + } + jsonData, err = common.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + } + } + + if common.DebugEnabled { + println(fmt.Sprintf("Rerank request body: %s", string(jsonData))) + } + requestBody = bytes.NewBuffer(jsonData) } + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) if err != nil { return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index d2fd6758..f20c86d9 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -121,6 +121,12 @@ const EditChannelModal = (props) => { weight: 0, tag: '', multi_key_mode: 'random', + // 渠道额外设置的默认值 + force_format: false, + thinking_to_content: false, + proxy: '', + pass_through_body_enabled: false, + system_prompt: '', }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -142,8 +148,69 @@ const EditChannelModal = (props) => { const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false); const [channelSearchValue, setChannelSearchValue] = useState(''); const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 + // 渠道额外设置状态 + const [channelSettings, setChannelSettings] = useState({ + force_format: false, + thinking_to_content: false, + proxy: '', + pass_through_body_enabled: false, + system_prompt: '', + }); const showApiConfigCard = inputs.type !== 45; // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示) const getInitValues = () => ({ ...originInputs }); + + // 处理渠道额外设置的更新 + const handleChannelSettingsChange = (key, value) => { + // 更新内部状态 + setChannelSettings(prev => ({ ...prev, [key]: value })); + + // 同步更新到表单字段 + if (formApiRef.current) { + formApiRef.current.setValue(key, value); + } + + // 同步更新inputs状态 + setInputs(prev => ({ ...prev, [key]: value })); + + // 生成setting JSON并更新 + const newSettings = { ...channelSettings, [key]: value }; + const settingsJson = JSON.stringify(newSettings); + handleInputChange('setting', settingsJson); + }; + + // 解析渠道设置JSON为单独的状态 + const parseChannelSettings = (settingJson) => { + try { + if (settingJson && settingJson.trim()) { + const parsed = JSON.parse(settingJson); + setChannelSettings({ + force_format: parsed.force_format || false, + thinking_to_content: parsed.thinking_to_content || false, + proxy: parsed.proxy || '', + pass_through_body_enabled: parsed.pass_through_body_enabled || false, + system_prompt: parsed.system_prompt || '', + }); + } else { + setChannelSettings({ + force_format: false, + thinking_to_content: false, + proxy: '', + pass_through_body_enabled: false, + system_prompt: '', + }); + } + } catch (error) { + console.error('解析渠道设置失败:', error); + setChannelSettings({ + force_format: false, + thinking_to_content: false, + proxy: '', + pass_through_body_enabled: false, + system_prompt: '', + }); + } + }; + const handleInputChange = (name, value) => { if (formApiRef.current) { formApiRef.current.setValue(name, value); @@ -256,6 +323,30 @@ const EditChannelModal = (props) => { setBatch(false); setMultiToSingle(false); } + // 解析渠道额外设置并合并到data中 + if (data.setting) { + try { + const parsedSettings = JSON.parse(data.setting); + data.force_format = parsedSettings.force_format || false; + data.thinking_to_content = parsedSettings.thinking_to_content || false; + data.proxy = parsedSettings.proxy || ''; + data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false; + data.system_prompt = parsedSettings.system_prompt || ''; + } catch (error) { + console.error('解析渠道设置失败:', error); + data.force_format = false; + data.thinking_to_content = false; + data.proxy = ''; + data.pass_through_body_enabled = false; + } + } else { + data.force_format = false; + data.thinking_to_content = false; + data.proxy = ''; + data.pass_through_body_enabled = false; + data.system_prompt = ''; + } + setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); @@ -266,6 +357,14 @@ const EditChannelModal = (props) => { setAutoBan(true); } setBasicModels(getChannelModels(data.type)); + // 同步更新channelSettings状态显示 + setChannelSettings({ + force_format: data.force_format, + thinking_to_content: data.thinking_to_content, + proxy: data.proxy, + pass_through_body_enabled: data.pass_through_body_enabled, + system_prompt: data.system_prompt, + }); // console.log(data); } else { showError(message); @@ -446,6 +545,14 @@ const EditChannelModal = (props) => { setUseManualInput(false); } else { formApiRef.current?.reset(); + // 重置渠道设置状态 + setChannelSettings({ + force_format: false, + thinking_to_content: false, + proxy: '', + pass_through_body_enabled: false, + system_prompt: '', + }); } }, [props.visible, channelId]); @@ -579,6 +686,24 @@ const EditChannelModal = (props) => { if (localInputs.type === 18 && localInputs.other === '') { localInputs.other = 'v2.1'; } + + // 生成渠道额外设置JSON + const channelExtraSettings = { + force_format: localInputs.force_format || false, + thinking_to_content: localInputs.thinking_to_content || false, + proxy: localInputs.proxy || '', + pass_through_body_enabled: localInputs.pass_through_body_enabled || false, + system_prompt: localInputs.system_prompt || '', + }; + localInputs.setting = JSON.stringify(channelExtraSettings); + + // 清理不需要发送到后端的字段 + delete localInputs.force_format; + delete localInputs.thinking_to_content; + delete localInputs.proxy; + delete localInputs.pass_through_body_enabled; + delete localInputs.system_prompt; + let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; localInputs.models = localInputs.models.join(','); @@ -1446,33 +1571,103 @@ const EditChannelModal = (props) => { showClear /> - handleInputChange('setting', value)} - extraText={( - +
+ + {t('渠道额外设置')} + +
+ +
+
+ {t('强制格式化(只适用于OpenAI渠道类型)')} + + {t('强制将响应格式化为 OpenAI 标准格式')} + +
+ + + handleChannelSettingsChange('force_format', val)} + /> + + + + + +
+ {t('思考内容转换')} + + {t('将 reasoning_content 转换为 标签拼接到内容中')} + +
+ + + handleChannelSettingsChange('thinking_to_content', val)} + /> + + + + + +
+ {t('透传请求体')} + + {t('启用请求体透传功能')} + +
+ + + handleChannelSettingsChange('pass_through_body_enabled', val)} + /> + + + +
+ handleChannelSettingsChange('proxy', val)} + showClear + helpText={t('用于配置网络代理')} + /> +
+ +
+ handleChannelSettingsChange('system_prompt', val)} + autosize + showClear + helpText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} + /> +
+ +
handleInputChange('setting', JSON.stringify({ force_format: true }, null, 2))} - > - {t('填入模板')} - - window.open('https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md')} > {t('设置说明')} - - )} - showClear - /> +
+ + + + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5762533f..d340d825 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1330,6 +1330,19 @@ "API地址": "Base URL", "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in", "渠道额外设置": "Channel extra settings", + "强制格式化": "Force format", + "强制格式化(只适用于OpenAI渠道类型)": "Force format (Only for OpenAI channel types)", + "强制将响应格式化为 OpenAI 标准格式": "Force format responses to OpenAI standard format", + "思考内容转换": "Thinking content conversion", + "将 reasoning_content 转换为 标签拼接到内容中": "Convert reasoning_content to tags and append to content", + "透传请求体": "Pass through body", + "启用请求体透传功能": "Enable request body pass-through functionality", + "代理地址": "Proxy address", + "例如: socks5://user:pass@host:port": "e.g.: socks5://user:pass@host:port", + "用于配置网络代理,支持 socks5 协议": "Used to configure network proxy, supports socks5 protocol", + "系统提示词": "System Prompt", + "输入系统提示词,用户的系统提示词将优先于此设置": "Enter system prompt, user's system prompt will take priority over this setting", + "用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置": "User priority: If the user specifies a system prompt in the request, the user's setting will be used first", "参数覆盖": "Parameters override", "模型请求速率限制": "Model request rate limit", "启用用户模型请求速率限制(可能会影响高并发性能)": "Enable user model request rate limit (may affect high concurrency performance)", From 2cb7b9ae627df55ffa5b6fa06ab68bad999c337e Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 26 Jul 2025 12:11:20 +0800 Subject: [PATCH 102/582] fix: improve error messaging and JSON schema handling in distributor and relay components --- dto/openai_request.go | 12 ++++++------ middleware/distributor.go | 13 ++++++------- relay/channel/gemini/relay-gemini.go | 10 +++++++--- relay/relay-text.go | 3 +++ 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/dto/openai_request.go b/dto/openai_request.go index b410dffe..29076ef6 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -7,15 +7,15 @@ import ( ) type ResponseFormat struct { - Type string `json:"type,omitempty"` - JsonSchema *FormatJsonSchema `json:"json_schema,omitempty"` + Type string `json:"type,omitempty"` + JsonSchema json.RawMessage `json:"json_schema,omitempty"` } type FormatJsonSchema struct { - Description string `json:"description,omitempty"` - Name string `json:"name"` - Schema any `json:"schema,omitempty"` - Strict any `json:"strict,omitempty"` + Description string `json:"description,omitempty"` + Name string `json:"name"` + Schema any `json:"schema,omitempty"` + Strict json.RawMessage `json:"strict,omitempty"` } type GeneralOpenAIRequest struct { diff --git a/middleware/distributor.go b/middleware/distributor.go index 3c529a41..cba9b521 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -111,18 +111,17 @@ func Distribute() func(c *gin.Context) { if userGroup == "auto" { showGroup = fmt.Sprintf("auto(%s)", selectGroup) } - message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(distributor): %s", showGroup, modelRequest.Model, err.Error()) + message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(数据库一致性已被破坏,distributor): %s", showGroup, modelRequest.Model, err.Error()) // 如果错误,但是渠道不为空,说明是数据库一致性问题 - if channel != nil { - common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) - message = "数据库一致性已被破坏,请联系管理员" - } - // 如果错误,而且渠道为空,说明是没有可用渠道 + //if channel != nil { + // common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) + // message = "数据库一致性已被破坏,请联系管理员" + //} abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message) return } if channel == nil { - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,distributor)", userGroup, modelRequest.Model)) + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", userGroup, modelRequest.Model)) return } } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 6f3babeb..d19ee1ae 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -219,9 +219,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { geminiRequest.GenerationConfig.ResponseMimeType = "application/json" - if textRequest.ResponseFormat.JsonSchema != nil && textRequest.ResponseFormat.JsonSchema.Schema != nil { - cleanedSchema := removeAdditionalPropertiesWithDepth(textRequest.ResponseFormat.JsonSchema.Schema, 0) - geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema + if len(textRequest.ResponseFormat.JsonSchema) > 0 { + // 先将json.RawMessage解析 + var jsonSchema dto.FormatJsonSchema + if err := common.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil { + cleanedSchema := removeAdditionalPropertiesWithDepth(jsonSchema.Schema, 0) + geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema + } } } tool_call_ids := make(map[string]string) diff --git a/relay/relay-text.go b/relay/relay-text.go index bcb93a65..84d4e38b 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -175,6 +175,9 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) } + if common.DebugEnabled { + println("requestBody: ", string(body)) + } requestBody = bytes.NewBuffer(body) } else { convertedRequest, err := adaptor.ConvertOpenAIRequest(c, relayInfo, textRequest) From 360012bed28f99d05051e8c52d251ece090e5c6a Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 26 Jul 2025 13:31:33 +0800 Subject: [PATCH 103/582] feat: support claude convert to gemini --- model/ability.go | 2 +- model/channel_cache.go | 2 +- relay/channel/gemini/adaptor.go | 12 ++++-- relay/channel/gemini/relay-gemini.go | 61 ++++++++++++++++++++++------ relay/channel/openai/helper.go | 4 +- relay/channel/openai/relay-openai.go | 21 ++-------- relay/helper/common.go | 18 ++++++++ types/error.go | 1 + 8 files changed, 84 insertions(+), 37 deletions(-) diff --git a/model/ability.go b/model/ability.go index f36ff764..6dd8d8a6 100644 --- a/model/ability.go +++ b/model/ability.go @@ -136,7 +136,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, } } } else { - return nil, errors.New("channel not found") + return nil, nil } err = DB.First(&channel, "id = ?", channel.Id).Error return &channel, err diff --git a/model/channel_cache.go b/model/channel_cache.go index d18e9c89..45069ba0 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -130,7 +130,7 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, channels := group2model2channels[group][model] if len(channels) == 0 { - return nil, errors.New("channel not found") + return nil, nil } if len(channels) == 1 { diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 71eb9ba4..2e31ec55 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -9,6 +9,7 @@ import ( "one-api/common" "one-api/dto" "one-api/relay/channel" + "one-api/relay/channel/openai" relaycommon "one-api/relay/common" "one-api/relay/constant" "one-api/setting/model_setting" @@ -21,10 +22,13 @@ import ( type Adaptor struct { } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - panic("implement me") - return nil, nil +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, oaiReq.(*dto.GeneralOpenAIRequest)) } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index d19ee1ae..7e57bdac 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -9,6 +9,7 @@ import ( "one-api/common" "one-api/constant" "one-api/dto" + "one-api/relay/channel/openai" relaycommon "one-api/relay/common" "one-api/relay/helper" "one-api/service" @@ -736,7 +737,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C choice := dto.ChatCompletionsStreamResponseChoice{ Index: int(candidate.Index), Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ - Role: "assistant", + //Role: "assistant", }, } var texts []string @@ -798,6 +799,27 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C return &response, isStop, hasImage } +func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { + streamData, err := common.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal stream response: %w", err) + } + err = openai.HandleStreamFormat(c, info, string(streamData), info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + if err != nil { + return fmt.Errorf("failed to handle stream format: %w", err) + } + return nil +} + +func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { + streamData, err := common.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal stream response: %w", err) + } + openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, info.ShouldIncludeUsage) + return nil +} + func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { // responseText := "" id := helper.GetResponseID(c) @@ -805,6 +827,8 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * var usage = &dto.Usage{} var imageCount int + respCount := 0 + helper.StreamScannerHandler(c, resp, info, func(data string) bool { var geminiResponse GeminiChatResponse err := common.UnmarshalJsonStr(data, &geminiResponse) @@ -833,18 +857,31 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * } } } - err = helper.ObjectData(c, response) + + if respCount == 0 { + // send first response + err = handleStream(c, info, helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)) + if err != nil { + common.LogError(c, err.Error()) + } + } + + err = handleStream(c, info, response) if err != nil { common.LogError(c, err.Error()) } if isStop { - response := helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop) - helper.ObjectData(c, response) + _ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop)) } + respCount++ return true }) - var response *dto.ChatCompletionsStreamResponse + if respCount == 0 { + // 空补全,报错不计费 + // empty response, throw an error + return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) + } if imageCount != 0 { if usage.CompletionTokens == 0 { @@ -855,14 +892,14 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * usage.PromptTokensDetails.TextTokens = usage.PromptTokens usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens - if info.ShouldIncludeUsage { - response = helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) - err := helper.ObjectData(c, response) - if err != nil { - common.SysError("send final response failed: " + err.Error()) - } + response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) + err := handleFinalStream(c, info, response) + if err != nil { + common.SysError("send final response failed: " + err.Error()) } - helper.Done(c) + //if info.RelayFormat == relaycommon.RelayFormatOpenAI { + // helper.Done(c) + //} //resp.Body.Close() return usage, nil } diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 7fee505a..1681c9ff 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -14,7 +14,7 @@ import ( ) // 辅助函数 -func handleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { +func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { info.SendResponseCount++ switch info.RelayFormat { case relaycommon.RelayFormatOpenAI: @@ -158,7 +158,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int return nil } -func handleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStreamData string, +func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStreamData string, responseId string, createAt int64, model string, systemFingerprint string, usage *dto.Usage, containStreamUsage bool) { diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index d739ea19..82bd2d26 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -123,24 +123,11 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re var toolCount int var usage = &dto.Usage{} var streamItems []string // store stream items - var forceFormat bool - var thinkToContent bool - - if info.ChannelSetting.ForceFormat { - forceFormat = true - } - - if info.ChannelSetting.ThinkingToContent { - thinkToContent = true - } - - var ( - lastStreamData string - ) + var lastStreamData string helper.StreamScannerHandler(c, resp, info, func(data string) bool { if lastStreamData != "" { - err := handleStreamFormat(c, info, lastStreamData, forceFormat, thinkToContent) + err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) if err != nil { common.SysError("error handling stream format: " + err.Error()) } @@ -161,7 +148,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re if info.RelayFormat == relaycommon.RelayFormatOpenAI { if shouldSendLastResp { - _ = sendStreamData(c, info, lastStreamData, forceFormat, thinkToContent) + _ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) } } @@ -180,7 +167,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re } } } - handleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) + HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) return usage, nil } diff --git a/relay/helper/common.go b/relay/helper/common.go index 5d23b512..c8edb798 100644 --- a/relay/helper/common.go +++ b/relay/helper/common.go @@ -139,6 +139,24 @@ func GetLocalRealtimeID(c *gin.Context) string { return fmt.Sprintf("evt_%s", logID) } +func GenerateStartEmptyResponse(id string, createAt int64, model string, systemFingerprint *string) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + SystemFingerprint: systemFingerprint, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + Content: common.GetPointer(""), + }, + }, + }, + } +} + func GenerateStopResponse(id string, createAt int64, model string, finishReason string) *dto.ChatCompletionsStreamResponse { return &dto.ChatCompletionsStreamResponse{ Id: id, diff --git a/types/error.go b/types/error.go index fa07c231..c94bd001 100644 --- a/types/error.go +++ b/types/error.go @@ -63,6 +63,7 @@ const ( ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code" ErrorCodeBadResponse ErrorCode = "bad_response" ErrorCodeBadResponseBody ErrorCode = "bad_response_body" + ErrorCodeEmptyResponse ErrorCode = "empty_response" // sql error ErrorCodeQueryDataError ErrorCode = "query_data_error" From 2356c3fe667ae3d402abfd9bea85af698ade3415 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 26 Jul 2025 13:33:10 +0800 Subject: [PATCH 104/582] =?UTF-8?q?=F0=9F=8E=A8=20refactor(ui):=20redesign?= =?UTF-8?q?=20channel=20extra=20settings=20section=20in=20EditChannelModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract channel extra settings into a dedicated Card component for better visual hierarchy - Replace custom gray background container with consistent Form component styling - Simplify layout structure by removing complex Row/Col grid layout in favor of native Form component layout - Unify help text styling by using extraText prop consistently across all form fields - Move "Settings Documentation" link to card header subtitle for better accessibility - Improve visual consistency with other setting cards by using matching design patterns The channel extra settings (force format, thinking content conversion, pass-through body, proxy address, and system prompt) now follow the same design language as other configuration sections, providing a more cohesive user experience. Affected settings: - Force Format (OpenAI channels only) - Thinking Content Conversion - Pass-through Body - Proxy Address - System Prompt --- i18n/zh-cn.json | 3 +- .../channels/modals/EditChannelModal.jsx | 159 +++++++----------- web/src/i18n/locales/en.json | 3 +- 3 files changed, 66 insertions(+), 99 deletions(-) diff --git a/i18n/zh-cn.json b/i18n/zh-cn.json index 0c838c5c..dc7a1e4c 100644 --- a/i18n/zh-cn.json +++ b/i18n/zh-cn.json @@ -586,8 +586,7 @@ "渠道额外设置": "渠道额外设置", "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:": "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:", "强制格式化": "强制格式化", - "强制格式化(只适用于OpenAI渠道类型)": "强制格式化(只适用于OpenAI渠道类型)", - "强制将响应格式化为 OpenAI 标准格式": "强制将响应格式化为 OpenAI 标准格式", + "强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)": "强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)", "思考内容转换": "思考内容转换", "将 reasoning_content 转换为 标签拼接到内容中": "将 reasoning_content 转换为 标签拼接到内容中", "透传请求体": "透传请求体", diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index f20c86d9..a4c8ea76 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -158,20 +158,20 @@ const EditChannelModal = (props) => { }); const showApiConfigCard = inputs.type !== 45; // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示) const getInitValues = () => ({ ...originInputs }); - + // 处理渠道额外设置的更新 const handleChannelSettingsChange = (key, value) => { // 更新内部状态 setChannelSettings(prev => ({ ...prev, [key]: value })); - + // 同步更新到表单字段 if (formApiRef.current) { formApiRef.current.setValue(key, value); } - + // 同步更新inputs状态 setInputs(prev => ({ ...prev, [key]: value })); - + // 生成setting JSON并更新 const newSettings = { ...channelSettings, [key]: value }; const settingsJson = JSON.stringify(newSettings); @@ -686,7 +686,7 @@ const EditChannelModal = (props) => { if (localInputs.type === 18 && localInputs.other === '') { localInputs.other = 'v2.1'; } - + // 生成渠道额外设置JSON const channelExtraSettings = { force_format: localInputs.force_format || false, @@ -696,14 +696,14 @@ const EditChannelModal = (props) => { system_prompt: localInputs.system_prompt || '', }; localInputs.setting = JSON.stringify(channelExtraSettings); - + // 清理不需要发送到后端的字段 delete localInputs.force_format; delete localInputs.thinking_to_content; delete localInputs.proxy; delete localInputs.pass_through_body_enabled; delete localInputs.system_prompt; - + let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; localInputs.models = localInputs.models.join(','); @@ -1525,7 +1525,7 @@ const EditChannelModal = (props) => { label={t('是否自动禁用')} checkedText={t('开')} uncheckedText={t('关')} - onChange={(val) => setAutoBan(val)} + onChange={(value) => setAutoBan(value)} extraText={t('仅当自动禁用开启时有效,关闭后不会自动禁用该渠道')} initValue={autoBan} /> @@ -1570,95 +1570,20 @@ const EditChannelModal = (props) => { } showClear /> + -
- - {t('渠道额外设置')} - -
- -
-
- {t('强制格式化(只适用于OpenAI渠道类型)')} - - {t('强制将响应格式化为 OpenAI 标准格式')} - -
- - - handleChannelSettingsChange('force_format', val)} - /> - - - - - -
- {t('思考内容转换')} - - {t('将 reasoning_content 转换为 标签拼接到内容中')} - -
- - - handleChannelSettingsChange('thinking_to_content', val)} - /> - - - - - -
- {t('透传请求体')} - - {t('启用请求体透传功能')} - -
- - - handleChannelSettingsChange('pass_through_body_enabled', val)} - /> - - - -
- handleChannelSettingsChange('proxy', val)} - showClear - helpText={t('用于配置网络代理')} - /> -
- -
- handleChannelSettingsChange('system_prompt', val)} - autosize - showClear - helpText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} - /> -
- -
+ {/* Channel Extra Settings Card */} + + {/* Header: Channel Extra Settings */} +
+ + + +
+ {t('渠道额外设置')} +
window.open('https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md')} > {t('设置说明')} @@ -1667,7 +1592,51 @@ const EditChannelModal = (props) => {
+ handleChannelSettingsChange('force_format', value)} + extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')} + /> + handleChannelSettingsChange('thinking_to_content', value)} + extraText={t('将 reasoning_content 转换为 标签拼接到内容中')} + /> + + handleChannelSettingsChange('pass_through_body_enabled', value)} + extraText={t('启用请求体透传功能')} + /> + + handleChannelSettingsChange('proxy', value)} + showClear + extraText={t('用于配置网络代理,支持 socks5 协议')} + /> + + handleChannelSettingsChange('system_prompt', value)} + autosize + showClear + extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} + />
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d340d825..a1bf619d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1331,8 +1331,7 @@ "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in", "渠道额外设置": "Channel extra settings", "强制格式化": "Force format", - "强制格式化(只适用于OpenAI渠道类型)": "Force format (Only for OpenAI channel types)", - "强制将响应格式化为 OpenAI 标准格式": "Force format responses to OpenAI standard format", + "强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)": "Force format responses to OpenAI standard format (Only for OpenAI channel types)", "思考内容转换": "Thinking content conversion", "将 reasoning_content 转换为 标签拼接到内容中": "Convert reasoning_content to tags and append to content", "透传请求体": "Pass through body", From 94f0751d251e8b9ddc2c8463fb18b45c29de41a4 Mon Sep 17 00:00:00 2001 From: Raymond <240029725@qq.com> Date: Sat, 26 Jul 2025 17:09:38 +0800 Subject: [PATCH 105/582] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=80=9F=E7=8E=87=E9=99=90=E5=88=B6=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9=E8=AF=B7=E6=B1=82=E6=AC=A1=E6=95=B0=E6=9C=80=E5=A4=A7?= =?UTF-8?q?=E5=80=BC=E7=9A=84=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setting/rate_limit.go | 3 +++ web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/setting/rate_limit.go b/setting/rate_limit.go index 53b53f88..535fd831 100644 --- a/setting/rate_limit.go +++ b/setting/rate_limit.go @@ -58,6 +58,9 @@ func CheckModelRequestRateLimitGroup(jsonStr string) error { if limits[0] < 0 || limits[1] < 1 { return fmt.Errorf("group %s has negative rate limit values: [%d, %d]", group, limits[0], limits[1]) } + if limits[0] > 2147483647 || limits[1] > 2147483647 { + return fmt.Errorf("group %s [%d, %d] has max rate limits value 2147483647", group, limits[0], limits[1]) + } } return nil diff --git a/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js b/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js index 85473ec9..d5b1c637 100644 --- a/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js +++ b/web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js @@ -128,6 +128,7 @@ export default function RequestRateLimit(props) { label={t('用户每周期最多请求次数')} step={1} min={0} + max={2147483647} suffix={t('次')} extraText={t('包括失败请求的次数,0代表不限制')} field={'ModelRequestRateLimitCount'} @@ -144,6 +145,7 @@ export default function RequestRateLimit(props) { label={t('用户每周期最多请求完成次数')} step={1} min={1} + max={2147483647} suffix={t('次')} extraText={t('只包括请求成功的次数')} field={'ModelRequestRateLimitSuccessCount'} @@ -180,6 +182,7 @@ export default function RequestRateLimit(props) {
  • {t('使用 JSON 对象格式,格式为:{"组名": [最多请求次数, 最多请求完成次数]}')}
  • {t('示例:{"default": [200, 100], "vip": [0, 1000]}。')}
  • {t('[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。')}
  • +
  • {t('[最多请求次数]和[最多请求完成次数]的最大值为2147483647。')}
  • {t('分组速率配置优先级高于全局速率限制。')}
  • {t('限制周期统一使用上方配置的“限制周期”值。')}
  • From bbc3ae6e7b41a5c2834ce678ef9ff18580abe87c Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 26 Jul 2025 17:18:47 +0800 Subject: [PATCH 106/582] =?UTF-8?q?=F0=9F=92=84=20style(ui):=20show=20"For?= =?UTF-8?q?ce=20Format"=20toggle=20only=20for=20OpenAI=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the "Force Format" switch was displayed for every channel type although it only applies to OpenAI (type === 1). This change wraps the switch in a conditional so it renders exclusively when the selected channel type is OpenAI. Why: - Prevents user confusion when configuring non-OpenAI channels - Keeps the UI clean and context-relevant Scope: - web/src/components/table/channels/modals/EditChannelModal.jsx No backend logic affected. --- .../table/channels/modals/EditChannelModal.jsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index a4c8ea76..248307c4 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -1592,14 +1592,16 @@ const EditChannelModal = (props) => {
    - handleChannelSettingsChange('force_format', value)} - extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')} - /> + {inputs.type === 1 && ( + handleChannelSettingsChange('force_format', value)} + extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')} + /> + )} Date: Sat, 26 Jul 2025 18:06:46 +0800 Subject: [PATCH 107/582] feat: add claude code channel --- common/api_type.go | 2 + constant/api_type.go | 1 + constant/channel.go | 2 + controller/claude_oauth.go | 73 +++++ go.mod | 1 + go.sum | 2 + main.go | 3 + relay/channel/claude_code/adaptor.go | 158 +++++++++++ relay/channel/claude_code/constants.go | 15 + relay/channel/claude_code/dto.go | 4 + relay/relay_adaptor.go | 3 + router/api-router.go | 3 + service/claude_oauth.go | 164 +++++++++++ service/claude_token_refresh.go | 94 +++++++ .../channels/modals/EditChannelModal.jsx | 259 ++++++++++++++++-- web/src/constants/channel.constants.js | 8 + web/src/helpers/render.js | 1 + 17 files changed, 766 insertions(+), 27 deletions(-) create mode 100644 controller/claude_oauth.go create mode 100644 relay/channel/claude_code/adaptor.go create mode 100644 relay/channel/claude_code/constants.go create mode 100644 relay/channel/claude_code/dto.go create mode 100644 service/claude_oauth.go create mode 100644 service/claude_token_refresh.go diff --git a/common/api_type.go b/common/api_type.go index f045866a..c31f2e2c 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeCoze case constant.ChannelTypeJimeng: apiType = constant.APITypeJimeng + case constant.ChannelTypeClaudeCode: + apiType = constant.APITypeClaudeCode } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/api_type.go b/constant/api_type.go index 6ba5f257..bca5e311 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -31,5 +31,6 @@ const ( APITypeXai APITypeCoze APITypeJimeng + APITypeClaudeCode APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 2e1cc5b0..cc71caf3 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -50,6 +50,7 @@ const ( ChannelTypeKling = 50 ChannelTypeJimeng = 51 ChannelTypeVidu = 52 + ChannelTypeClaudeCode = 53 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -108,4 +109,5 @@ var ChannelBaseURLs = []string{ "https://api.klingai.com", //50 "https://visual.volcengineapi.com", //51 "https://api.vidu.cn", //52 + "https://api.anthropic.com", //53 } diff --git a/controller/claude_oauth.go b/controller/claude_oauth.go new file mode 100644 index 00000000..de711b93 --- /dev/null +++ b/controller/claude_oauth.go @@ -0,0 +1,73 @@ +package controller + +import ( + "net/http" + "one-api/common" + "one-api/service" + + "github.com/gin-gonic/gin" +) + +// ExchangeCodeRequest 授权码交换请求 +type ExchangeCodeRequest struct { + AuthorizationCode string `json:"authorization_code" binding:"required"` + CodeVerifier string `json:"code_verifier" binding:"required"` + State string `json:"state" binding:"required"` +} + +// GenerateClaudeOAuthURL 生成Claude OAuth授权URL +func GenerateClaudeOAuthURL(c *gin.Context) { + params, err := service.GenerateOAuthParams() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "生成OAuth授权URL失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "生成OAuth授权URL成功", + "data": params, + }) +} + +// ExchangeClaudeOAuthCode 交换Claude OAuth授权码 +func ExchangeClaudeOAuthCode(c *gin.Context) { + var req ExchangeCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "请求参数错误: " + err.Error(), + }) + return + } + + // 解析授权码 + cleanedCode, err := service.ParseAuthorizationCode(req.AuthorizationCode) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // 交换token + tokenResult, err := service.ExchangeCode(cleanedCode, req.CodeVerifier, req.State, nil) + if err != nil { + common.SysError("Claude OAuth token exchange failed: " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "授权码交换失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "授权码交换成功", + "data": tokenResult, + }) +} diff --git a/go.mod b/go.mod index 94873c88..bae7a4e8 100644 --- a/go.mod +++ b/go.mod @@ -87,6 +87,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 74eecd4c..8ded1a03 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/main.go b/main.go index ca3da601..f49995c2 100644 --- a/main.go +++ b/main.go @@ -86,6 +86,9 @@ func main() { // 数据看板 go model.UpdateQuotaData() + // Start Claude Code token refresh scheduler + service.StartClaudeTokenRefreshScheduler() + if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) if err != nil { diff --git a/relay/channel/claude_code/adaptor.go b/relay/channel/claude_code/adaptor.go new file mode 100644 index 00000000..7a0be927 --- /dev/null +++ b/relay/channel/claude_code/adaptor.go @@ -0,0 +1,158 @@ +package claude_code + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/claude" + relaycommon "one-api/relay/common" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + RequestModeCompletion = 1 + RequestModeMessage = 2 + DefaultSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." +) + +type Adaptor struct { + RequestMode int +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + // Use configured system prompt if available, otherwise use default + if info.ChannelSetting.SystemPrompt != "" { + request.System = info.ChannelSetting.SystemPrompt + } else { + request.System = DefaultSystemPrompt + } + + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + if strings.HasPrefix(info.UpstreamModelName, "claude-2") || strings.HasPrefix(info.UpstreamModelName, "claude-instant") { + a.RequestMode = RequestModeCompletion + } else { + a.RequestMode = RequestModeMessage + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if a.RequestMode == RequestModeMessage { + return fmt.Sprintf("%s/v1/messages", info.BaseUrl), nil + } else { + return fmt.Sprintf("%s/v1/complete", info.BaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + + // Parse accesstoken|refreshtoken format and use only the access token + accessToken := info.ApiKey + if strings.Contains(info.ApiKey, "|") { + parts := strings.Split(info.ApiKey, "|") + if len(parts) >= 1 { + accessToken = parts[0] + } + } + + // Claude Code specific headers - force override + req.Set("Authorization", "Bearer "+accessToken) + // 只有在没有设置的情况下才设置 anthropic-version + if req.Get("anthropic-version") == "" { + req.Set("anthropic-version", "2023-06-01") + } + req.Set("content-type", "application/json") + + // 只有在 user-agent 不包含 claude-cli 时才设置 + userAgent := req.Get("user-agent") + if userAgent == "" || !strings.Contains(strings.ToLower(userAgent), "claude-cli") { + req.Set("user-agent", "claude-cli/1.0.61 (external, cli)") + } + + // 只有在 anthropic-beta 不包含 claude-code 时才设置 + anthropicBeta := req.Get("anthropic-beta") + if anthropicBeta == "" || !strings.Contains(strings.ToLower(anthropicBeta), "claude-code") { + req.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14") + } + // if Anthropic-Dangerous-Direct-Browser-Access + anthropicDangerousDirectBrowserAccess := req.Get("anthropic-dangerous-direct-browser-access") + if anthropicDangerousDirectBrowserAccess == "" { + req.Set("anthropic-dangerous-direct-browser-access", "true") + } + + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + if a.RequestMode == RequestModeCompletion { + return claude.RequestOpenAI2ClaudeComplete(*request), nil + } else { + claudeRequest, err := claude.RequestOpenAI2ClaudeMessage(*request) + if err != nil { + return nil, err + } + + // Use configured system prompt if available, otherwise use default + if info.ChannelSetting.SystemPrompt != "" { + claudeRequest.System = info.ChannelSetting.SystemPrompt + } else { + claudeRequest.System = DefaultSystemPrompt + } + + return claudeRequest, nil + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + err, usage = claude.ClaudeStreamHandler(c, resp, info, a.RequestMode) + } else { + err, usage = claude.ClaudeHandler(c, resp, a.RequestMode, info) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/claude_code/constants.go b/relay/channel/claude_code/constants.go new file mode 100644 index 00000000..7c28e48d --- /dev/null +++ b/relay/channel/claude_code/constants.go @@ -0,0 +1,15 @@ +package claude_code + +var ModelList = []string{ + "claude-3-5-haiku-20241022", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-20241022", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-20250219-thinking", + "claude-sonnet-4-20250514", + "claude-sonnet-4-20250514-thinking", + "claude-opus-4-20250514", + "claude-opus-4-20250514-thinking", +} + +var ChannelName = "claude_code" diff --git a/relay/channel/claude_code/dto.go b/relay/channel/claude_code/dto.go new file mode 100644 index 00000000..68bb9269 --- /dev/null +++ b/relay/channel/claude_code/dto.go @@ -0,0 +1,4 @@ +package claude_code + +// Claude Code uses the same DTO structures as Claude since it's based on the same API +// This file is kept for consistency with the channel structure pattern \ No newline at end of file diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index cc9c5bbb..2456c77f 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -9,6 +9,7 @@ import ( "one-api/relay/channel/baidu" "one-api/relay/channel/baidu_v2" "one-api/relay/channel/claude" + "one-api/relay/channel/claude_code" "one-api/relay/channel/cloudflare" "one-api/relay/channel/cohere" "one-api/relay/channel/coze" @@ -98,6 +99,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &coze.Adaptor{} case constant.APITypeJimeng: return &jimeng.Adaptor{} + case constant.APITypeClaudeCode: + return &claude_code.Adaptor{} } return nil } diff --git a/router/api-router.go b/router/api-router.go index bc49803a..702fc99f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -120,6 +120,9 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.GET("/tag/models", controller.GetTagModels) channelRoute.POST("/copy/:id", controller.CopyChannel) + // Claude OAuth路由 + channelRoute.GET("/claude/oauth/url", controller.GenerateClaudeOAuthURL) + channelRoute.POST("/claude/oauth/exchange", controller.ExchangeClaudeOAuthCode) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) diff --git a/service/claude_oauth.go b/service/claude_oauth.go new file mode 100644 index 00000000..136269ae --- /dev/null +++ b/service/claude_oauth.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/oauth2" +) + +const ( + // Default OAuth configuration values + DefaultAuthorizeURL = "https://claude.ai/oauth/authorize" + DefaultTokenURL = "https://console.anthropic.com/v1/oauth/token" + DefaultClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + DefaultRedirectURI = "https://console.anthropic.com/oauth/code/callback" + DefaultScopes = "user:inference" +) + +// getOAuthValues returns OAuth configuration values from environment variables or defaults +func getOAuthValues() (authorizeURL, tokenURL, clientID, redirectURI, scopes string) { + authorizeURL = os.Getenv("CLAUDE_AUTHORIZE_URL") + if authorizeURL == "" { + authorizeURL = DefaultAuthorizeURL + } + + tokenURL = os.Getenv("CLAUDE_TOKEN_URL") + if tokenURL == "" { + tokenURL = DefaultTokenURL + } + + clientID = os.Getenv("CLAUDE_CLIENT_ID") + if clientID == "" { + clientID = DefaultClientID + } + + redirectURI = os.Getenv("CLAUDE_REDIRECT_URI") + if redirectURI == "" { + redirectURI = DefaultRedirectURI + } + + scopes = os.Getenv("CLAUDE_SCOPES") + if scopes == "" { + scopes = DefaultScopes + } + + return +} + +type OAuth2Credentials struct { + AuthURL string `json:"auth_url"` + CodeVerifier string `json:"code_verifier"` + State string `json:"state"` + CodeChallenge string `json:"code_challenge"` +} + +// GetClaudeOAuthConfig returns the Claude OAuth2 configuration +func GetClaudeOAuthConfig() *oauth2.Config { + authorizeURL, tokenURL, clientID, redirectURI, scopes := getOAuthValues() + + return &oauth2.Config{ + ClientID: clientID, + RedirectURL: redirectURI, + Scopes: strings.Split(scopes, " "), + Endpoint: oauth2.Endpoint{ + AuthURL: authorizeURL, + TokenURL: tokenURL, + }, + } +} + +// getOAuthConfig is kept for backward compatibility +func getOAuthConfig() *oauth2.Config { + return GetClaudeOAuthConfig() +} + +// GenerateOAuthParams generates OAuth authorization URL and related parameters +func GenerateOAuthParams() (*OAuth2Credentials, error) { + config := getOAuthConfig() + + // Generate PKCE parameters + codeVerifier := oauth2.GenerateVerifier() + state := oauth2.GenerateVerifier() // Reuse generator as state + + // Generate authorization URL + authURL := config.AuthCodeURL(state, + oauth2.S256ChallengeOption(codeVerifier), + oauth2.SetAuthURLParam("code", "true"), // Claude-specific parameter + ) + + return &OAuth2Credentials{ + AuthURL: authURL, + CodeVerifier: codeVerifier, + State: state, + CodeChallenge: oauth2.S256ChallengeFromVerifier(codeVerifier), + }, nil +} + +// ExchangeCode +func ExchangeCode(authorizationCode, codeVerifier, state string, client *http.Client) (*oauth2.Token, error) { + config := getOAuthConfig() + + ctx := context.Background() + if client != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) + } + + token, err := config.Exchange(ctx, authorizationCode, + oauth2.VerifierOption(codeVerifier), + oauth2.SetAuthURLParam("state", state), + ) + if err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + return token, nil +} + +func ParseAuthorizationCode(input string) (string, error) { + if input == "" { + return "", fmt.Errorf("please provide a valid authorization code") + } + // URLs are not allowed + if strings.Contains(input, "http") || strings.Contains(input, "https") { + return "", fmt.Errorf("authorization code cannot contain URLs") + } + + return input, nil +} + +// GetClaudeHTTPClient returns a configured HTTP client for Claude OAuth operations +func GetClaudeHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + } +} + +// RefreshClaudeToken refreshes a Claude OAuth token using the refresh token +func RefreshClaudeToken(accessToken, refreshToken string) (*oauth2.Token, error) { + config := GetClaudeOAuthConfig() + + // Create token from current values + currentToken := &oauth2.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + TokenType: "Bearer", + } + + ctx := context.Background() + if client := GetClaudeHTTPClient(); client != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) + } + + // Refresh the token + newToken, err := config.TokenSource(ctx, currentToken).Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh Claude token: %w", err) + } + + return newToken, nil +} diff --git a/service/claude_token_refresh.go b/service/claude_token_refresh.go new file mode 100644 index 00000000..5dc35367 --- /dev/null +++ b/service/claude_token_refresh.go @@ -0,0 +1,94 @@ +package service + +import ( + "fmt" + "one-api/common" + "one-api/constant" + "one-api/model" + "strings" + "time" + + "github.com/bytedance/gopkg/util/gopool" +) + +// StartClaudeTokenRefreshScheduler starts the scheduled token refresh for Claude Code channels +func StartClaudeTokenRefreshScheduler() { + ticker := time.NewTicker(5 * time.Minute) + gopool.Go(func() { + defer ticker.Stop() + for range ticker.C { + RefreshClaudeCodeTokens() + } + }) + common.SysLog("Claude Code token refresh scheduler started (5 minute interval)") +} + +// RefreshClaudeCodeTokens refreshes tokens for all active Claude Code channels +func RefreshClaudeCodeTokens() { + var channels []model.Channel + + // Get all active Claude Code channels + err := model.DB.Where("type = ? AND status = ?", constant.ChannelTypeClaudeCode, common.ChannelStatusEnabled).Find(&channels).Error + if err != nil { + common.SysError("Failed to get Claude Code channels: " + err.Error()) + return + } + + refreshCount := 0 + for _, channel := range channels { + if refreshTokenForChannel(&channel) { + refreshCount++ + } + } + + if refreshCount > 0 { + common.SysLog(fmt.Sprintf("Successfully refreshed %d Claude Code channel tokens", refreshCount)) + } +} + +// refreshTokenForChannel attempts to refresh token for a single channel +func refreshTokenForChannel(channel *model.Channel) bool { + // Parse key in format: accesstoken|refreshtoken + if channel.Key == "" || !strings.Contains(channel.Key, "|") { + common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id)) + return false + } + + parts := strings.Split(channel.Key, "|") + if len(parts) < 2 { + common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id)) + return false + } + + accessToken := parts[0] + refreshToken := parts[1] + + if refreshToken == "" { + common.SysError(fmt.Sprintf("Channel %d has empty refresh token", channel.Id)) + return false + } + + // Check if token needs refresh (refresh 30 minutes before expiry) + // if !shouldRefreshToken(accessToken) { + // return false + // } + + // Use shared refresh function + newToken, err := RefreshClaudeToken(accessToken, refreshToken) + if err != nil { + common.SysError(fmt.Sprintf("Failed to refresh token for channel %d: %s", channel.Id, err.Error())) + return false + } + + // Update channel with new tokens + newKey := fmt.Sprintf("%s|%s", newToken.AccessToken, newToken.RefreshToken) + + err = model.DB.Model(channel).Update("key", newKey).Error + if err != nil { + common.SysError(fmt.Sprintf("Failed to update channel %d with new token: %s", channel.Id, err.Error())) + return false + } + + common.SysLog(fmt.Sprintf("Successfully refreshed token for Claude Code channel %d (%s)", channel.Id, channel.Name)) + return true +} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index a4c8ea76..f037e5a0 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -17,8 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState, useRef, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { API, showError, @@ -26,36 +24,40 @@ import { showSuccess, verifyJSON, } from '../../../../helpers'; -import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; -import { CHANNEL_OPTIONS } from '../../../../constants'; import { + Avatar, + Banner, + Button, + Card, + Checkbox, + Col, + Form, + Highlight, + ImagePreview, + Input, + Modal, + Row, SideSheet, Space, Spin, - Button, - Typography, - Checkbox, - Banner, - Modal, - ImagePreview, - Card, Tag, - Avatar, - Form, - Row, - Col, - Highlight, + Typography, } from '@douyinfe/semi-ui'; -import { getChannelModels, copy, getChannelIcon, getModelCategories, modelSelectFilter } from '../../../../helpers'; +import { CHANNEL_OPTIONS, CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT } from '../../../../constants'; import { - IconSave, + IconBolt, IconClose, - IconServer, - IconSetting, IconCode, IconGlobe, - IconBolt, + IconSave, + IconServer, + IconSetting, } from '@douyinfe/semi-icons'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { copy, getChannelIcon, getChannelModels, getModelCategories, modelSelectFilter } from '../../../../helpers'; + +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; +import { useTranslation } from 'react-i18next'; const { Text, Title } = Typography; @@ -89,6 +91,8 @@ function type2secretPrompt(type) { return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey'; case 51: return '按照如下格式输入: Access Key ID|Secret Access Key'; + case 53: + return '按照如下格式输入:AccessToken|RefreshToken'; default: return '请输入渠道对应的鉴权密钥'; } @@ -141,6 +145,10 @@ const EditChannelModal = (props) => { const [customModel, setCustomModel] = useState(''); const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); + const [showOAuthModal, setShowOAuthModal] = useState(false); + const [authorizationCode, setAuthorizationCode] = useState(''); + const [oauthParams, setOauthParams] = useState(null); + const [isExchangingCode, setIsExchangingCode] = useState(false); const formApiRef = useRef(null); const [vertexKeys, setVertexKeys] = useState([]); const [vertexFileList, setVertexFileList] = useState([]); @@ -347,6 +355,24 @@ const EditChannelModal = (props) => { data.system_prompt = ''; } + // 特殊处理Claude Code渠道的密钥拆分和系统提示词 + if (data.type === 53) { + // 拆分密钥 + if (data.key) { + const keyParts = data.key.split('|'); + if (keyParts.length === 2) { + data.access_token = keyParts[0]; + data.refresh_token = keyParts[1]; + } else { + // 如果没有 | 分隔符,表示只有access token + data.access_token = data.key; + data.refresh_token = ''; + } + } + // 强制设置固定系统提示词 + data.system_prompt = CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT; + } + setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); @@ -469,6 +495,72 @@ const EditChannelModal = (props) => { } }; + // 生成OAuth授权URL + const handleGenerateOAuth = async () => { + try { + setLoading(true); + const res = await API.get('/api/channel/claude/oauth/url'); + if (res.data.success) { + setOauthParams(res.data.data); + setShowOAuthModal(true); + showSuccess(t('OAuth授权URL生成成功')); + } else { + showError(res.data.message || t('生成OAuth授权URL失败')); + } + } catch (error) { + showError(t('生成OAuth授权URL失败:') + error.message); + } finally { + setLoading(false); + } + }; + + // 交换授权码 + const handleExchangeCode = async () => { + if (!authorizationCode.trim()) { + showError(t('请输入授权码')); + return; + } + + if (!oauthParams) { + showError(t('OAuth参数丢失,请重新生成')); + return; + } + + try { + setIsExchangingCode(true); + const res = await API.post('/api/channel/claude/oauth/exchange', { + authorization_code: authorizationCode, + code_verifier: oauthParams.code_verifier, + state: oauthParams.state, + }); + + if (res.data.success) { + const tokenData = res.data.data; + // 自动填充access token和refresh token + handleInputChange('access_token', tokenData.access_token); + handleInputChange('refresh_token', tokenData.refresh_token); + handleInputChange('key', `${tokenData.access_token}|${tokenData.refresh_token}`); + + // 更新表单字段 + if (formApiRef.current) { + formApiRef.current.setValue('access_token', tokenData.access_token); + formApiRef.current.setValue('refresh_token', tokenData.refresh_token); + } + + setShowOAuthModal(false); + setAuthorizationCode(''); + setOauthParams(null); + showSuccess(t('授权码交换成功,已自动填充tokens')); + } else { + showError(res.data.message || t('授权码交换失败')); + } + } catch (error) { + showError(t('授权码交换失败:') + error.message); + } finally { + setIsExchangingCode(false); + } + }; + useEffect(() => { const modelMap = new Map(); @@ -781,7 +873,7 @@ const EditChannelModal = (props) => { const batchExtra = batchAllowed ? ( { const checked = e.target.checked; @@ -1117,6 +1209,49 @@ const EditChannelModal = (props) => { /> )} + ) : inputs.type === 53 ? ( + <> + { + handleInputChange('access_token', value); + // 同时更新key字段,格式为access_token|refresh_token + const refreshToken = inputs.refresh_token || ''; + handleInputChange('key', `${value}|${refreshToken}`); + }} + suffix={ + + } + extraText={batchExtra} + showClear + /> + { + handleInputChange('refresh_token', value); + // 同时更新key字段,格式为access_token|refresh_token + const accessToken = inputs.access_token || ''; + handleInputChange('key', `${accessToken}|${value}`); + }} + extraText={batchExtra} + showClear + /> + ) : ( { handleChannelSettingsChange('system_prompt', value)} + placeholder={inputs.type === 53 ? CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT : t('输入系统提示词,用户的系统提示词将优先于此设置')} + onChange={(value) => { + if (inputs.type === 53) { + // Claude Code渠道系统提示词固定,不允许修改 + return; + } + handleChannelSettingsChange('system_prompt', value); + }} + disabled={inputs.type === 53} + value={inputs.type === 53 ? CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT : undefined} autosize - showClear - extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} + showClear={inputs.type !== 53} + extraText={inputs.type === 53 ? t('Claude Code渠道系统提示词固定为官方CLI身份,不可修改') : t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> @@ -1648,8 +1791,70 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> + + {/* OAuth Authorization Modal */} + { + setShowOAuthModal(false); + setAuthorizationCode(''); + setOauthParams(null); + }} + onOk={handleExchangeCode} + okText={isExchangingCode ? t('交换中...') : t('确认')} + cancelText={t('取消')} + confirmLoading={isExchangingCode} + width={600} + > +
    +
    + {t('请访问以下授权地址:')} +
    + { + if (oauthParams?.auth_url) { + window.open(oauthParams.auth_url, '_blank'); + } + }} + > + {oauthParams?.auth_url || t('正在生成授权地址...')} + +
    + + {t('复制链接')} + +
    +
    +
    + +
    + {t('授权后,请将获得的授权码粘贴到下方:')} + +
    + + +
    +
    ); }; -export default EditChannelModal; \ No newline at end of file +export default EditChannelModal; \ No newline at end of file diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 43372a25..6035548e 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -159,6 +159,14 @@ export const CHANNEL_OPTIONS = [ color: 'purple', label: 'Vidu', }, + { + value: 53, + color: 'indigo', + label: 'Claude Code', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; + +// Claude Code 相关常量 +export const CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index bd0a8131..3bdf7c76 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -358,6 +358,7 @@ export function getChannelIcon(channelType) { return ; case 14: // Anthropic Claude case 33: // AWS Claude + case 53: // Claude Code return ; case 41: // Vertex AI return ; From 017f32b978cb51afc41bf990f250a96062080cbf Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sat, 26 Jul 2025 18:38:18 +0800 Subject: [PATCH 108/582] =?UTF-8?q?=E2=9C=A8=20refactor:=20pricing=20filte?= =?UTF-8?q?rs=20for=20dynamic=20counting=20&=20cleaner=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a unified, maintainable solution for all model-pricing filter buttons and removes redundant code. Key points • Added `usePricingFilterCounts` hook - Centralises filtering logic and returns: - `quotaTypeModels`, `endpointTypeModels`, `dynamicCategoryCounts`, `groupCountModels` - Keeps internal helpers private (removed public `modelsAfterCategory`). • Updated components to consume the new hook - `PricingSidebar.jsx` - `FilterModalContent.jsx` • Improved button UI/UX - `SelectableButtonGroup.jsx` now respects `item.disabled` and auto-disables when `tagCount === 0`. - `PricingGroups.jsx` counts models per group (after all other filters) and disables groups with zero matches. - `PricingEndpointTypes.jsx` enumerates all endpoint types, computes filtered counts, and disables entries with zero matches. • Removed obsolete / duplicate calculations and comments to keep components lean. The result is consistent, real-time tag counts across all filter groups, automatic disabling of unavailable options, and a single source of truth for filter computations, making future extensions straightforward. --- .../common/ui/SelectableButtonGroup.jsx | 4 + .../filter/PricingEndpointTypes.jsx | 22 +-- .../model-pricing/filter/PricingGroups.jsx | 4 + .../model-pricing/layout/PricingSidebar.jsx | 24 +++- .../modal/components/FilterModalContent.jsx | 31 ++++- .../model-pricing/usePricingFilterCounts.js | 131 ++++++++++++++++++ 6 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 web/src/hooks/model-pricing/usePricingFilterCounts.js diff --git a/web/src/components/common/ui/SelectableButtonGroup.jsx b/web/src/components/common/ui/SelectableButtonGroup.jsx index 6792c5aa..591634a5 100644 --- a/web/src/components/common/ui/SelectableButtonGroup.jsx +++ b/web/src/components/common/ui/SelectableButtonGroup.jsx @@ -133,6 +133,7 @@ const SelectableButtonGroup = ({ const contentElement = showSkeleton ? renderSkeletonButtons() : ( {items.map((item) => { + const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0); const isActive = Array.isArray(activeValue) ? activeValue.includes(item.value) : activeValue === item.value; @@ -150,10 +151,12 @@ const SelectableButtonGroup = ({ onClick={() => { /* disabled */ }} theme={isActive ? 'light' : 'outline'} type={isActive ? 'primary' : 'tertiary'} + disabled={isDisabled} icon={ onChange(item.value)} + disabled={isDisabled} style={{ pointerEvents: 'auto' }} /> } @@ -190,6 +193,7 @@ const SelectableButtonGroup = ({ theme={isActive ? 'light' : 'outline'} type={isActive ? 'primary' : 'tertiary'} icon={item.icon} + disabled={isDisabled} style={{ width: '100%' }} > {item.label} diff --git a/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx b/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx index c60f0ef2..c4258f67 100644 --- a/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx +++ b/web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx @@ -28,11 +28,11 @@ import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; * @param {boolean} loading 是否加载中 * @param {Function} t i18n */ -const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], loading = false, t }) => { - // 获取所有可用的端点类型 +const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], allModels = [], loading = false, t }) => { + // 获取系统中所有端点类型(基于 allModels,如果未提供则退化为 models) const getAllEndpointTypes = () => { const endpointTypes = new Set(); - models.forEach(model => { + (allModels.length > 0 ? allModels : models).forEach(model => { if (model.supported_endpoint_types && Array.isArray(model.supported_endpoint_types)) { model.supported_endpoint_types.forEach(endpoint => { endpointTypes.add(endpoint); @@ -61,12 +61,16 @@ const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, model const availableEndpointTypes = getAllEndpointTypes(); const items = [ - { value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all') }, - ...availableEndpointTypes.map(endpointType => ({ - value: endpointType, - label: getEndpointTypeLabel(endpointType), - tagCount: getEndpointTypeCount(endpointType) - })) + { value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all'), disabled: models.length === 0 }, + ...availableEndpointTypes.map(endpointType => { + const count = getEndpointTypeCount(endpointType); + return ({ + value: endpointType, + label: getEndpointTypeLabel(endpointType), + tagCount: count, + disabled: count === 0 + }); + }) ]; return ( diff --git a/web/src/components/table/model-pricing/filter/PricingGroups.jsx b/web/src/components/table/model-pricing/filter/PricingGroups.jsx index e389bd12..432d23ab 100644 --- a/web/src/components/table/model-pricing/filter/PricingGroups.jsx +++ b/web/src/components/table/model-pricing/filter/PricingGroups.jsx @@ -34,6 +34,9 @@ const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRat const groups = ['all', ...Object.keys(usableGroup).filter(key => key !== '')]; const items = groups.map((g) => { + const modelCount = g === 'all' + ? models.length + : models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length; let ratioDisplay = ''; if (g === 'all') { ratioDisplay = t('全部'); @@ -49,6 +52,7 @@ const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRat value: g, label: g === 'all' ? t('全部分组') : g, tagCount: ratioDisplay, + disabled: modelCount === 0 }; }); diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index a3e275c6..d6b5df79 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -25,6 +25,7 @@ import PricingQuotaTypes from '../filter/PricingQuotaTypes'; import PricingEndpointTypes from '../filter/PricingEndpointTypes'; import PricingDisplaySettings from '../filter/PricingDisplaySettings'; import { resetPricingFilters } from '../../../../helpers/utils'; +import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts'; const PricingSidebar = ({ showWithRecharge, @@ -52,6 +53,21 @@ const PricingSidebar = ({ ...categoryProps }) => { + const { + quotaTypeModels, + endpointTypeModels, + dynamicCategoryCounts, + groupCountModels, + } = usePricingFilterCounts({ + models: categoryProps.models, + modelCategories: categoryProps.modelCategories, + activeKey: categoryProps.activeKey, + filterGroup, + filterQuotaType, + filterEndpointType, + searchValue: categoryProps.searchValue, + }); + const handleResetFilters = () => resetPricingFilters({ handleChange, @@ -101,6 +117,7 @@ const PricingSidebar = ({ @@ -119,7 +136,7 @@ const PricingSidebar = ({ @@ -127,7 +144,8 @@ const PricingSidebar = ({ diff --git a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx index aa9646fe..e9f3178e 100644 --- a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx +++ b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx @@ -23,6 +23,7 @@ import PricingCategories from '../../filter/PricingCategories'; import PricingGroups from '../../filter/PricingGroups'; import PricingQuotaTypes from '../../filter/PricingQuotaTypes'; import PricingEndpointTypes from '../../filter/PricingEndpointTypes'; +import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts'; const FilterModalContent = ({ sidebarProps, t }) => { const { @@ -48,6 +49,21 @@ const FilterModalContent = ({ sidebarProps, t }) => { ...categoryProps } = sidebarProps; + const { + quotaTypeModels, + endpointTypeModels, + dynamicCategoryCounts, + groupCountModels, + } = usePricingFilterCounts({ + models: categoryProps.models, + modelCategories: categoryProps.modelCategories, + activeKey: categoryProps.activeKey, + filterGroup, + filterQuotaType, + filterEndpointType, + searchValue: sidebarProps.searchValue, + }); + return (
    { t={t} /> - + @@ -80,7 +102,7 @@ const FilterModalContent = ({ sidebarProps, t }) => { @@ -88,7 +110,8 @@ const FilterModalContent = ({ sidebarProps, t }) => { diff --git a/web/src/hooks/model-pricing/usePricingFilterCounts.js b/web/src/hooks/model-pricing/usePricingFilterCounts.js new file mode 100644 index 00000000..e23111f3 --- /dev/null +++ b/web/src/hooks/model-pricing/usePricingFilterCounts.js @@ -0,0 +1,131 @@ +/* +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 { useMemo } from 'react'; + +export const usePricingFilterCounts = ({ + models = [], + modelCategories = {}, + activeKey = 'all', + filterGroup = 'all', + filterQuotaType = 'all', + filterEndpointType = 'all', + searchValue = '', +}) => { + // 根据分类过滤后的模型 + const modelsAfterCategory = useMemo(() => { + if (activeKey === 'all') return models; + const category = modelCategories[activeKey]; + if (category && typeof category.filter === 'function') { + return models.filter(category.filter); + } + return models; + }, [models, activeKey, modelCategories]); + + // 根据除分类外其它过滤条件后的模型 (用于动态分类计数) + const modelsAfterOtherFilters = useMemo(() => { + let result = models; + if (filterGroup !== 'all') { + result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); + } + if (filterQuotaType !== 'all') { + result = result.filter(m => m.quota_type === filterQuotaType); + } + if (filterEndpointType !== 'all') { + result = result.filter(m => + m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) + ); + } + if (searchValue && searchValue.length > 0) { + const term = searchValue.toLowerCase(); + result = result.filter(m => m.model_name.toLowerCase().includes(term)); + } + return result; + }, [models, filterGroup, filterQuotaType, filterEndpointType, searchValue]); + + // 动态分类计数 + const dynamicCategoryCounts = useMemo(() => { + const counts = { all: modelsAfterOtherFilters.length }; + Object.entries(modelCategories).forEach(([key, category]) => { + if (key === 'all') return; + if (typeof category.filter === 'function') { + counts[key] = modelsAfterOtherFilters.filter(category.filter).length; + } else { + counts[key] = 0; + } + }); + return counts; + }, [modelsAfterOtherFilters, modelCategories]); + + // 针对计费类型按钮计数 + const quotaTypeModels = useMemo(() => { + let result = modelsAfterCategory; + if (filterGroup !== 'all') { + result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); + } + if (filterEndpointType !== 'all') { + result = result.filter(m => + m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) + ); + } + return result; + }, [modelsAfterCategory, filterGroup, filterEndpointType]); + + // 针对端点类型按钮计数 + const endpointTypeModels = useMemo(() => { + let result = modelsAfterCategory; + if (filterGroup !== 'all') { + result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); + } + if (filterQuotaType !== 'all') { + result = result.filter(m => m.quota_type === filterQuotaType); + } + return result; + }, [modelsAfterCategory, filterGroup, filterQuotaType]); + + // === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) === + const groupCountModels = useMemo(() => { + let result = modelsAfterCategory; // 已包含分类筛选 + + // 不应用 filterGroup 本身 + if (filterQuotaType !== 'all') { + result = result.filter(m => m.quota_type === filterQuotaType); + } + if (filterEndpointType !== 'all') { + result = result.filter(m => + m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) + ); + } + if (searchValue && searchValue.length > 0) { + const term = searchValue.toLowerCase(); + result = result.filter(m => m.model_name.toLowerCase().includes(term)); + } + return result; + }, [modelsAfterCategory, filterQuotaType, filterEndpointType, searchValue]); + + return { + quotaTypeModels, + endpointTypeModels, + dynamicCategoryCounts, + groupCountModels, + }; +}; \ No newline at end of file From a4afa3a11f67ec227ac7c47f3c50135e3f702a7e Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 26 Jul 2025 18:40:18 +0800 Subject: [PATCH 109/582] chore: claude code automatic disable --- relay/channel/claude_code/constants.go | 1 - setting/operation_setting/operation_setting.go | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/relay/channel/claude_code/constants.go b/relay/channel/claude_code/constants.go index 7c28e48d..82695be2 100644 --- a/relay/channel/claude_code/constants.go +++ b/relay/channel/claude_code/constants.go @@ -2,7 +2,6 @@ package claude_code var ModelList = []string{ "claude-3-5-haiku-20241022", - "claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20241022", "claude-3-7-sonnet-20250219", "claude-3-7-sonnet-20250219-thinking", diff --git a/setting/operation_setting/operation_setting.go b/setting/operation_setting/operation_setting.go index ef330d1a..29b77d66 100644 --- a/setting/operation_setting/operation_setting.go +++ b/setting/operation_setting/operation_setting.go @@ -13,6 +13,9 @@ var AutomaticDisableKeywords = []string{ "The security token included in the request is invalid", "Operation not allowed", "Your account is not authorized", + // Claude Code + "Invalid bearer token", + "OAuth authentication is currently not allowed for this endpoint", } func AutomaticDisableKeywordsToString() string { From 90cf9efd6ba265cbff1a56a7abb17670018dc410 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 27 Jul 2025 00:01:12 +0800 Subject: [PATCH 110/582] =?UTF-8?q?=F0=9F=94=8D=20fix:=20select=20search?= =?UTF-8?q?=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary • Introduced a unified `selectFilter` helper that matches both `option.value` and `option.label`, ensuring all ` -export const modelSelectFilter = (input, option) => { +// 使用方式: } + placeholder={t('搜索模型')} + value={keyword} + onChange={(v) => setKeyword(v)} + showClear + /> + + +
    + {filteredModels.length === 0 ? ( + } + darkModeImage={} + description={t('暂无匹配模型')} + style={{ padding: 30 }} + /> + ) : ( + setCheckedList(vals)}> + {activeTab === 'new' && newModels.length > 0 && ( +
    + {renderModelsByCategory(newModelsByCategory, 'new')} +
    + )} + {activeTab === 'existing' && existingModels.length > 0 && ( +
    + {renderModelsByCategory(existingModelsByCategory, 'existing')} +
    + )} +
    + )} +
    +
    + + +
    + {(() => { + const currentModels = activeTab === 'new' ? newModels : existingModels; + const currentSelected = currentModels.filter(model => checkedList.includes(model)).length; + const isAllSelected = currentModels.length > 0 && currentSelected === currentModels.length; + const isIndeterminate = currentSelected > 0 && currentSelected < currentModels.length; + + return ( + <> + + {t('已选择 {{selected}} / {{total}}', { + selected: currentSelected, + total: currentModels.length + })} + + { + handleCategorySelectAll(currentModels, e.target.checked); + }} + /> + + ); + })()} +
    +
    + + ); +}; + +export default ModelSelectModal; \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a1bf619d..29190b13 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1799,5 +1799,10 @@ "显示第": "Showing", "条 - 第": "to", "条,共": "of", - "条": "items" + "条": "items", + "选择模型": "Select model", + "已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}", + "新获取的模型": "New models", + "已有的模型": "Existing models", + "搜索模型": "Search models" } \ No newline at end of file From 894ddf3fa11ce6b50a537cc3dacd237e9514d1ad Mon Sep 17 00:00:00 2001 From: ZhengJin Date: Mon, 28 Jul 2025 17:52:59 +0800 Subject: [PATCH 117/582] Update api.js --- web/src/helpers/api.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 55228fd8..294e1775 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -215,14 +215,16 @@ export async function getOAuthState() { export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) { const state = await getOAuthState(); if (!state) return; - const redirect_uri = `${window.location.origin}/oauth/oidc`; - const response_type = 'code'; - const scope = 'openid profile email'; - const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`; + const url = new URL(auth_url); + url.searchParams.set('client_id', client_id); + url.searchParams.set('redirect_uri', `${window.location.origin}/oauth/oidc`); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid profile email'); + url.searchParams.set('state', state); if (openInNewTab) { - window.open(url); + window.open(url.toString(), '_blank'); } else { - window.location.href = url; + window.location.href = url.toString(); } } From a1caf37f5bc8d959319047cb495a321f7913560f Mon Sep 17 00:00:00 2001 From: CaIon Date: Tue, 29 Jul 2025 15:20:08 +0800 Subject: [PATCH 118/582] fix: auto ban --- relay/channel/gemini/relay-gemini-native.go | 6 +++--- relay/gemini_handler.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 0870e3fa..7d459cc2 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -20,7 +20,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re // 读取响应体 responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if common.DebugEnabled { @@ -31,7 +31,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re var geminiResponse GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } // 计算使用量(基于 UsageMetadata) @@ -54,7 +54,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re // 直接返回 Gemini 原生格式的 JSON 响应 jsonResponse, err := common.Marshal(geminiResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } common.IOCopyBytesGracefully(c, resp, jsonResponse) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 0f1aa5bf..3b27bfe2 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -230,7 +230,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { resp, err := adaptor.DoRequest(c, relayInfo, requestBody) if err != nil { common.LogError(c, "Do gemini request failed: "+err.Error()) - return types.NewError(err, types.ErrorCodeDoRequestFailed) + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) } statusCodeMappingStr := c.GetString("status_code_mapping") From efb7c6a15ec5fca73128ba73c5b439ef99d4ca08 Mon Sep 17 00:00:00 2001 From: CaIon Date: Tue, 29 Jul 2025 23:08:16 +0800 Subject: [PATCH 119/582] fix: auto ban --- controller/channel-test.go | 10 +++++----- service/error.go | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index c1c3c21d..75fec463 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -209,7 +209,7 @@ func testChannel(channel *model.Channel, testModel string) testResult { return testResult{ context: c, localErr: err, - newAPIError: types.NewError(err, types.ErrorCodeDoRequestFailed), + newAPIError: types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError), } } var httpResp *http.Response @@ -220,7 +220,7 @@ func testChannel(channel *model.Channel, testModel string) testResult { return testResult{ context: c, localErr: err, - newAPIError: types.NewError(err, types.ErrorCodeBadResponse), + newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError), } } } @@ -236,7 +236,7 @@ func testChannel(channel *model.Channel, testModel string) testResult { return testResult{ context: c, localErr: errors.New("usage is nil"), - newAPIError: types.NewError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody), + newAPIError: types.NewOpenAIError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError), } } usage := usageA.(*dto.Usage) @@ -246,7 +246,7 @@ func testChannel(channel *model.Channel, testModel string) testResult { return testResult{ context: c, localErr: err, - newAPIError: types.NewError(err, types.ErrorCodeReadResponseBodyFailed), + newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), } } info.PromptTokens = usage.PromptTokens @@ -417,7 +417,7 @@ func testAllChannels(notify bool) error { if common.AutomaticDisableChannelEnabled && !shouldBanChannel { if milliseconds > disableThreshold { err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)) - newAPIError = types.NewError(err, types.ErrorCodeChannelResponseTimeExceeded) + newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout) shouldBanChannel = true } } diff --git a/service/error.go b/service/error.go index 83979add..94d9c250 100644 --- a/service/error.go +++ b/service/error.go @@ -1,7 +1,6 @@ package service import ( - "encoding/json" "errors" "fmt" "io" @@ -112,7 +111,7 @@ func ResetStatusCode(newApiErr *types.NewAPIError, statusCodeMappingStr string) return } statusCodeMapping := make(map[string]string) - err := json.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping) + err := common.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping) if err != nil { return } From f56040ae21ec3b23ba36ddf4f3072c77cdde4dd5 Mon Sep 17 00:00:00 2001 From: IcedTangerine Date: Wed, 30 Jul 2025 12:17:56 +0800 Subject: [PATCH 120/582] Revert "Update relay-claude.go" --- relay/channel/claude/relay-claude.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 7d6c973f..f20b573d 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -185,10 +185,7 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla } // TODO: 临时处理 // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking - // Anthropic 要求去掉 top_k - claudeRequest.TopK = nil - //top_p值可以在0.95-1之间 - claudeRequest.TopP = 0.95 + claudeRequest.TopP = 0 claudeRequest.Temperature = common.GetPointer[float64](1.0) claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") } From 2db71673a57c4f6f89dbb85ced5c79e78ecd3057 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 30 Jul 2025 18:39:19 +0800 Subject: [PATCH 121/582] fix: auto ban --- relay/channel/ali/image.go | 4 +- relay/channel/ali/rerank.go | 4 +- relay/channel/ali/text.go | 6 +- relay/channel/gemini/adaptor.go | 56 ---------------- relay/channel/gemini/relay-gemini.go | 66 +++++++++++++++++-- relay/channel/jimeng/image.go | 4 +- relay/channel/openai/relay-openai.go | 12 ++-- relay/channel/openai/relay_responses.go | 4 +- relay/channel/palm/relay-palm.go | 4 +- .../channel/siliconflow/relay-siliconflow.go | 4 +- relay/channel/tencent/relay-tencent.go | 4 +- relay/channel/vertex/adaptor.go | 12 +++- relay/channel/zhipu/relay-zhipu.go | 4 +- relay/common_handler/rerank.go | 6 +- relay/gemini_handler.go | 3 +- 15 files changed, 99 insertions(+), 94 deletions(-) diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 0d430c62..754f29c8 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -132,12 +132,12 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela var aliTaskResponse AliResponse responseBody, err := io.ReadAll(resp.Body) if err != nil { - return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil } common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &aliTaskResponse) if err != nil { - return types.NewError(err, types.ErrorCodeBadResponseBody), nil + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil } if aliTaskResponse.Message != "" { diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go index 59cb0a11..4f448e01 100644 --- a/relay/channel/ali/rerank.go +++ b/relay/channel/ali/rerank.go @@ -34,14 +34,14 @@ func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest { func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { responseBody, err := io.ReadAll(resp.Body) if err != nil { - return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil } common.CloseResponseBodyGracefully(resp) var aliResponse AliRerankResponse err = json.Unmarshal(responseBody, &aliResponse) if err != nil { - return types.NewError(err, types.ErrorCodeBadResponseBody), nil + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil } if aliResponse.Code != "" { diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go index 6d90fa71..fcf63854 100644 --- a/relay/channel/ali/text.go +++ b/relay/channel/ali/text.go @@ -43,7 +43,7 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*types.NewAPIErro var fullTextResponse dto.FlexibleEmbeddingResponse err := json.NewDecoder(resp.Body).Decode(&fullTextResponse) if err != nil { - return types.NewError(err, types.ErrorCodeBadResponseBody), nil + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil } common.CloseResponseBodyGracefully(resp) @@ -179,12 +179,12 @@ func aliHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.U var aliResponse AliResponse responseBody, err := io.ReadAll(resp.Body) if err != nil { - return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil } common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &aliResponse) if err != nil { - return types.NewError(err, types.ErrorCodeBadResponseBody), nil + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil } if aliResponse.Code != "" { return types.WithOpenAIError(types.OpenAIError{ diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 2e31ec55..da291aa9 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -1,12 +1,10 @@ package gemini import ( - "encoding/json" "errors" "fmt" "io" "net/http" - "one-api/common" "one-api/dto" "one-api/relay/channel" "one-api/relay/channel/openai" @@ -212,60 +210,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom return nil, types.NewError(errors.New("not implemented"), types.ErrorCodeBadResponseBody) } -func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { - responseBody, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return nil, types.NewError(readErr, types.ErrorCodeBadResponseBody) - } - _ = resp.Body.Close() - - var geminiResponse GeminiImageResponse - if jsonErr := json.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { - return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) - } - - if len(geminiResponse.Predictions) == 0 { - return nil, types.NewError(errors.New("no images generated"), types.ErrorCodeBadResponseBody) - } - - // convert to openai format response - openAIResponse := dto.ImageResponse{ - Created: common.GetTimestamp(), - Data: make([]dto.ImageData, 0, len(geminiResponse.Predictions)), - } - - for _, prediction := range geminiResponse.Predictions { - if prediction.RaiFilteredReason != "" { - continue // skip filtered image - } - openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{ - B64Json: prediction.BytesBase64Encoded, - }) - } - - jsonResponse, jsonErr := json.Marshal(openAIResponse) - if jsonErr != nil { - return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) - } - - c.Writer.Header().Set("Content-Type", "application/json") - c.Writer.WriteHeader(resp.StatusCode) - _, _ = c.Writer.Write(jsonResponse) - - // https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb - // each image has fixed 258 tokens - const imageTokens = 258 - generatedImages := len(openAIResponse.Data) - - usage := &dto.Usage{ - PromptTokens: imageTokens * generatedImages, // each generated image has fixed 258 tokens - CompletionTokens: 0, // image generation does not calculate completion tokens - TotalTokens: imageTokens * generatedImages, - } - - return usage, nil -} - func (a *Adaptor) GetModelList() []string { return ModelList } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 7e57bdac..5dac0ce5 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -907,7 +907,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) if common.DebugEnabled { @@ -916,10 +916,10 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R var geminiResponse GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if len(geminiResponse.Candidates) == 0 { - return nil, types.NewError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) fullTextResponse.Model = info.UpstreamModelName @@ -956,12 +956,12 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h responseBody, readErr := io.ReadAll(resp.Body) if readErr != nil { - return nil, types.NewError(readErr, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } var geminiResponse GeminiEmbeddingResponse if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { - return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } // convert to openai format response @@ -991,9 +991,63 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h jsonResponse, jsonErr := common.Marshal(openAIResponse) if jsonErr != nil { - return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } common.IOCopyBytesGracefully(c, resp, jsonResponse) return usage, nil } + +func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var geminiResponse GeminiImageResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if len(geminiResponse.Predictions) == 0 { + return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // convert to openai format response + openAIResponse := dto.ImageResponse{ + Created: common.GetTimestamp(), + Data: make([]dto.ImageData, 0, len(geminiResponse.Predictions)), + } + + for _, prediction := range geminiResponse.Predictions { + if prediction.RaiFilteredReason != "" { + continue // skip filtered image + } + openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{ + B64Json: prediction.BytesBase64Encoded, + }) + } + + jsonResponse, jsonErr := json.Marshal(openAIResponse) + if jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + // https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb + // each image has fixed 258 tokens + const imageTokens = 258 + generatedImages := len(openAIResponse.Data) + + usage := &dto.Usage{ + PromptTokens: imageTokens * generatedImages, // each generated image has fixed 258 tokens + CompletionTokens: 0, // image generation does not calculate completion tokens + TotalTokens: imageTokens * generatedImages, + } + + return usage, nil +} diff --git a/relay/channel/jimeng/image.go b/relay/channel/jimeng/image.go index 3c6a1d99..28af1866 100644 --- a/relay/channel/jimeng/image.go +++ b/relay/channel/jimeng/image.go @@ -52,13 +52,13 @@ func jimengImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.R var jimengResponse ImageResponse responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &jimengResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } // Check if the response indicates an error diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 82bd2d26..2252b407 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -109,7 +109,7 @@ func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, fo func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { if resp == nil || resp.Body == nil { common.LogError(c, "invalid response or response body") - return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse) + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) } defer common.CloseResponseBodyGracefully(resp) @@ -178,11 +178,11 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo var simpleResponse dto.OpenAITextResponse responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } err = common.Unmarshal(responseBody, &simpleResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if simpleResponse.Error != nil && simpleResponse.Error.Type != "" { return nil, types.WithOpenAIError(*simpleResponse.Error, resp.StatusCode) @@ -263,7 +263,7 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel } responseBody, err := io.ReadAll(resp.Body) if err != nil { - return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil } // 写入新的 response body common.IOCopyBytesGracefully(c, resp, responseBody) @@ -547,13 +547,13 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } var usageResp dto.SimpleResponse err = common.Unmarshal(responseBody, &usageResp) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } // 写入新的 response body diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index d9dd96b9..fd57924b 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -22,11 +22,11 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http var responsesResponse dto.OpenAIResponsesResponse responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } err = common.Unmarshal(responseBody, &responsesResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if responsesResponse.Error != nil { return nil, types.WithOpenAIError(*responsesResponse.Error, resp.StatusCode) diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index 4db31573..cbd60f5e 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -127,13 +127,13 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, func palmHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) var palmResponse PaLMChatResponse err = json.Unmarshal(responseBody, &palmResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 { return nil, types.WithOpenAIError(types.OpenAIError{ diff --git a/relay/channel/siliconflow/relay-siliconflow.go b/relay/channel/siliconflow/relay-siliconflow.go index fabaf9c6..2e37ad15 100644 --- a/relay/channel/siliconflow/relay-siliconflow.go +++ b/relay/channel/siliconflow/relay-siliconflow.go @@ -15,13 +15,13 @@ import ( func siliconflowRerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) var siliconflowResp SFRerankResponse err = json.Unmarshal(responseBody, &siliconflowResp) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } usage := &dto.Usage{ PromptTokens: siliconflowResp.Meta.Tokens.InputTokens, diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go index c3d96c49..78ce6238 100644 --- a/relay/channel/tencent/relay-tencent.go +++ b/relay/channel/tencent/relay-tencent.go @@ -136,12 +136,12 @@ func tencentHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Resp var tencentSb TencentChatResponseSB responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &tencentSb) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if tencentSb.Response.Error.Code != 0 { return nil, types.WithOpenAIError(types.OpenAIError{ diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index fa895de0..40c9ca89 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -67,11 +67,10 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf func (a *Adaptor) Init(info *relaycommon.RelayInfo) { if strings.HasPrefix(info.UpstreamModelName, "claude") { a.RequestMode = RequestModeClaude - } else if strings.HasPrefix(info.UpstreamModelName, "gemini") { - a.RequestMode = RequestModeGemini } else if strings.Contains(info.UpstreamModelName, "llama") { a.RequestMode = RequestModeLlama } + a.RequestMode = RequestModeGemini } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { @@ -83,6 +82,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { a.AccountCredentials = *adc suffix := "" if a.RequestMode == RequestModeGemini { + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { // 新增逻辑:处理 -thinking- 格式 if strings.Contains(info.UpstreamModelName, "-thinking-") { @@ -100,6 +100,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } else { suffix = "generateContent" } + + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + suffix = "predict" + } + if region == "global" { return fmt.Sprintf( "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", @@ -231,6 +236,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom if info.RelayMode == constant.RelayModeGemini { usage, err = gemini.GeminiTextGenerationHandler(c, info, resp) } else { + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return gemini.GeminiImageHandler(c, info, resp) + } usage, err = gemini.GeminiChatHandler(c, info, resp) } case RequestModeLlama: diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go index 916a200d..35882ed5 100644 --- a/relay/channel/zhipu/relay-zhipu.go +++ b/relay/channel/zhipu/relay-zhipu.go @@ -220,12 +220,12 @@ func zhipuHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respon var zhipuResponse ZhipuResponse responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) err = json.Unmarshal(responseBody, &zhipuResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if !zhipuResponse.Success { return nil, types.WithOpenAIError(types.OpenAIError{ diff --git a/relay/common_handler/rerank.go b/relay/common_handler/rerank.go index ce823b3a..57df5fe3 100644 --- a/relay/common_handler/rerank.go +++ b/relay/common_handler/rerank.go @@ -16,7 +16,7 @@ import ( func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { responseBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } common.CloseResponseBodyGracefully(resp) if common.DebugEnabled { @@ -27,7 +27,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo var xinRerankResponse xinference.XinRerankResponse err = common.Unmarshal(responseBody, &xinRerankResponse) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } jinaRespResults := make([]dto.RerankResponseResult, len(xinRerankResponse.Results)) for i, result := range xinRerankResponse.Results { @@ -62,7 +62,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo } else { err = common.Unmarshal(responseBody, &jinaResp) if err != nil { - return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } jinaResp.Usage.PromptTokens = jinaResp.Usage.TotalTokens } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 3b27bfe2..6da8b131 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -2,7 +2,6 @@ package relay import ( "bytes" - "encoding/json" "errors" "fmt" "io" @@ -203,7 +202,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } requestBody = bytes.NewReader(body) } else { - jsonData, err := json.Marshal(req) + jsonData, err := common.Marshal(req) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed) } From 9afb9b594d08a47c8d4dcc8efd0f7e3e4a11eb07 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 30 Jul 2025 19:08:35 +0800 Subject: [PATCH 122/582] =?UTF-8?q?feat:=20=E9=94=99=E8=AF=AF=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/str.go | 95 +++++++++++++++++++++++++++++++++++++++++++++ controller/relay.go | 3 +- types/error.go | 29 +++++++++++--- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/common/str.go b/common/str.go index 88b58c72..f5399eab 100644 --- a/common/str.go +++ b/common/str.go @@ -4,7 +4,10 @@ import ( "encoding/base64" "encoding/json" "math/rand" + "net/url" + "regexp" "strconv" + "strings" "unsafe" ) @@ -95,3 +98,95 @@ func GetJsonString(data any) string { b, _ := json.Marshal(data) return string(b) } + +// MaskSensitiveInfo masks sensitive information like URLs, IPs in a string +// Example: +// http://example.com -> http://***.com +// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=*** +// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/*** +// 192.168.1.1 -> ***.***.***.*** +func MaskSensitiveInfo(str string) string { + // Mask URLs + urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`) + str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string { + u, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + + host := u.Host + if host == "" { + return urlStr + } + + // Split host by dots + parts := strings.Split(host, ".") + if len(parts) < 2 { + // If less than 2 parts, just mask the whole host + return u.Scheme + "://***" + u.Path + } + + // Keep the TLD (Top Level Domain) and mask the rest + var maskedHost string + if len(parts) == 2 { + // example.com -> ***.com + maskedHost = "***." + parts[len(parts)-1] + } else { + // Handle cases like sub.domain.co.uk or api.example.com + // Keep last 2 parts if they look like country code TLD (co.uk, com.cn, etc.) + lastPart := parts[len(parts)-1] + secondLastPart := parts[len(parts)-2] + + if len(lastPart) == 2 && len(secondLastPart) <= 3 { + // Likely country code TLD like co.uk, com.cn + maskedHost = "***." + secondLastPart + "." + lastPart + } else { + // Regular TLD like .com, .org + maskedHost = "***." + lastPart + } + } + + result := u.Scheme + "://" + maskedHost + + // Mask path + if u.Path != "" && u.Path != "/" { + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + maskedPathParts := make([]string, len(pathParts)) + for i := range pathParts { + if pathParts[i] != "" { + maskedPathParts[i] = "***" + } + } + if len(maskedPathParts) > 0 { + result += "/" + strings.Join(maskedPathParts, "/") + } + } else if u.Path == "/" { + result += "/" + } + + // Mask query parameters + if u.RawQuery != "" { + values, err := url.ParseQuery(u.RawQuery) + if err != nil { + // If can't parse query, just mask the whole query string + result += "?***" + } else { + maskedParams := make([]string, 0, len(values)) + for key := range values { + maskedParams = append(maskedParams, key+"=***") + } + if len(maskedParams) > 0 { + result += "?" + strings.Join(maskedParams, "&") + } + } + } + + return result + }) + + // Mask IP addresses + ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) + str = ipPattern.ReplaceAllString(str, "***.***.***.***") + + return str +} diff --git a/controller/relay.go b/controller/relay.go index d4b5fd18..01081d3d 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -62,8 +62,7 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError { other["channel_id"] = channelId other["channel_name"] = c.GetString("channel_name") other["channel_type"] = c.GetInt("channel_type") - - model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.Error(), tokenId, 0, false, userGroup, other) + model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other) } return err diff --git a/types/error.go b/types/error.go index c94bd001..2a8105c7 100644 --- a/types/error.go +++ b/types/error.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "one-api/common" "strings" ) @@ -107,19 +108,30 @@ func (e *NewAPIError) Error() string { return e.Err.Error() } +func (e *NewAPIError) MaskSensitiveError() string { + if e == nil { + return "" + } + if e.Err == nil { + return string(e.errorCode) + } + return common.MaskSensitiveInfo(e.Err.Error()) +} + func (e *NewAPIError) SetMessage(message string) { e.Err = errors.New(message) } func (e *NewAPIError) ToOpenAIError() OpenAIError { + var result OpenAIError switch e.errorType { case ErrorTypeOpenAIError: if openAIError, ok := e.RelayError.(OpenAIError); ok { - return openAIError + result = openAIError } case ErrorTypeClaudeError: if claudeError, ok := e.RelayError.(ClaudeError); ok { - return OpenAIError{ + result = OpenAIError{ Message: e.Error(), Type: claudeError.Type, Param: "", @@ -127,30 +139,35 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError { } } } - return OpenAIError{ + result = OpenAIError{ Message: e.Error(), Type: string(e.errorType), Param: "", Code: e.errorCode, } + result.Message = common.MaskSensitiveInfo(result.Message) + return result } func (e *NewAPIError) ToClaudeError() ClaudeError { + var result ClaudeError switch e.errorType { case ErrorTypeOpenAIError: openAIError := e.RelayError.(OpenAIError) - return ClaudeError{ + result = ClaudeError{ Message: e.Error(), Type: fmt.Sprintf("%v", openAIError.Code), } case ErrorTypeClaudeError: - return e.RelayError.(ClaudeError) + result = e.RelayError.(ClaudeError) default: - return ClaudeError{ + result = ClaudeError{ Message: e.Error(), Type: string(e.errorType), } } + result.Message = common.MaskSensitiveInfo(result.Message) + return result } func NewError(err error, errorCode ErrorCode) *NewAPIError { From 28ededaf8ecb21ce15aef00f23a150c825278794 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 30 Jul 2025 20:31:51 +0800 Subject: [PATCH 123/582] fix: WriteContentType panic --- common/custom-event.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/common/custom-event.go b/common/custom-event.go index d8f9ec9f..256db546 100644 --- a/common/custom-event.go +++ b/common/custom-event.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strings" + "sync" ) type stringWriter interface { @@ -52,6 +53,8 @@ type CustomEvent struct { Id string Retry uint Data interface{} + + Mutex sync.Mutex } func encode(writer io.Writer, event CustomEvent) error { @@ -73,6 +76,8 @@ func (r CustomEvent) Render(w http.ResponseWriter) error { } func (r CustomEvent) WriteContentType(w http.ResponseWriter) { + r.Mutex.Lock() + defer r.Mutex.Unlock() header := w.Header() header["Content-Type"] = contentType From 9055698b4f26598285272f1f0ef78c03d7136fcc Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Wed, 30 Jul 2025 22:35:31 +0800 Subject: [PATCH 124/582] =?UTF-8?q?feat:=20=E6=98=BE=E5=BC=8F=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=20error=20=E8=B7=B3=E8=BF=87=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/playground.go | 10 +++---- controller/relay.go | 8 +++--- middleware/distributor.go | 2 +- model/channel.go | 2 +- relay/audio_handler.go | 10 +++---- relay/claude_handler.go | 18 ++++++------- relay/embedding_handler.go | 15 +++++------ relay/gemini_handler.go | 16 +++++------ relay/image_handler.go | 20 +++++++------- relay/relay-text.go | 32 +++++++++++----------- relay/rerank_handler.go | 20 +++++++------- relay/responses_handler.go | 20 +++++++------- relay/websocket.go | 6 ++--- service/channel.go | 2 +- types/error.go | 54 ++++++++++++++++++++++++++++---------- 15 files changed, 129 insertions(+), 106 deletions(-) diff --git a/controller/playground.go b/controller/playground.go index 0073cf06..64c0e1ce 100644 --- a/controller/playground.go +++ b/controller/playground.go @@ -28,19 +28,19 @@ func Playground(c *gin.Context) { useAccessToken := c.GetBool("use_access_token") if useAccessToken { - newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied) + newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry()) return } playgroundRequest := &dto.PlayGroundRequest{} err := common.UnmarshalBodyReusable(c, playgroundRequest) if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest) + newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) return } if playgroundRequest.Model == "" { - newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest) + newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) return } c.Set("original_model", playgroundRequest.Model) @@ -51,7 +51,7 @@ func Playground(c *gin.Context) { group = userGroup } else { if !setting.GroupInUserUsableGroups(group) && group != userGroup { - newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied) + newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry()) return } c.Set("group", group) @@ -62,7 +62,7 @@ func Playground(c *gin.Context) { // Write user context to ensure acceptUnsetRatio is available userCache, err := model.GetUserCache(userId) if err != nil { - newAPIError = types.NewError(err, types.ErrorCodeQueryDataError) + newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) return } userCache.WriteContext(c) diff --git a/controller/relay.go b/controller/relay.go index 01081d3d..e7318e9b 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -127,7 +127,7 @@ func WssRelay(c *gin.Context) { defer ws.Close() if err != nil { - helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed).ToOpenAIError()) + helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError()) return } @@ -258,10 +258,10 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m } channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) if err != nil { - return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed) + return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } if channel == nil { - return nil, types.NewError(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry)", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed) + return nil, types.NewError(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry)", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel) if newAPIError != nil { @@ -277,7 +277,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if types.IsChannelError(openaiErr) { return true } - if types.IsLocalError(openaiErr) { + if types.IsSkipRetryError(openaiErr) { return false } if retryTimes <= 0 { diff --git a/middleware/distributor.go b/middleware/distributor.go index cba9b521..fb4a6645 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -247,7 +247,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError { c.Set("original_model", modelName) // for retry if channel == nil { - return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed) + return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id) common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name) diff --git a/model/channel.go b/model/channel.go index 6277fcda..ea263c84 100644 --- a/model/channel.go +++ b/model/channel.go @@ -138,7 +138,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { channelInfo, err := CacheGetChannelInfo(channel.Id) if err != nil { - return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed) + return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } //println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex) defer func() { diff --git a/relay/audio_handler.go b/relay/audio_handler.go index f39dbd82..88777838 100644 --- a/relay/audio_handler.go +++ b/relay/audio_handler.go @@ -62,7 +62,7 @@ func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) { if err != nil { common.LogError(c, fmt.Sprintf("getAndValidAudioRequest failed: %s", err.Error())) - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } promptTokens := 0 @@ -75,7 +75,7 @@ func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) { priceData, err := helper.ModelPriceHelper(c, relayInfo, preConsumedTokens, 0) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) @@ -90,18 +90,18 @@ func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) { err = helper.ModelMappedHelper(c, relayInfo, audioRequest) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) ioReader, err := adaptor.ConvertAudioRequest(c, relayInfo, *audioRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } resp, err := adaptor.DoRequest(c, relayInfo, ioReader) diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 2c60a91e..b4bf78ff 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -40,7 +40,7 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { // get & validate textRequest 获取并验证文本请求 textRequest, err := getAndValidateClaudeRequest(c) if err != nil { - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } if textRequest.Stream { @@ -49,18 +49,18 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { err = helper.ModelMappedHelper(c, relayInfo, textRequest) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } promptTokens, err := getClaudePromptTokens(textRequest, relayInfo) // count messages token error 计算promptTokens错误 if err != nil { - return types.NewError(err, types.ErrorCodeCountTokenFailed) + return types.NewError(err, types.ErrorCodeCountTokenFailed, types.ErrOptionWithSkipRetry()) } priceData, err := helper.ModelPriceHelper(c, relayInfo, promptTokens, int(textRequest.MaxTokens)) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } // pre-consume quota 预消耗配额 @@ -77,7 +77,7 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) @@ -111,17 +111,17 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { - return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } requestBody = bytes.NewBuffer(body) } else { convertedRequest, err := adaptor.ConvertClaudeRequest(c, relayInfo, textRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } jsonData, err := common.Marshal(convertedRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } // apply param override @@ -133,7 +133,7 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } jsonData, err = common.Marshal(reqMap) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } } diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index be11bb2b..fef8d2c9 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -41,17 +41,17 @@ func EmbeddingHelper(c *gin.Context) (newAPIError *types.NewAPIError) { err := common.UnmarshalBodyReusable(c, &embeddingRequest) if err != nil { common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } err = validateEmbeddingRequest(c, relayInfo, *embeddingRequest) if err != nil { - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } err = helper.ModelMappedHelper(c, relayInfo, embeddingRequest) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } promptToken := getEmbeddingPromptToken(*embeddingRequest) @@ -59,7 +59,7 @@ func EmbeddingHelper(c *gin.Context) (newAPIError *types.NewAPIError) { priceData, err := helper.ModelPriceHelper(c, relayInfo, promptToken, 0) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } // pre-consume quota 预消耗配额 preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) @@ -74,18 +74,17 @@ func EmbeddingHelper(c *gin.Context) (newAPIError *types.NewAPIError) { adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) convertedRequest, err := adaptor.ConvertEmbeddingRequest(c, relayInfo, *embeddingRequest) - if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } jsonData, err := json.Marshal(convertedRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } requestBody := bytes.NewBuffer(jsonData) statusCodeMappingStr := c.GetString("status_code_mapping") diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 2e2c1480..43c7ca58 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -109,7 +109,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { req, err := getAndValidateGeminiRequest(c) if err != nil { common.LogError(c, fmt.Sprintf("getAndValidateGeminiRequest error: %s", err.Error())) - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } relayInfo := relaycommon.GenRelayInfoGemini(c) @@ -121,14 +121,14 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { sensitiveWords, err := checkGeminiInputSensitive(req) if err != nil { common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", "))) - return types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + return types.NewError(err, types.ErrorCodeSensitiveWordsDetected, types.ErrOptionWithSkipRetry()) } } // model mapped 模型映射 err = helper.ModelMappedHelper(c, relayInfo, req) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } if value, exists := c.Get("prompt_tokens"); exists { @@ -159,7 +159,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens)) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } // pre consume quota @@ -175,7 +175,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) @@ -198,13 +198,13 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { - return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } requestBody = bytes.NewReader(body) } else { jsonData, err := common.Marshal(req) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } // apply param override @@ -216,7 +216,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } jsonData, err = common.Marshal(reqMap) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } } diff --git a/relay/image_handler.go b/relay/image_handler.go index c97eb48e..f0b69699 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -115,17 +115,17 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) { imageRequest, err := getAndValidImageRequest(c, relayInfo) if err != nil { common.LogError(c, fmt.Sprintf("getAndValidImageRequest failed: %s", err.Error())) - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } err = helper.ModelMappedHelper(c, relayInfo, imageRequest) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } priceData, err := helper.ModelPriceHelper(c, relayInfo, len(imageRequest.Prompt), 0) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } var preConsumedQuota int var quota int @@ -173,16 +173,16 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) { quota = int(priceData.ModelPrice * priceData.GroupRatioInfo.GroupRatio * common.QuotaPerUnit) userQuota, err = model.GetUserQuota(relayInfo.UserId, false) if err != nil { - return types.NewError(err, types.ErrorCodeQueryDataError) + return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) } if userQuota-quota < 0 { - return types.NewError(fmt.Errorf("image pre-consumed quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(quota)), types.ErrorCodeInsufficientUserQuota) + return types.NewError(fmt.Errorf("image pre-consumed quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(quota)), types.ErrorCodeInsufficientUserQuota, types.ErrOptionWithSkipRetry()) } } adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) @@ -191,20 +191,20 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) { if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { - return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } requestBody = bytes.NewBuffer(body) } else { convertedRequest, err := adaptor.ConvertImageRequest(c, relayInfo, *imageRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } if relayInfo.RelayMode == relayconstant.RelayModeImagesEdits { requestBody = convertedRequest.(io.Reader) } else { jsonData, err := json.Marshal(convertedRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } // apply param override @@ -216,7 +216,7 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } jsonData, err = common.Marshal(reqMap) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } } diff --git a/relay/relay-text.go b/relay/relay-text.go index 1856a2a1..97313be6 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -90,9 +90,8 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { // get & validate textRequest 获取并验证文本请求 textRequest, err := getAndValidateTextRequest(c, relayInfo) - if err != nil { - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } if textRequest.WebSearchOptions != nil { @@ -103,13 +102,13 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { words, err := checkRequestSensitive(textRequest, relayInfo) if err != nil { common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", "))) - return types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + return types.NewError(err, types.ErrorCodeSensitiveWordsDetected, types.ErrOptionWithSkipRetry()) } } err = helper.ModelMappedHelper(c, relayInfo, textRequest) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } // 获取 promptTokens,如果上下文中已经存在,则直接使用 @@ -121,14 +120,14 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { promptTokens, err = getPromptTokens(textRequest, relayInfo) // count messages token error 计算promptTokens错误 if err != nil { - return types.NewError(err, types.ErrorCodeCountTokenFailed) + return types.NewError(err, types.ErrorCodeCountTokenFailed, types.ErrOptionWithSkipRetry()) } c.Set("prompt_tokens", promptTokens) } priceData, err := helper.ModelPriceHelper(c, relayInfo, promptTokens, int(math.Max(float64(textRequest.MaxTokens), float64(textRequest.MaxCompletionTokens)))) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } // pre-consume quota 预消耗配额 @@ -165,7 +164,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) var requestBody io.Reader @@ -173,7 +172,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { - return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } if common.DebugEnabled { println("requestBody: ", string(body)) @@ -182,7 +181,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } else { convertedRequest, err := adaptor.ConvertOpenAIRequest(c, relayInfo, textRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } if relayInfo.ChannelSetting.SystemPrompt != "" { @@ -207,7 +206,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { jsonData, err := common.Marshal(convertedRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } // apply param override @@ -219,7 +218,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } jsonData, err = common.Marshal(reqMap) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } } @@ -231,7 +230,6 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { var httpResp *http.Response resp, err := adaptor.DoRequest(c, relayInfo, requestBody) - if err != nil { return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) } @@ -304,13 +302,13 @@ func checkRequestSensitive(textRequest *dto.GeneralOpenAIRequest, info *relaycom func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) (int, int, *types.NewAPIError) { userQuota, err := model.GetUserQuota(relayInfo.UserId, false) if err != nil { - return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError) + return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) } if userQuota <= 0 { - return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden) + return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry()) } if userQuota-preConsumedQuota < 0 { - return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden) + return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry()) } relayInfo.UserQuota = userQuota if userQuota > 100*preConsumedQuota { @@ -334,11 +332,11 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo if preConsumedQuota > 0 { err := service.PreConsumeTokenQuota(relayInfo, preConsumedQuota) if err != nil { - return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden) + return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry()) } err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) if err != nil { - return 0, 0, types.NewError(err, types.ErrorCodeUpdateDataError) + return 0, 0, types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) } } return preConsumedQuota, userQuota, nil diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 0190cf08..1e547e2a 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -31,21 +31,21 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError err := common.UnmarshalBodyReusable(c, &rerankRequest) if err != nil { common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } relayInfo := relaycommon.GenRelayInfoRerank(c, rerankRequest) if rerankRequest.Query == "" { - return types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest) + return types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } if len(rerankRequest.Documents) == 0 { - return types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest) + return types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } err = helper.ModelMappedHelper(c, relayInfo, rerankRequest) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } promptToken := getRerankPromptToken(*rerankRequest) @@ -53,7 +53,7 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError priceData, err := helper.ModelPriceHelper(c, relayInfo, promptToken, 0) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } // pre-consume quota 预消耗配额 preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) @@ -68,7 +68,7 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) @@ -76,17 +76,17 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { - return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } requestBody = bytes.NewBuffer(body) } else { convertedRequest, err := adaptor.ConvertRerankRequest(c, relayInfo.RelayMode, *rerankRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } jsonData, err := common.Marshal(convertedRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } // apply param override @@ -98,7 +98,7 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError } jsonData, err = common.Marshal(reqMap) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 52d1db6e..65c240b2 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -51,7 +51,7 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) { req, err := getAndValidateResponsesRequest(c) if err != nil { common.LogError(c, fmt.Sprintf("getAndValidateResponsesRequest error: %s", err.Error())) - return types.NewError(err, types.ErrorCodeInvalidRequest) + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } relayInfo := relaycommon.GenRelayInfoResponses(c, req) @@ -60,13 +60,13 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) { sensitiveWords, err := checkInputSensitive(req, relayInfo) if err != nil { common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", "))) - return types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + return types.NewError(err, types.ErrorCodeSensitiveWordsDetected, types.ErrOptionWithSkipRetry()) } } err = helper.ModelMappedHelper(c, relayInfo, req) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } if value, exists := c.Get("prompt_tokens"); exists { @@ -79,7 +79,7 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) { priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.MaxOutputTokens)) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } // pre consume quota preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) @@ -93,38 +93,38 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) { }() adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) var requestBody io.Reader if model_setting.GetGlobalSettings().PassThroughRequestEnabled { body, err := common.GetRequestBody(c) if err != nil { - return types.NewError(err, types.ErrorCodeReadRequestBodyFailed) + return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) } requestBody = bytes.NewBuffer(body) } else { convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, relayInfo, *req) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } jsonData, err := json.Marshal(convertedRequest) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } // apply param override if len(relayInfo.ParamOverride) > 0 { reqMap := make(map[string]interface{}) err = json.Unmarshal(jsonData, &reqMap) if err != nil { - return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) } for key, value := range relayInfo.ParamOverride { reqMap[key] = value } jsonData, err = json.Marshal(reqMap) if err != nil { - return types.NewError(err, types.ErrorCodeConvertRequestFailed) + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } } diff --git a/relay/websocket.go b/relay/websocket.go index 659e27d5..3715b237 100644 --- a/relay/websocket.go +++ b/relay/websocket.go @@ -24,12 +24,12 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (newAPIError *types.NewAPIErr err := helper.ModelMappedHelper(c, relayInfo, nil) if err != nil { - return types.NewError(err, types.ErrorCodeChannelModelMappedError) + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } priceData, err := helper.ModelPriceHelper(c, relayInfo, 0, 0) if err != nil { - return types.NewError(err, types.ErrorCodeModelPriceError) + return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry()) } // pre-consume quota 预消耗配额 @@ -46,7 +46,7 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (newAPIError *types.NewAPIErr adaptor := GetAdaptor(relayInfo.ApiType) if adaptor == nil { - return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(relayInfo) //var requestBody io.Reader diff --git a/service/channel.go b/service/channel.go index 4d38e6ed..faac6d10 100644 --- a/service/channel.go +++ b/service/channel.go @@ -45,7 +45,7 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool { if types.IsChannelError(err) { return true } - if types.IsLocalError(err) { + if types.IsSkipRetryError(err) { return false } if err.StatusCode == http.StatusUnauthorized { diff --git a/types/error.go b/types/error.go index 2a8105c7..74c3bae5 100644 --- a/types/error.go +++ b/types/error.go @@ -78,6 +78,7 @@ const ( type NewAPIError struct { Err error RelayError any + skipRetry bool errorType ErrorType errorCode ErrorCode StatusCode int @@ -170,33 +171,39 @@ func (e *NewAPIError) ToClaudeError() ClaudeError { return result } -func NewError(err error, errorCode ErrorCode) *NewAPIError { - return &NewAPIError{ +type NewAPIErrorOptions func(*NewAPIError) + +func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError { + e := &NewAPIError{ Err: err, RelayError: nil, errorType: ErrorTypeNewAPIError, StatusCode: http.StatusInternalServerError, errorCode: errorCode, } + for _, op := range ops { + op(e) + } + return e } -func NewOpenAIError(err error, errorCode ErrorCode, statusCode int) *NewAPIError { +func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { openaiError := OpenAIError{ Message: err.Error(), Type: string(errorCode), } - return WithOpenAIError(openaiError, statusCode) + return WithOpenAIError(openaiError, statusCode, ops...) } -func InitOpenAIError(errorCode ErrorCode, statusCode int) *NewAPIError { +func InitOpenAIError(errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { openaiError := OpenAIError{ Type: string(errorCode), } - return WithOpenAIError(openaiError, statusCode) + return WithOpenAIError(openaiError, statusCode, ops...) } -func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *NewAPIError { - return &NewAPIError{ +func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + e := &NewAPIError{ Err: err, RelayError: OpenAIError{ Message: err.Error(), @@ -206,9 +213,14 @@ func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *New StatusCode: statusCode, errorCode: errorCode, } + for _, op := range ops { + op(e) + } + + return e } -func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError { +func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { code, ok := openAIError.Code.(string) if !ok { code = fmt.Sprintf("%v", openAIError.Code) @@ -216,26 +228,34 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError { if openAIError.Type == "" { openAIError.Type = "upstream_error" } - return &NewAPIError{ + e := &NewAPIError{ RelayError: openAIError, errorType: ErrorTypeOpenAIError, StatusCode: statusCode, Err: errors.New(openAIError.Message), errorCode: ErrorCode(code), } + for _, op := range ops { + op(e) + } + return e } -func WithClaudeError(claudeError ClaudeError, statusCode int) *NewAPIError { +func WithClaudeError(claudeError ClaudeError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { if claudeError.Type == "" { claudeError.Type = "upstream_error" } - return &NewAPIError{ + e := &NewAPIError{ RelayError: claudeError, errorType: ErrorTypeClaudeError, StatusCode: statusCode, Err: errors.New(claudeError.Message), errorCode: ErrorCode(claudeError.Type), } + for _, op := range ops { + op(e) + } + return e } func IsChannelError(err *NewAPIError) bool { @@ -245,10 +265,16 @@ func IsChannelError(err *NewAPIError) bool { return strings.HasPrefix(string(err.errorCode), "channel:") } -func IsLocalError(err *NewAPIError) bool { +func IsSkipRetryError(err *NewAPIError) bool { if err == nil { return false } - return err.errorType == ErrorTypeNewAPIError + return err.skipRetry +} + +func ErrOptionWithSkipRetry() NewAPIErrorOptions { + return func(e *NewAPIError) { + e.skipRetry = true + } } From a2fbf368c1801e594e0dd274c4e6e9fbac2fd6da Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 30 Jul 2025 23:26:09 +0800 Subject: [PATCH 125/582] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=BC=80=E5=90=AF=E4=B8=8B=E8=87=AA=E5=8A=A8=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- model/channel.go | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/model/channel.go b/model/channel.go index ea263c84..e3535d64 100644 --- a/model/channel.go +++ b/model/channel.go @@ -75,7 +75,7 @@ func (channel *Channel) getKeys() []string { // If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios) if strings.HasPrefix(trimmed, "[") { var arr []json.RawMessage - if err := json.Unmarshal([]byte(trimmed), &arr); err == nil { + if err := common.Unmarshal([]byte(trimmed), &arr); err == nil { res := make([]string, len(arr)) for i, v := range arr { res[i] = string(v) @@ -197,7 +197,7 @@ func (channel *Channel) GetGroups() []string { func (channel *Channel) GetOtherInfo() map[string]interface{} { otherInfo := make(map[string]interface{}) if channel.OtherInfo != "" { - err := json.Unmarshal([]byte(channel.OtherInfo), &otherInfo) + err := common.Unmarshal([]byte(channel.OtherInfo), &otherInfo) if err != nil { common.SysError("failed to unmarshal other info: " + err.Error()) } @@ -425,7 +425,7 @@ func (channel *Channel) Update() error { trimmed := strings.TrimSpace(keyStr) if strings.HasPrefix(trimmed, "[") { var arr []json.RawMessage - if err := json.Unmarshal([]byte(trimmed), &arr); err == nil { + if err := common.Unmarshal([]byte(trimmed), &arr); err == nil { keys = make([]string, len(arr)) for i, v := range arr { keys[i] = string(v) @@ -553,6 +553,7 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) { } func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool { + println("UpdateChannelStatus called with channelId:", channelId, "usingKey:", usingKey, "status:", status, "reason:", reason) if common.MemoryCacheEnabled { channelStatusLock.Lock() defer channelStatusLock.Unlock() @@ -571,10 +572,6 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri if channelCache.Status == status { return false } - // 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回 - if status != common.ChannelStatusEnabled { - return false - } CacheUpdateChannelStatus(channelId, status) } } @@ -778,7 +775,7 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str func (channel *Channel) ValidateSettings() error { channelParams := &dto.ChannelSettings{} if channel.Setting != nil && *channel.Setting != "" { - err := json.Unmarshal([]byte(*channel.Setting), channelParams) + err := common.Unmarshal([]byte(*channel.Setting), channelParams) if err != nil { return err } @@ -789,7 +786,7 @@ func (channel *Channel) ValidateSettings() error { func (channel *Channel) GetSetting() dto.ChannelSettings { setting := dto.ChannelSettings{} if channel.Setting != nil && *channel.Setting != "" { - err := json.Unmarshal([]byte(*channel.Setting), &setting) + err := common.Unmarshal([]byte(*channel.Setting), &setting) if err != nil { common.SysError("failed to unmarshal setting: " + err.Error()) channel.Setting = nil // 清空设置以避免后续错误 @@ -800,7 +797,7 @@ func (channel *Channel) GetSetting() dto.ChannelSettings { } func (channel *Channel) SetSetting(setting dto.ChannelSettings) { - settingBytes, err := json.Marshal(setting) + settingBytes, err := common.Marshal(setting) if err != nil { common.SysError("failed to marshal setting: " + err.Error()) return @@ -811,7 +808,7 @@ func (channel *Channel) SetSetting(setting dto.ChannelSettings) { func (channel *Channel) GetParamOverride() map[string]interface{} { paramOverride := make(map[string]interface{}) if channel.ParamOverride != nil && *channel.ParamOverride != "" { - err := json.Unmarshal([]byte(*channel.ParamOverride), ¶mOverride) + err := common.Unmarshal([]byte(*channel.ParamOverride), ¶mOverride) if err != nil { common.SysError("failed to unmarshal param override: " + err.Error()) } From 91febc3097aaec841ea220e491768177aa80388c Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 30 Jul 2025 23:29:45 +0800 Subject: [PATCH 126/582] fix: remove debug print statement --- model/channel.go | 1 - 1 file changed, 1 deletion(-) diff --git a/model/channel.go b/model/channel.go index e3535d64..58f0a064 100644 --- a/model/channel.go +++ b/model/channel.go @@ -553,7 +553,6 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) { } func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool { - println("UpdateChannelStatus called with channelId:", channelId, "usingKey:", usingKey, "status:", status, "reason:", reason) if common.MemoryCacheEnabled { channelStatusLock.Lock() defer channelStatusLock.Unlock() From 40848f28d4fa4d3676b5716dddb7202e14062ab5 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 30 Jul 2025 23:32:20 +0800 Subject: [PATCH 127/582] fix: correct request mode assignment logic in adaptor --- relay/channel/vertex/adaptor.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 40c9ca89..c88b4359 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -69,8 +69,9 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { a.RequestMode = RequestModeClaude } else if strings.Contains(info.UpstreamModelName, "llama") { a.RequestMode = RequestModeLlama + } else { + a.RequestMode = RequestModeGemini } - a.RequestMode = RequestModeGemini } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { From 590e6db670a437c4e38fcca4e1a8ef7529cbc5b7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 31 Jul 2025 10:56:51 +0800 Subject: [PATCH 128/582] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A2=AB?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E7=9A=84=E6=B8=A0=E9=81=93=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/channel-test.go | 7 +++++-- model/channel_cache.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 75fec463..3a7c582b 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -332,8 +332,11 @@ func TestChannel(c *gin.Context) { } channel, err := model.CacheGetChannel(channelId) if err != nil { - common.ApiError(c, err) - return + channel, err = model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, err) + return + } } //defer func() { // if channel.ChannelInfo.IsMultiKey { diff --git a/model/channel_cache.go b/model/channel_cache.go index 45069ba0..1abc8b85 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -239,6 +239,20 @@ func CacheUpdateChannelStatus(id int, status int) { if channel, ok := channelsIDM[id]; ok { channel.Status = status } + if status != common.ChannelStatusEnabled { + // delete the channel from group2model2channels + for group, model2channels := range group2model2channels { + for model, channels := range model2channels { + for i, channelId := range channels { + if channelId == id { + // remove the channel from the slice + group2model2channels[group][model] = append(channels[:i], channels[i+1:]...) + break + } + } + } + } + } } func CacheUpdateChannel(channel *Channel) { From 6d2e92fb7b6c5347ebc7eec19dbfaeeb8a768c8f Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 31 Jul 2025 12:54:07 +0800 Subject: [PATCH 129/582] feat: add JSONEditor component for enhanced JSON input handling --- web/src/components/common/JSONEditor.js | 609 ++++++++++++++++++ .../channels/modals/EditChannelModal.jsx | 61 +- 2 files changed, 637 insertions(+), 33 deletions(-) create mode 100644 web/src/components/common/JSONEditor.js diff --git a/web/src/components/common/JSONEditor.js b/web/src/components/common/JSONEditor.js new file mode 100644 index 00000000..d0c159b2 --- /dev/null +++ b/web/src/components/common/JSONEditor.js @@ -0,0 +1,609 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Space, + Button, + Form, + Card, + Typography, + Banner, + Row, + Col, + InputNumber, + Switch, + Select, + Input, +} from '@douyinfe/semi-ui'; +import { + IconCode, + IconEdit, + IconPlus, + IconDelete, + IconSetting, +} from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +const JSONEditor = ({ + value = '', + onChange, + field, + label, + placeholder, + extraText, + showClear = true, + template, + templateLabel, + editorType = 'keyValue', // keyValue, object, region + autosize = true, + rules = [], + formApi = null, + ...props +}) => { + const { t } = useTranslation(); + + // 初始化JSON数据 + const [jsonData, setJsonData] = useState(() => { + // 初始化时解析JSON数据 + if (value && value.trim()) { + try { + const parsed = JSON.parse(value); + return parsed; + } catch (error) { + return {}; + } + } + return {}; + }); + + // 根据键数量决定默认编辑模式 + const [editMode, setEditMode] = useState(() => { + // 如果初始JSON数据的键数量大于10个,则默认使用手动模式 + if (value && value.trim()) { + try { + const parsed = JSON.parse(value); + const keyCount = Object.keys(parsed).length; + return keyCount > 10 ? 'manual' : 'visual'; + } catch (error) { + return 'visual'; + } + } + return 'visual'; + }); + const [jsonError, setJsonError] = useState(''); + + // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效) + useEffect(() => { + try { + const parsed = value && value.trim() ? JSON.parse(value) : {}; + setJsonData(parsed); + setJsonError(''); + } catch (error) { + console.log('JSON解析失败:', error.message); + setJsonError(error.message); + // JSON格式错误时不更新jsonData + } + }, [value]); + + + // 处理可视化编辑的数据变化 + const handleVisualChange = useCallback((newData) => { + setJsonData(newData); + setJsonError(''); + const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2); + + // 通过formApi设置值(如果提供的话) + if (formApi && field) { + formApi.setValue(field, jsonString); + } + + onChange?.(jsonString); + }, [onChange, formApi, field]); + + // 处理手动编辑的数据变化 + const handleManualChange = useCallback((newValue) => { + onChange?.(newValue); + // 验证JSON格式 + if (newValue && newValue.trim()) { + try { + const parsed = JSON.parse(newValue); + setJsonError(''); + // 预先准备可视化数据,但不立即应用 + // 这样切换到可视化模式时数据已经准备好了 + } catch (error) { + setJsonError(error.message); + } + } else { + setJsonError(''); + } + }, [onChange]); + + // 切换编辑模式 + const toggleEditMode = useCallback(() => { + if (editMode === 'visual') { + // 从可视化模式切换到手动模式 + setEditMode('manual'); + } else { + // 从手动模式切换到可视化模式,需要验证JSON + try { + const parsed = value && value.trim() ? JSON.parse(value) : {}; + setJsonData(parsed); + setJsonError(''); + setEditMode('visual'); + } catch (error) { + setJsonError(error.message); + // JSON格式错误时不切换模式 + return; + } + } + }, [editMode, value]); + + // 添加键值对 + const addKeyValue = useCallback(() => { + const newData = { ...jsonData }; + const keys = Object.keys(newData); + let newKey = 'key'; + let counter = 1; + while (newData.hasOwnProperty(newKey)) { + newKey = `key${counter}`; + counter++; + } + newData[newKey] = ''; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 删除键值对 + const removeKeyValue = useCallback((keyToRemove) => { + const newData = { ...jsonData }; + delete newData[keyToRemove]; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 更新键名 + const updateKey = useCallback((oldKey, newKey) => { + if (oldKey === newKey) return; + const newData = { ...jsonData }; + const value = newData[oldKey]; + delete newData[oldKey]; + newData[newKey] = value; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 更新值 + const updateValue = useCallback((key, newValue) => { + const newData = { ...jsonData }; + newData[key] = newValue; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 填入模板 + const fillTemplate = useCallback(() => { + if (template) { + const templateString = JSON.stringify(template, null, 2); + + // 通过formApi设置值(如果提供的话) + if (formApi && field) { + formApi.setValue(field, templateString); + } + + // 无论哪种模式都要更新值 + onChange?.(templateString); + + // 如果是可视化模式,同时更新jsonData + if (editMode === 'visual') { + setJsonData(template); + } + + // 清除错误状态 + setJsonError(''); + } + }, [template, onChange, editMode, formApi, field]); + + // 渲染键值对编辑器 + const renderKeyValueEditor = () => { + const entries = Object.entries(jsonData); + + return ( +
    + {entries.length === 0 && ( +
    +
    + +
    + + {t('暂无数据,点击下方按钮添加键值对')} + +
    + )} + + {entries.map(([key, value], index) => ( + + +
    +
    + {t('键名')} + updateKey(key, newKey)} + size="small" + /> +
    + + +
    + {t('值')} + updateValue(key, newValue)} + size="small" + /> +
    + + +
    +
    + + + + ))} + +
    + +
    + + ); + }; + + // 渲染对象编辑器(用于复杂JSON) + const renderObjectEditor = () => { + const entries = Object.entries(jsonData); + + return ( +
    + {entries.length === 0 && ( +
    +
    + +
    + + {t('暂无参数,点击下方按钮添加请求参数')} + +
    + )} + + {entries.map(([key, value], index) => ( + + +
    +
    + {t('参数名')} + updateKey(key, newKey)} + size="small" + /> +
    + + +
    + {t('参数值')} ({typeof value}) + {renderValueInput(key, value)} +
    + + +
    +
    + + + + ))} + +
    + +
    + + ); + }; + + // 渲染参数值输入控件 + const renderValueInput = (key, value) => { + const valueType = typeof value; + + if (valueType === 'boolean') { + return ( +
    + updateValue(key, newValue)} + size="small" + /> + + {value ? t('true') : t('false')} + +
    + ); + } + + if (valueType === 'number') { + return ( + updateValue(key, newValue)} + size="small" + style={{ width: '100%' }} + step={key === 'temperature' ? 0.1 : 1} + precision={key === 'temperature' ? 2 : 0} + placeholder={t('输入数字')} + /> + ); + } + + // 字符串类型或其他类型 + return ( + { + // 尝试转换为适当的类型 + let convertedValue = newValue; + if (newValue === 'true') convertedValue = true; + else if (newValue === 'false') convertedValue = false; + else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') { + convertedValue = Number(newValue); + } + + updateValue(key, convertedValue); + }} + size="small" + /> + ); + }; + + // 渲染区域编辑器(特殊格式) + const renderRegionEditor = () => { + const entries = Object.entries(jsonData); + const defaultEntry = entries.find(([key]) => key === 'default'); + const modelEntries = entries.filter(([key]) => key !== 'default'); + + return ( +
    + {/* 默认区域 */} + +
    + {t('默认区域')} +
    + updateValue('default', value)} + size="small" + /> +
    + + {/* 模型专用区域 */} +
    + {t('模型专用区域')} + {modelEntries.map(([modelName, region], index) => ( + + +
    +
    + {t('模型名称')} + updateKey(modelName, newKey)} + size="small" + /> +
    + + +
    + {t('区域')} + updateValue(modelName, newValue)} + size="small" + /> +
    + + +
    +
    + + + + ))} + +
    + +
    + + + ); + }; + + // 渲染可视化编辑器 + const renderVisualEditor = () => { + switch (editorType) { + case 'region': + return renderRegionEditor(); + case 'object': + return renderObjectEditor(); + case 'keyValue': + default: + return renderKeyValueEditor(); + } + }; + + const hasJsonError = jsonError && jsonError.trim() !== ''; + + return ( +
    + {/* Label统一显示在上方 */} + {label && ( +
    + {label} +
    + )} + + {/* 编辑模式切换 */} +
    +
    + {editMode === 'visual' && ( + + {t('可视化模式')} + + )} + {editMode === 'manual' && ( + + {t('手动编辑模式')} + + )} +
    +
    + {template && templateLabel && ( + + )} + + + + +
    +
    + + {/* JSON错误提示 */} + {hasJsonError && ( + + )} + + {/* 编辑器内容 */} + {editMode === 'visual' ? ( +
    + + {renderVisualEditor()} + + {/* 可视化模式下的额外文本显示在下方 */} + {extraText && ( +
    + {extraText} +
    + )} + {/* 隐藏的Form字段用于验证和数据绑定 */} + +
    + ) : ( + + )} + + {/* 额外文本在手动编辑模式下显示 */} + {extraText && editMode === 'manual' && ( +
    + {extraText} +
    + )} +
    + ); +}; + +export default JSONEditor; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index a3f09166..37e9af75 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -48,6 +48,7 @@ import { } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; +import JSONEditor from '../../../common/JSONEditor'; import { IconSave, IconClose, @@ -69,7 +70,9 @@ const STATUS_CODE_MAPPING_EXAMPLE = { }; const REGION_EXAMPLE = { - default: 'us-central1', + "default": 'global', + "gemini-1.5-pro-002": "europe-west2", + "gemini-1.5-flash-002": "europe-west2", 'claude-3-5-sonnet-20240620': 'europe-west1', }; @@ -1174,24 +1177,24 @@ const EditChannelModal = (props) => { )} {inputs.type === 41 && ( - handleInputChange('other', value)} rules={[{ required: true, message: t('请填写部署地区') }]} + template={REGION_EXAMPLE} + templateLabel={t('填入模板')} + editorType="region" + formApi={formApiRef.current} extraText={ - handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))} - > - {t('填入模板')} + + {t('设置默认地区和特定模型的专用地区')} } - showClear /> )} @@ -1447,24 +1450,24 @@ const EditChannelModal = (props) => { showClear /> - handleInputChange('model_mapping', value)} + template={MODEL_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType="keyValue" + formApi={formApiRef.current} extraText={ - handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))} - > - {t('填入模板')} + + {t('键为请求中的模型名称,值为要替换的模型名称')} } - showClear /> @@ -1554,7 +1557,7 @@ const EditChannelModal = (props) => { showClear /> - { '\n' + JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2) } - autosize + value={inputs.status_code_mapping || ''} onChange={(value) => handleInputChange('status_code_mapping', value)} + template={STATUS_CODE_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType="keyValue" + formApi={formApiRef.current} extraText={ - handleInputChange('status_code_mapping', JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2))} - > - {t('填入模板')} + + {t('键为原状态码,值为要复写的状态码,仅影响本地判断')} } - showClear /> @@ -1585,14 +1588,6 @@ const EditChannelModal = (props) => {
    {t('渠道额外设置')} -
    - window.open('https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md')} - > - {t('设置说明')} - -
    From 563d056ff7c2fd66d8b1269b96eec6ef040fd8f3 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 31 Jul 2025 21:16:01 +0800 Subject: [PATCH 130/582] refactor: update error handling to support dynamic error types --- dto/claude.go | 48 +++++++++++++++---- dto/openai_response.go | 64 +++++++++++++++++++++++-- relay/channel/claude/relay-claude.go | 8 ++-- relay/channel/gemini/dto.go | 9 ++-- relay/channel/openai/relay-openai.go | 4 +- relay/channel/openai/relay_responses.go | 4 +- service/convert.go | 22 --------- service/error.go | 2 +- types/error.go | 2 +- 9 files changed, 117 insertions(+), 46 deletions(-) diff --git a/dto/claude.go b/dto/claude.go index 1a7eacb1..ea099df4 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -2,6 +2,7 @@ package dto import ( "encoding/json" + "fmt" "one-api/common" "one-api/types" ) @@ -284,14 +285,9 @@ func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { return mediaContent } -type ClaudeError struct { - Type string `json:"type,omitempty"` - Message string `json:"message,omitempty"` -} - type ClaudeErrorWithStatusCode struct { - Error ClaudeError `json:"error"` - StatusCode int `json:"status_code"` + Error types.ClaudeError `json:"error"` + StatusCode int `json:"status_code"` LocalError bool } @@ -303,7 +299,7 @@ type ClaudeResponse struct { Completion string `json:"completion,omitempty"` StopReason string `json:"stop_reason,omitempty"` Model string `json:"model,omitempty"` - Error *types.ClaudeError `json:"error,omitempty"` + Error any `json:"error,omitempty"` Usage *ClaudeUsage `json:"usage,omitempty"` Index *int `json:"index,omitempty"` ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"` @@ -324,6 +320,42 @@ func (c *ClaudeResponse) GetIndex() int { return *c.Index } +// GetClaudeError 从动态错误类型中提取ClaudeError结构 +func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError { + if c.Error == nil { + return nil + } + + switch err := c.Error.(type) { + case types.ClaudeError: + return &err + case *types.ClaudeError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + claudeErr := &types.ClaudeError{} + if errType, ok := err["type"].(string); ok { + claudeErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + claudeErr.Message = errMsg + } + return claudeErr + case string: + // 处理简单字符串错误 + return &types.ClaudeError{ + Type: "error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.ClaudeError{ + Type: "unknown_error", + Message: fmt.Sprintf("%v", err), + } + } +} + type ClaudeUsage struct { InputTokens int `json:"input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` diff --git a/dto/openai_response.go b/dto/openai_response.go index 4e534823..b050cd03 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -2,12 +2,18 @@ package dto import ( "encoding/json" + "fmt" "one-api/types" ) type SimpleResponse struct { Usage `json:"usage"` - Error *OpenAIError `json:"error"` + Error any `json:"error"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(s.Error) } type TextResponse struct { @@ -31,10 +37,15 @@ type OpenAITextResponse struct { Object string `json:"object"` Created any `json:"created"` Choices []OpenAITextResponseChoice `json:"choices"` - Error *types.OpenAIError `json:"error,omitempty"` + Error any `json:"error,omitempty"` Usage `json:"usage"` } +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + type OpenAIEmbeddingResponseItem struct { Object string `json:"object"` Index int `json:"index"` @@ -217,7 +228,7 @@ type OpenAIResponsesResponse struct { Object string `json:"object"` CreatedAt int `json:"created_at"` Status string `json:"status"` - Error *types.OpenAIError `json:"error,omitempty"` + Error any `json:"error,omitempty"` IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"` Instructions string `json:"instructions"` MaxOutputTokens int `json:"max_output_tokens"` @@ -237,6 +248,11 @@ type OpenAIResponsesResponse struct { Metadata json.RawMessage `json:"metadata"` } +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + type IncompleteDetails struct { Reasoning string `json:"reasoning"` } @@ -276,3 +292,45 @@ type ResponsesStreamResponse struct { Delta string `json:"delta,omitempty"` Item *ResponsesOutput `json:"item,omitempty"` } + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func GetOpenAIError(errorField any) *types.OpenAIError { + if errorField == nil { + return nil + } + + switch err := errorField.(type) { + case types.OpenAIError: + return &err + case *types.OpenAIError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + openaiErr := &types.OpenAIError{} + if errType, ok := err["type"].(string); ok { + openaiErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + openaiErr.Message = errMsg + } + if errParam, ok := err["param"].(string); ok { + openaiErr.Param = errParam + } + if errCode, ok := err["code"]; ok { + openaiErr.Code = errCode + } + return openaiErr + case string: + // 处理简单字符串错误 + return &types.OpenAIError{ + Type: "error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.OpenAIError{ + Type: "unknown_error", + Message: fmt.Sprintf("%v", err), + } + } +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index f20b573d..64739aa9 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -612,8 +612,8 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud common.SysError("error unmarshalling stream response: " + err.Error()) return types.NewError(err, types.ErrorCodeBadResponseBody) } - if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { - return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) } if info.RelayFormat == relaycommon.RelayFormatClaude { FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo) @@ -704,8 +704,8 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud if err != nil { return types.NewError(err, types.ErrorCodeBadResponseBody) } - if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { - return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) } if requestMode == RequestModeCompletion { completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) diff --git a/relay/channel/gemini/dto.go b/relay/channel/gemini/dto.go index b22e092a..a5e41c83 100644 --- a/relay/channel/gemini/dto.go +++ b/relay/channel/gemini/dto.go @@ -1,6 +1,9 @@ package gemini -import "encoding/json" +import ( + "encoding/json" + "one-api/common" +) type GeminiChatRequest struct { Contents []GeminiChatContent `json:"contents"` @@ -32,7 +35,7 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { MimeTypeSnake string `json:"mime_type"` } - if err := json.Unmarshal(data, &aux); err != nil { + if err := common.Unmarshal(data, &aux); err != nil { return err } @@ -93,7 +96,7 @@ func (p *GeminiPart) UnmarshalJSON(data []byte) error { InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant } - if err := json.Unmarshal(data, &aux); err != nil { + if err := common.Unmarshal(data, &aux); err != nil { return err } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 2252b407..f6a04f3a 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -184,8 +184,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - if simpleResponse.Error != nil && simpleResponse.Error.Type != "" { - return nil, types.WithOpenAIError(*simpleResponse.Error, resp.StatusCode) + if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } forceFormat := false diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index fd57924b..ef063e7c 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -28,8 +28,8 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - if responsesResponse.Error != nil { - return nil, types.WithOpenAIError(*responsesResponse.Error, resp.StatusCode) + if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } // 写入新的 response body diff --git a/service/convert.go b/service/convert.go index 7d697840..787cc79d 100644 --- a/service/convert.go +++ b/service/convert.go @@ -188,28 +188,6 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re return &openAIRequest, nil } -func OpenAIErrorToClaudeError(openAIError *dto.OpenAIErrorWithStatusCode) *dto.ClaudeErrorWithStatusCode { - claudeError := dto.ClaudeError{ - Type: "new_api_error", - Message: openAIError.Error.Message, - } - return &dto.ClaudeErrorWithStatusCode{ - Error: claudeError, - StatusCode: openAIError.StatusCode, - } -} - -func ClaudeErrorToOpenAIError(claudeError *dto.ClaudeErrorWithStatusCode) *dto.OpenAIErrorWithStatusCode { - openAIError := dto.OpenAIError{ - Message: claudeError.Error.Message, - Type: "new_api_error", - } - return &dto.OpenAIErrorWithStatusCode{ - Error: openAIError, - StatusCode: claudeError.StatusCode, - } -} - func generateStopBlock(index int) *dto.ClaudeResponse { return &dto.ClaudeResponse{ Type: "content_block_stop", diff --git a/service/error.go b/service/error.go index 94d9c250..ad28c90f 100644 --- a/service/error.go +++ b/service/error.go @@ -62,7 +62,7 @@ func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeError text = "请求上游地址失败" } } - claudeError := dto.ClaudeError{ + claudeError := types.ClaudeError{ Message: text, Type: "new_api_error", } diff --git a/types/error.go b/types/error.go index 74c3bae5..86aaf692 100644 --- a/types/error.go +++ b/types/error.go @@ -16,8 +16,8 @@ type OpenAIError struct { } type ClaudeError struct { - Message string `json:"message,omitempty"` Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` } type ErrorType string From 2bc8de398eaab1e3fc22000456b43bb01572ede3 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 31 Jul 2025 21:27:24 +0800 Subject: [PATCH 131/582] fix: handle authorization code format in ExchangeCode function and update placeholder in EditChannelModal --- service/claude_oauth.go | 11 +++++++++-- .../table/channels/modals/EditChannelModal.jsx | 3 +-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/service/claude_oauth.go b/service/claude_oauth.go index 136269ae..b0e1f84d 100644 --- a/service/claude_oauth.go +++ b/service/claude_oauth.go @@ -60,7 +60,7 @@ type OAuth2Credentials struct { // GetClaudeOAuthConfig returns the Claude OAuth2 configuration func GetClaudeOAuthConfig() *oauth2.Config { authorizeURL, tokenURL, clientID, redirectURI, scopes := getOAuthValues() - + return &oauth2.Config{ ClientID: clientID, RedirectURL: redirectURI, @@ -103,6 +103,13 @@ func GenerateOAuthParams() (*OAuth2Credentials, error) { func ExchangeCode(authorizationCode, codeVerifier, state string, client *http.Client) (*oauth2.Token, error) { config := getOAuthConfig() + if strings.Contains(authorizationCode, "#") { + parts := strings.Split(authorizationCode, "#") + if len(parts) > 0 { + authorizationCode = parts[0] + } + } + ctx := context.Background() if client != nil { ctx = context.WithValue(ctx, oauth2.HTTPClient, client) @@ -141,7 +148,7 @@ func GetClaudeHTTPClient() *http.Client { // RefreshClaudeToken refreshes a Claude OAuth token using the refresh token func RefreshClaudeToken(accessToken, refreshToken string) (*oauth2.Token, error) { config := GetClaudeOAuthConfig() - + // Create token from current values currentToken := &oauth2.Token{ AccessToken: accessToken, diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 8eb3c5a6..cb09b3c9 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -57,7 +57,6 @@ import { IconSetting, } from '@douyinfe/semi-icons'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { copy, getChannelIcon, getChannelModels, getModelCategories, modelSelectFilter } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; import { useTranslation } from 'react-i18next'; @@ -1853,7 +1852,7 @@ const EditChannelModal = (props) => { From 3b34afa8c98aec124fd199ebe292d74a881ca7ba Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:08:16 +0800 Subject: [PATCH 132/582] Revert "feat: add Claude Code channel support with OAuth integration" --- common/api_type.go | 2 - constant/api_type.go | 1 - constant/channel.go | 2 - controller/claude_oauth.go | 73 ----- go.mod | 1 - go.sum | 2 - main.go | 3 - relay/channel/claude_code/adaptor.go | 158 ----------- relay/channel/claude_code/constants.go | 14 - relay/channel/claude_code/dto.go | 4 - relay/relay_adaptor.go | 3 - router/api-router.go | 3 - service/claude_oauth.go | 171 ------------ service/claude_token_refresh.go | 94 ------- .../operation_setting/operation_setting.go | 3 - .../channels/modals/EditChannelModal.jsx | 257 ++---------------- web/src/constants/channel.constants.js | 8 - web/src/helpers/render.js | 1 - 18 files changed, 26 insertions(+), 774 deletions(-) delete mode 100644 controller/claude_oauth.go delete mode 100644 relay/channel/claude_code/adaptor.go delete mode 100644 relay/channel/claude_code/constants.go delete mode 100644 relay/channel/claude_code/dto.go delete mode 100644 service/claude_oauth.go delete mode 100644 service/claude_token_refresh.go diff --git a/common/api_type.go b/common/api_type.go index c31f2e2c..f045866a 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -65,8 +65,6 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeCoze case constant.ChannelTypeJimeng: apiType = constant.APITypeJimeng - case constant.ChannelTypeClaudeCode: - apiType = constant.APITypeClaudeCode } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/api_type.go b/constant/api_type.go index bca5e311..6ba5f257 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -31,6 +31,5 @@ const ( APITypeXai APITypeCoze APITypeJimeng - APITypeClaudeCode APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index cc71caf3..2e1cc5b0 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -50,7 +50,6 @@ const ( ChannelTypeKling = 50 ChannelTypeJimeng = 51 ChannelTypeVidu = 52 - ChannelTypeClaudeCode = 53 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -109,5 +108,4 @@ var ChannelBaseURLs = []string{ "https://api.klingai.com", //50 "https://visual.volcengineapi.com", //51 "https://api.vidu.cn", //52 - "https://api.anthropic.com", //53 } diff --git a/controller/claude_oauth.go b/controller/claude_oauth.go deleted file mode 100644 index de711b93..00000000 --- a/controller/claude_oauth.go +++ /dev/null @@ -1,73 +0,0 @@ -package controller - -import ( - "net/http" - "one-api/common" - "one-api/service" - - "github.com/gin-gonic/gin" -) - -// ExchangeCodeRequest 授权码交换请求 -type ExchangeCodeRequest struct { - AuthorizationCode string `json:"authorization_code" binding:"required"` - CodeVerifier string `json:"code_verifier" binding:"required"` - State string `json:"state" binding:"required"` -} - -// GenerateClaudeOAuthURL 生成Claude OAuth授权URL -func GenerateClaudeOAuthURL(c *gin.Context) { - params, err := service.GenerateOAuthParams() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "生成OAuth授权URL失败: " + err.Error(), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "生成OAuth授权URL成功", - "data": params, - }) -} - -// ExchangeClaudeOAuthCode 交换Claude OAuth授权码 -func ExchangeClaudeOAuthCode(c *gin.Context) { - var req ExchangeCodeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "success": false, - "message": "请求参数错误: " + err.Error(), - }) - return - } - - // 解析授权码 - cleanedCode, err := service.ParseAuthorizationCode(req.AuthorizationCode) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - - // 交换token - tokenResult, err := service.ExchangeCode(cleanedCode, req.CodeVerifier, req.State, nil) - if err != nil { - common.SysError("Claude OAuth token exchange failed: " + err.Error()) - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "授权码交换失败: " + err.Error(), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "授权码交换成功", - "data": tokenResult, - }) -} diff --git a/go.mod b/go.mod index bae7a4e8..94873c88 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,6 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 8ded1a03..74eecd4c 100644 --- a/go.sum +++ b/go.sum @@ -231,8 +231,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/main.go b/main.go index f49995c2..ca3da601 100644 --- a/main.go +++ b/main.go @@ -86,9 +86,6 @@ func main() { // 数据看板 go model.UpdateQuotaData() - // Start Claude Code token refresh scheduler - service.StartClaudeTokenRefreshScheduler() - if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) if err != nil { diff --git a/relay/channel/claude_code/adaptor.go b/relay/channel/claude_code/adaptor.go deleted file mode 100644 index 7a0be927..00000000 --- a/relay/channel/claude_code/adaptor.go +++ /dev/null @@ -1,158 +0,0 @@ -package claude_code - -import ( - "errors" - "fmt" - "io" - "net/http" - "one-api/dto" - "one-api/relay/channel" - "one-api/relay/channel/claude" - relaycommon "one-api/relay/common" - "one-api/types" - "strings" - - "github.com/gin-gonic/gin" -) - -const ( - RequestModeCompletion = 1 - RequestModeMessage = 2 - DefaultSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." -) - -type Adaptor struct { - RequestMode int -} - -func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { - // Use configured system prompt if available, otherwise use default - if info.ChannelSetting.SystemPrompt != "" { - request.System = info.ChannelSetting.SystemPrompt - } else { - request.System = DefaultSystemPrompt - } - - return request, nil -} - -func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { - return nil, errors.New("not implemented") -} - -func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { - return nil, errors.New("not implemented") -} - -func (a *Adaptor) Init(info *relaycommon.RelayInfo) { - if strings.HasPrefix(info.UpstreamModelName, "claude-2") || strings.HasPrefix(info.UpstreamModelName, "claude-instant") { - a.RequestMode = RequestModeCompletion - } else { - a.RequestMode = RequestModeMessage - } -} - -func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if a.RequestMode == RequestModeMessage { - return fmt.Sprintf("%s/v1/messages", info.BaseUrl), nil - } else { - return fmt.Sprintf("%s/v1/complete", info.BaseUrl), nil - } -} - -func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { - channel.SetupApiRequestHeader(info, c, req) - - // Parse accesstoken|refreshtoken format and use only the access token - accessToken := info.ApiKey - if strings.Contains(info.ApiKey, "|") { - parts := strings.Split(info.ApiKey, "|") - if len(parts) >= 1 { - accessToken = parts[0] - } - } - - // Claude Code specific headers - force override - req.Set("Authorization", "Bearer "+accessToken) - // 只有在没有设置的情况下才设置 anthropic-version - if req.Get("anthropic-version") == "" { - req.Set("anthropic-version", "2023-06-01") - } - req.Set("content-type", "application/json") - - // 只有在 user-agent 不包含 claude-cli 时才设置 - userAgent := req.Get("user-agent") - if userAgent == "" || !strings.Contains(strings.ToLower(userAgent), "claude-cli") { - req.Set("user-agent", "claude-cli/1.0.61 (external, cli)") - } - - // 只有在 anthropic-beta 不包含 claude-code 时才设置 - anthropicBeta := req.Get("anthropic-beta") - if anthropicBeta == "" || !strings.Contains(strings.ToLower(anthropicBeta), "claude-code") { - req.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14") - } - // if Anthropic-Dangerous-Direct-Browser-Access - anthropicDangerousDirectBrowserAccess := req.Get("anthropic-dangerous-direct-browser-access") - if anthropicDangerousDirectBrowserAccess == "" { - req.Set("anthropic-dangerous-direct-browser-access", "true") - } - - return nil -} - -func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { - if request == nil { - return nil, errors.New("request is nil") - } - - if a.RequestMode == RequestModeCompletion { - return claude.RequestOpenAI2ClaudeComplete(*request), nil - } else { - claudeRequest, err := claude.RequestOpenAI2ClaudeMessage(*request) - if err != nil { - return nil, err - } - - // Use configured system prompt if available, otherwise use default - if info.ChannelSetting.SystemPrompt != "" { - claudeRequest.System = info.ChannelSetting.SystemPrompt - } else { - claudeRequest.System = DefaultSystemPrompt - } - - return claudeRequest, nil - } -} - -func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { - return nil, nil -} - -func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { - return nil, errors.New("not implemented") -} - -func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - return nil, errors.New("not implemented") -} - -func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { - return channel.DoApiRequest(a, c, info, requestBody) -} - -func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - if info.IsStream { - err, usage = claude.ClaudeStreamHandler(c, resp, info, a.RequestMode) - } else { - err, usage = claude.ClaudeHandler(c, resp, a.RequestMode, info) - } - return -} - -func (a *Adaptor) GetModelList() []string { - return ModelList -} - -func (a *Adaptor) GetChannelName() string { - return ChannelName -} diff --git a/relay/channel/claude_code/constants.go b/relay/channel/claude_code/constants.go deleted file mode 100644 index 82695be2..00000000 --- a/relay/channel/claude_code/constants.go +++ /dev/null @@ -1,14 +0,0 @@ -package claude_code - -var ModelList = []string{ - "claude-3-5-haiku-20241022", - "claude-3-5-sonnet-20241022", - "claude-3-7-sonnet-20250219", - "claude-3-7-sonnet-20250219-thinking", - "claude-sonnet-4-20250514", - "claude-sonnet-4-20250514-thinking", - "claude-opus-4-20250514", - "claude-opus-4-20250514-thinking", -} - -var ChannelName = "claude_code" diff --git a/relay/channel/claude_code/dto.go b/relay/channel/claude_code/dto.go deleted file mode 100644 index 68bb9269..00000000 --- a/relay/channel/claude_code/dto.go +++ /dev/null @@ -1,4 +0,0 @@ -package claude_code - -// Claude Code uses the same DTO structures as Claude since it's based on the same API -// This file is kept for consistency with the channel structure pattern \ No newline at end of file diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 2456c77f..cc9c5bbb 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -9,7 +9,6 @@ import ( "one-api/relay/channel/baidu" "one-api/relay/channel/baidu_v2" "one-api/relay/channel/claude" - "one-api/relay/channel/claude_code" "one-api/relay/channel/cloudflare" "one-api/relay/channel/cohere" "one-api/relay/channel/coze" @@ -99,8 +98,6 @@ func GetAdaptor(apiType int) channel.Adaptor { return &coze.Adaptor{} case constant.APITypeJimeng: return &jimeng.Adaptor{} - case constant.APITypeClaudeCode: - return &claude_code.Adaptor{} } return nil } diff --git a/router/api-router.go b/router/api-router.go index 702fc99f..bc49803a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -120,9 +120,6 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.GET("/tag/models", controller.GetTagModels) channelRoute.POST("/copy/:id", controller.CopyChannel) - // Claude OAuth路由 - channelRoute.GET("/claude/oauth/url", controller.GenerateClaudeOAuthURL) - channelRoute.POST("/claude/oauth/exchange", controller.ExchangeClaudeOAuthCode) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) diff --git a/service/claude_oauth.go b/service/claude_oauth.go deleted file mode 100644 index b0e1f84d..00000000 --- a/service/claude_oauth.go +++ /dev/null @@ -1,171 +0,0 @@ -package service - -import ( - "context" - "fmt" - "net/http" - "os" - "strings" - "time" - - "golang.org/x/oauth2" -) - -const ( - // Default OAuth configuration values - DefaultAuthorizeURL = "https://claude.ai/oauth/authorize" - DefaultTokenURL = "https://console.anthropic.com/v1/oauth/token" - DefaultClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - DefaultRedirectURI = "https://console.anthropic.com/oauth/code/callback" - DefaultScopes = "user:inference" -) - -// getOAuthValues returns OAuth configuration values from environment variables or defaults -func getOAuthValues() (authorizeURL, tokenURL, clientID, redirectURI, scopes string) { - authorizeURL = os.Getenv("CLAUDE_AUTHORIZE_URL") - if authorizeURL == "" { - authorizeURL = DefaultAuthorizeURL - } - - tokenURL = os.Getenv("CLAUDE_TOKEN_URL") - if tokenURL == "" { - tokenURL = DefaultTokenURL - } - - clientID = os.Getenv("CLAUDE_CLIENT_ID") - if clientID == "" { - clientID = DefaultClientID - } - - redirectURI = os.Getenv("CLAUDE_REDIRECT_URI") - if redirectURI == "" { - redirectURI = DefaultRedirectURI - } - - scopes = os.Getenv("CLAUDE_SCOPES") - if scopes == "" { - scopes = DefaultScopes - } - - return -} - -type OAuth2Credentials struct { - AuthURL string `json:"auth_url"` - CodeVerifier string `json:"code_verifier"` - State string `json:"state"` - CodeChallenge string `json:"code_challenge"` -} - -// GetClaudeOAuthConfig returns the Claude OAuth2 configuration -func GetClaudeOAuthConfig() *oauth2.Config { - authorizeURL, tokenURL, clientID, redirectURI, scopes := getOAuthValues() - - return &oauth2.Config{ - ClientID: clientID, - RedirectURL: redirectURI, - Scopes: strings.Split(scopes, " "), - Endpoint: oauth2.Endpoint{ - AuthURL: authorizeURL, - TokenURL: tokenURL, - }, - } -} - -// getOAuthConfig is kept for backward compatibility -func getOAuthConfig() *oauth2.Config { - return GetClaudeOAuthConfig() -} - -// GenerateOAuthParams generates OAuth authorization URL and related parameters -func GenerateOAuthParams() (*OAuth2Credentials, error) { - config := getOAuthConfig() - - // Generate PKCE parameters - codeVerifier := oauth2.GenerateVerifier() - state := oauth2.GenerateVerifier() // Reuse generator as state - - // Generate authorization URL - authURL := config.AuthCodeURL(state, - oauth2.S256ChallengeOption(codeVerifier), - oauth2.SetAuthURLParam("code", "true"), // Claude-specific parameter - ) - - return &OAuth2Credentials{ - AuthURL: authURL, - CodeVerifier: codeVerifier, - State: state, - CodeChallenge: oauth2.S256ChallengeFromVerifier(codeVerifier), - }, nil -} - -// ExchangeCode -func ExchangeCode(authorizationCode, codeVerifier, state string, client *http.Client) (*oauth2.Token, error) { - config := getOAuthConfig() - - if strings.Contains(authorizationCode, "#") { - parts := strings.Split(authorizationCode, "#") - if len(parts) > 0 { - authorizationCode = parts[0] - } - } - - ctx := context.Background() - if client != nil { - ctx = context.WithValue(ctx, oauth2.HTTPClient, client) - } - - token, err := config.Exchange(ctx, authorizationCode, - oauth2.VerifierOption(codeVerifier), - oauth2.SetAuthURLParam("state", state), - ) - if err != nil { - return nil, fmt.Errorf("token exchange failed: %w", err) - } - - return token, nil -} - -func ParseAuthorizationCode(input string) (string, error) { - if input == "" { - return "", fmt.Errorf("please provide a valid authorization code") - } - // URLs are not allowed - if strings.Contains(input, "http") || strings.Contains(input, "https") { - return "", fmt.Errorf("authorization code cannot contain URLs") - } - - return input, nil -} - -// GetClaudeHTTPClient returns a configured HTTP client for Claude OAuth operations -func GetClaudeHTTPClient() *http.Client { - return &http.Client{ - Timeout: 30 * time.Second, - } -} - -// RefreshClaudeToken refreshes a Claude OAuth token using the refresh token -func RefreshClaudeToken(accessToken, refreshToken string) (*oauth2.Token, error) { - config := GetClaudeOAuthConfig() - - // Create token from current values - currentToken := &oauth2.Token{ - AccessToken: accessToken, - RefreshToken: refreshToken, - TokenType: "Bearer", - } - - ctx := context.Background() - if client := GetClaudeHTTPClient(); client != nil { - ctx = context.WithValue(ctx, oauth2.HTTPClient, client) - } - - // Refresh the token - newToken, err := config.TokenSource(ctx, currentToken).Token() - if err != nil { - return nil, fmt.Errorf("failed to refresh Claude token: %w", err) - } - - return newToken, nil -} diff --git a/service/claude_token_refresh.go b/service/claude_token_refresh.go deleted file mode 100644 index 5dc35367..00000000 --- a/service/claude_token_refresh.go +++ /dev/null @@ -1,94 +0,0 @@ -package service - -import ( - "fmt" - "one-api/common" - "one-api/constant" - "one-api/model" - "strings" - "time" - - "github.com/bytedance/gopkg/util/gopool" -) - -// StartClaudeTokenRefreshScheduler starts the scheduled token refresh for Claude Code channels -func StartClaudeTokenRefreshScheduler() { - ticker := time.NewTicker(5 * time.Minute) - gopool.Go(func() { - defer ticker.Stop() - for range ticker.C { - RefreshClaudeCodeTokens() - } - }) - common.SysLog("Claude Code token refresh scheduler started (5 minute interval)") -} - -// RefreshClaudeCodeTokens refreshes tokens for all active Claude Code channels -func RefreshClaudeCodeTokens() { - var channels []model.Channel - - // Get all active Claude Code channels - err := model.DB.Where("type = ? AND status = ?", constant.ChannelTypeClaudeCode, common.ChannelStatusEnabled).Find(&channels).Error - if err != nil { - common.SysError("Failed to get Claude Code channels: " + err.Error()) - return - } - - refreshCount := 0 - for _, channel := range channels { - if refreshTokenForChannel(&channel) { - refreshCount++ - } - } - - if refreshCount > 0 { - common.SysLog(fmt.Sprintf("Successfully refreshed %d Claude Code channel tokens", refreshCount)) - } -} - -// refreshTokenForChannel attempts to refresh token for a single channel -func refreshTokenForChannel(channel *model.Channel) bool { - // Parse key in format: accesstoken|refreshtoken - if channel.Key == "" || !strings.Contains(channel.Key, "|") { - common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id)) - return false - } - - parts := strings.Split(channel.Key, "|") - if len(parts) < 2 { - common.SysError(fmt.Sprintf("Channel %d has invalid key format, expected accesstoken|refreshtoken", channel.Id)) - return false - } - - accessToken := parts[0] - refreshToken := parts[1] - - if refreshToken == "" { - common.SysError(fmt.Sprintf("Channel %d has empty refresh token", channel.Id)) - return false - } - - // Check if token needs refresh (refresh 30 minutes before expiry) - // if !shouldRefreshToken(accessToken) { - // return false - // } - - // Use shared refresh function - newToken, err := RefreshClaudeToken(accessToken, refreshToken) - if err != nil { - common.SysError(fmt.Sprintf("Failed to refresh token for channel %d: %s", channel.Id, err.Error())) - return false - } - - // Update channel with new tokens - newKey := fmt.Sprintf("%s|%s", newToken.AccessToken, newToken.RefreshToken) - - err = model.DB.Model(channel).Update("key", newKey).Error - if err != nil { - common.SysError(fmt.Sprintf("Failed to update channel %d with new token: %s", channel.Id, err.Error())) - return false - } - - common.SysLog(fmt.Sprintf("Successfully refreshed token for Claude Code channel %d (%s)", channel.Id, channel.Name)) - return true -} diff --git a/setting/operation_setting/operation_setting.go b/setting/operation_setting/operation_setting.go index 29b77d66..ef330d1a 100644 --- a/setting/operation_setting/operation_setting.go +++ b/setting/operation_setting/operation_setting.go @@ -13,9 +13,6 @@ var AutomaticDisableKeywords = []string{ "The security token included in the request is invalid", "Operation not allowed", "Your account is not authorized", - // Claude Code - "Invalid bearer token", - "OAuth authentication is currently not allowed for this endpoint", } func AutomaticDisableKeywordsToString() string { diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index cb09b3c9..37e9af75 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -17,6 +17,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { API, showError, @@ -24,42 +26,38 @@ import { showSuccess, verifyJSON, } from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; +import { CHANNEL_OPTIONS } from '../../../../constants'; import { - Avatar, - Banner, - Button, - Card, - Checkbox, - Col, - Form, - Highlight, - ImagePreview, - Input, - Modal, - Row, SideSheet, Space, Spin, - Tag, + Button, Typography, + Checkbox, + Banner, + Modal, + ImagePreview, + Card, + Tag, + Avatar, + Form, + Row, + Col, + Highlight, } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/JSONEditor'; -import { CHANNEL_OPTIONS, CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT } from '../../../../constants'; import { - IconBolt, - IconClose, - IconCode, - IconGlobe, IconSave, + IconClose, IconServer, IconSetting, + IconCode, + IconGlobe, + IconBolt, } from '@douyinfe/semi-icons'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; - -import { useIsMobile } from '../../../../hooks/common/useIsMobile.js'; -import { useTranslation } from 'react-i18next'; const { Text, Title } = Typography; @@ -95,8 +93,6 @@ function type2secretPrompt(type) { return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey'; case 51: return '按照如下格式输入: Access Key ID|Secret Access Key'; - case 53: - return '按照如下格式输入:AccessToken|RefreshToken'; default: return '请输入渠道对应的鉴权密钥'; } @@ -149,10 +145,6 @@ const EditChannelModal = (props) => { const [customModel, setCustomModel] = useState(''); const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); - const [showOAuthModal, setShowOAuthModal] = useState(false); - const [authorizationCode, setAuthorizationCode] = useState(''); - const [oauthParams, setOauthParams] = useState(null); - const [isExchangingCode, setIsExchangingCode] = useState(false); const [modelModalVisible, setModelModalVisible] = useState(false); const [fetchedModels, setFetchedModels] = useState([]); const formApiRef = useRef(null); @@ -361,24 +353,6 @@ const EditChannelModal = (props) => { data.system_prompt = ''; } - // 特殊处理Claude Code渠道的密钥拆分和系统提示词 - if (data.type === 53) { - // 拆分密钥 - if (data.key) { - const keyParts = data.key.split('|'); - if (keyParts.length === 2) { - data.access_token = keyParts[0]; - data.refresh_token = keyParts[1]; - } else { - // 如果没有 | 分隔符,表示只有access token - data.access_token = data.key; - data.refresh_token = ''; - } - } - // 强制设置固定系统提示词 - data.system_prompt = CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT; - } - setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); @@ -502,72 +476,6 @@ const EditChannelModal = (props) => { } }; - // 生成OAuth授权URL - const handleGenerateOAuth = async () => { - try { - setLoading(true); - const res = await API.get('/api/channel/claude/oauth/url'); - if (res.data.success) { - setOauthParams(res.data.data); - setShowOAuthModal(true); - showSuccess(t('OAuth授权URL生成成功')); - } else { - showError(res.data.message || t('生成OAuth授权URL失败')); - } - } catch (error) { - showError(t('生成OAuth授权URL失败:') + error.message); - } finally { - setLoading(false); - } - }; - - // 交换授权码 - const handleExchangeCode = async () => { - if (!authorizationCode.trim()) { - showError(t('请输入授权码')); - return; - } - - if (!oauthParams) { - showError(t('OAuth参数丢失,请重新生成')); - return; - } - - try { - setIsExchangingCode(true); - const res = await API.post('/api/channel/claude/oauth/exchange', { - authorization_code: authorizationCode, - code_verifier: oauthParams.code_verifier, - state: oauthParams.state, - }); - - if (res.data.success) { - const tokenData = res.data.data; - // 自动填充access token和refresh token - handleInputChange('access_token', tokenData.access_token); - handleInputChange('refresh_token', tokenData.refresh_token); - handleInputChange('key', `${tokenData.access_token}|${tokenData.refresh_token}`); - - // 更新表单字段 - if (formApiRef.current) { - formApiRef.current.setValue('access_token', tokenData.access_token); - formApiRef.current.setValue('refresh_token', tokenData.refresh_token); - } - - setShowOAuthModal(false); - setAuthorizationCode(''); - setOauthParams(null); - showSuccess(t('授权码交换成功,已自动填充tokens')); - } else { - showError(res.data.message || t('授权码交换失败')); - } - } catch (error) { - showError(t('授权码交换失败:') + error.message); - } finally { - setIsExchangingCode(false); - } - }; - useEffect(() => { const modelMap = new Map(); @@ -880,7 +788,7 @@ const EditChannelModal = (props) => { const batchExtra = batchAllowed ? ( { const checked = e.target.checked; @@ -1216,49 +1124,6 @@ const EditChannelModal = (props) => { /> )} - ) : inputs.type === 53 ? ( - <> - { - handleInputChange('access_token', value); - // 同时更新key字段,格式为access_token|refresh_token - const refreshToken = inputs.refresh_token || ''; - handleInputChange('key', `${value}|${refreshToken}`); - }} - suffix={ - - } - extraText={batchExtra} - showClear - /> - { - handleInputChange('refresh_token', value); - // 同时更新key字段,格式为access_token|refresh_token - const accessToken = inputs.access_token || ''; - handleInputChange('key', `${accessToken}|${value}`); - }} - extraText={batchExtra} - showClear - /> - ) : ( { { - if (inputs.type === 53) { - // Claude Code渠道系统提示词固定,不允许修改 - return; - } - handleChannelSettingsChange('system_prompt', value); - }} - disabled={inputs.type === 53} - value={inputs.type === 53 ? CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT : undefined} + placeholder={t('输入系统提示词,用户的系统提示词将优先于此设置')} + onChange={(value) => handleChannelSettingsChange('system_prompt', value)} autosize - showClear={inputs.type !== 53} - extraText={inputs.type === 53 ? t('Claude Code渠道系统提示词固定为官方CLI身份,不可修改') : t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} + showClear + extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> @@ -1803,70 +1660,8 @@ const EditChannelModal = (props) => { }} onCancel={() => setModelModalVisible(false)} /> - - {/* OAuth Authorization Modal */} - { - setShowOAuthModal(false); - setAuthorizationCode(''); - setOauthParams(null); - }} - onOk={handleExchangeCode} - okText={isExchangingCode ? t('交换中...') : t('确认')} - cancelText={t('取消')} - confirmLoading={isExchangingCode} - width={600} - > -
    -
    - {t('请访问以下授权地址:')} -
    - { - if (oauthParams?.auth_url) { - window.open(oauthParams.auth_url, '_blank'); - } - }} - > - {oauthParams?.auth_url || t('正在生成授权地址...')} - -
    - - {t('复制链接')} - -
    -
    -
    - -
    - {t('授权后,请将获得的授权码粘贴到下方:')} - -
    - - -
    -
    ); }; -export default EditChannelModal; \ No newline at end of file +export default EditChannelModal; \ No newline at end of file diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 6035548e..43372a25 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -159,14 +159,6 @@ export const CHANNEL_OPTIONS = [ color: 'purple', label: 'Vidu', }, - { - value: 53, - color: 'indigo', - label: 'Claude Code', - }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; - -// Claude Code 相关常量 -export const CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 7886f03b..1178d5f9 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -358,7 +358,6 @@ export function getChannelIcon(channelType) { return ; case 14: // Anthropic Claude case 33: // AWS Claude - case 53: // Claude Code return ; case 41: // Vertex AI return ; From 1baad070d776b8c1a595c94f0d488759aebb64b9 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 31 Jul 2025 22:28:09 +0800 Subject: [PATCH 133/582] =?UTF-8?q?=F0=9F=9A=80=20feat:=20Introduce=20full?= =?UTF-8?q?=20Model=20&=20Vendor=20Management=20suite=20(backend=20+=20fro?= =?UTF-8?q?ntend)=20and=20UI=20refinements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables. --- controller/model_meta.go | 143 +++++++ controller/vendor_meta.go | 114 ++++++ model/main.go | 4 + model/model_meta.go | 108 +++++ model/vendor_meta.go | 78 ++++ router/api-router.go | 22 + web/src/App.js | 9 + web/src/components/layout/SiderBar.js | 7 + .../components/table/models/ModelsActions.jsx | 100 +++++ .../table/models/ModelsColumnDefs.js | 259 ++++++++++++ .../table/models/ModelsDescription.jsx | 44 ++ .../components/table/models/ModelsFilters.jsx | 106 +++++ .../components/table/models/ModelsTable.jsx | 110 +++++ .../components/table/models/ModelsTabs.jsx | 169 ++++++++ web/src/components/table/models/index.jsx | 140 +++++++ .../table/models/modals/EditModelModal.jsx | 368 +++++++++++++++++ .../table/models/modals/EditVendorModal.jsx | 177 ++++++++ web/src/helpers/render.js | 46 ++- web/src/hooks/models/useModelsData.js | 378 ++++++++++++++++++ web/src/index.css | 1 + web/src/pages/Model/index.js | 12 + 21 files changed, 2392 insertions(+), 3 deletions(-) create mode 100644 controller/model_meta.go create mode 100644 controller/vendor_meta.go create mode 100644 model/model_meta.go create mode 100644 model/vendor_meta.go create mode 100644 web/src/components/table/models/ModelsActions.jsx create mode 100644 web/src/components/table/models/ModelsColumnDefs.js create mode 100644 web/src/components/table/models/ModelsDescription.jsx create mode 100644 web/src/components/table/models/ModelsFilters.jsx create mode 100644 web/src/components/table/models/ModelsTable.jsx create mode 100644 web/src/components/table/models/ModelsTabs.jsx create mode 100644 web/src/components/table/models/index.jsx create mode 100644 web/src/components/table/models/modals/EditModelModal.jsx create mode 100644 web/src/components/table/models/modals/EditVendorModal.jsx create mode 100644 web/src/hooks/models/useModelsData.js create mode 100644 web/src/pages/Model/index.js diff --git a/controller/model_meta.go b/controller/model_meta.go new file mode 100644 index 00000000..9039419d --- /dev/null +++ b/controller/model_meta.go @@ -0,0 +1,143 @@ +package controller + +import ( + "encoding/json" + "strconv" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllModelsMeta 获取模型列表(分页) +func GetAllModelsMeta(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + // 填充附加字段 + for _, m := range modelsMeta { + fillModelExtra(m) + } + var total int64 + model.DB.Model(&model.Model{}).Count(&total) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, pageInfo) +} + +// SearchModelsMeta 搜索模型列表 +func SearchModelsMeta(c *gin.Context) { + keyword := c.Query("keyword") + vendor := c.Query("vendor") + pageInfo := common.GetPageQuery(c) + + modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + for _, m := range modelsMeta { + fillModelExtra(m) + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, pageInfo) +} + +// GetModelMeta 根据 ID 获取单条模型信息 +func GetModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + var m model.Model + if err := model.DB.First(&m, id).Error; err != nil { + common.ApiError(c, err) + return + } + fillModelExtra(&m) + common.ApiSuccess(c, &m) +} + +// CreateModelMeta 新建模型 +func CreateModelMeta(c *gin.Context) { + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.ModelName == "" { + common.ApiErrorMsg(c, "模型名称不能为空") + return + } + + if err := m.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &m) +} + +// UpdateModelMeta 更新模型 +func UpdateModelMeta(c *gin.Context) { + statusOnly := c.Query("status_only") == "true" + + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.Id == 0 { + common.ApiErrorMsg(c, "缺少模型 ID") + return + } + + if statusOnly { + // 只更新状态,防止误清空其他字段 + if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil { + common.ApiError(c, err) + return + } + } else { + if err := m.Update(); err != nil { + common.ApiError(c, err) + return + } + } + common.ApiSuccess(c, &m) +} + +// DeleteModelMeta 删除模型 +func DeleteModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Model{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +// 辅助函数:填充 Endpoints 和 BoundChannels +func fillModelExtra(m *model.Model) { + if m.Endpoints == "" { + eps := model.GetModelSupportEndpointTypes(m.ModelName) + if b, err := json.Marshal(eps); err == nil { + m.Endpoints = string(b) + } + } + if channels, err := model.GetBoundChannels(m.ModelName); err == nil { + m.BoundChannels = channels + } + +} diff --git a/controller/vendor_meta.go b/controller/vendor_meta.go new file mode 100644 index 00000000..27e4294b --- /dev/null +++ b/controller/vendor_meta.go @@ -0,0 +1,114 @@ +package controller + +import ( + "strconv" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllVendors 获取供应商列表(分页) +func GetAllVendors(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + var total int64 + model.DB.Model(&model.Vendor{}).Count(&total) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// SearchVendors 搜索供应商 +func SearchVendors(c *gin.Context) { + keyword := c.Query("keyword") + pageInfo := common.GetPageQuery(c) + vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// GetVendorMeta 根据 ID 获取供应商 +func GetVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + v, err := model.GetVendorByID(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, v) +} + +// CreateVendorMeta 新建供应商 +func CreateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Name == "" { + common.ApiErrorMsg(c, "供应商名称不能为空") + return + } + if err := v.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// UpdateVendorMeta 更新供应商 +func UpdateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Id == 0 { + common.ApiErrorMsg(c, "缺少供应商 ID") + return + } + // 检查名称冲突 + var dup int64 + _ = model.DB.Model(&model.Vendor{}).Where("name = ? AND id <> ?", v.Name, v.Id).Count(&dup).Error + if dup > 0 { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + + if err := v.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// DeleteVendorMeta 删除供应商 +func DeleteVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} \ No newline at end of file diff --git a/model/main.go b/model/main.go index 013beacd..5be43703 100644 --- a/model/main.go +++ b/model/main.go @@ -250,6 +250,8 @@ func migrateDB() error { &TopUp{}, &QuotaData{}, &Task{}, + &Model{}, + &Vendor{}, &Setup{}, ) if err != nil { @@ -276,6 +278,8 @@ func migrateDBFast() error { {&TopUp{}, "TopUp"}, {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, + {&Model{}, "Model"}, + {&Vendor{}, "Vendor"}, {&Setup{}, "Setup"}, } // 动态计算migration数量,确保errChan缓冲区足够大 diff --git a/model/model_meta.go b/model/model_meta.go new file mode 100644 index 00000000..f9b3dfc9 --- /dev/null +++ b/model/model_meta.go @@ -0,0 +1,108 @@ +package model + +import ( + "one-api/common" + + "gorm.io/gorm" +) + +// Model 用于存储模型的元数据,例如描述、标签等 +// ModelName 字段具有唯一性约束,确保每个模型只会出现一次 +// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型 +// Status: 1 表示启用,0 表示禁用,保留以便后续功能扩展 +// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植 +// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复 +// +// 该表设计遵循第三范式(3NF): +// 1. 每一列都与主键(Id 或 ModelName)直接相关 +// 2. 不存在部分依赖(ModelName 是唯一键) +// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列) +// 这样既保证了数据一致性,也方便后期扩展 + +type BoundChannel struct { + Name string `json:"name"` + Type int `json:"type"` +} + +type Model struct { + Id int `json:"id"` + ModelName string `json:"model_name" gorm:"uniqueIndex;size:128;not null"` + Description string `json:"description,omitempty" gorm:"type:text"` + Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` + VendorID int `json:"vendor_id,omitempty" gorm:"index"` + Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` + Status int `json:"status" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` +} + +// Insert 创建新的模型元数据记录 +func (mi *Model) Insert() error { + now := common.GetTimestamp() + mi.CreatedTime = now + mi.UpdatedTime = now + return DB.Create(mi).Error +} + +// Update 更新现有模型记录 +func (mi *Model) Update() error { + mi.UpdatedTime = common.GetTimestamp() + return DB.Save(mi).Error +} + +// Delete 软删除模型记录 +func (mi *Model) Delete() error { + return DB.Delete(mi).Error +} + +// GetModelByName 根据模型名称查询元数据 +func GetModelByName(name string) (*Model, error) { + var mi Model + err := DB.Where("model_name = ?", name).First(&mi).Error + if err != nil { + return nil, err + } + return &mi, nil +} + +// GetAllModels 分页获取所有模型元数据 +func GetAllModels(offset int, limit int) ([]*Model, error) { + var models []*Model + err := DB.Offset(offset).Limit(limit).Find(&models).Error + return models, err +} + +// GetBoundChannels 查询支持该模型的渠道(名称+类型) +func GetBoundChannels(modelName string) ([]BoundChannel, error) { + var channels []BoundChannel + err := DB.Table("channels"). + Select("channels.name, channels.type"). + Joins("join abilities on abilities.channel_id = channels.id"). + Where("abilities.model = ? AND abilities.enabled = ?", modelName, true). + Group("channels.id"). + Scan(&channels).Error + return channels, err +} + +// SearchModels 根据关键词和供应商搜索模型,支持分页 +func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) { + var models []*Model + db := DB.Model(&Model{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like) + } + if vendor != "" { + db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%") + } + var total int64 + err := db.Count(&total).Error + if err != nil { + return nil, 0, err + } + err = db.Offset(offset).Limit(limit).Order("id DESC").Find(&models).Error + return models, total, err +} diff --git a/model/vendor_meta.go b/model/vendor_meta.go new file mode 100644 index 00000000..1dcec351 --- /dev/null +++ b/model/vendor_meta.go @@ -0,0 +1,78 @@ +package model + +import ( + "one-api/common" + + "gorm.io/gorm" +) + +// Vendor 用于存储供应商信息,供模型引用 +// Name 唯一,用于在模型中关联 +// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染 +// Status 预留字段,1 表示启用 +// 本表同样遵循 3NF 设计范式 + +type Vendor struct { + Id int `json:"id"` + Name string `json:"name" gorm:"uniqueIndex;size:128;not null"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Status int `json:"status" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// Insert 创建新的供应商记录 +func (v *Vendor) Insert() error { + now := common.GetTimestamp() + v.CreatedTime = now + v.UpdatedTime = now + return DB.Create(v).Error +} + +// Update 更新供应商记录 +func (v *Vendor) Update() error { + v.UpdatedTime = common.GetTimestamp() + return DB.Save(v).Error +} + +// Delete 软删除供应商 +func (v *Vendor) Delete() error { + return DB.Delete(v).Error +} + +// GetVendorByID 根据 ID 获取供应商 +func GetVendorByID(id int) (*Vendor, error) { + var v Vendor + err := DB.First(&v, id).Error + if err != nil { + return nil, err + } + return &v, nil +} + +// GetAllVendors 获取全部供应商(分页) +func GetAllVendors(offset int, limit int) ([]*Vendor, error) { + var vendors []*Vendor + err := DB.Offset(offset).Limit(limit).Find(&vendors).Error + return vendors, err +} + +// SearchVendors 按关键字搜索供应商 +func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) { + db := DB.Model(&Vendor{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var vendors []*Vendor + if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil { + return nil, 0, err + } + return vendors, total, nil +} \ No newline at end of file diff --git a/router/api-router.go b/router/api-router.go index bc49803a..e2b35be0 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -175,5 +175,27 @@ func SetApiRouter(router *gin.Engine) { taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) } + + vendorRoute := apiRouter.Group("/vendors") + vendorRoute.Use(middleware.AdminAuth()) + { + vendorRoute.GET("/", controller.GetAllVendors) + vendorRoute.GET("/search", controller.SearchVendors) + vendorRoute.GET("/:id", controller.GetVendorMeta) + vendorRoute.POST("/", controller.CreateVendorMeta) + vendorRoute.PUT("/", controller.UpdateVendorMeta) + vendorRoute.DELETE("/:id", controller.DeleteVendorMeta) + } + + modelsRoute := apiRouter.Group("/models") + modelsRoute.Use(middleware.AdminAuth()) + { + modelsRoute.GET("/", controller.GetAllModelsMeta) + modelsRoute.GET("/search", controller.SearchModelsMeta) + modelsRoute.GET("/:id", controller.GetModelMeta) + modelsRoute.POST("/", controller.CreateModelMeta) + modelsRoute.PUT("/", controller.UpdateModelMeta) + modelsRoute.DELETE("/:id", controller.DeleteModelMeta) + } } } diff --git a/web/src/App.js b/web/src/App.js index 47304b16..bf8397ba 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link'; import Midjourney from './pages/Midjourney'; import Pricing from './pages/Pricing/index.js'; import Task from './pages/Task/index.js'; +import ModelPage from './pages/Model/index.js'; import Playground from './pages/Playground/index.js'; import OAuth2Callback from './components/auth/OAuth2Callback.js'; import PersonalSetting from './components/settings/PersonalSetting.js'; @@ -71,6 +72,14 @@ function App() { } /> + + + + } + /> { } }) => { const adminItems = useMemo( () => [ + { + text: t('模型管理'), + itemKey: 'models', + to: '/console/models', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('渠道管理'), itemKey: 'channel', diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx new file mode 100644 index 00000000..78d3d5b0 --- /dev/null +++ b/web/src/components/table/models/ModelsActions.jsx @@ -0,0 +1,100 @@ +/* +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, Modal } from '@douyinfe/semi-ui'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; +import { showError } from '../../../helpers'; + +const ModelsActions = ({ + selectedKeys, + setEditingModel, + setShowEdit, + batchDeleteModels, + compactMode, + setCompactMode, + t, +}) => { + // Modal states + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // Handle delete selected models with confirmation + const handleDeleteSelectedModels = () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个模型!')); + return; + } + setShowDeleteModal(true); + }; + + // Handle delete confirmation + const handleConfirmDelete = () => { + batchDeleteModels(); + setShowDeleteModal(false); + }; + + return ( + <> +
    + + + + + +
    + + setShowDeleteModal(false)} + onOk={handleConfirmDelete} + type="warning" + > +
    + {t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })} +
    +
    + + ); +}; + +export default ModelsActions; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js new file mode 100644 index 00000000..ef404958 --- /dev/null +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -0,0 +1,259 @@ +/* +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, + Typography, + Modal, + Popover +} from '@douyinfe/semi-ui'; +import { + timestamp2string, + getLobeHubIcon, + stringToColor +} from '../../../helpers'; + +const { Text } = Typography; + +// Render timestamp +function renderTimestamp(timestamp) { + return <>{timestamp2string(timestamp)}; +} + +// Render vendor column with icon +const renderVendorTag = (vendorId, vendorMap, t) => { + if (!vendorId || !vendorMap[vendorId]) return '-'; + const v = vendorMap[vendorId]; + return ( + + {v.name} + + ); +}; + +// Render description with ellipsis +const renderDescription = (text) => { + return ( + + {text || '-'} + + ); +}; + +// Render tags +const renderTags = (text) => { + if (!text) return '-'; + const tagsArr = text.split(',').filter(Boolean); + const maxDisplayTags = 3; + const displayTags = tagsArr.slice(0, maxDisplayTags); + const remainingTags = tagsArr.slice(maxDisplayTags); + + return ( + + {displayTags.map((tag, index) => ( + + {tag} + + ))} + {remainingTags.length > 0 && ( + + + {remainingTags.map((tag, index) => ( + + {tag} + + ))} + + + } + position="top" + > + + +{remainingTags.length} + + + )} + + ); +}; + +// Render endpoints +const renderEndpoints = (text) => { + try { + const arr = JSON.parse(text); + if (Array.isArray(arr)) { + return ( + + {arr.map((ep) => ( + + {ep} + + ))} + + ); + } + } catch (_) { } + return text || '-'; +}; + +// Render bound channels +const renderBoundChannels = (channels) => { + if (!channels || channels.length === 0) return '-'; + return ( + + {channels.map((c, idx) => ( + + {c.name}({c.type}) + + ))} + + ); +}; + +// Render operations column +const renderOperations = (text, record, setEditingModel, setShowEdit, manageModel, refresh, t) => { + return ( + + {record.status === 1 ? ( + + ) : ( + + )} + + + + + + ); +}; + +export const getModelsColumns = ({ + t, + manageModel, + setEditingModel, + setShowEdit, + refresh, + vendorMap, +}) => { + return [ + { + title: t('模型名称'), + dataIndex: 'model_name', + }, + { + title: t('描述'), + dataIndex: 'description', + render: renderDescription, + }, + { + title: t('供应商'), + dataIndex: 'vendor_id', + render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t), + }, + { + title: t('标签'), + dataIndex: 'tags', + render: renderTags, + }, + { + title: t('端点'), + dataIndex: 'endpoints', + render: renderEndpoints, + }, + { + title: t('已绑定渠道'), + dataIndex: 'bound_channels', + render: renderBoundChannels, + }, + { + title: t('创建时间'), + dataIndex: 'created_time', + render: (text, record, index) => { + return
    {renderTimestamp(text)}
    ; + }, + }, + { + title: t('更新时间'), + dataIndex: 'updated_time', + render: (text, record, index) => { + return
    {renderTimestamp(text)}
    ; + }, + }, + { + title: '', + dataIndex: 'operate', + fixed: 'right', + render: (text, record, index) => renderOperations( + text, + record, + setEditingModel, + setShowEdit, + manageModel, + refresh, + t + ), + }, + ]; +}; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsDescription.jsx b/web/src/components/table/models/ModelsDescription.jsx new file mode 100644 index 00000000..5fc3f1f7 --- /dev/null +++ b/web/src/components/table/models/ModelsDescription.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 { Layers } from 'lucide-react'; +import CompactModeToggle from '../../common/ui/CompactModeToggle'; + +const { Text } = Typography; + +const ModelsDescription = ({ compactMode, setCompactMode, t }) => { + return ( +
    +
    + + {t('模型管理')} +
    + + +
    + ); +}; + +export default ModelsDescription; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsFilters.jsx b/web/src/components/table/models/ModelsFilters.jsx new file mode 100644 index 00000000..0bccb835 --- /dev/null +++ b/web/src/components/table/models/ModelsFilters.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 ModelsFilters = ({ + formInitValues, + setFormApi, + searchModels, + loading, + searching, + t, +}) => { + // Handle form reset and immediate search + const formApiRef = useRef(null); + + const handleReset = () => { + if (!formApiRef.current) return; + formApiRef.current.reset(); + setTimeout(() => { + searchModels(); + }, 100); + }; + + return ( +
    { + setFormApi(api); + formApiRef.current = api; + }} + onSubmit={searchModels} + 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 ModelsFilters; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsTable.jsx b/web/src/components/table/models/ModelsTable.jsx new file mode 100644 index 00000000..7ced70c5 --- /dev/null +++ b/web/src/components/table/models/ModelsTable.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, { useMemo } from 'react'; +import { Empty } from '@douyinfe/semi-ui'; +import CardTable from '../../common/ui/CardTable.js'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { getModelsColumns } from './ModelsColumnDefs.js'; + +const ModelsTable = (modelsData) => { + const { + models, + loading, + activePage, + pageSize, + modelCount, + compactMode, + handlePageChange, + handlePageSizeChange, + rowSelection, + handleRow, + manageModel, + setEditingModel, + setShowEdit, + refresh, + vendorMap, + t, + } = modelsData; + + // Get all columns + const columns = useMemo(() => { + return getModelsColumns({ + t, + manageModel, + setEditingModel, + setShowEdit, + refresh, + vendorMap, + }); + }, [ + t, + manageModel, + setEditingModel, + 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 ModelsTable; \ No newline at end of file diff --git a/web/src/components/table/models/ModelsTabs.jsx b/web/src/components/table/models/ModelsTabs.jsx new file mode 100644 index 00000000..09dab91f --- /dev/null +++ b/web/src/components/table/models/ModelsTabs.jsx @@ -0,0 +1,169 @@ +/* +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, Button, Dropdown, Modal } from '@douyinfe/semi-ui'; +import { IconEdit, IconDelete } from '@douyinfe/semi-icons'; +import { getLobeHubIcon, showError, showSuccess } from '../../../helpers'; +import { API } from '../../../helpers'; + +const ModelsTabs = ({ + activeVendorKey, + setActiveVendorKey, + vendorCounts, + vendors, + loadModels, + activePage, + pageSize, + setActivePage, + setShowAddVendor, + setShowEditVendor, + setEditingVendor, + loadVendors, + t +}) => { + const handleTabChange = (key) => { + setActiveVendorKey(key); + setActivePage(1); + loadModels(1, pageSize, key); + }; + + const handleEditVendor = (vendor, e) => { + e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 + setEditingVendor(vendor); + setShowEditVendor(true); + }; + + const handleDeleteVendor = async (vendor, e) => { + e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 + try { + const res = await API.delete(`/api/vendors/${vendor.id}`); + if (res.data.success) { + showSuccess(t('供应商删除成功')); + // 如果删除的是当前选中的供应商,切换到"全部" + if (activeVendorKey === String(vendor.id)) { + setActiveVendorKey('all'); + loadModels(1, pageSize, 'all'); + } else { + loadModels(activePage, pageSize, activeVendorKey); + } + loadVendors(); // 重新加载供应商列表 + } else { + showError(res.data.message || t('删除失败')); + } + } catch (error) { + showError(error.response?.data?.message || t('删除失败')); + } + }; + + return ( + setShowAddVendor(true)} + > + {t('新增供应商')} + + } + > + + {t('全部')} + + {vendorCounts['all'] || 0} + + + } + /> + + {vendors.map((vendor) => { + const key = String(vendor.id); + const count = vendorCounts[vendor.id] || 0; + return ( + + {getLobeHubIcon(vendor.icon || 'Layers', 14)} + {vendor.name} + + {count} + + + } + onClick={(e) => handleEditVendor(vendor, e)} + > + {t('编辑')} + + } + onClick={(e) => { + e.stopPropagation(); + Modal.confirm({ + title: t('确认删除'), + content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }), + onOk: () => handleDeleteVendor(vendor, e), + okText: t('删除'), + cancelText: t('取消'), + type: 'warning', + okType: 'danger', + }); + }} + > + {t('删除')} + + + } + onClickOutSide={(e) => e.stopPropagation()} + > + + + + } + /> + ); + })} + + ); +}; + +export default ModelsTabs; \ No newline at end of file diff --git a/web/src/components/table/models/index.jsx b/web/src/components/table/models/index.jsx new file mode 100644 index 00000000..4732e83d --- /dev/null +++ b/web/src/components/table/models/index.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 from 'react'; +import CardPro from '../../common/ui/CardPro'; +import ModelsTable from './ModelsTable.jsx'; +import ModelsActions from './ModelsActions.jsx'; +import ModelsFilters from './ModelsFilters.jsx'; +import ModelsTabs from './ModelsTabs.jsx'; +import EditModelModal from './modals/EditModelModal.jsx'; +import EditVendorModal from './modals/EditVendorModal.jsx'; +import { useModelsData } from '../../../hooks/models/useModelsData'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { createCardProPagination } from '../../../helpers/utils'; + +const ModelsPage = () => { + const modelsData = useModelsData(); + const isMobile = useIsMobile(); + + const { + // Edit state + showEdit, + editingModel, + closeEdit, + refresh, + + // Actions state + selectedKeys, + setEditingModel, + setShowEdit, + batchDeleteModels, + + // Filters state + formInitValues, + setFormApi, + searchModels, + loading, + searching, + + // Description state + compactMode, + setCompactMode, + + // Vendor state + showAddVendor, + setShowAddVendor, + showEditVendor, + setShowEditVendor, + editingVendor, + setEditingVendor, + loadVendors, + + // Translation + t, + } = modelsData; + + return ( + <> + + + { + setShowAddVendor(false); + setShowEditVendor(false); + setEditingVendor({ id: undefined }); + }} + editingVendor={showEditVendor ? editingVendor : { id: undefined }} + refresh={() => { + loadVendors(); + refresh(); + }} + /> + + } + actionsArea={ +
    + + +
    + +
    +
    + } + paginationArea={createCardProPagination({ + currentPage: modelsData.activePage, + pageSize: modelsData.pageSize, + total: modelsData.modelCount, + onPageChange: modelsData.handlePageChange, + onPageSizeChange: modelsData.handlePageSizeChange, + isMobile: isMobile, + t: modelsData.t, + })} + t={modelsData.t} + > + +
    + + ); +}; + +export default ModelsPage; diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx new file mode 100644 index 00000000..70015cca --- /dev/null +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -0,0 +1,368 @@ +/* +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, useMemo } from 'react'; +import { + SideSheet, + Form, + Button, + Space, + Spin, + Typography, + Card, + Tag, + Avatar, + Col, + Row, +} from '@douyinfe/semi-ui'; +import { + IconSave, + IconClose, + IconLayers, +} from '@douyinfe/semi-icons'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; + +const endpointOptions = [ + { label: 'OpenAI', value: 'openai' }, + { label: 'Anthropic', value: 'anthropic' }, + { label: 'Gemini', value: 'gemini' }, + { label: 'Image Generation', value: 'image-generation' }, + { label: 'Jina Rerank', value: 'jina-rerank' }, +]; + +const { Text, Title } = Typography; + +const EditModelModal = (props) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const isMobile = useIsMobile(); + const formApiRef = useRef(null); + const isEdit = props.editingModel && props.editingModel.id !== undefined; + const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]); + + // 供应商列表 + const [vendors, setVendors] = useState([]); + + // 获取供应商列表 + const fetchVendors = async () => { + try { + const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商 + if (res.data.success) { + const items = res.data.data.items || res.data.data || []; + setVendors(Array.isArray(items) ? items : []); + } + } catch (error) { + // ignore + } + }; + + useEffect(() => { + fetchVendors(); + }, []); + + + const getInitValues = () => ({ + model_name: '', + description: '', + tags: [], + vendor_id: undefined, + vendor: '', + vendor_icon: '', + endpoints: [], + status: true, + }); + + const handleCancel = () => { + props.handleClose(); + }; + + const loadModel = async () => { + if (!isEdit || !props.editingModel.id) return; + + setLoading(true); + try { + const res = await API.get(`/api/models/${props.editingModel.id}`); + const { success, message, data } = res.data; + if (success) { + // 处理tags + if (data.tags) { + data.tags = data.tags.split(',').filter(Boolean); + } else { + data.tags = []; + } + // 处理endpoints + if (data.endpoints) { + try { + data.endpoints = JSON.parse(data.endpoints); + } catch (e) { + data.endpoints = []; + } + } else { + data.endpoints = []; + } + // 处理status,将数字转为布尔值 + data.status = data.status === 1; + if (formApiRef.current) { + formApiRef.current.setValues({ ...getInitValues(), ...data }); + } + } else { + showError(message); + } + } catch (error) { + showError(t('加载模型信息失败')); + } + setLoading(false); + }; + + useEffect(() => { + if (formApiRef.current) { + if (!isEdit) { + formApiRef.current.setValues(getInitValues()); + } + } + }, [props.editingModel?.id]); + + useEffect(() => { + if (props.visiable) { + if (isEdit) { + loadModel(); + } else { + formApiRef.current?.setValues(getInitValues()); + } + } else { + formApiRef.current?.reset(); + } + }, [props.visiable, props.editingModel?.id]); + + const submit = async (values) => { + setLoading(true); + try { + const submitData = { + ...values, + tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags, + endpoints: JSON.stringify(values.endpoints || []), + status: values.status ? 1 : 0, + }; + + if (isEdit) { + submitData.id = props.editingModel.id; + const res = await API.put('/api/models/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('模型更新成功!')); + props.refresh(); + props.handleClose(); + } else { + showError(t(message)); + } + } else { + const res = await API.post('/api/models/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('模型创建成功!')); + props.refresh(); + props.handleClose(); + } else { + showError(t(message)); + } + } + } catch (error) { + showError(error.response?.data?.message || t('操作失败')); + } + setLoading(false); + formApiRef.current?.setValues(getInitValues()); + }; + + return ( + + {isEdit ? ( + + {t('更新')} + + ) : ( + + {t('新建')} + + )} + + {isEdit ? t('更新模型信息') : t('创建新的模型')} + +
    + } + bodyStyle={{ padding: '0' }} + visible={props.visiable} + width={isMobile ? '100%' : 600} + footer={ +
    + + + + +
    + } + closeIcon={null} + onCancel={() => handleCancel()} + > + +
    (formApiRef.current = api)} + onSubmit={submit} + > + {({ values }) => ( +
    + {/* 基本信息 */} + +
    + + + +
    + {t('基本信息')} +
    {t('设置模型的基本信息')}
    +
    +
    + +
    + + + + + + + + + + + + {/* 供应商信息 */} + +
    + + + +
    + {t('供应商信息')} +
    {t('设置模型的供应商相关信息')}
    +
    +
    + +
    + ({ label: v.name, value: v.id }))} + filter + showClear + style={{ width: '100%' }} + onChange={(value) => { + const vendorInfo = vendors.find(v => v.id === value); + if (vendorInfo && formApiRef.current) { + formApiRef.current.setValue('vendor', vendorInfo.name); + } + }} + /> + + + + + {/* 功能配置 */} + +
    + + + +
    + {t('功能配置')} +
    {t('设置模型的功能和状态')}
    +
    +
    + +
    + + + + + + + + + )} + + + + ); +}; + +export default EditModelModal; \ No newline at end of file diff --git a/web/src/components/table/models/modals/EditVendorModal.jsx b/web/src/components/table/models/modals/EditVendorModal.jsx new file mode 100644 index 00000000..9ddf5cb4 --- /dev/null +++ b/web/src/components/table/models/modals/EditVendorModal.jsx @@ -0,0 +1,177 @@ +/* +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, useEffect } from 'react'; +import { + Modal, + Form, + Col, + Row, +} from '@douyinfe/semi-ui'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; + +const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const formApiRef = useRef(null); + + const isMobile = useIsMobile(); + const isEdit = editingVendor && editingVendor.id !== undefined; + + const getInitValues = () => ({ + name: '', + description: '', + icon: '', + status: true, + }); + + const handleCancel = () => { + handleClose(); + formApiRef.current?.reset(); + }; + + const loadVendor = async () => { + if (!isEdit || !editingVendor.id) return; + + setLoading(true); + try { + const res = await API.get(`/api/vendors/${editingVendor.id}`); + const { success, message, data } = res.data; + if (success) { + // 将数字状态转为布尔值 + data.status = data.status === 1; + if (formApiRef.current) { + formApiRef.current.setValues({ ...getInitValues(), ...data }); + } + } else { + showError(message); + } + } catch (error) { + showError(t('加载供应商信息失败')); + } + setLoading(false); + }; + + useEffect(() => { + if (visible) { + if (isEdit) { + loadVendor(); + } else { + formApiRef.current?.setValues(getInitValues()); + } + } else { + formApiRef.current?.reset(); + } + }, [visible, editingVendor?.id]); + + const submit = async (values) => { + setLoading(true); + try { + // 转换 status 为数字 + const submitData = { + ...values, + status: values.status ? 1 : 0, + }; + + if (isEdit) { + submitData.id = editingVendor.id; + const res = await API.put('/api/vendors/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('供应商更新成功!')); + refresh(); + handleClose(); + } else { + showError(t(message)); + } + } else { + const res = await API.post('/api/vendors/', submitData); + const { success, message } = res.data; + if (success) { + showSuccess(t('供应商创建成功!')); + refresh(); + handleClose(); + } else { + showError(t(message)); + } + } + } catch (error) { + showError(error.response?.data?.message || t('操作失败')); + } + setLoading(false); + }; + + return ( + formApiRef.current?.submitForm()} + onCancel={handleCancel} + confirmLoading={loading} + size={isMobile ? 'full-width' : 'small'} + > +
    (formApiRef.current = api)} + onSubmit={submit} + > + +
    + + + + + + + + + + + + + + + ); +}; + +export default EditVendorModal; \ No newline at end of file diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 1178d5f9..8371c9ba 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com */ import i18next from 'i18next'; -import { Modal, Tag, Typography } from '@douyinfe/semi-ui'; +import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui'; import { copy, showSuccess } from './utils'; import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js'; import { visit } from 'unist-util-visit'; +import * as LobeIcons from '@lobehub/icons'; import { OpenAI, Claude, @@ -85,6 +86,7 @@ export const sidebarIconColors = { gift: '#F43F5E', // 玫红色 user: '#10B981', // 绿色 settings: '#F97316', // 橙色 + models: '#10B981', // 绿色 }; // 获取侧边栏Lucide图标组件 @@ -177,6 +179,13 @@ export function getLucideIcon(key, selected = false) { color={selected ? sidebarIconColors.user : 'currentColor'} /> ); + case 'models': + return ( + + ); case 'setting': return ( ?; + } + + let IconComponent; + + if (iconName.includes('.')) { + const [base, variant] = iconName.split('.'); + const BaseIcon = LobeIcons[base]; + IconComponent = BaseIcon ? BaseIcon[variant] : undefined; + } else { + IconComponent = LobeIcons[iconName]; + } + + if (IconComponent && (typeof IconComponent === 'function' || typeof IconComponent === 'object')) { + return ; + } + + const firstLetter = iconName.charAt(0).toUpperCase(); + return {firstLetter}; +} + // 颜色列表 const colors = [ 'amber', @@ -891,13 +931,13 @@ export function renderQuota(quota, digits = 2) { if (displayInCurrency) { const result = quota / quotaPerUnit; const fixedResult = result.toFixed(digits); - + // 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值 if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) { const minValue = Math.pow(10, -digits); return '$' + minValue.toFixed(digits); } - + return '$' + fixedResult; } return renderNumber(quota); diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js new file mode 100644 index 00000000..fe83168e --- /dev/null +++ b/web/src/hooks/models/useModelsData.js @@ -0,0 +1,378 @@ +/* +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 { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess } from '../../helpers'; +import { ITEMS_PER_PAGE } from '../../constants'; +import { useTableCompactMode } from '../common/useTableCompactMode'; + +export const useModelsData = () => { + const { t } = useTranslation(); + const [compactMode, setCompactMode] = useTableCompactMode('models'); + + // State management + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [activePage, setActivePage] = useState(1); + const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); + const [searching, setSearching] = useState(false); + const [modelCount, setModelCount] = useState(0); + + // Modal states + const [showEdit, setShowEdit] = useState(false); + const [editingModel, setEditingModel] = useState({ + id: undefined, + }); + + // Row selection + const [selectedKeys, setSelectedKeys] = useState([]); + const rowSelection = { + getCheckboxProps: (record) => ({ + name: record.model_name, + }), + selectedRowKeys: selectedKeys.map((model) => model.id), + onChange: (selectedRowKeys, selectedRows) => { + setSelectedKeys(selectedRows); + }, + }; + + // Form initial values + const formInitValues = { + searchKeyword: '', + searchVendor: '', + }; + + // Form API reference + const [formApi, setFormApi] = useState(null); + + // Get form values helper function + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchVendor: formValues.searchVendor || '', + }; + }; + + // Close edit modal + const closeEdit = () => { + setShowEdit(false); + setTimeout(() => { + setEditingModel({ id: undefined }); + }, 500); + }; + + // Set model format with key field + const setModelFormat = (models) => { + for (let i = 0; i < models.length; i++) { + models[i].key = models[i].id; + } + setModels(models); + }; + + // 获取供应商列表 + const [vendors, setVendors] = useState([]); + const [vendorCounts, setVendorCounts] = useState({}); + const [activeVendorKey, setActiveVendorKey] = useState('all'); + const [showAddVendor, setShowAddVendor] = useState(false); + const [showEditVendor, setShowEditVendor] = useState(false); + const [editingVendor, setEditingVendor] = useState({ id: undefined }); + + const vendorMap = useMemo(() => { + const map = {}; + vendors.forEach(v => { + map[v.id] = v; + }); + return map; + }, [vendors]); + + // 加载供应商列表 + const loadVendors = async () => { + try { + const res = await API.get('/api/vendors/?page_size=1000'); + if (res.data.success) { + const items = res.data.data.items || res.data.data || []; + setVendors(Array.isArray(items) ? items : []); + } + } catch (_) { + // ignore + } + }; + + // Load models data + const loadModels = async (page = 1, size = pageSize, vendorKey = activeVendorKey) => { + setLoading(true); + try { + let url = `/api/models/?p=${page}&page_size=${size}`; + if (vendorKey && vendorKey !== 'all') { + // 按供应商筛选,通过vendor搜索接口 + const vendor = vendors.find(v => String(v.id) === vendorKey); + if (vendor) { + url = `/api/models/search?vendor=${vendor.name}&p=${page}&page_size=${size}`; + } + } + + const res = await API.get(url); + const { success, message, data } = res.data; + if (success) { + const items = data.items || data || []; + const newPageData = Array.isArray(items) ? items : []; + setActivePage(data.page || page); + setModelCount(data.total || newPageData.length); + setModelFormat(newPageData); + + // 更新供应商统计 + updateVendorCounts(newPageData); + } else { + showError(message); + setModels([]); + } + } catch (error) { + console.error(error); + showError(t('获取模型列表失败')); + setModels([]); + } + setLoading(false); + }; + + // Refresh data + const refresh = async (page = activePage) => { + await loadModels(page, pageSize); + }; + + // Search models with keyword and vendor + const searchModels = async () => { + const formValues = getFormValues(); + const { searchKeyword, searchVendor } = formValues; + + if (searchKeyword === '' && searchVendor === '') { + // If keyword is blank, load models instead + await loadModels(1, pageSize); + return; + } + + setSearching(true); + try { + const res = await API.get( + `/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`, + ); + const { success, message, data } = res.data; + if (success) { + const items = data.items || data || []; + const newPageData = Array.isArray(items) ? items : []; + setActivePage(data.page || 1); + setModelCount(data.total || newPageData.length); + setModelFormat(newPageData); + } else { + showError(message); + setModels([]); + } + } catch (error) { + console.error(error); + showError(t('搜索模型失败')); + setModels([]); + } + setSearching(false); + }; + + // Manage model (enable/disable/delete) + const manageModel = async (id, action, record) => { + let res; + switch (action) { + case 'delete': + res = await API.delete(`/api/models/${id}`); + break; + case 'enable': + res = await API.put('/api/models/?status_only=true', { id, status: 1 }); + break; + case 'disable': + res = await API.put('/api/models/?status_only=true', { id, status: 0 }); + break; + default: + return; + } + + const { success, message } = res.data; + if (success) { + showSuccess(t('操作成功完成!')); + if (action === 'delete') { + await refresh(); + } else { + // Update local state for enable/disable + setModels(prevModels => + prevModels.map(model => + model.id === id ? { ...model, status: action === 'enable' ? 1 : 0 } : model + ) + ); + } + } else { + showError(message); + } + }; + + // 更新供应商统计 + const updateVendorCounts = (models) => { + const counts = { all: models.length }; + models.forEach(model => { + if (model.vendor_id) { + counts[model.vendor_id] = (counts[model.vendor_id] || 0) + 1; + } + }); + setVendorCounts(counts); + }; + + // Handle page change + const handlePageChange = (page) => { + setActivePage(page); + loadModels(page, pageSize, activeVendorKey); + }; + + // Handle page size change + const handlePageSizeChange = async (size) => { + setPageSize(size); + setActivePage(1); + await loadModels(1, size, activeVendorKey); + }; + + // Handle row click + const handleRow = (record, index) => { + return { + onClick: (event) => { + // Don't trigger row selection when clicking on buttons + if (event.target.closest('button, .semi-button')) { + return; + } + const newSelectedKeys = selectedKeys.some(item => item.id === record.id) + ? selectedKeys.filter(item => item.id !== record.id) + : [...selectedKeys, record]; + setSelectedKeys(newSelectedKeys); + }, + }; + }; + + // Batch delete models + const batchDeleteModels = async () => { + if (selectedKeys.length === 0) { + showError(t('请至少选择一个模型')); + return; + } + + try { + const deletePromises = selectedKeys.map(model => + API.delete(`/api/models/${model.id}`) + ); + + const results = await Promise.all(deletePromises); + let successCount = 0; + + results.forEach((res, index) => { + if (res.data.success) { + successCount++; + } else { + showError(`删除模型 ${selectedKeys[index].model_name} 失败: ${res.data.message}`); + } + }); + + if (successCount > 0) { + showSuccess(t(`成功删除 ${successCount} 个模型`)); + setSelectedKeys([]); + await refresh(); + } + } catch (error) { + showError(t('批量删除失败')); + } + }; + + // Copy text helper + const copyText = async (text) => { + try { + await navigator.clipboard.writeText(text); + showSuccess(t('复制成功')); + } catch (error) { + console.error('Copy failed:', error); + showError(t('复制失败')); + } + }; + + // Initial load + useEffect(() => { + loadVendors(); + loadModels(); + }, []); + + return { + // Data state + models, + loading, + searching, + activePage, + pageSize, + modelCount, + + // Selection state + selectedKeys, + rowSelection, + handleRow, + + // Modal state + showEdit, + editingModel, + setEditingModel, + setShowEdit, + closeEdit, + + // Form state + formInitValues, + setFormApi, + + // Actions + loadModels, + searchModels, + refresh, + manageModel, + batchDeleteModels, + copyText, + + // Pagination + handlePageChange, + handlePageSizeChange, + + // UI state + compactMode, + setCompactMode, + + // Vendor data + vendors, + vendorMap, + vendorCounts, + activeVendorKey, + setActiveVendorKey, + showAddVendor, + setShowAddVendor, + showEditVendor, + setShowEditVendor, + editingVendor, + setEditingVendor, + loadVendors, + + // Translation + t, + }; +}; \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index b624d749..98d96679 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -53,6 +53,7 @@ code { /* ==================== 导航和侧边栏样式 ==================== */ /* 导航项样式 */ +.semi-tagInput, .semi-input-textarea-wrapper, .semi-navigation-sub-title, .semi-chat-inputBox-sendButton, diff --git a/web/src/pages/Model/index.js b/web/src/pages/Model/index.js new file mode 100644 index 00000000..7d9d1c9f --- /dev/null +++ b/web/src/pages/Model/index.js @@ -0,0 +1,12 @@ +import React from 'react'; +import ModelsTable from '../../components/table/models'; + +const ModelPage = () => { + return ( +
    + +
    + ); +}; + +export default ModelPage; From ad4de7aaefad91d9c578db329a8f208b1bf4d84b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 31 Jul 2025 23:30:45 +0800 Subject: [PATCH 134/582] =?UTF-8?q?=F0=9F=94=84=20fix:=20improve=20vendor-?= =?UTF-8?q?tab=20filtering=20&=20counts,=20resolve=20SQL=20ambiguity,=20an?= =?UTF-8?q?d=20reload=20data=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend • model/model_meta.go – Import strconv – SearchModels: support numeric vendor ID filter vs. fuzzy name search – Explicitly order by `models.id` to avoid “ambiguous column name: id” error Frontend • hooks/useModelsData.js – Change vendor-filter API to pass vendor ID – Automatically reload models when `activeVendorKey` changes – Update vendor counts only when viewing “All” to preserve other tab totals • Add missing effect in EditModelModal to refresh vendor list only when modal visible • Other minor updates to keep lints clean Result Tabs now: 1. Trigger API requests on click 2. Show accurate per-vendor totals 3. Filter models without resetting other counts Backend search handles both vendor IDs and names without SQL errors. --- model/model_meta.go | 10 ++++++-- .../table/models/modals/EditModelModal.jsx | 7 +++--- web/src/hooks/models/useModelsData.js | 24 +++++++++++-------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/model/model_meta.go b/model/model_meta.go index f9b3dfc9..ef5e883a 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -2,6 +2,7 @@ package model import ( "one-api/common" + "strconv" "gorm.io/gorm" ) @@ -96,13 +97,18 @@ func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Mode db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like) } if vendor != "" { - db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%") + // 如果是数字,按供应商 ID 精确匹配;否则按名称模糊匹配 + if vid, err := strconv.Atoi(vendor); err == nil { + db = db.Where("models.vendor_id = ?", vid) + } else { + db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%") + } } var total int64 err := db.Count(&total).Error if err != nil { return nil, 0, err } - err = db.Offset(offset).Limit(limit).Order("id DESC").Find(&models).Error + err = db.Offset(offset).Limit(limit).Order("models.id DESC").Find(&models).Error return models, total, err } diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index 70015cca..038a50d9 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -75,9 +75,10 @@ const EditModelModal = (props) => { }; useEffect(() => { - fetchVendors(); - }, []); - + if (props.visiable) { + fetchVendors(); + } + }, [props.visiable]); const getInitValues = () => ({ model_name: '', diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js index fe83168e..93b68783 100644 --- a/web/src/hooks/models/useModelsData.js +++ b/web/src/hooks/models/useModelsData.js @@ -87,7 +87,7 @@ export const useModelsData = () => { setModels(models); }; - // 获取供应商列表 + // Vendor list const [vendors, setVendors] = useState([]); const [vendorCounts, setVendorCounts] = useState({}); const [activeVendorKey, setActiveVendorKey] = useState('all'); @@ -103,7 +103,7 @@ export const useModelsData = () => { return map; }, [vendors]); - // 加载供应商列表 + // Load vendor list const loadVendors = async () => { try { const res = await API.get('/api/vendors/?page_size=1000'); @@ -122,11 +122,8 @@ export const useModelsData = () => { try { let url = `/api/models/?p=${page}&page_size=${size}`; if (vendorKey && vendorKey !== 'all') { - // 按供应商筛选,通过vendor搜索接口 - const vendor = vendors.find(v => String(v.id) === vendorKey); - if (vendor) { - url = `/api/models/search?vendor=${vendor.name}&p=${page}&page_size=${size}`; - } + // Filter by vendor ID + url = `/api/models/search?vendor=${vendorKey}&p=${page}&page_size=${size}`; } const res = await API.get(url); @@ -138,8 +135,10 @@ export const useModelsData = () => { setModelCount(data.total || newPageData.length); setModelFormat(newPageData); - // 更新供应商统计 - updateVendorCounts(newPageData); + // Refresh vendor counts only when viewing 'all' to preserve other counts + if (vendorKey === 'all') { + updateVendorCounts(newPageData); + } } else { showError(message); setModels([]); @@ -227,7 +226,7 @@ export const useModelsData = () => { } }; - // 更新供应商统计 + // Update vendor counts const updateVendorCounts = (models) => { const counts = { all: models.length }; models.forEach(model => { @@ -244,6 +243,11 @@ export const useModelsData = () => { loadModels(page, pageSize, activeVendorKey); }; + // Reload models when activeVendorKey changes + useEffect(() => { + loadModels(1, pageSize, activeVendorKey); + }, [activeVendorKey]); + // Handle page size change const handlePageSizeChange = async (size) => { setPageSize(size); From b0fe72910f2669ae5647a619d2af0896102dc90b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 1 Aug 2025 02:21:14 +0800 Subject: [PATCH 135/582] =?UTF-8?q?=F0=9F=90=9B=20fix(model):=20preserve?= =?UTF-8?q?=20created=5Ftime=20on=20Model=20update=20and=20streamline=20fi?= =?UTF-8?q?eld=20maintenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update operation for Model previously overwrote `created_time` with zero because GORM included every struct field in the UPDATE statement. This commit adjusts `Model.Update()` to: * Call `Omit("created_time")` so the creation timestamp is never modified. * Refresh `UpdatedTime` with `common.GetTimestamp()` before persisting. * Delegate the remainder of the struct to GORM, eliminating the need to maintain an explicit allow-list whenever new fields are introduced. No API contract is changed; existing CRUD endpoints continue to work normally while data integrity for historical records is now guaranteed. --- model/model_meta.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/model/model_meta.go b/model/model_meta.go index ef5e883a..6f6c5e22 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -50,8 +50,11 @@ func (mi *Model) Insert() error { // Update 更新现有模型记录 func (mi *Model) Update() error { + // 仅更新需要变更的字段,避免覆盖 CreatedTime mi.UpdatedTime = common.GetTimestamp() - return DB.Save(mi).Error + + // 排除 created_time,其余字段自动更新,避免新增字段时需要维护列表 + return DB.Model(&Model{}).Where("id = ?", mi.Id).Omit("created_time").Updates(mi).Error } // Delete 软删除模型记录 From 1f35f668201e25181f472666ba0a3228152888b0 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 1 Aug 2025 02:39:12 +0800 Subject: [PATCH 136/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(hooks/mod?= =?UTF-8?q?els):=20deduplicate=20`useModelsData`=20and=20optimize=20vendor?= =?UTF-8?q?-tab=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlights ────────── 1. Removed code duplication • Introduced `extractItems` helper to safely unwrap API payloads. • Simplified `getFormValues` to a single-line fallback expression. • Replaced repeated list-extraction code in `loadModels`, `searchModels`, and `refreshVendorCounts` with the new helper. 2. Vendor tab accuracy & performance • Added `refreshVendorCounts` to recalc counts via a single lightweight request; invoked only when必要 (current tab ≠ "all“) to avoid redundancy. • `loadModels` still updates counts instantly when viewing "all", ensuring accurate numbers on initial load and page changes. 3. Misc clean-ups • Streamlined conditional URL building and state updates. • Confirmed all async branches include error handling with i18n messages. • Ran linter → zero issues. Result: leaner, easier-to-maintain hook with correct, real-time vendor counts and no repeated logic. --- web/src/hooks/models/useModelsData.js | 42 ++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js index 93b68783..da222429 100644 --- a/web/src/hooks/models/useModelsData.js +++ b/web/src/hooks/models/useModelsData.js @@ -59,17 +59,18 @@ export const useModelsData = () => { searchVendor: '', }; + // ---------- helpers ---------- + // Safely extract array items from API payload + const extractItems = (payload) => { + const items = payload?.items || payload || []; + return Array.isArray(items) ? items : []; + }; + // Form API reference const [formApi, setFormApi] = useState(null); // Get form values helper function - const getFormValues = () => { - const formValues = formApi ? formApi.getValues() : {}; - return { - searchKeyword: formValues.searchKeyword || '', - searchVendor: formValues.searchVendor || '', - }; - }; + const getFormValues = () => formApi?.getValues() || formInitValues; // Close edit modal const closeEdit = () => { @@ -129,8 +130,7 @@ export const useModelsData = () => { const res = await API.get(url); const { success, message, data } = res.data; if (success) { - const items = data.items || data || []; - const newPageData = Array.isArray(items) ? items : []; + const newPageData = extractItems(data); setActivePage(data.page || page); setModelCount(data.total || newPageData.length); setModelFormat(newPageData); @@ -151,15 +151,32 @@ export const useModelsData = () => { setLoading(false); }; + // Fetch vendor counts separately to keep tab numbers accurate + const refreshVendorCounts = async () => { + try { + // Load all models (large page_size) to compute counts for every vendor + const res = await API.get('/api/models/?p=1&page_size=100000'); + if (res.data.success) { + const newItems = extractItems(res.data.data); + updateVendorCounts(newItems); + } + } catch (_) { + // ignore count refresh errors + } + }; + // Refresh data const refresh = async (page = activePage) => { await loadModels(page, pageSize); + // When not viewing 'all', tab counts need a separate refresh + if (activeVendorKey !== 'all') { + await refreshVendorCounts(); + } }; // Search models with keyword and vendor const searchModels = async () => { - const formValues = getFormValues(); - const { searchKeyword, searchVendor } = formValues; + const { searchKeyword = '', searchVendor = '' } = getFormValues(); if (searchKeyword === '' && searchVendor === '') { // If keyword is blank, load models instead @@ -174,8 +191,7 @@ export const useModelsData = () => { ); const { success, message, data } = res.data; if (success) { - const items = data.items || data || []; - const newPageData = Array.isArray(items) ? items : []; + const newPageData = extractItems(data); setActivePage(data.page || 1); setModelCount(data.total || newPageData.length); setModelFormat(newPageData); From e3d98a3f5b78e8815ead99a85d92e9564721caf1 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 1 Aug 2025 02:50:06 +0800 Subject: [PATCH 137/582] =?UTF-8?q?=F0=9F=8E=A8=20style(sidebar):=20unify?= =?UTF-8?q?=20highlight=20color=20&=20assign=20unique=20icon=20for=20Model?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Removed obsolete `sidebarIconColors` map and `getItemColor` util from SiderBar/render; all selected states now use the single CSS variable `--semi-color-primary` for both text and icons. • Simplified `getLucideIcon`: – Added `Package` to Lucide imports. – Switched “models” case to ``, avoiding duplication with the Layers glyph. – Replaced per-key color logic with `iconColor` derived from the new uniform highlight color. • Stripped any unused imports / dead code paths after the refactor. • Lint passes; sidebar hover/focus behavior unchanged while visual consistency is improved. --- web/src/components/layout/SiderBar.js | 32 ++++------------- web/src/helpers/render.js | 50 ++++++++++----------------- 2 files changed, 24 insertions(+), 58 deletions(-) diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js index dbcb01df..cd623ded 100644 --- a/web/src/components/layout/SiderBar.js +++ b/web/src/components/layout/SiderBar.js @@ -20,7 +20,7 @@ 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 { getLucideIcon } from '../../helpers/render.js'; import { ChevronLeft } from 'lucide-react'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; import { @@ -251,28 +251,8 @@ const SiderBar = ({ onNavigate = () => { } }) => { } }, [collapsed]); - // 获取菜单项对应的颜色 - const getItemColor = (itemKey) => { - switch (itemKey) { - case 'detail': return sidebarIconColors.dashboard; - case 'playground': return sidebarIconColors.terminal; - case 'chat': return sidebarIconColors.message; - case 'token': return sidebarIconColors.key; - case 'log': return sidebarIconColors.chart; - case 'midjourney': return sidebarIconColors.image; - case 'task': return sidebarIconColors.check; - case 'topup': return sidebarIconColors.credit; - case 'channel': return sidebarIconColors.layers; - case 'redemption': return sidebarIconColors.gift; - case 'user': - case 'personal': return sidebarIconColors.user; - case 'setting': return sidebarIconColors.settings; - default: - // 处理聊天项 - if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message; - return 'currentColor'; - } - }; + // 选中高亮颜色(统一) + const SELECTED_COLOR = 'var(--semi-color-primary)'; // 渲染自定义菜单项 const renderNavItem = (item) => { @@ -280,7 +260,7 @@ const SiderBar = ({ onNavigate = () => { } }) => { if (item.className === 'tableHiddle') return null; const isSelected = selectedKeys.includes(item.itemKey); - const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + const textColor = isSelected ? SELECTED_COLOR : 'inherit'; return ( { } }) => { const renderSubItem = (item) => { if (item.items && item.items.length > 0) { const isSelected = selectedKeys.includes(item.itemKey); - const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + const textColor = isSelected ? SELECTED_COLOR : 'inherit'; return ( { } }) => { > {item.items.map((subItem) => { const isSubSelected = selectedKeys.includes(subItem.itemKey); - const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit'; + const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit'; return ( ); case 'playground': return ( ); case 'chat': return ( ); case 'token': return ( ); case 'log': return ( ); case 'midjourney': return ( ); case 'task': return ( ); case 'topup': return ( ); case 'channel': return ( ); case 'redemption': return ( ); case 'user': @@ -176,28 +162,28 @@ export function getLucideIcon(key, selected = false) { return ( ); case 'models': return ( - ); case 'setting': return ( ); default: return ( ); } From 8ffa48ab3402f30b6834894e54d75e3096292afd Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 1 Aug 2025 03:00:12 +0800 Subject: [PATCH 138/582] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20enhance=20tag?= =?UTF-8?q?=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. EditModelModal quality-of-life • Added comma parsing to `Form.TagInput`; users can now paste `tag1, tag2 , tag3` to bulk-create tags. • Updated placeholder copy to reflect the new capability. All files pass linting; no runtime changes outside the intended UI updates. --- .../components/table/models/modals/EditModelModal.jsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index 038a50d9..f1539d07 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -285,10 +285,19 @@ const EditModelModal = (props) => { { + if (!formApiRef.current) return; + const normalize = (tags) => { + if (!Array.isArray(tags)) return []; + return [...new Set(tags.flatMap(tag => tag.split(',').map(t => t.trim()).filter(Boolean)))]; + }; + const normalized = normalize(newTags); + formApiRef.current.setValue('tags', normalized); + }} /> From de23f66785d4ace538d202c425913cb3665cfb31 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 17:04:16 +0800 Subject: [PATCH 139/582] fix: handle case where no response is received from Gemini API --- relay/channel/gemini/relay-gemini-native.go | 6 ++++++ relay/channel/gemini/relay-gemini.go | 7 ++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 7d459cc2..29544d1e 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -1,6 +1,7 @@ package gemini import ( + "github.com/pkg/errors" "io" "net/http" "one-api/common" @@ -107,6 +108,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn // 直接发送 GeminiChatResponse 响应 err = helper.StringData(c, data) + info.SendResponseCount++ if err != nil { common.LogError(c, err.Error()) } @@ -114,6 +116,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn return true }) + if info.SendResponseCount == 0 { + return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) + } + if imageCount != 0 { if usage.CompletionTokens == 0 { usage.CompletionTokens = imageCount * 258 diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 5dac0ce5..1a0b221b 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -827,8 +827,6 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * var usage = &dto.Usage{} var imageCount int - respCount := 0 - helper.StreamScannerHandler(c, resp, info, func(data string) bool { var geminiResponse GeminiChatResponse err := common.UnmarshalJsonStr(data, &geminiResponse) @@ -858,7 +856,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * } } - if respCount == 0 { + if info.SendResponseCount == 0 { // send first response err = handleStream(c, info, helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)) if err != nil { @@ -873,11 +871,10 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * if isStop { _ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop)) } - respCount++ return true }) - if respCount == 0 { + if info.SendResponseCount == 0 { // 空补全,报错不计费 // empty response, throw an error return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) From 87c16db2d7df4ae761d054e47d18848d8de20a98 Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 17:21:25 +0800 Subject: [PATCH 140/582] fix: update JSONEditor to default to manual mode for invalid JSON and add error message for invalid data --- relay/channel/gemini/relay-gemini-native.go | 3 +-- web/src/components/common/JSONEditor.js | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 29544d1e..5725a53a 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -108,11 +108,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn // 直接发送 GeminiChatResponse 响应 err = helper.StringData(c, data) - info.SendResponseCount++ if err != nil { common.LogError(c, err.Error()) } - + info.SendResponseCount++ return true }) diff --git a/web/src/components/common/JSONEditor.js b/web/src/components/common/JSONEditor.js index d0c159b2..649d5a58 100644 --- a/web/src/components/common/JSONEditor.js +++ b/web/src/components/common/JSONEditor.js @@ -65,7 +65,8 @@ const JSONEditor = ({ const keyCount = Object.keys(parsed).length; return keyCount > 10 ? 'manual' : 'visual'; } catch (error) { - return 'visual'; + // JSON无效时默认显示手动编辑模式 + return 'manual'; } } return 'visual'; @@ -201,6 +202,18 @@ const JSONEditor = ({ // 渲染键值对编辑器 const renderKeyValueEditor = () => { + if (typeof jsonData !== 'object' || jsonData === null) { + return ( +
    +
    + +
    + + {t('无效的JSON数据,请检查格式')} + +
    + ); + } const entries = Object.entries(jsonData); return ( From 2578c39c7d9d18ddadf95f8bd13e1189c6f25e8d Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 17:58:21 +0800 Subject: [PATCH 141/582] refactor: simplify streamResponseGeminiChat2OpenAI by removing hasImage return value and optimizing response text handling --- relay/channel/gemini/relay-gemini.go | 32 ++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 1a0b221b..7f8ab303 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -725,10 +725,9 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dt return &fullTextResponse } -func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) { +func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) { choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) isStop := false - hasImage := false for _, candidate := range geminiResponse.Candidates { if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" { isStop = true @@ -759,7 +758,6 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C if strings.HasPrefix(part.InlineData.MimeType, "image") { imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")" texts = append(texts, imgText) - hasImage = true } } else if part.FunctionCall != nil { isTools = true @@ -796,7 +794,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C var response dto.ChatCompletionsStreamResponse response.Object = "chat.completion.chunk" response.Choices = choices - return &response, isStop, hasImage + return &response, isStop } func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { @@ -824,6 +822,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * // responseText := "" id := helper.GetResponseID(c) createAt := common.GetTimestamp() + responseText := strings.Builder{} var usage = &dto.Usage{} var imageCount int @@ -835,10 +834,19 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * return false } - response, isStop, hasImage := streamResponseGeminiChat2OpenAI(&geminiResponse) - if hasImage { - imageCount++ + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && part.InlineData.MimeType != "" { + imageCount++ + } + if part.Text != "" { + responseText.WriteString(part.Text) + } + } } + + response, isStop := streamResponseGeminiChat2OpenAI(&geminiResponse) + response.Id = id response.Created = createAt response.Model = info.UpstreamModelName @@ -889,6 +897,16 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * usage.PromptTokensDetails.TextTokens = usage.PromptTokens usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + if usage.CompletionTokens == 0 { + str := responseText.String() + if len(str) > 0 { + usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + // 空补全,不需要使用量 + usage = &dto.Usage{} + } + } + response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) err := handleFinalStream(c, info, response) if err != nil { From 0080123779df85fd216a780ccf4dd763e531535b Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 18:09:20 +0800 Subject: [PATCH 142/582] fix: ensure ChannelIsMultiKey context key is set to false for single key retries --- middleware/distributor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/middleware/distributor.go b/middleware/distributor.go index fb4a6645..c7a55f4c 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -269,6 +269,9 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode if channel.ChannelInfo.IsMultiKey { common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true) common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index) + } else { + // 必须设置为 false,否则在重试到单个 key 的时候会导致日志显示错误 + common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, false) } // c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) common.SetContextKey(c, constant.ContextKeyChannelKey, key) From 52f419bc3bc6941d6384b5aec05e7cd45362fdad Mon Sep 17 00:00:00 2001 From: CaIon Date: Fri, 1 Aug 2025 18:19:28 +0800 Subject: [PATCH 143/582] feat: add admin info to error logging with multi-key support --- controller/relay.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/controller/relay.go b/controller/relay.go index e7318e9b..b5b8f8fe 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -62,6 +62,14 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError { other["channel_id"] = channelId other["channel_name"] = c.GetString("channel_name") other["channel_type"] = c.GetInt("channel_type") + adminInfo := make(map[string]interface{}) + adminInfo["use_channel"] = c.GetStringSlice("use_channel") + isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) + if isMultiKey { + adminInfo["is_multi_key"] = true + adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) + } + other["admin_info"] = adminInfo model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other) } From 689dbfe71aa4b55b9ea8240f242438ddb33eb0de Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Fri, 1 Aug 2025 22:23:35 +0800 Subject: [PATCH 144/582] feat: convert gemini format to openai chat completions --- relay/channel/gemini/dto.go => dto/gemini.go | 6 +- relay/channel/adapter.go | 1 + relay/channel/ali/adaptor.go | 5 + relay/channel/aws/adaptor.go | 5 + relay/channel/baidu/adaptor.go | 5 + relay/channel/baidu_v2/adaptor.go | 21 +- relay/channel/claude/adaptor.go | 5 + relay/channel/claude_code/adaptor.go | 5 + relay/channel/cloudflare/adaptor.go | 5 + relay/channel/cohere/adaptor.go | 5 + relay/channel/coze/adaptor.go | 5 + relay/channel/deepseek/adaptor.go | 5 + relay/channel/dify/adaptor.go | 5 + relay/channel/gemini/adaptor.go | 16 +- relay/channel/gemini/relay-gemini-native.go | 4 +- relay/channel/gemini/relay-gemini.go | 78 ++--- relay/channel/jimeng/adaptor.go | 8 +- relay/channel/jina/adaptor.go | 5 + relay/channel/mistral/adaptor.go | 5 + relay/channel/mokaai/adaptor.go | 5 + relay/channel/ollama/adaptor.go | 5 + relay/channel/openai/adaptor.go | 11 +- relay/channel/openai/helper.go | 76 ++++ relay/channel/openai/relay-openai.go | 7 + relay/channel/palm/adaptor.go | 5 + relay/channel/perplexity/adaptor.go | 5 + relay/channel/siliconflow/adaptor.go | 5 + relay/channel/tencent/adaptor.go | 5 + relay/channel/vertex/adaptor.go | 4 + relay/channel/volcengine/adaptor.go | 5 + relay/channel/xai/adaptor.go | 5 + relay/channel/xunfei/adaptor.go | 5 + relay/channel/zhipu/adaptor.go | 5 + relay/channel/zhipu_4v/adaptor.go | 5 + relay/gemini_handler.go | 17 +- service/convert.go | 350 +++++++++++++++++++ 36 files changed, 648 insertions(+), 66 deletions(-) rename relay/channel/gemini/dto.go => dto/gemini.go (98%) diff --git a/relay/channel/gemini/dto.go b/dto/gemini.go similarity index 98% rename from relay/channel/gemini/dto.go rename to dto/gemini.go index a5e41c83..f7acd355 100644 --- a/relay/channel/gemini/dto.go +++ b/dto/gemini.go @@ -1,4 +1,4 @@ -package gemini +package dto import ( "encoding/json" @@ -56,7 +56,7 @@ type FunctionCall struct { Arguments any `json:"args"` } -type FunctionResponse struct { +type GeminiFunctionResponse struct { Name string `json:"name"` Response map[string]interface{} `json:"response"` } @@ -81,7 +81,7 @@ type GeminiPart struct { Thought bool `json:"thought,omitempty"` InlineData *GeminiInlineData `json:"inlineData,omitempty"` FunctionCall *FunctionCall `json:"functionCall,omitempty"` - FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"` + FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` FileData *GeminiFileData `json:"fileData,omitempty"` ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"` CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"` diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index ab8836ba..ec749133 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -26,6 +26,7 @@ type Adaptor interface { GetModelList() []string GetChannelName() string ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) + ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) } type TaskAdaptor interface { diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index d941a1bc..067fac37 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index d3354f00..d7910725 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -22,6 +22,11 @@ type Adaptor struct { RequestMode int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { c.Set("request_model", request.Model) c.Set("converted_request", request) diff --git a/relay/channel/baidu/adaptor.go b/relay/channel/baidu/adaptor.go index 22443354..8396a844 100644 --- a/relay/channel/baidu/adaptor.go +++ b/relay/channel/baidu/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go index 375fd531..b8a4ac2f 100644 --- a/relay/channel/baidu_v2/adaptor.go +++ b/relay/channel/baidu_v2/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") @@ -43,15 +48,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) - keyParts := strings.Split(info.ApiKey, "|") + keyParts := strings.Split(info.ApiKey, "|") if len(keyParts) == 0 || keyParts[0] == "" { - return errors.New("invalid API key: authorization token is required") - } - if len(keyParts) > 1 { - if keyParts[1] != "" { - req.Set("appid", keyParts[1]) - } - } + return errors.New("invalid API key: authorization token is required") + } + if len(keyParts) > 1 { + if keyParts[1] != "" { + req.Set("appid", keyParts[1]) + } + } req.Set("Authorization", "Bearer "+keyParts[0]) return nil } diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 540742d6..0f7a9414 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -24,6 +24,11 @@ type Adaptor struct { RequestMode int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { return request, nil } diff --git a/relay/channel/claude_code/adaptor.go b/relay/channel/claude_code/adaptor.go index 7a0be927..a5926f9d 100644 --- a/relay/channel/claude_code/adaptor.go +++ b/relay/channel/claude_code/adaptor.go @@ -25,6 +25,11 @@ type Adaptor struct { RequestMode int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { // Use configured system prompt if available, otherwise use default if info.ChannelSetting.SystemPrompt != "" { diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go index 6e59ad71..74a65ba4 100644 --- a/relay/channel/cloudflare/adaptor.go +++ b/relay/channel/cloudflare/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/cohere/adaptor.go b/relay/channel/cohere/adaptor.go index 4f3a96c3..887f9efd 100644 --- a/relay/channel/cohere/adaptor.go +++ b/relay/channel/cohere/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go index fe5f5f00..658c6193 100644 --- a/relay/channel/coze/adaptor.go +++ b/relay/channel/coze/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *common.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + // ConvertAudioRequest implements channel.Adaptor. func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *common.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index edfc7fd3..ac8ea18f 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -19,6 +19,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go index 4ad16766..8c7898c9 100644 --- a/relay/channel/dify/adaptor.go +++ b/relay/channel/dify/adaptor.go @@ -24,6 +24,11 @@ type Adaptor struct { BotType int } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 2b7b7e39..20d43020 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -20,6 +20,10 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return request, nil +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { adaptor := openai.Adaptor{} oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req) @@ -51,13 +55,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } // build gemini imagen request - geminiRequest := GeminiImageRequest{ - Instances: []GeminiImageInstance{ + geminiRequest := dto.GeminiImageRequest{ + Instances: []dto.GeminiImageInstance{ { Prompt: request.Prompt, }, }, - Parameters: GeminiImageParameters{ + Parameters: dto.GeminiImageParameters{ SampleCount: request.N, AspectRatio: aspectRatio, PersonGeneration: "allow_adult", // default allow adult @@ -138,9 +142,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } // only process the first input - geminiRequest := GeminiEmbeddingRequest{ - Content: GeminiChatContent{ - Parts: []GeminiPart{ + geminiRequest := dto.GeminiEmbeddingRequest{ + Content: dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ { Text: inputs[0], }, diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 7d459cc2..2060fd8c 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -28,7 +28,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re } // 解析为 Gemini 原生响应格式 - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) @@ -71,7 +71,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn responseText := strings.Builder{} helper.StreamScannerHandler(c, resp, info, func(data string) bool { - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err := common.UnmarshalJsonStr(data, &geminiResponse) if err != nil { common.LogError(c, "error unmarshalling stream response: "+err.Error()) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 5dac0ce5..4065259f 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -81,7 +81,7 @@ func clampThinkingBudget(modelName string, budget int) int { return budget } -func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) { +func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { modelName := info.UpstreamModelName isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && @@ -93,7 +93,7 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn if len(parts) == 2 && parts[1] != "" { if budgetTokens, err := strconv.Atoi(parts[1]); err == nil { clampedBudget := clampThinkingBudget(modelName, budgetTokens) - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ ThinkingBudget: common.GetPointer(clampedBudget), IncludeThoughts: true, } @@ -113,11 +113,11 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn } if isUnsupported { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ IncludeThoughts: true, } } else { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ IncludeThoughts: true, } if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { @@ -128,7 +128,7 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn } } else if strings.HasSuffix(modelName, "-nothinking") { if !isNew25Pro { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ ThinkingBudget: common.GetPointer(0), } } @@ -137,11 +137,11 @@ func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayIn } // Setting safety to the lowest possible values since Gemini is already powerless enough -func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { +func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) { - geminiRequest := GeminiChatRequest{ - Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)), - GenerationConfig: GeminiChatGenerationConfig{ + geminiRequest := dto.GeminiChatRequest{ + Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)), + GenerationConfig: dto.GeminiChatGenerationConfig{ Temperature: textRequest.Temperature, TopP: textRequest.TopP, MaxOutputTokens: textRequest.MaxTokens, @@ -158,9 +158,9 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ThinkingAdaptor(&geminiRequest, info) - safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList)) + safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList)) for _, category := range SafetySettingList { - safetySettings = append(safetySettings, GeminiChatSafetySettings{ + safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{ Category: category, Threshold: model_setting.GetGeminiSafetySetting(category), }) @@ -198,17 +198,17 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon functions = append(functions, tool.Function) } if codeExecution { - geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{ CodeExecution: make(map[string]string), }) } if googleSearch { - geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{ GoogleSearch: make(map[string]string), }) } if len(functions) > 0 { - geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{ FunctionDeclarations: functions, }) } @@ -238,7 +238,7 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon continue } else if message.Role == "tool" || message.Role == "function" { if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" { - geminiRequest.Contents = append(geminiRequest.Contents, GeminiChatContent{ + geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{ Role: "user", }) } @@ -265,18 +265,18 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } } - functionResp := &FunctionResponse{ + functionResp := &dto.GeminiFunctionResponse{ Name: name, Response: contentMap, } - *parts = append(*parts, GeminiPart{ + *parts = append(*parts, dto.GeminiPart{ FunctionResponse: functionResp, }) continue } - var parts []GeminiPart - content := GeminiChatContent{ + var parts []dto.GeminiPart + content := dto.GeminiChatContent{ Role: message.Role, } // isToolCall := false @@ -290,8 +290,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments) } } - toolCall := GeminiPart{ - FunctionCall: &FunctionCall{ + toolCall := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ FunctionName: call.Function.Name, Arguments: args, }, @@ -308,7 +308,7 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if part.Text == "" { continue } - parts = append(parts, GeminiPart{ + parts = append(parts, dto.GeminiPart{ Text: part.Text, }) } else if part.Type == dto.ContentTypeImageURL { @@ -331,8 +331,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: fileData.MimeType, // 使用原始的 MimeType,因为大小写可能对API有意义 Data: fileData.Base64Data, }, @@ -342,8 +342,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if err != nil { return nil, fmt.Errorf("decode base64 image data failed: %s", err.Error()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: format, Data: base64String, }, @@ -357,8 +357,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if err != nil { return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: format, Data: base64String, }, @@ -371,8 +371,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if err != nil { return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) } - parts = append(parts, GeminiPart{ - InlineData: &GeminiInlineData{ + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ MimeType: "audio/" + part.GetInputAudio().Format, Data: base64String, }, @@ -392,8 +392,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } if len(system_content) > 0 { - geminiRequest.SystemInstructions = &GeminiChatContent{ - Parts: []GeminiPart{ + geminiRequest.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ { Text: strings.Join(system_content, "\n"), }, @@ -636,7 +636,7 @@ func unescapeMapOrSlice(data interface{}) interface{} { return data } -func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse { +func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse { var argsBytes []byte var err error if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok { @@ -658,7 +658,7 @@ func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse { } } -func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse { +func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse { fullTextResponse := dto.OpenAITextResponse{ Id: helper.GetResponseID(c), Object: "chat.completion", @@ -725,7 +725,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dt return &fullTextResponse } -func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) { +func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) { choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) isStop := false hasImage := false @@ -830,7 +830,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * respCount := 0 helper.StreamScannerHandler(c, resp, info, func(data string) bool { - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err := common.UnmarshalJsonStr(data, &geminiResponse) if err != nil { common.LogError(c, "error unmarshalling stream response: "+err.Error()) @@ -913,7 +913,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R if common.DebugEnabled { println(string(responseBody)) } - var geminiResponse GeminiChatResponse + var geminiResponse dto.GeminiChatResponse err = common.Unmarshal(responseBody, &geminiResponse) if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) @@ -959,7 +959,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - var geminiResponse GeminiEmbeddingResponse + var geminiResponse dto.GeminiEmbeddingResponse if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } @@ -1005,7 +1005,7 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. } _ = resp.Body.Close() - var geminiResponse GeminiImageResponse + var geminiResponse dto.GeminiImageResponse if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go index 0b743879..ff9ac678 100644 --- a/relay/channel/jimeng/adaptor.go +++ b/relay/channel/jimeng/adaptor.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/gin-gonic/gin" "io" "net/http" "one-api/dto" @@ -13,11 +12,18 @@ import ( relaycommon "one-api/relay/common" relayconstant "one-api/relay/constant" "one-api/types" + + "github.com/gin-gonic/gin" ) type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { return nil, errors.New("not implemented") } diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go index 408a5c6e..bf318aa7 100644 --- a/relay/channel/jina/adaptor.go +++ b/relay/channel/jina/adaptor.go @@ -19,6 +19,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go index 434a1031..45cb3290 100644 --- a/relay/channel/mistral/adaptor.go +++ b/relay/channel/mistral/adaptor.go @@ -16,6 +16,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/mokaai/adaptor.go b/relay/channel/mokaai/adaptor.go index b0b54b0c..37db2aec 100644 --- a/relay/channel/mokaai/adaptor.go +++ b/relay/channel/mokaai/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index ff88de8b..1f3fda8d 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { openaiAdaptor := openai.Adaptor{} openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index efd22878..df858ea2 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -34,6 +34,15 @@ type Adaptor struct { ResponseFormat string } +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + // 使用 service.GeminiToOpenAIRequest 转换请求格式 + openaiRequest, err := service.GeminiToOpenAIRequest(request, info) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, openaiRequest) +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { //if !strings.Contains(request.Model, "claude") { // return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model) @@ -64,7 +73,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if info.RelayFormat == relaycommon.RelayFormatClaude { + if info.RelayFormat == relaycommon.RelayFormatClaude || info.RelayFormat == relaycommon.RelayFormatGemini { return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil } if info.RelayMode == relayconstant.RelayModeRealtime { diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 1681c9ff..528f1276 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -2,6 +2,8 @@ package openai import ( "encoding/json" + "errors" + "net/http" "one-api/common" "one-api/dto" relaycommon "one-api/relay/common" @@ -16,11 +18,14 @@ import ( // 辅助函数 func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { info.SendResponseCount++ + switch info.RelayFormat { case relaycommon.RelayFormatOpenAI: return sendStreamData(c, info, data, forceFormat, thinkToContent) case relaycommon.RelayFormatClaude: return handleClaudeFormat(c, data, info) + case relaycommon.RelayFormatGemini: + return handleGeminiFormat(c, data, info) } return nil } @@ -41,6 +46,46 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo return nil } +func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { + // 截取前50个字符用于调试 + debugData := data + if len(data) > 50 { + debugData = data[:50] + "..." + } + common.LogInfo(c, "handleGeminiFormat called with data: "+debugData) + + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + common.LogError(c, "failed to unmarshal stream response: "+err.Error()) + return err + } + + common.LogInfo(c, "successfully unmarshaled stream response") + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // 如果返回 nil,表示没有实际内容,跳过发送 + if geminiResponse == nil { + common.LogInfo(c, "handleGeminiFormat: no content to send, skipping") + return nil + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + common.LogError(c, "failed to marshal gemini response: "+err.Error()) + return err + } + + common.LogInfo(c, "sending gemini format response") + // send gemini format response + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } else { + return errors.New("streaming error: flusher not found") + } + return nil +} + func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, responseTextBuilder *strings.Builder, toolCount *int) error { for _, choice := range streamResponse.Choices { responseTextBuilder.WriteString(choice.Delta.GetContentString()) @@ -185,6 +230,37 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream for _, resp := range claudeResponses { _ = helper.ClaudeData(c, *resp) } + + case relaycommon.RelayFormatGemini: + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return + } + + // 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段 + // 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空,finishReason 为 STOP 的响应 + // 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null + // 暂不知是否有程序会不兼容。 + + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // openai 流响应开头的空数据 + if geminiResponse == nil { + return + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + common.SysError("error marshalling gemini response: " + err.Error()) + return + } + + // 发送最终的 Gemini 响应 + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } } } diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index f6a04f3a..9ae0a200 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -223,6 +223,13 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo return nil, types.NewError(err, types.ErrorCodeBadResponseBody) } responseBody = claudeRespStr + case relaycommon.RelayFormatGemini: + geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info) + geminiRespStr, err := common.Marshal(geminiResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = geminiRespStr } common.IOCopyBytesGracefully(c, resp, responseBody) diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go index a60dc4b2..4d1ab783 100644 --- a/relay/channel/palm/adaptor.go +++ b/relay/channel/palm/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/perplexity/adaptor.go b/relay/channel/perplexity/adaptor.go index 19830aca..92cb08a2 100644 --- a/relay/channel/perplexity/adaptor.go +++ b/relay/channel/perplexity/adaptor.go @@ -17,6 +17,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go index c80e9ea1..05e6d453 100644 --- a/relay/channel/siliconflow/adaptor.go +++ b/relay/channel/siliconflow/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { adaptor := openai.Adaptor{} return adaptor.ConvertClaudeRequest(c, info, req) diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go index 520276a7..b86d8a16 100644 --- a/relay/channel/tencent/adaptor.go +++ b/relay/channel/tencent/adaptor.go @@ -25,6 +25,11 @@ type Adaptor struct { Timestamp int64 } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index c88b4359..39be998e 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -44,6 +44,10 @@ type Adaptor struct { AccountCredentials Credentials } +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return request, nil +} + func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { if v, ok := claudeModelMap[info.UpstreamModelName]; ok { c.Set("request_model", v) diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index af15d636..225b3895 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -23,6 +23,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 8d880137..6a3a5370 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -19,6 +19,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me //panic("implement me") diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go index 0d218ada..7ee76f1a 100644 --- a/relay/channel/xunfei/adaptor.go +++ b/relay/channel/xunfei/adaptor.go @@ -17,6 +17,11 @@ type Adaptor struct { request *dto.GeneralOpenAIRequest } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go index 43344428..e3be0e8e 100644 --- a/relay/channel/zhipu/adaptor.go +++ b/relay/channel/zhipu/adaptor.go @@ -16,6 +16,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go index edd7a534..83070fe5 100644 --- a/relay/channel/zhipu_4v/adaptor.go +++ b/relay/channel/zhipu_4v/adaptor.go @@ -18,6 +18,11 @@ import ( type Adaptor struct { } +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { //TODO implement me panic("implement me") diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 43c7ca58..862630ea 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -20,8 +20,8 @@ import ( "github.com/gin-gonic/gin" ) -func getAndValidateGeminiRequest(c *gin.Context) (*gemini.GeminiChatRequest, error) { - request := &gemini.GeminiChatRequest{} +func getAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) { + request := &dto.GeminiChatRequest{} err := common.UnmarshalBodyReusable(c, request) if err != nil { return nil, err @@ -44,7 +44,7 @@ func checkGeminiStreamMode(c *gin.Context, relayInfo *relaycommon.RelayInfo) { // } } -func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, error) { +func checkGeminiInputSensitive(textRequest *dto.GeminiChatRequest) ([]string, error) { var inputTexts []string for _, content := range textRequest.Contents { for _, part := range content.Parts { @@ -61,7 +61,7 @@ func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, return sensitiveWords, err } -func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) int { +func getGeminiInputTokens(req *dto.GeminiChatRequest, info *relaycommon.RelayInfo) int { // 计算输入 token 数量 var inputTexts []string for _, content := range req.Contents { @@ -78,7 +78,7 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay return inputTokens } -func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool { +func isNoThinkingRequest(req *dto.GeminiChatRequest) bool { if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { return *req.GenerationConfig.ThinkingConfig.ThinkingBudget == 0 } @@ -202,7 +202,12 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { } requestBody = bytes.NewReader(body) } else { - jsonData, err := common.Marshal(req) + // 使用 ConvertGeminiRequest 转换请求格式 + convertedRequest, err := adaptor.ConvertGeminiRequest(c, relayInfo, req) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } diff --git a/service/convert.go b/service/convert.go index 787cc79d..ee8ecee5 100644 --- a/service/convert.go +++ b/service/convert.go @@ -448,3 +448,353 @@ func toJSONString(v interface{}) string { } return string(b) } + +func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { + openaiRequest := &dto.GeneralOpenAIRequest{ + Model: info.UpstreamModelName, + Stream: info.IsStream, + } + + // 转换 messages + var messages []dto.Message + for _, content := range geminiRequest.Contents { + message := dto.Message{ + Role: convertGeminiRoleToOpenAI(content.Role), + } + + // 处理 parts + var mediaContents []dto.MediaContent + var toolCalls []dto.ToolCallRequest + for _, part := range content.Parts { + if part.Text != "" { + mediaContent := dto.MediaContent{ + Type: "text", + Text: part.Text, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.InlineData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data), + Detail: "auto", + MimeType: part.InlineData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FileData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: part.FileData.FileUri, + Detail: "auto", + MimeType: part.FileData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FunctionCall != nil { + // 处理 Gemini 的工具调用 + toolCall := dto.ToolCallRequest{ + ID: fmt.Sprintf("call_%d", len(toolCalls)+1), // 生成唯一ID + Type: "function", + Function: dto.FunctionRequest{ + Name: part.FunctionCall.FunctionName, + Arguments: toJSONString(part.FunctionCall.Arguments), + }, + } + toolCalls = append(toolCalls, toolCall) + } else if part.FunctionResponse != nil { + // 处理 Gemini 的工具响应,创建单独的 tool 消息 + toolMessage := dto.Message{ + Role: "tool", + ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)), // 使用对应的调用ID + } + toolMessage.SetStringContent(toJSONString(part.FunctionResponse.Response)) + messages = append(messages, toolMessage) + } + } + + // 设置消息内容 + if len(toolCalls) > 0 { + // 如果有工具调用,设置工具调用 + message.SetToolCalls(toolCalls) + } else if len(mediaContents) == 1 && mediaContents[0].Type == "text" { + // 如果只有一个文本内容,直接设置字符串 + message.Content = mediaContents[0].Text + } else if len(mediaContents) > 0 { + // 如果有多个内容或包含媒体,设置为数组 + message.SetMediaContent(mediaContents) + } + + // 只有当消息有内容或工具调用时才添加 + if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 { + messages = append(messages, message) + } + } + + openaiRequest.Messages = messages + + if geminiRequest.GenerationConfig.Temperature != nil { + openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature + } + if geminiRequest.GenerationConfig.TopP > 0 { + openaiRequest.TopP = geminiRequest.GenerationConfig.TopP + } + if geminiRequest.GenerationConfig.TopK > 0 { + openaiRequest.TopK = int(geminiRequest.GenerationConfig.TopK) + } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + openaiRequest.MaxTokens = geminiRequest.GenerationConfig.MaxOutputTokens + } + // gemini stop sequences 最多 5 个,openai stop 最多 4 个 + if len(geminiRequest.GenerationConfig.StopSequences) > 0 { + openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:4] + } + if geminiRequest.GenerationConfig.CandidateCount > 0 { + openaiRequest.N = geminiRequest.GenerationConfig.CandidateCount + } + + // 转换工具调用 + if len(geminiRequest.Tools) > 0 { + var tools []dto.ToolCallRequest + for _, tool := range geminiRequest.Tools { + if tool.FunctionDeclarations != nil { + // 将 Gemini 的 FunctionDeclarations 转换为 OpenAI 的 ToolCallRequest + functionDeclarations, ok := tool.FunctionDeclarations.([]dto.FunctionRequest) + if ok { + for _, function := range functionDeclarations { + openAITool := dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: function.Name, + Description: function.Description, + Parameters: function.Parameters, + }, + } + tools = append(tools, openAITool) + } + } + } + } + if len(tools) > 0 { + openaiRequest.Tools = tools + } + } + + // gemini system instructions + if geminiRequest.SystemInstructions != nil { + // 将系统指令作为第一条消息插入 + systemMessage := dto.Message{ + Role: "system", + Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts), + } + openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...) + } + + return openaiRequest, nil +} + +func convertGeminiRoleToOpenAI(geminiRole string) string { + switch geminiRole { + case "user": + return "user" + case "model": + return "assistant" + case "function": + return "function" + default: + return "user" + } +} + +func extractTextFromGeminiParts(parts []dto.GeminiPart) string { + var texts []string + for _, part := range parts { + if part.Text != "" { + texts = append(texts, part.Text) + } + } + return strings.Join(texts, "\n") +} + +// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式 +func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + PromptFeedback: dto.GeminiChatPromptFeedback{ + SafetyRatings: []dto.GeminiChatSafetyRating{}, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: openAIResponse.PromptTokens, + CandidatesTokenCount: openAIResponse.CompletionTokens, + TotalTokenCount: openAIResponse.PromptTokens + openAIResponse.CompletionTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + var finishReason string + switch choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + toolCalls := choice.Message.ParseToolCalls() + if len(toolCalls) > 0 { + for _, toolCall := range toolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Message.StringContent() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} + +// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式 +func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + // 检查是否有实际内容或结束标志 + hasContent := false + hasFinishReason := false + for _, choice := range openAIResponse.Choices { + if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) { + hasContent = true + } + if choice.FinishReason != nil { + hasFinishReason = true + } + } + + // 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据 + if !hasContent && !hasFinishReason { + return nil + } + + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + PromptFeedback: dto.GeminiChatPromptFeedback{ + SafetyRatings: []dto.GeminiChatSafetyRating{}, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: info.PromptTokens, + CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息 + TotalTokenCount: info.PromptTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + if choice.FinishReason != nil { + var finishReason string + switch *choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + } + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + if choice.Delta.ToolCalls != nil { + for _, toolCall := range choice.Delta.ToolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Delta.GetContentString() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} From 1968cff5bd4dd171b88a7b3f74d0d119f551ded5 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Fri, 1 Aug 2025 22:29:19 +0800 Subject: [PATCH 145/582] chore: remove debug log --- relay/channel/openai/helper.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go index 528f1276..11a34ca5 100644 --- a/relay/channel/openai/helper.go +++ b/relay/channel/openai/helper.go @@ -47,25 +47,16 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo } func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { - // 截取前50个字符用于调试 - debugData := data - if len(data) > 50 { - debugData = data[:50] + "..." - } - common.LogInfo(c, "handleGeminiFormat called with data: "+debugData) - var streamResponse dto.ChatCompletionsStreamResponse if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { common.LogError(c, "failed to unmarshal stream response: "+err.Error()) return err } - common.LogInfo(c, "successfully unmarshaled stream response") geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) // 如果返回 nil,表示没有实际内容,跳过发送 if geminiResponse == nil { - common.LogInfo(c, "handleGeminiFormat: no content to send, skipping") return nil } @@ -75,7 +66,6 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo return err } - common.LogInfo(c, "sending gemini format response") // send gemini format response c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) if flusher, ok := c.Writer.(http.Flusher); ok { From 2d4149c5dca60e99d26cd6ed9b463687c7cbbc66 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 10:57:03 +0800 Subject: [PATCH 146/582] feat: implement key mode for multi-key channels with append/replace options --- controller/channel.go | 66 +++++++- .../channels/modals/EditChannelModal.jsx | 149 ++++++++++++------ 2 files changed, 168 insertions(+), 47 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index d3bfa202..513e3024 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -669,6 +669,7 @@ func DeleteChannelBatch(c *gin.Context) { type PatchChannel struct { model.Channel MultiKeyMode *string `json:"multi_key_mode"` + KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 } func UpdateChannel(c *gin.Context) { @@ -688,7 +689,7 @@ func UpdateChannel(c *gin.Context) { return } // Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request. - originChannel, err := model.GetChannelById(channel.Id, false) + originChannel, err := model.GetChannelById(channel.Id, true) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -704,6 +705,69 @@ func UpdateChannel(c *gin.Context) { if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" { channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode) } + + // 处理多key模式下的密钥追加/覆盖逻辑 + if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey { + switch *channel.KeyMode { + case "append": + // 追加模式:将新密钥添加到现有密钥列表 + if originChannel.Key != "" { + var newKeys []string + var existingKeys []string + + // 解析现有密钥 + if strings.HasPrefix(strings.TrimSpace(originChannel.Key), "[") { + // JSON数组格式 + var arr []json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(originChannel.Key)), &arr); err == nil { + existingKeys = make([]string, len(arr)) + for i, v := range arr { + existingKeys[i] = string(v) + } + } + } else { + // 换行分隔格式 + existingKeys = strings.Split(strings.Trim(originChannel.Key, "\n"), "\n") + } + + // 处理 Vertex AI 的特殊情况 + if channel.Type == constant.ChannelTypeVertexAi { + // 尝试解析新密钥为JSON数组 + if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { + array, err := getVertexArrayKeys(channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "追加密钥解析失败: " + err.Error(), + }) + return + } + newKeys = array + } else { + // 单个JSON密钥 + newKeys = []string{channel.Key} + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } else { + // 普通渠道的处理 + inputKeys := strings.Split(channel.Key, "\n") + for _, key := range inputKeys { + key = strings.TrimSpace(key) + if key != "" { + newKeys = append(newKeys, key) + } + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } + } + case "replace": + // 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理) + } + } err = channel.Update() if err != nil { common.ApiError(c, err) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 37e9af75..8c8bdb70 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -154,6 +154,7 @@ const EditChannelModal = (props) => { const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false); const [channelSearchValue, setChannelSearchValue] = useState(''); const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 + const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -560,6 +561,12 @@ const EditChannelModal = (props) => { pass_through_body_enabled: false, system_prompt: '', }); + // 重置密钥模式状态 + setKeyMode('append'); + // 清空表单中的key_mode字段 + if (formApiRef.current) { + formApiRef.current.setValue('key_mode', undefined); + } } }, [props.visible, channelId]); @@ -725,6 +732,7 @@ const EditChannelModal = (props) => { res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId), + key_mode: isMultiKeyChannel ? keyMode : undefined, // 只在多key模式下传递 }); } else { res = await API.post(`/api/channel/`, { @@ -787,55 +795,59 @@ const EditChannelModal = (props) => { const batchAllowed = !isEdit || isMultiKeyChannel; const batchExtra = batchAllowed ? ( - { - const checked = e.target.checked; + {!isEdit && ( + { + const checked = e.target.checked; - if (!checked && vertexFileList.length > 1) { - Modal.confirm({ - title: t('切换为单密钥模式'), - content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'), - onOk: () => { - const firstFile = vertexFileList[0]; - const firstKey = vertexKeys[0] ? [vertexKeys[0]] : []; + if (!checked && vertexFileList.length > 1) { + Modal.confirm({ + title: t('切换为单密钥模式'), + content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'), + onOk: () => { + const firstFile = vertexFileList[0]; + const firstKey = vertexKeys[0] ? [vertexKeys[0]] : []; - setVertexFileList([firstFile]); - setVertexKeys(firstKey); + setVertexFileList([firstFile]); + setVertexKeys(firstKey); - formApiRef.current?.setValue('vertex_files', [firstFile]); - setInputs((prev) => ({ ...prev, vertex_files: [firstFile] })); + formApiRef.current?.setValue('vertex_files', [firstFile]); + setInputs((prev) => ({ ...prev, vertex_files: [firstFile] })); - setBatch(false); - setMultiToSingle(false); - setMultiKeyMode('random'); - }, - onCancel: () => { - setBatch(true); - }, - centered: true, - }); - return; - } - - setBatch(checked); - if (!checked) { - setMultiToSingle(false); - setMultiKeyMode('random'); - } else { - // 批量模式下禁用手动输入,并清空手动输入的内容 - setUseManualInput(false); - if (inputs.type === 41) { - // 清空手动输入的密钥内容 - if (formApiRef.current) { - formApiRef.current.setValue('key', ''); - } - handleInputChange('key', ''); + setBatch(false); + setMultiToSingle(false); + setMultiKeyMode('random'); + }, + onCancel: () => { + setBatch(true); + }, + centered: true, + }); + return; } - } - }} - >{t('批量创建')} + + setBatch(checked); + if (!checked) { + setMultiToSingle(false); + setMultiKeyMode('random'); + } else { + // 批量模式下禁用手动输入,并清空手动输入的内容 + setUseManualInput(false); + if (inputs.type === 41) { + // 清空手动输入的密钥内容 + if (formApiRef.current) { + formApiRef.current.setValue('key', ''); + } + handleInputChange('key', ''); + } + } + }} + > + {t('批量创建')} + + )} {batch && ( { setMultiToSingle(prev => !prev); @@ -1032,7 +1044,16 @@ const EditChannelModal = (props) => { autosize autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} - extraText={batchExtra} + extraText={ +
    + {isEdit && isMultiKeyChannel && keyMode === 'append' && ( + + {t('追加模式:新密钥将添加到现有密钥列表的末尾')} + + )} + {batchExtra} +
    + } showClear /> ) @@ -1099,6 +1120,11 @@ const EditChannelModal = (props) => { {t('请输入完整的 JSON 格式密钥内容')} + {isEdit && isMultiKeyChannel && keyMode === 'append' && ( + + {t('追加模式:新密钥将添加到现有密钥列表的末尾')} + + )} {batchExtra} } @@ -1132,13 +1158,44 @@ const EditChannelModal = (props) => { rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]} autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} - extraText={batchExtra} + extraText={ +
    + {isEdit && isMultiKeyChannel && keyMode === 'append' && ( + + {t('追加模式:新密钥将添加到现有密钥列表的末尾')} + + )} + {batchExtra} +
    + } showClear /> )} )} + {isEdit && isMultiKeyChannel && ( + setKeyMode(value)} + extraText={ + + {keyMode === 'replace' + ? t('覆盖模式:将完全替换现有的所有密钥') + : t('追加模式:将新密钥添加到现有密钥列表末尾') + } + + } + /> + )} {batch && multiToSingle && ( <> Date: Sat, 2 Aug 2025 11:07:50 +0800 Subject: [PATCH 147/582] feat: add recordErrorLog option to NewAPIError for conditional error logging --- controller/relay.go | 2 +- relay/relay-text.go | 6 +++--- types/error.go | 30 ++++++++++++++++++++++++------ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index b5b8f8fe..1a35c7d7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -47,7 +47,7 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError { err = relay.TextHelper(c) } - if constant2.ErrorLogEnabled && err != nil { + if constant2.ErrorLogEnabled && err != nil && types.IsRecordErrorLog(err) { // 保存错误日志到mysql中 userId := c.GetInt("id") tokenName := c.GetString("token_name") diff --git a/relay/relay-text.go b/relay/relay-text.go index 97313be6..f175dbfb 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -305,10 +305,10 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) } if userQuota <= 0 { - return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry()) + return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } if userQuota-preConsumedQuota < 0 { - return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry()) + return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } relayInfo.UserQuota = userQuota if userQuota > 100*preConsumedQuota { @@ -332,7 +332,7 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo if preConsumedQuota > 0 { err := service.PreConsumeTokenQuota(relayInfo, preConsumedQuota) if err != nil { - return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry()) + return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) } err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) if err != nil { diff --git a/types/error.go b/types/error.go index 86aaf692..e7265e21 100644 --- a/types/error.go +++ b/types/error.go @@ -76,12 +76,13 @@ const ( ) type NewAPIError struct { - Err error - RelayError any - skipRetry bool - errorType ErrorType - errorCode ErrorCode - StatusCode int + Err error + RelayError any + skipRetry bool + recordErrorLog *bool + errorType ErrorType + errorCode ErrorCode + StatusCode int } func (e *NewAPIError) GetErrorCode() ErrorCode { @@ -278,3 +279,20 @@ func ErrOptionWithSkipRetry() NewAPIErrorOptions { e.skipRetry = true } } + +func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions { + return func(e *NewAPIError) { + e.recordErrorLog = common.GetPointer(false) + } +} + +func IsRecordErrorLog(e *NewAPIError) bool { + if e == nil { + return false + } + if e.recordErrorLog == nil { + // default to true if not set + return true + } + return *e.recordErrorLog +} From f0b024eb77387934949a55ba71354fab2f2a105d Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 12:53:58 +0800 Subject: [PATCH 148/582] feat: enhance ConvertGeminiRequest to set default role and handle YouTube video MIME type --- relay/channel/gemini/adaptor.go | 16 ++++++++++++++++ relay/channel/vertex/adaptor.go | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 20d43020..14fd278d 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -21,6 +21,22 @@ type Adaptor struct { } func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + if len(request.Contents) > 0 { + for i, content := range request.Contents { + if i == 0 { + if request.Contents[0].Role == "" { + request.Contents[0].Role = "user" + } + } + for _, part := range content.Parts { + if part.FileData != nil { + if part.FileData.MimeType == "" && strings.Contains(part.FileData.FileUri, "www.youtube.com") { + part.FileData.MimeType = "video/webm" + } + } + } + } + } return request, nil } diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 39be998e..9b62cffc 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -45,7 +45,8 @@ type Adaptor struct { } func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { - return request, nil + geminiAdaptor := gemini.Adaptor{} + return geminiAdaptor.ConvertGeminiRequest(c, info, request) } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { From 316c0a594e972eb483fbb3a2ee8029a66d3f98c7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 13:04:48 +0800 Subject: [PATCH 149/582] feat: retain polling index for multi-key channels during sync --- model/channel_cache.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/model/channel_cache.go b/model/channel_cache.go index 1abc8b85..98522f70 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -5,6 +5,7 @@ import ( "fmt" "math/rand" "one-api/common" + "one-api/constant" "one-api/setting" "sort" "strings" @@ -66,6 +67,15 @@ func InitChannelCache() { channelSyncLock.Lock() group2model2channels = newGroup2model2channels + //channelsIDM = newChannelId2channel + for i, channel := range newChannelId2channel { + if oldChannel, ok := channelsIDM[i]; ok { + // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 + if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + } + } + } channelsIDM = newChannelId2channel channelSyncLock.Unlock() common.SysLog("channels synced from database") @@ -203,9 +213,6 @@ func CacheGetChannel(id int) (*Channel, error) { if !ok { return nil, fmt.Errorf("渠道# %d,已不存在", id) } - if c.Status != common.ChannelStatusEnabled { - return nil, fmt.Errorf("渠道# %d,已被禁用", id) - } return c, nil } @@ -224,9 +231,6 @@ func CacheGetChannelInfo(id int) (*ChannelInfo, error) { if !ok { return nil, fmt.Errorf("渠道# %d,已不存在", id) } - if c.Status != common.ChannelStatusEnabled { - return nil, fmt.Errorf("渠道# %d,已被禁用", id) - } return &c.ChannelInfo, nil } From 8ceda7b95f38b9f8e0ff9cc90690a51ec92551b0 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 13:16:30 +0800 Subject: [PATCH 150/582] feat: add caching for keys in channel structure and retain polling index during sync --- model/channel.go | 6 ++++++ model/channel_cache.go | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/model/channel.go b/model/channel.go index 58f0a064..bcffc102 100644 --- a/model/channel.go +++ b/model/channel.go @@ -46,6 +46,9 @@ type Channel struct { ParamOverride *string `json:"param_override" gorm:"type:text"` // add after v0.8.5 ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + + // cache info + Keys []string `json:"-" gorm:"-"` } type ChannelInfo struct { @@ -71,6 +74,9 @@ func (channel *Channel) getKeys() []string { if channel.Key == "" { return []string{} } + if len(channel.Keys) > 0 { + return channel.Keys + } trimmed := strings.TrimSpace(channel.Key) // If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios) if strings.HasPrefix(trimmed, "[") { diff --git a/model/channel_cache.go b/model/channel_cache.go index 98522f70..ecd87607 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -69,10 +69,15 @@ func InitChannelCache() { group2model2channels = newGroup2model2channels //channelsIDM = newChannelId2channel for i, channel := range newChannelId2channel { - if oldChannel, ok := channelsIDM[i]; ok { - // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 - if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { - channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + if channel.ChannelInfo.IsMultiKey { + channel.Keys = channel.getKeys() + if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + if oldChannel, ok := channelsIDM[i]; ok { + // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 + if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + } + } } } } From fad5fc3f7dd2883ff2c2c40e39e953de105ed35f Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 13:39:53 +0800 Subject: [PATCH 151/582] feat: truncate abilities table before processing channels --- model/ability.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/model/ability.go b/model/ability.go index 6dd8d8a6..08519de0 100644 --- a/model/ability.go +++ b/model/ability.go @@ -284,9 +284,24 @@ func FixAbility() (int, int, error) { return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试") } defer fixLock.Unlock() + + // truncate abilities table + if common.UsingSQLite { + err := DB.Exec("DELETE FROM abilities").Error + if err != nil { + common.SysError(fmt.Sprintf("Delete abilities failed: %s", err.Error())) + return 0, 0, err + } + } else { + err := DB.Exec("TRUNCATE TABLE abilities").Error + if err != nil { + common.SysError(fmt.Sprintf("Truncate abilities failed: %s", err.Error())) + return 0, 0, err + } + } var channels []*Channel // Find all channels - err := DB.Model(&Channel{}).Find(&channels).Error + err = DB.Model(&Channel{}).Find(&channels).Error if err != nil { return 0, 0, err } From f4f73016bf3379db9fabf3892e352265797664ba Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 14:06:12 +0800 Subject: [PATCH 152/582] fix: improve error handling and readability in ability.go --- model/ability.go | 2 +- relay/gemini_handler.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/model/ability.go b/model/ability.go index 08519de0..2df45917 100644 --- a/model/ability.go +++ b/model/ability.go @@ -301,7 +301,7 @@ func FixAbility() (int, int, error) { } var channels []*Channel // Find all channels - err = DB.Model(&Channel{}).Find(&channels).Error + err := DB.Model(&Channel{}).Find(&channels).Error if err != nil { return 0, 0, err } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 862630ea..42b695b7 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -80,7 +80,11 @@ func getGeminiInputTokens(req *dto.GeminiChatRequest, info *relaycommon.RelayInf func isNoThinkingRequest(req *dto.GeminiChatRequest) bool { if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { - return *req.GenerationConfig.ThinkingConfig.ThinkingBudget == 0 + configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget + if configBudget != nil && *configBudget == 0 { + // 如果思考预算为 0,则认为是非思考请求 + return true + } } return false } From 272c52b4d9f7a966dcdcbdff2d932c0cbb8041d5 Mon Sep 17 00:00:00 2001 From: Nekohy Date: Sat, 2 Aug 2025 14:19:32 +0800 Subject: [PATCH 153/582] fix: correct Gemini channel model retrieval logic --- controller/channel.go | 70 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index d3bfa202..dcf9de85 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -36,11 +36,30 @@ type OpenAIModel struct { Parent string `json:"parent"` } +type GoogleOpenAICompatibleModels []struct { + Name string `json:"name"` + Version string `json:"version"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + InputTokenLimit int `json:"inputTokenLimit"` + OutputTokenLimit int `json:"outputTokenLimit"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"topP,omitempty"` + TopK int `json:"topK,omitempty"` + MaxTemperature int `json:"maxTemperature,omitempty"` +} + type OpenAIModelsResponse struct { Data []OpenAIModel `json:"data"` Success bool `json:"success"` } +type GoogleOpenAICompatibleResponse struct { + Models []GoogleOpenAICompatibleModels `json:"models"` + NextPageToken string `json:"nextPageToken"` +} + func parseStatusFilter(statusParam string) int { switch strings.ToLower(statusParam) { case "enabled", "1": @@ -168,26 +187,59 @@ func FetchUpstreamModels(c *gin.Context) { if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } - url := fmt.Sprintf("%s/v1/models", baseURL) + + var url string switch channel.Type { case constant.ChannelTypeGemini: - url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) + // curl https://example.com/v1beta/models?key=$GEMINI_API_KEY + url = fmt.Sprintf("%s/v1beta/openai/models?key=%s", baseURL, channel.Key) case constant.ChannelTypeAli: url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) + default: + url = fmt.Sprintf("%s/v1/models", baseURL) + } + + // 获取响应体 - 根据渠道类型决定是否添加 AuthHeader + var body []byte + if channel.Type == constant.ChannelTypeGemini { + body, err = GetResponseBody("GET", url, channel, nil) // I don't know why, but Gemini requires no AuthHeader + } else { + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) } - body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) if err != nil { common.ApiError(c, err) return } var result OpenAIModelsResponse - if err = json.Unmarshal(body, &result); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("解析响应失败: %s", err.Error()), - }) - return + var parseSuccess bool + + // 适配特殊格式 + switch channel.Type { + case constant.ChannelTypeGemini: + var googleResult GoogleOpenAICompatibleResponse + if err = json.Unmarshal(body, &googleResult); err == nil { + // 转换Google格式到OpenAI格式 + for _, model := range googleResult.Models { + for _, gModel := range model { + result.Data = append(result.Data, OpenAIModel{ + ID: gModel.Name, + }) + } + } + parseSuccess = true + } + } + + // 如果解析失败,尝试OpenAI格式 + if !parseSuccess { + if err = json.Unmarshal(body, &result); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("解析响应失败: %s", err.Error()), + }) + return + } } var ids []string From b48d3a6b4012df7149941b73fdd0de733927153f Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 2 Aug 2025 14:53:28 +0800 Subject: [PATCH 154/582] feat: implement two-factor authentication (2FA) support with user login and settings integration --- common/totp.go | 153 +++++ controller/twofa.go | 547 ++++++++++++++++++ controller/user.go | 26 + go.mod | 2 + go.sum | 4 + model/main.go | 4 + model/twofa.go | 315 ++++++++++ router/api-router.go | 12 + web/bun.lock | 9 +- web/package.json | 1 + web/src/components/auth/LoginForm.js | 54 ++ web/src/components/auth/TwoFAVerification.js | 222 +++++++ .../components/settings/PersonalSetting.js | 4 + web/src/components/settings/TwoFASetting.js | 524 +++++++++++++++++ 14 files changed, 1874 insertions(+), 3 deletions(-) create mode 100644 common/totp.go create mode 100644 controller/twofa.go create mode 100644 model/twofa.go create mode 100644 web/src/components/auth/TwoFAVerification.js create mode 100644 web/src/components/settings/TwoFASetting.js diff --git a/common/totp.go b/common/totp.go new file mode 100644 index 00000000..ece5bc31 --- /dev/null +++ b/common/totp.go @@ -0,0 +1,153 @@ +package common + +import ( + "crypto/rand" + "fmt" + "os" + "strconv" + "strings" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +const ( + // 备用码配置 + BackupCodeLength = 8 // 备用码长度 + BackupCodeCount = 4 // 生成备用码数量 + + // 限制配置 + MaxFailAttempts = 5 // 最大失败尝试次数 + LockoutDuration = 300 // 锁定时间(秒) +) + +// GenerateTOTPSecret 生成TOTP密钥和配置 +func GenerateTOTPSecret(accountName string) (*otp.Key, error) { + issuer := Get2FAIssuer() + return totp.Generate(totp.GenerateOpts{ + Issuer: issuer, + AccountName: accountName, + Period: 30, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) +} + +// ValidateTOTPCode 验证TOTP验证码 +func ValidateTOTPCode(secret, code string) bool { + // 清理验证码格式 + cleanCode := strings.ReplaceAll(code, " ", "") + if len(cleanCode) != 6 { + return false + } + + // 验证验证码 + return totp.Validate(cleanCode, secret) +} + +// GenerateBackupCodes 生成备用恢复码 +func GenerateBackupCodes() ([]string, error) { + codes := make([]string, BackupCodeCount) + + for i := 0; i < BackupCodeCount; i++ { + code, err := generateRandomBackupCode() + if err != nil { + return nil, err + } + codes[i] = code + } + + return codes, nil +} + +// generateRandomBackupCode 生成单个备用码 +func generateRandomBackupCode() (string, error) { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + code := make([]byte, BackupCodeLength) + + for i := range code { + randomBytes := make([]byte, 1) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + code[i] = charset[int(randomBytes[0])%len(charset)] + } + + // 格式化为 XXXX-XXXX 格式 + return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil +} + +// ValidateBackupCode 验证备用码格式 +func ValidateBackupCode(code string) bool { + // 移除所有分隔符并转为大写 + cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", "")) + if len(cleanCode) != BackupCodeLength { + return false + } + + // 检查字符是否合法 + for _, char := range cleanCode { + if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) { + return false + } + } + + return true +} + +// NormalizeBackupCode 标准化备用码格式 +func NormalizeBackupCode(code string) string { + cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", "")) + if len(cleanCode) == BackupCodeLength { + return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:]) + } + return code +} + +// HashBackupCode 对备用码进行哈希 +func HashBackupCode(code string) (string, error) { + normalizedCode := NormalizeBackupCode(code) + return Password2Hash(normalizedCode) +} + +// Get2FAIssuer 获取2FA发行者名称 +func Get2FAIssuer() string { + if issuer := SystemName; issuer != "" { + return issuer + } + return "NewAPI" +} + +// getEnvOrDefault 获取环境变量或默认值 +func getEnvOrDefault(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +// ValidateNumericCode 验证数字验证码格式 +func ValidateNumericCode(code string) (string, error) { + // 移除空格 + code = strings.ReplaceAll(code, " ", "") + + if len(code) != 6 { + return "", fmt.Errorf("验证码必须是6位数字") + } + + // 检查是否为纯数字 + if _, err := strconv.Atoi(code); err != nil { + return "", fmt.Errorf("验证码只能包含数字") + } + + return code, nil +} + +// GenerateQRCodeData 生成二维码数据 +func GenerateQRCodeData(secret, username string) string { + issuer := Get2FAIssuer() + accountName := fmt.Sprintf("%s (%s)", username, issuer) + return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30", + issuer, accountName, secret, issuer) +} diff --git a/controller/twofa.go b/controller/twofa.go new file mode 100644 index 00000000..368289c9 --- /dev/null +++ b/controller/twofa.go @@ -0,0 +1,547 @@ +package controller + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// Setup2FARequest 设置2FA请求结构 +type Setup2FARequest struct { + Code string `json:"code" binding:"required"` +} + +// Verify2FARequest 验证2FA请求结构 +type Verify2FARequest struct { + Code string `json:"code" binding:"required"` +} + +// Setup2FAResponse 设置2FA响应结构 +type Setup2FAResponse struct { + Secret string `json:"secret"` + QRCodeData string `json:"qr_code_data"` + BackupCodes []string `json:"backup_codes"` +} + +// Setup2FA 初始化2FA设置 +func Setup2FA(c *gin.Context) { + userId := c.GetInt("id") + + // 检查用户是否已经启用2FA + existing, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if existing != nil && existing.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已启用2FA,请先禁用后重新设置", + }) + return + } + + // 如果存在已禁用的2FA记录,先删除它 + if existing != nil && !existing.IsEnabled { + if err := existing.Delete(); err != nil { + common.ApiError(c, err) + return + } + existing = nil // 重置为nil,后续将创建新记录 + } + + // 获取用户信息 + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 生成TOTP密钥 + key, err := common.GenerateTOTPSecret(user.Username) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成2FA密钥失败", + }) + common.SysError("生成TOTP密钥失败: " + err.Error()) + return + } + + // 生成备用码 + backupCodes, err := common.GenerateBackupCodes() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成备用码失败", + }) + common.SysError("生成备用码失败: " + err.Error()) + return + } + + // 生成二维码数据 + qrCodeData := common.GenerateQRCodeData(key.Secret(), user.Username) + + // 创建或更新2FA记录(暂未启用) + twoFA := &model.TwoFA{ + UserId: userId, + Secret: key.Secret(), + IsEnabled: false, + } + + if existing != nil { + // 更新现有记录 + twoFA.Id = existing.Id + err = twoFA.Update() + } else { + // 创建新记录 + err = twoFA.Create() + } + + if err != nil { + common.ApiError(c, err) + return + } + + // 创建备用码记录 + if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存备用码失败", + }) + common.SysError("保存备用码失败: " + err.Error()) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "2FA设置初始化成功,请使用认证器扫描二维码并输入验证码完成设置", + "data": Setup2FAResponse{ + Secret: key.Secret(), + QRCodeData: qrCodeData, + BackupCodes: backupCodes, + }, + }) +} + +// Enable2FA 启用2FA +func Enable2FA(c *gin.Context) { + var req Setup2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请先完成2FA初始化设置", + }) + return + } + if twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "2FA已经启用", + }) + return + } + + // 验证TOTP验证码 + cleanCode, err := common.ValidateNumericCode(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + if !common.ValidateTOTPCode(twoFA.Secret, cleanCode) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 启用2FA + if err := twoFA.Enable(); err != nil { + common.ApiError(c, err) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "两步验证启用成功", + }) +} + +// Disable2FA 禁用2FA +func Disable2FA(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码或备用码 + cleanCode, err := common.ValidateNumericCode(req.Code) + isValidTOTP := false + isValidBackup := false + + if err == nil { + // 尝试验证TOTP + isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + } + + if !isValidTOTP { + // 尝试验证备用码 + isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + + if !isValidTOTP && !isValidBackup { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 禁用2FA + if err := model.DisableTwoFA(userId); err != nil { + common.ApiError(c, err) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "两步验证已禁用", + }) +} + +// Get2FAStatus 获取用户2FA状态 +func Get2FAStatus(c *gin.Context) { + userId := c.GetInt("id") + + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + + status := map[string]interface{}{ + "enabled": false, + "locked": false, + } + + if twoFA != nil { + status["enabled"] = twoFA.IsEnabled + status["locked"] = twoFA.IsLocked() + if twoFA.IsEnabled { + // 获取剩余备用码数量 + backupCount, err := model.GetUnusedBackupCodeCount(userId) + if err != nil { + common.SysError("获取备用码数量失败: " + err.Error()) + } else { + status["backup_codes_remaining"] = backupCount + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": status, + }) +} + +// RegenerateBackupCodes 重新生成备用码 +func RegenerateBackupCodes(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码 + cleanCode, err := common.ValidateNumericCode(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + valid, err := twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if !valid { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 生成新的备用码 + backupCodes, err := common.GenerateBackupCodes() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成备用码失败", + }) + common.SysError("生成备用码失败: " + err.Error()) + return + } + + // 保存新的备用码 + if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存备用码失败", + }) + common.SysError("保存备用码失败: " + err.Error()) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "备用码重新生成成功", + "data": map[string]interface{}{ + "backup_codes": backupCodes, + }, + }) +} + +// Verify2FALogin 登录时验证2FA +func Verify2FALogin(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + // 从会话中获取pending用户信息 + session := sessions.Default(c) + pendingUserId := session.Get("pending_user_id") + if pendingUserId == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话已过期,请重新登录", + }) + return + } + userId := pendingUserId.(int) + + // 获取用户信息 + user, err := model.GetUserById(userId, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户不存在", + }) + return + } + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(user.Id) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码或备用码 + cleanCode, err := common.ValidateNumericCode(req.Code) + isValidTOTP := false + isValidBackup := false + + if err == nil { + // 尝试验证TOTP + isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + } + + if !isValidTOTP { + // 尝试验证备用码 + isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + + if !isValidTOTP && !isValidBackup { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 2FA验证成功,清理pending会话信息并完成登录 + session.Delete("pending_username") + session.Delete("pending_user_id") + session.Save() + + setupLogin(user, c) +} + +// Admin2FAStats 管理员获取2FA统计信息 +func Admin2FAStats(c *gin.Context) { + stats, err := model.GetTwoFAStats() + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": stats, + }) +} + +// AdminDisable2FA 管理员强制禁用用户2FA +func AdminDisable2FA(c *gin.Context) { + userIdStr := c.Param("id") + userId, err := strconv.Atoi(userIdStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户ID格式错误", + }) + return + } + + // 检查目标用户权限 + targetUser, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + myRole := c.GetInt("role") + if myRole <= targetUser.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权操作同级或更高级用户的2FA设置", + }) + return + } + + // 禁用2FA + if err := model.DisableTwoFA(userId); err != nil { + if strings.Contains(err.Error(), "未启用2FA") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + common.ApiError(c, err) + return + } + + // 记录操作日志 + adminId := c.GetInt("id") + model.RecordLog(userId, model.LogTypeManage, + fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "用户2FA已被强制禁用", + }) +} diff --git a/controller/user.go b/controller/user.go index 292ed8c6..6e968037 100644 --- a/controller/user.go +++ b/controller/user.go @@ -62,6 +62,32 @@ func Login(c *gin.Context) { }) return } + + // 检查是否启用2FA + if model.IsTwoFAEnabled(user.Id) { + // 设置pending session,等待2FA验证 + session := sessions.Default(c) + session.Set("pending_username", user.Username) + session.Set("pending_user_id", user.Id) + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无法保存会话信息,请重试", + "success": false, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "请输入两步验证码", + "success": true, + "data": map[string]interface{}{ + "require_2fa": true, + }, + }) + return + } + setupLogin(&user, c) } diff --git a/go.mod b/go.mod index 94873c88..1def0b08 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect github.com/aws/smithy-go v1.20.2 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -79,6 +80,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect diff --git a/go.sum b/go.sum index 74eecd4c..4f5ae530 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 h1:JgHnonzbnA3pbqj76w github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg= github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0= github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= @@ -169,6 +171,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= diff --git a/model/main.go b/model/main.go index 013beacd..38dd2aee 100644 --- a/model/main.go +++ b/model/main.go @@ -251,6 +251,8 @@ func migrateDB() error { &QuotaData{}, &Task{}, &Setup{}, + &TwoFA{}, + &TwoFABackupCode{}, ) if err != nil { return err @@ -277,6 +279,8 @@ func migrateDBFast() error { {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, {&Setup{}, "Setup"}, + {&TwoFA{}, "TwoFA"}, + {&TwoFABackupCode{}, "TwoFABackupCode"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/twofa.go b/model/twofa.go new file mode 100644 index 00000000..4a96ffb0 --- /dev/null +++ b/model/twofa.go @@ -0,0 +1,315 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "time" + + "gorm.io/gorm" +) + +// TwoFA 用户2FA设置表 +type TwoFA struct { + Id int `json:"id" gorm:"primaryKey"` + UserId int `json:"user_id" gorm:"unique;not null;index"` + Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端 + IsEnabled bool `json:"is_enabled" gorm:"default:false"` + FailedAttempts int `json:"failed_attempts" gorm:"default:0"` + LockedUntil *time.Time `json:"locked_until,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// TwoFABackupCode 备用码使用记录表 +type TwoFABackupCode struct { + Id int `json:"id" gorm:"primaryKey"` + UserId int `json:"user_id" gorm:"not null;index"` + CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希 + IsUsed bool `json:"is_used" gorm:"default:false"` + UsedAt *time.Time `json:"used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// GetTwoFAByUserId 根据用户ID获取2FA设置 +func GetTwoFAByUserId(userId int) (*TwoFA, error) { + if userId == 0 { + return nil, errors.New("用户ID不能为空") + } + + var twoFA TwoFA + err := DB.Where("user_id = ?", userId).First(&twoFA).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // 返回nil表示未设置2FA + } + return nil, err + } + + return &twoFA, nil +} + +// IsTwoFAEnabled 检查用户是否启用了2FA +func IsTwoFAEnabled(userId int) bool { + twoFA, err := GetTwoFAByUserId(userId) + if err != nil || twoFA == nil { + return false + } + return twoFA.IsEnabled +} + +// CreateTwoFA 创建2FA设置 +func (t *TwoFA) Create() error { + // 检查用户是否已存在2FA设置 + existing, err := GetTwoFAByUserId(t.UserId) + if err != nil { + return err + } + if existing != nil { + return errors.New("用户已存在2FA设置") + } + + // 验证用户存在 + var user User + if err := DB.First(&user, t.UserId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + + return DB.Create(t).Error +} + +// Update 更新2FA设置 +func (t *TwoFA) Update() error { + if t.Id == 0 { + return errors.New("2FA记录ID不能为空") + } + return DB.Save(t).Error +} + +// Delete 删除2FA设置 +func (t *TwoFA) Delete() error { + if t.Id == 0 { + return errors.New("2FA记录ID不能为空") + } + + // 同时删除相关的备用码记录(硬删除) + if err := DB.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + + // 硬删除2FA记录 + return DB.Unscoped().Delete(t).Error +} + +// ResetFailedAttempts 重置失败尝试次数 +func (t *TwoFA) ResetFailedAttempts() error { + t.FailedAttempts = 0 + t.LockedUntil = nil + return t.Update() +} + +// IncrementFailedAttempts 增加失败尝试次数 +func (t *TwoFA) IncrementFailedAttempts() error { + t.FailedAttempts++ + + // 检查是否需要锁定 + if t.FailedAttempts >= common.MaxFailAttempts { + lockUntil := time.Now().Add(time.Duration(common.LockoutDuration) * time.Second) + t.LockedUntil = &lockUntil + } + + return t.Update() +} + +// IsLocked 检查账户是否被锁定 +func (t *TwoFA) IsLocked() bool { + if t.LockedUntil == nil { + return false + } + return time.Now().Before(*t.LockedUntil) +} + +// CreateBackupCodes 创建备用码 +func CreateBackupCodes(userId int, codes []string) error { + // 先删除现有的备用码 + if err := DB.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + + // 创建新的备用码记录 + for _, code := range codes { + hashedCode, err := common.HashBackupCode(code) + if err != nil { + return err + } + + backupCode := TwoFABackupCode{ + UserId: userId, + CodeHash: hashedCode, + IsUsed: false, + } + + if err := DB.Create(&backupCode).Error; err != nil { + return err + } + } + + return nil +} + +// ValidateBackupCode 验证并使用备用码 +func ValidateBackupCode(userId int, code string) (bool, error) { + if !common.ValidateBackupCode(code) { + return false, errors.New("验证码或备用码不正确") + } + + normalizedCode := common.NormalizeBackupCode(code) + + // 查找未使用的备用码 + var backupCodes []TwoFABackupCode + if err := DB.Where("user_id = ? AND is_used = false", userId).Find(&backupCodes).Error; err != nil { + return false, err + } + + // 验证备用码 + for _, bc := range backupCodes { + if common.ValidatePasswordAndHash(normalizedCode, bc.CodeHash) { + // 标记为已使用 + now := time.Now() + bc.IsUsed = true + bc.UsedAt = &now + + if err := DB.Save(&bc).Error; err != nil { + return false, err + } + + return true, nil + } + } + + return false, nil +} + +// GetUnusedBackupCodeCount 获取未使用的备用码数量 +func GetUnusedBackupCodeCount(userId int) (int, error) { + var count int64 + err := DB.Model(&TwoFABackupCode{}).Where("user_id = ? AND is_used = false", userId).Count(&count).Error + return int(count), err +} + +// DisableTwoFA 禁用用户的2FA +func DisableTwoFA(userId int) error { + twoFA, err := GetTwoFAByUserId(userId) + if err != nil { + return err + } + if twoFA == nil { + return errors.New("用户未启用2FA") + } + + // 删除2FA设置和备用码 + return twoFA.Delete() +} + +// EnableTwoFA 启用2FA +func (t *TwoFA) Enable() error { + t.IsEnabled = true + t.FailedAttempts = 0 + t.LockedUntil = nil + return t.Update() +} + +// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录 +func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) { + // 检查是否被锁定 + if t.IsLocked() { + return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05")) + } + + // 验证TOTP码 + if !common.ValidateTOTPCode(t.Secret, code) { + // 增加失败次数 + if err := t.IncrementFailedAttempts(); err != nil { + common.SysError("更新2FA失败次数失败: " + err.Error()) + } + return false, nil + } + + // 验证成功,重置失败次数并更新最后使用时间 + now := time.Now() + t.FailedAttempts = 0 + t.LockedUntil = nil + t.LastUsedAt = &now + + if err := t.Update(); err != nil { + common.SysError("更新2FA使用记录失败: " + err.Error()) + } + + return true, nil +} + +// ValidateBackupCodeAndUpdateUsage 验证备用码并更新使用记录 +func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) { + // 检查是否被锁定 + if t.IsLocked() { + return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05")) + } + + // 验证备用码 + valid, err := ValidateBackupCode(t.UserId, code) + if err != nil { + return false, err + } + + if !valid { + // 增加失败次数 + if err := t.IncrementFailedAttempts(); err != nil { + common.SysError("更新2FA失败次数失败: " + err.Error()) + } + return false, nil + } + + // 验证成功,重置失败次数并更新最后使用时间 + now := time.Now() + t.FailedAttempts = 0 + t.LockedUntil = nil + t.LastUsedAt = &now + + if err := t.Update(); err != nil { + common.SysError("更新2FA使用记录失败: " + err.Error()) + } + + return true, nil +} + +// GetTwoFAStats 获取2FA统计信息(管理员使用) +func GetTwoFAStats() (map[string]interface{}, error) { + var totalUsers, enabledUsers int64 + + // 总用户数 + if err := DB.Model(&User{}).Count(&totalUsers).Error; err != nil { + return nil, err + } + + // 启用2FA的用户数 + if err := DB.Model(&TwoFA{}).Where("is_enabled = true").Count(&enabledUsers).Error; err != nil { + return nil, err + } + + enabledRate := float64(0) + if totalUsers > 0 { + enabledRate = float64(enabledUsers) / float64(totalUsers) * 100 + } + + return map[string]interface{}{ + "total_users": totalUsers, + "enabled_users": enabledUsers, + "enabled_rate": fmt.Sprintf("%.1f%%", enabledRate), + }, nil +} diff --git a/router/api-router.go b/router/api-router.go index bc49803a..16c78186 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -44,6 +44,7 @@ func SetApiRouter(router *gin.Engine) { { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) + userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin) //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) userRoute.GET("/logout", controller.Logout) userRoute.GET("/epay/notify", controller.EpayNotify) @@ -66,6 +67,13 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) + + // 2FA routes + selfRoute.GET("/2fa/status", controller.Get2FAStatus) + selfRoute.POST("/2fa/setup", controller.Setup2FA) + selfRoute.POST("/2fa/enable", controller.Enable2FA) + selfRoute.POST("/2fa/disable", controller.Disable2FA) + selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes) } adminRoute := userRoute.Group("/") @@ -78,6 +86,10 @@ func SetApiRouter(router *gin.Engine) { adminRoute.POST("/manage", controller.ManageUser) adminRoute.PUT("/", controller.UpdateUser) adminRoute.DELETE("/:id", controller.DeleteUser) + + // Admin 2FA routes + adminRoute.GET("/2fa/stats", controller.Admin2FAStats) + adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA) } } optionRoute := apiRouter.Group("/option") diff --git a/web/bun.lock b/web/bun.lock index ca4e337c..53467aa5 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -21,6 +21,7 @@ "lucide-react": "^0.511.0", "marked": "^4.1.1", "mermaid": "^11.6.0", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", @@ -1492,6 +1493,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "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=="], @@ -1502,7 +1505,7 @@ "rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="], - "rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="], + "rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="], "rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="], @@ -1946,8 +1949,6 @@ "@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="], - "@lobehub/ui/rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="], - "@radix-ui/react-dismissable-layer/@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=="], "@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="], @@ -1964,6 +1965,8 @@ "@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], + "antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="], + "antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/web/package.json b/web/package.json index ba0df966..f014d84b 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "lucide-react": "^0.511.0", "marked": "^4.1.1", "mermaid": "^11.6.0", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js index f81dfd81..9c6650f8 100644 --- a/web/src/components/auth/LoginForm.js +++ b/web/src/components/auth/LoginForm.js @@ -50,6 +50,7 @@ import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons'; import OIDCIcon from '../common/logo/OIDCIcon.js'; import WeChatIcon from '../common/logo/WeChatIcon.js'; import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import TwoFAVerification from './TwoFAVerification.js'; import { useTranslation } from 'react-i18next'; const LoginForm = () => { @@ -78,6 +79,7 @@ const LoginForm = () => { const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + const [showTwoFA, setShowTwoFA] = useState(false); const logo = getLogo(); const systemName = getSystemName(); @@ -162,6 +164,13 @@ const LoginForm = () => { ); const { success, message, data } = res.data; if (success) { + // 检查是否需要2FA验证 + if (data && data.require_2fa) { + setShowTwoFA(true); + setLoginLoading(false); + return; + } + userDispatch({ type: 'login', payload: data }); setUserData(data); updateAPI(); @@ -280,6 +289,21 @@ const LoginForm = () => { setOtherLoginOptionsLoading(false); }; + // 2FA验证成功处理 + const handle2FASuccess = (data) => { + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + showSuccess('登录成功!'); + navigate('/console'); + }; + + // 返回登录页面 + const handleBackToLogin = () => { + setShowTwoFA(false); + setInputs({ username: '', password: '', wechat_verification_code: '' }); + }; + const renderOAuthOptions = () => { return (
    @@ -537,6 +561,35 @@ const LoginForm = () => { ); }; + // 2FA验证弹窗 + const render2FAModal = () => { + return ( + +
    + + + +
    + 两步验证 +
    + } + visible={showTwoFA} + onCancel={handleBackToLogin} + footer={null} + width={450} + centered + > + + + ); + }; + return (
    {/* 背景模糊晕染球 */} @@ -547,6 +600,7 @@ const LoginForm = () => { ? renderEmailLoginForm() : renderOAuthOptions()} {renderWeChatLoginModal()} + {render2FAModal()} {turnstileEnabled && (
    diff --git a/web/src/components/auth/TwoFAVerification.js b/web/src/components/auth/TwoFAVerification.js new file mode 100644 index 00000000..384273ed --- /dev/null +++ b/web/src/components/auth/TwoFAVerification.js @@ -0,0 +1,222 @@ +/* +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 { Button, Card, Divider, Form, Input, Typography } from '@douyinfe/semi-ui'; +import React, { useState } from 'react'; +import { showError, showSuccess, API } from '../../helpers'; + +const { Title, Text, Paragraph } = Typography; + +const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => { + const [loading, setLoading] = useState(false); + const [useBackupCode, setUseBackupCode] = useState(false); + const [verificationCode, setVerificationCode] = useState(''); + + const handleSubmit = async () => { + if (!verificationCode) { + showError('请输入验证码'); + return; + } + + setLoading(true); + try { + const res = await API.post('/api/user/login/2fa', { + code: verificationCode + }); + + if (res.data.success) { + showSuccess('登录成功'); + // 保存用户信息到本地存储 + localStorage.setItem('user', JSON.stringify(res.data.data)); + if (onSuccess) { + onSuccess(res.data.data); + } + } else { + showError(res.data.message); + } + } catch (error) { + showError('验证失败,请重试'); + } finally { + setLoading(false); + } + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }; + + if (isModal) { + return ( +
    + + 请输入认证器应用显示的验证码完成登录 + + +
    + + + + + + + +
    + + + {onBack && ( + + )} +
    + +
    + + 提示: +
    + • 验证码每30秒更新一次 +
    + • 如果无法获取验证码,请使用备用码 +
    + • 每个备用码只能使用一次 +
    +
    +
    + ); + } + + return ( +
    + +
    + 两步验证 + + 请输入认证器应用显示的验证码完成登录 + +
    + +
    + + + + + + + +
    + + + {onBack && ( + + )} +
    + +
    + + 提示: +
    + • 验证码每30秒更新一次 +
    + • 如果无法获取验证码,请使用备用码 +
    + • 每个备用码只能使用一次 +
    +
    +
    +
    + ); +}; + +export default TwoFAVerification; \ No newline at end of file diff --git a/web/src/components/settings/PersonalSetting.js b/web/src/components/settings/PersonalSetting.js index 1e0132cf..0a350084 100644 --- a/web/src/components/settings/PersonalSetting.js +++ b/web/src/components/settings/PersonalSetting.js @@ -36,6 +36,7 @@ import { renderModelTag, getModelCategories } from '../../helpers'; +import TwoFASetting from './TwoFASetting'; import Turnstile from 'react-turnstile'; import { UserContext } from '../../context/User'; import { useTheme } from '../../context/Theme'; @@ -1041,6 +1042,9 @@ const PersonalSetting = () => {
    + {/* 两步验证设置 */} + + {/* 危险区域 */} . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { API, showError, showSuccess, showWarning } from '../../helpers'; +import { Banner, Button, Card, Checkbox, Divider, Form, Input, Modal, Tag, Typography } from '@douyinfe/semi-ui'; +import React, { useEffect, useState } from 'react'; + +import { QRCodeSVG } from 'qrcode.react'; + +const { Text, Paragraph } = Typography; + +const TwoFASetting = () => { + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState({ + enabled: false, + locked: false, + backup_codes_remaining: 0 + }); + + // 模态框状态 + const [setupModalVisible, setSetupModalVisible] = useState(false); + const [enableModalVisible, setEnableModalVisible] = useState(false); + const [disableModalVisible, setDisableModalVisible] = useState(false); + const [backupModalVisible, setBackupModalVisible] = useState(false); + + // 表单数据 + const [setupData, setSetupData] = useState(null); + const [verificationCode, setVerificationCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [confirmDisable, setConfirmDisable] = useState(false); + + // 获取2FA状态 + const fetchStatus = async () => { + try { + const res = await API.get('/api/user/2fa/status'); + if (res.data.success) { + setStatus(res.data.data); + } + } catch (error) { + showError('获取2FA状态失败'); + } + }; + + useEffect(() => { + fetchStatus(); + }, []); + + // 初始化2FA设置 + const handleSetup2FA = async () => { + setLoading(true); + try { + const res = await API.post('/api/user/2fa/setup'); + if (res.data.success) { + setSetupData(res.data.data); + setSetupModalVisible(true); + } else { + showError(res.data.message); + } + } catch (error) { + showError('设置2FA失败'); + } finally { + setLoading(false); + } + }; + + // 启用2FA + const handleEnable2FA = async () => { + if (!verificationCode) { + showWarning('请输入验证码'); + return; + } + + setLoading(true); + try { + const res = await API.post('/api/user/2fa/enable', { + code: verificationCode + }); + if (res.data.success) { + showSuccess('两步验证启用成功!'); + setEnableModalVisible(false); + setSetupModalVisible(false); + setVerificationCode(''); + fetchStatus(); + } else { + showError(res.data.message); + } + } catch (error) { + showError('启用2FA失败'); + } finally { + setLoading(false); + } + }; + + // 禁用2FA + const handleDisable2FA = async () => { + if (!verificationCode) { + showWarning('请输入验证码或备用码'); + return; + } + + if (!confirmDisable) { + showWarning('请确认您已了解禁用两步验证的后果'); + return; + } + + setLoading(true); + try { + const res = await API.post('/api/user/2fa/disable', { + code: verificationCode + }); + if (res.data.success) { + showSuccess('两步验证已禁用'); + setDisableModalVisible(false); + setVerificationCode(''); + setConfirmDisable(false); + fetchStatus(); + } else { + showError(res.data.message); + } + } catch (error) { + showError('禁用2FA失败'); + } finally { + setLoading(false); + } + }; + + // 重新生成备用码 + const handleRegenerateBackupCodes = async () => { + if (!verificationCode) { + showWarning('请输入验证码'); + return; + } + + setLoading(true); + try { + const res = await API.post('/api/user/2fa/backup_codes', { + code: verificationCode + }); + if (res.data.success) { + setBackupCodes(res.data.data.backup_codes); + showSuccess('备用码重新生成成功'); + setVerificationCode(''); + fetchStatus(); + } else { + showError(res.data.message); + } + } catch (error) { + showError('重新生成备用码失败'); + } finally { + setLoading(false); + } + }; + + const copyBackupCodes = () => { + const codesText = backupCodes.join('\n'); + navigator.clipboard.writeText(codesText).then(() => { + showSuccess('备用码已复制到剪贴板'); + }).catch(() => { + showError('复制失败,请手动复制'); + }); + }; + + return ( +
    + +
    +
    +
    + + + +
    +
    +
    两步验证设置
    +
    + 两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。 +
    +
    + 当前状态: + {status.enabled ? ( + 已启用 + ) : ( + 未启用 + )} + {status.locked && ( + 账户已锁定 + )} +
    + {status.enabled && ( +
    + 剩余备用码:{status.backup_codes_remaining || 0} 个 +
    + )} +
    +
    +
    + {!status.enabled ? ( + + ) : ( +
    + + +
    + )} +
    +
    +
    + + {/* 2FA设置模态框 */} + +
    + + + +
    + 设置两步验证 +
    + } + visible={setupModalVisible} + onCancel={() => { + setSetupModalVisible(false); + setSetupData(null); + }} + footer={null} + width={650} + style={{ maxWidth: '90vw' }} + > + {setupData && ( +
    + {/* 步骤 1:扫描二维码 */} +
    +
    +
    + 1 +
    + 扫描二维码 +
    + + 使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码: + +
    +
    + +
    +
    +
    + + 或手动输入密钥:{setupData.secret} + +
    +
    + + {/* 步骤 2:保存备用码 */} +
    +
    +
    + 2 +
    + 保存备用码 +
    + + 请将以下备用码保存在安全的地方。如果丢失手机,可以使用这些备用码登录: + +
    +
    + {setupData.backup_codes.map((code, index) => ( +
    + {code} +
    + ))} +
    + +
    +
    + + {/* 步骤 3:验证设置 */} +
    +
    +
    + 3 +
    + 验证设置 +
    + + 输入认证器应用显示的6位数字验证码: + +
    + + + +
    +
    + )} + + + {/* 禁用2FA模态框 */} + +
    + + + +
    + 禁用两步验证 +
    + } + visible={disableModalVisible} + onCancel={() => { + setDisableModalVisible(false); + setVerificationCode(''); + setConfirmDisable(false); + }} + footer={null} + width={550} + > +
    + +
    警告:禁用两步验证将会:
    +
      +
    • 降低您账户的安全性
    • +
    • 永久删除您的两步验证设置
    • +
    • 永久删除所有备用码(包括未使用的)
    • +
    • 需要重新完整设置才能再次启用
    • +
    +
    + 此操作不可撤销,请谨慎操作! +
    +
    + } + className="rounded-lg" + /> +
    + +
    + setConfirmDisable(e.target.checked)} + className="text-sm" + > + 我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销 + +
    + + + + + + {/* 重新生成备用码模态框 */} + +
    + + + +
    + 重新生成备用码 + + } + visible={backupModalVisible} + onCancel={() => { + setBackupModalVisible(false); + setVerificationCode(''); + setBackupCodes([]); + }} + footer={null} + width={500} + > +
    + {backupCodes.length === 0 ? ( + <> + +
    + + + + + ) : ( + <> +
    +
    + + + +
    + 新的备用码已生成 + + 请将以下备用码保存在安全的地方: + +
    +
    +
    + {backupCodes.map((code, index) => ( +
    + {code} +
    + ))} +
    + +
    + + )} +
    +
    + + ); +}; + +export default TwoFASetting; \ No newline at end of file From 1f9134cd6be475e27742bfa0b4c3e6d20ce069e4 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 2 Aug 2025 22:12:15 +0800 Subject: [PATCH 155/582] feat: add support for multi-key channels in RelayInfo and access token caching --- relay/channel/vertex/service_account.go | 7 ++++++- relay/common/relay_info.go | 27 +++++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go index 5a97c021..9a4650d9 100644 --- a/relay/channel/vertex/service_account.go +++ b/relay/channel/vertex/service_account.go @@ -36,7 +36,12 @@ var Cache = asynccache.NewAsyncCache(asynccache.Options{ }) func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) { - cacheKey := fmt.Sprintf("access-token-%d", info.ChannelId) + var cacheKey string + if info.ChannelIsMultiKey { + cacheKey = fmt.Sprintf("access-token-%d-%d", info.ChannelId, info.ChannelMultiKeyIndex) + } else { + cacheKey = fmt.Sprintf("access-token-%d", info.ChannelId) + } val, err := Cache.Get(cacheKey) if err == nil { return val.(string), nil diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 27827d97..266486c4 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -60,17 +60,19 @@ type ResponsesUsageInfo struct { } type RelayInfo struct { - ChannelType int - ChannelId int - TokenId int - TokenKey string - UserId int - UsingGroup string // 使用的分组 - UserGroup string // 用户所在分组 - TokenUnlimited bool - StartTime time.Time - FirstResponseTime time.Time - isFirstResponse bool + ChannelType int + ChannelId int + ChannelIsMultiKey bool // 是否多密钥 + ChannelMultiKeyIndex int // 多密钥索引 + TokenId int + TokenKey string + UserId int + UsingGroup string // 使用的分组 + UserGroup string // 用户所在分组 + TokenUnlimited bool + StartTime time.Time + FirstResponseTime time.Time + isFirstResponse bool //SendLastReasoningResponse bool ApiType int IsStream bool @@ -260,6 +262,9 @@ func GenRelayInfo(c *gin.Context) *RelayInfo { IsFirstThinkingContent: true, SendLastThinkingContent: false, }, + + ChannelIsMultiKey: common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey), + ChannelMultiKeyIndex: common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex), } if strings.HasPrefix(c.Request.URL.Path, "/pg") { info.IsPlayground = true From b56a75cbe3caefb63fc78055fc6784ff5e8c4161 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 3 Aug 2025 10:41:00 +0800 Subject: [PATCH 156/582] fix: coderabbit review --- common/totp.go | 5 +---- controller/twofa.go | 12 +++++++++--- go.mod | 2 +- go.sum | 2 ++ model/twofa.go | 4 +++- web/src/components/auth/TwoFAVerification.js | 10 +++++++++- 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/common/totp.go b/common/totp.go index ece5bc31..400f9d05 100644 --- a/common/totp.go +++ b/common/totp.go @@ -113,10 +113,7 @@ func HashBackupCode(code string) (string, error) { // Get2FAIssuer 获取2FA发行者名称 func Get2FAIssuer() string { - if issuer := SystemName; issuer != "" { - return issuer - } - return "NewAPI" + return SystemName } // getEnvOrDefault 获取环境变量或默认值 diff --git a/controller/twofa.go b/controller/twofa.go index 368289c9..2a7016c5 100644 --- a/controller/twofa.go +++ b/controller/twofa.go @@ -46,7 +46,7 @@ func Setup2FA(c *gin.Context) { }) return } - + // 如果存在已禁用的2FA记录,先删除它 if existing != nil && !existing.IsEnabled { if err := existing.Delete(); err != nil { @@ -415,8 +415,14 @@ func Verify2FALogin(c *gin.Context) { }) return } - userId := pendingUserId.(int) - + userId, ok := pendingUserId.(int) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话数据无效,请重新登录", + }) + return + } // 获取用户信息 user, err := model.GetUserById(userId, false) if err != nil { diff --git a/go.mod b/go.mod index 1def0b08..86576bc2 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect github.com/aws/smithy-go v1.20.2 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/boombuler/barcode v1.1.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 4f5ae530..a1cc5ece 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0= github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= diff --git a/model/twofa.go b/model/twofa.go index 4a96ffb0..d7b08f93 100644 --- a/model/twofa.go +++ b/model/twofa.go @@ -9,6 +9,8 @@ import ( "gorm.io/gorm" ) +var ErrTwoFANotEnabled = errors.New("用户未启用2FA") + // TwoFA 用户2FA设置表 type TwoFA struct { Id int `json:"id" gorm:"primaryKey"` @@ -210,7 +212,7 @@ func DisableTwoFA(userId int) error { return err } if twoFA == nil { - return errors.New("用户未启用2FA") + return ErrTwoFANotEnabled } // 删除2FA设置和备用码 diff --git a/web/src/components/auth/TwoFAVerification.js b/web/src/components/auth/TwoFAVerification.js index 384273ed..69756384 100644 --- a/web/src/components/auth/TwoFAVerification.js +++ b/web/src/components/auth/TwoFAVerification.js @@ -16,9 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { API, showError, showSuccess } from '../../helpers'; import { Button, Card, Divider, Form, Input, Typography } from '@douyinfe/semi-ui'; import React, { useState } from 'react'; -import { showError, showSuccess, API } from '../../helpers'; const { Title, Text, Paragraph } = Typography; @@ -32,6 +32,14 @@ const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => { showError('请输入验证码'); return; } + // Validate code format + if (useBackupCode && verificationCode.length !== 8) { + showError('备用码必须是8位'); + return; + } else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) { + showError('验证码必须是6位数字'); + return; + } setLoading(true); try { From 2f2546dffbf4dde596d19ca1e2412ea03634de5c Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 3 Aug 2025 10:49:55 +0800 Subject: [PATCH 157/582] refactor: improve error handling and database transactions in 2FA model methods --- controller/twofa.go | 4 ++-- model/twofa.go | 55 ++++++++++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/controller/twofa.go b/controller/twofa.go index 2a7016c5..9f48eed8 100644 --- a/controller/twofa.go +++ b/controller/twofa.go @@ -1,12 +1,12 @@ package controller import ( + "errors" "fmt" "net/http" "one-api/common" "one-api/model" "strconv" - "strings" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -530,7 +530,7 @@ func AdminDisable2FA(c *gin.Context) { // 禁用2FA if err := model.DisableTwoFA(userId); err != nil { - if strings.Contains(err.Error(), "未启用2FA") { + if errors.Is(err, model.ErrTwoFANotEnabled) { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "用户未启用2FA", diff --git a/model/twofa.go b/model/twofa.go index d7b08f93..d09ff9fe 100644 --- a/model/twofa.go +++ b/model/twofa.go @@ -100,13 +100,16 @@ func (t *TwoFA) Delete() error { return errors.New("2FA记录ID不能为空") } - // 同时删除相关的备用码记录(硬删除) - if err := DB.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil { - return err - } + // 使用事务确保原子性 + return DB.Transaction(func(tx *gorm.DB) error { + // 同时删除相关的备用码记录(硬删除) + if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } - // 硬删除2FA记录 - return DB.Unscoped().Delete(t).Error + // 硬删除2FA记录 + return tx.Unscoped().Delete(t).Error + }) } // ResetFailedAttempts 重置失败尝试次数 @@ -139,30 +142,32 @@ func (t *TwoFA) IsLocked() bool { // CreateBackupCodes 创建备用码 func CreateBackupCodes(userId int, codes []string) error { - // 先删除现有的备用码 - if err := DB.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { - return err - } - - // 创建新的备用码记录 - for _, code := range codes { - hashedCode, err := common.HashBackupCode(code) - if err != nil { + return DB.Transaction(func(tx *gorm.DB) error { + // 先删除现有的备用码 + if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { return err } - backupCode := TwoFABackupCode{ - UserId: userId, - CodeHash: hashedCode, - IsUsed: false, + // 创建新的备用码记录 + for _, code := range codes { + hashedCode, err := common.HashBackupCode(code) + if err != nil { + return err + } + + backupCode := TwoFABackupCode{ + UserId: userId, + CodeHash: hashedCode, + IsUsed: false, + } + + if err := tx.Create(&backupCode).Error; err != nil { + return err + } } - if err := DB.Create(&backupCode).Error; err != nil { - return err - } - } - - return nil + return nil + }) } // ValidateBackupCode 验证并使用备用码 From 4a9610e200d9588ff44280691d31e6c47f66cc87 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 3 Aug 2025 19:31:29 +0800 Subject: [PATCH 158/582] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(models-ta?= =?UTF-8?q?ble):=20extract=20reusable=20`renderLimitedItems`=20for=20list?= =?UTF-8?q?=20popovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a generic `renderLimitedItems` helper within `ModelsColumnDefs.js` to eliminate duplicated logic for list-style columns. Key changes • Added `renderLimitedItems` to handle item limiting, “+N” indicator, and popover display. • Migrated `renderTags`, `renderEndpoints`, and `renderBoundChannels` to use the new helper. • Removed redundant inline implementations, reducing complexity and improving readability. • Preserved previous UX: first 3 items shown, overflow accessible via popover. This refactor streamlines code maintenance and ensures consistent behavior across related columns. --- .../table/models/ModelsColumnDefs.js | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index ef404958..db4dae88 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -39,6 +39,34 @@ function renderTimestamp(timestamp) { return <>{timestamp2string(timestamp)}; } +// Generic renderer for list-style tags with limit and popover +function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) { + if (!items || items.length === 0) return '-'; + const displayItems = items.slice(0, maxDisplay); + const remainingItems = items.slice(maxDisplay); + return ( + + {displayItems.map((item, idx) => renderItem(item, idx))} + {remainingItems.length > 0 && ( + + + {remainingItems.map((item, idx) => renderItem(item, idx))} + + + } + position='top' + > + + +{remainingItems.length} + + + )} + + ); +} + // Render vendor column with icon const renderVendorTag = (vendorId, vendorMap, t) => { if (!vendorId || !vendorMap[vendorId]) return '-'; @@ -67,72 +95,44 @@ const renderDescription = (text) => { const renderTags = (text) => { if (!text) return '-'; const tagsArr = text.split(',').filter(Boolean); - const maxDisplayTags = 3; - const displayTags = tagsArr.slice(0, maxDisplayTags); - const remainingTags = tagsArr.slice(maxDisplayTags); - - return ( - - {displayTags.map((tag, index) => ( - - {tag} - - ))} - {remainingTags.length > 0 && ( - - - {remainingTags.map((tag, index) => ( - - {tag} - - ))} - - - } - position="top" - > - - +{remainingTags.length} - - - )} - - ); + return renderLimitedItems({ + items: tagsArr, + renderItem: (tag, idx) => ( + + {tag} + + ), + }); }; // Render endpoints const renderEndpoints = (text) => { + let arr; try { - const arr = JSON.parse(text); - if (Array.isArray(arr)) { - return ( - - {arr.map((ep) => ( - - {ep} - - ))} - - ); - } + arr = JSON.parse(text); } catch (_) { } - return text || '-'; + if (!Array.isArray(arr)) return text || '-'; + return renderLimitedItems({ + items: arr, + renderItem: (ep, idx) => ( + + {ep} + + ), + }); }; // Render bound channels const renderBoundChannels = (channels) => { if (!channels || channels.length === 0) return '-'; - return ( - - {channels.map((c, idx) => ( - - {c.name}({c.type}) - - ))} - - ); + return renderLimitedItems({ + items: channels, + renderItem: (c, idx) => ( + + {c.name}({c.type}) + + ), + }); }; // Render operations column From 1bd8e2a157147586758fef17813b50362d9fe408 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 3 Aug 2025 19:45:58 +0800 Subject: [PATCH 159/582] =?UTF-8?q?=E2=9C=A8=20feat(edit-vendor-modal):=20?= =?UTF-8?q?add=20icon-library=20reference=20link=20&=20tidy=20status=20swi?= =?UTF-8?q?tch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlights • Introduced `Typography.Text` link with `IconLink` in `extraText` for the **icon** field, pointing to LobeHub’s full icon catalogue; only “请点击我” is clickable for clarity. • Added required imports for `Typography` and `IconLink`. • Removed unnecessary `size="large"` prop from the status `Form.Switch` to align with default form styling. These tweaks improve user guidance when selecting vendor icons and refine the modal’s visual consistency. --- .../components/table/models/ModelsColumnDefs.js | 4 ++-- .../table/models/modals/EditVendorModal.jsx | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index db4dae88..d2da5b0a 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -115,7 +115,7 @@ const renderEndpoints = (text) => { return renderLimitedItems({ items: arr, renderItem: (ep, idx) => ( - + {ep} ), @@ -128,7 +128,7 @@ const renderBoundChannels = (channels) => { return renderLimitedItems({ items: channels, renderItem: (c, idx) => ( - + {c.name}({c.type}) ), diff --git a/web/src/components/table/models/modals/EditVendorModal.jsx b/web/src/components/table/models/modals/EditVendorModal.jsx index 9ddf5cb4..f0e00387 100644 --- a/web/src/components/table/models/modals/EditVendorModal.jsx +++ b/web/src/components/table/models/modals/EditVendorModal.jsx @@ -25,6 +25,8 @@ import { Row, } from '@douyinfe/semi-ui'; import { API, showError, showSuccess } from '../../../../helpers'; +import { Typography } from '@douyinfe/semi-ui'; +import { IconLink } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; @@ -157,6 +159,18 @@ const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => { field="icon" label={t('供应商图标')} placeholder={t('请输入图标名称,如:OpenAI、Claude.Color')} + extraText={ + + {t('图标使用@lobehub/icons库,查询所有可用图标 ')} + } + underline + > + {t('请点击我')} + + + } showClear /> @@ -164,7 +178,6 @@ const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => { From 2e201a4355ad3bbec0e1d62005bc17dc524fa534 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 3 Aug 2025 22:51:24 +0800 Subject: [PATCH 160/582] =?UTF-8?q?=E2=9C=A8=20feat:=20polish=20=E2=80=9CM?= =?UTF-8?q?issing=20Models=E2=80=9D=20UX=20&=20mobile=20actions=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overview • Re-designed `MissingModelsModal` to align with `ModelTestModal` and deliver a cleaner, paginated experience. • Improved mobile responsiveness for action buttons in `ModelsActions`. Details 1. MissingModelsModal.jsx • Switched from `List` to `Table` for a more structured view. • Added search bar with live keyword filtering and clear icon. • Implemented pagination via `MODEL_TABLE_PAGE_SIZE`; auto-resets on search. • Dynamic rendering: when no data, show unified Empty state without column header. • Enhanced header layout with total-count subtitle and modal corner rounding. • Removed unused `Typography.Text` import. 2. ModelsActions.jsx • Set “Delete Selected Models” and “Missing Models” buttons to `flex-1 md:flex-initial`, placing them on the same row as “Add Model” on small screens. Result The “Missing Models” workflow now offers quicker discovery, a familiar table interface, and full mobile friendliness—without altering API behavior. --- controller/missing_models.go | 27 +++ model/missing_models.go | 30 +++ router/api-router.go | 3 +- web/src/components/common/ui/CardPro.js | 1 + .../components/table/models/ModelsActions.jsx | 24 ++- .../table/models/ModelsColumnDefs.js | 5 + .../table/models/modals/EditModelModal.jsx | 18 +- .../models/modals/MissingModelsModal.jsx | 175 ++++++++++++++++++ 8 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 controller/missing_models.go create mode 100644 model/missing_models.go create mode 100644 web/src/components/table/models/modals/MissingModelsModal.jsx diff --git a/controller/missing_models.go b/controller/missing_models.go new file mode 100644 index 00000000..a3409e29 --- /dev/null +++ b/controller/missing_models.go @@ -0,0 +1,27 @@ +package controller + +import ( + "net/http" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetMissingModels returns the list of model names that are referenced by channels +// but do not have corresponding records in the models meta table. +// This helps administrators quickly discover models that need configuration. +func GetMissingModels(c *gin.Context) { + missing, err := model.GetMissingModels() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": missing, + }) +} diff --git a/model/missing_models.go b/model/missing_models.go new file mode 100644 index 00000000..57269f5f --- /dev/null +++ b/model/missing_models.go @@ -0,0 +1,30 @@ +package model + +// GetMissingModels returns model names that are referenced in the system +func GetMissingModels() ([]string, error) { + // 1. 获取所有已启用模型(去重) + models := GetEnabledModels() + if len(models) == 0 { + return []string{}, nil + } + + // 2. 查询已有的元数据模型名 + var existing []string + if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil { + return nil, err + } + + existingSet := make(map[string]struct{}, len(existing)) + for _, e := range existing { + existingSet[e] = struct{}{} + } + + // 3. 收集缺失模型 + var missing []string + for _, name := range models { + if _, ok := existingSet[name]; !ok { + missing = append(missing, name) + } + } + return missing, nil +} diff --git a/router/api-router.go b/router/api-router.go index e2b35be0..a70c2ad4 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -190,7 +190,8 @@ func SetApiRouter(router *gin.Engine) { modelsRoute := apiRouter.Group("/models") modelsRoute.Use(middleware.AdminAuth()) { - modelsRoute.GET("/", controller.GetAllModelsMeta) + modelsRoute.GET("/missing", controller.GetMissingModels) + modelsRoute.GET("/", controller.GetAllModelsMeta) modelsRoute.GET("/search", controller.SearchModelsMeta) modelsRoute.GET("/:id", controller.GetModelMeta) modelsRoute.POST("/", controller.CreateModelMeta) diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index 5745b9b3..ad6dda85 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -112,6 +112,7 @@ const CardPro = ({ icon={showMobileActions ? : } type="tertiary" size="small" + theme='outline' block > {showMobileActions ? t('隐藏操作项') : t('显示操作项')} diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index 78d3d5b0..b27d51e4 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useState } from 'react'; +import MissingModelsModal from './modals/MissingModelsModal.jsx'; import { Button, Space, Modal } from '@douyinfe/semi-ui'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; import { showError } from '../../../helpers'; @@ -33,6 +34,7 @@ const ModelsActions = ({ }) => { // Modal states const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showMissingModal, setShowMissingModal] = useState(false); // Handle delete selected models with confirmation const handleDeleteSelectedModels = () => { @@ -68,13 +70,22 @@ const ModelsActions = ({ + + + + setShowMissingModal(false)} + onConfigureModel={(name) => { + setEditingModel({ id: undefined, model_name: name }); + setShowEdit(true); + setShowMissingModal(false); + }} + t={t} + /> ); }; diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index d2da5b0a..7e12ed6f 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -201,6 +201,11 @@ export const getModelsColumns = ({ { title: t('模型名称'), dataIndex: 'model_name', + render: (text) => ( + e.stopPropagation()}> + {text} + + ), }, { title: t('描述'), diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index f1539d07..eeff5d2b 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -81,7 +81,7 @@ const EditModelModal = (props) => { }, [props.visiable]); const getInitValues = () => ({ - model_name: '', + model_name: props.editingModel?.model_name || '', description: '', tags: [], vendor_id: undefined, @@ -136,22 +136,28 @@ const EditModelModal = (props) => { useEffect(() => { if (formApiRef.current) { if (!isEdit) { - formApiRef.current.setValues(getInitValues()); + formApiRef.current.setValues({ + ...getInitValues(), + model_name: props.editingModel?.model_name || '', + }); } } - }, [props.editingModel?.id]); + }, [props.editingModel?.id, props.editingModel?.model_name]); useEffect(() => { if (props.visiable) { if (isEdit) { loadModel(); } else { - formApiRef.current?.setValues(getInitValues()); + formApiRef.current?.setValues({ + ...getInitValues(), + model_name: props.editingModel?.model_name || '', + }); } } else { formApiRef.current?.reset(); } - }, [props.visiable, props.editingModel?.id]); + }, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]); const submit = async (values) => { setLoading(true); @@ -268,7 +274,7 @@ const EditModelModal = (props) => { label={t('模型名称')} placeholder={t('请输入模型名称,如:gpt-4')} rules={[{ required: true, message: t('请输入模型名称') }]} - disabled={isEdit} + disabled={isEdit || !!props.editingModel?.model_name} showClear /> diff --git a/web/src/components/table/models/modals/MissingModelsModal.jsx b/web/src/components/table/models/modals/MissingModelsModal.jsx new file mode 100644 index 00000000..5bd53944 --- /dev/null +++ b/web/src/components/table/models/modals/MissingModelsModal.jsx @@ -0,0 +1,175 @@ +/* +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 { Modal, Table, Spin, Button, Typography, Empty, Input } from '@douyinfe/semi-ui'; +import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; +import { IconSearch } from '@douyinfe/semi-icons'; +import { API, showError } from '../../../../helpers'; +import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants/index.js'; + +const MissingModelsModal = ({ + visible, + onClose, + onConfigureModel, + t, +}) => { + const [loading, setLoading] = useState(false); + const [missingModels, setMissingModels] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + + const fetchMissing = async () => { + setLoading(true); + try { + const res = await API.get('/api/models/missing'); + if (res.data.success) { + setMissingModels(res.data.data || []); + } else { + showError(res.data.message); + } + } catch (_) { + showError(t('获取未配置模型失败')); + } + setLoading(false); + }; + + useEffect(() => { + if (visible) { + fetchMissing(); + setSearchKeyword(''); + setCurrentPage(1); + } else { + setMissingModels([]); + } + }, [visible]); + + // 过滤和分页逻辑 + const filteredModels = missingModels.filter((model) => + model.toLowerCase().includes(searchKeyword.toLowerCase()) + ); + + const dataSource = (() => { + const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE; + const end = start + MODEL_TABLE_PAGE_SIZE; + return filteredModels.slice(start, end).map((model) => ({ + model, + key: model, + })); + })(); + + const columns = [ + { + title: t('模型名称'), + dataIndex: 'model', + render: (text) => ( +
    + {text} +
    + ) + }, + { + title: '', + dataIndex: 'operate', + render: (text, record) => ( + + ) + } + ]; + + return ( + +
    + + {t('未配置的模型列表')} + + + {t('共')} {missingModels.length} {t('个未配置模型')} + +
    + + } + visible={visible} + onCancel={onClose} + footer={null} + width={700} + className="!rounded-lg" + > + + {missingModels.length === 0 && !loading ? ( + } + darkModeImage={} + description={t('暂无缺失模型')} + style={{ padding: 30 }} + /> + ) : ( +
    + {/* 搜索框 */} +
    + { + setSearchKeyword(v); + setCurrentPage(1); + }} + className="!w-full" + prefix={} + showClear + /> +
    + + {/* 表格 */} + {filteredModels.length > 0 ? ( +
    setCurrentPage(page), + }} + /> + ) : ( + } + darkModeImage={} + description={searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')} + style={{ padding: 20 }} + /> + )} + + )} + + + ); +}; + +export default MissingModelsModal; From d70e9a48f15436e26a185ec0556ed5a2bd652d14 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 4 Aug 2025 00:00:51 +0800 Subject: [PATCH 161/582] =?UTF-8?q?=F0=9F=9A=80=20feat:=20expose=20?= =?UTF-8?q?=E2=80=9CEnabled=20Groups=E2=80=9D=20for=20models=20with=20real?= =?UTF-8?q?-time=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend • model/model_meta.go – Added `EnableGroups []string` to Model struct – fillModelExtra now populates EnableGroups • model/model_groups.go – New helper `GetModelEnableGroups` (reuses Pricing cache) • model/pricing_refresh.go – Added `RefreshPricing()` to force immediate cache rebuild • controller/model_meta.go – `GetAllModelsMeta` & `SearchModelsMeta` call `model.RefreshPricing()` before querying, ensuring groups / endpoints are up-to-date Frontend • ModelsColumnDefs.js – Added `renderGroups` util and “可用分组” table column displaying color-coded tags Result Admins can now see which user groups can access each model, and any ability/group changes are reflected instantly without the previous 1-minute delay. --- controller/model_meta.go | 10 +++++++++- model/model_groups.go | 12 ++++++++++++ model/model_meta.go | 1 + model/pricing_refresh.go | 14 ++++++++++++++ .../table/models/ModelsColumnDefs.js | 18 ++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 model/model_groups.go create mode 100644 model/pricing_refresh.go diff --git a/controller/model_meta.go b/controller/model_meta.go index 9039419d..3ba09240 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -12,6 +12,9 @@ import ( // GetAllModelsMeta 获取模型列表(分页) func GetAllModelsMeta(c *gin.Context) { + + model.RefreshPricing() + pageInfo := common.GetPageQuery(c) modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { @@ -31,6 +34,9 @@ func GetAllModelsMeta(c *gin.Context) { // SearchModelsMeta 搜索模型列表 func SearchModelsMeta(c *gin.Context) { + + model.RefreshPricing() + keyword := c.Query("keyword") vendor := c.Query("vendor") pageInfo := common.GetPageQuery(c) @@ -128,7 +134,7 @@ func DeleteModelMeta(c *gin.Context) { common.ApiSuccess(c, nil) } -// 辅助函数:填充 Endpoints 和 BoundChannels +// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups func fillModelExtra(m *model.Model) { if m.Endpoints == "" { eps := model.GetModelSupportEndpointTypes(m.ModelName) @@ -139,5 +145,7 @@ func fillModelExtra(m *model.Model) { if channels, err := model.GetBoundChannels(m.ModelName); err == nil { m.BoundChannels = channels } + // 填充启用分组 + m.EnableGroups = model.GetModelEnableGroups(m.ModelName) } diff --git a/model/model_groups.go b/model/model_groups.go new file mode 100644 index 00000000..3957b909 --- /dev/null +++ b/model/model_groups.go @@ -0,0 +1,12 @@ +package model + +// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。 +// 复用缓存的定价映射,避免额外的数据库查询。 +func GetModelEnableGroups(modelName string) []string { + for _, p := range GetPricing() { + if p.ModelName == modelName { + return p.EnableGroup + } + } + return make([]string, 0) +} diff --git a/model/model_meta.go b/model/model_meta.go index 6f6c5e22..84742288 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -38,6 +38,7 @@ type Model struct { DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` + EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` } // Insert 创建新的模型元数据记录 diff --git a/model/pricing_refresh.go b/model/pricing_refresh.go new file mode 100644 index 00000000..de72a8bb --- /dev/null +++ b/model/pricing_refresh.go @@ -0,0 +1,14 @@ +package model + +// RefreshPricing 强制立即重新计算与定价相关的缓存。 +// 该方法用于需要最新数据的内部管理 API, +// 因此会绕过默认的 1 分钟延迟刷新。 +func RefreshPricing() { + updatePricingLock.Lock() + defer updatePricingLock.Unlock() + + modelSupportEndpointsLock.Lock() + defer modelSupportEndpointsLock.Unlock() + + updatePricing() +} diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index 7e12ed6f..e02090d8 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -91,6 +91,19 @@ const renderDescription = (text) => { ); }; +// Render groups (enable_groups) +const renderGroups = (groups) => { + if (!groups || groups.length === 0) return '-'; + return renderLimitedItems({ + items: groups, + renderItem: (g, idx) => ( + + {g} + + ), + }); +}; + // Render tags const renderTags = (text) => { if (!text) return '-'; @@ -232,6 +245,11 @@ export const getModelsColumns = ({ dataIndex: 'bound_channels', render: renderBoundChannels, }, + { + title: t('可用分组'), + dataIndex: 'enable_groups', + render: renderGroups, + }, { title: t('创建时间'), dataIndex: 'created_time', From 663e25b3113d930ceb0b41618bc3fcbd6c463fba Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 4 Aug 2025 02:54:37 +0800 Subject: [PATCH 162/582] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20prefill=20gro?= =?UTF-8?q?up=20management=20system=20for=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new PrefillGroup model with CRUD operations * Support for model, tag, and endpoint group types * JSON storage for group items with GORM datatypes * Automatic database migration support - Implement backend API endpoints * GET /api/prefill_group - List groups by type with admin auth * POST /api/prefill_group - Create new groups * PUT /api/prefill_group - Update existing groups * DELETE /api/prefill_group/:id - Delete groups - Add comprehensive frontend management interface * PrefillGroupManagement component for group listing * EditPrefillGroupModal for group creation/editing * Integration with EditModelModal for auto-filling * Responsive design with CardTable and SideSheet - Enhance model editing workflow * Tag group selection with auto-fill functionality * Endpoint group selection with auto-fill functionality * Seamless integration with existing model forms - Create reusable UI components * Extract common rendering utilities to models/ui/ * Shared renderLimitedItems and renderDescription functions * Consistent styling across all model-related components - Improve user experience * Empty state illustrations matching existing patterns * Fixed column positioning for operation buttons * Item content display with +x indicators for overflow * Tooltip support for long descriptions --- controller/prefill_group.go | 72 +++++ go.mod | 8 +- go.sum | 11 + model/main.go | 2 + model/prefill_group.go | 56 ++++ router/api-router.go | 10 + .../components/table/models/ModelsActions.jsx | 16 ++ .../table/models/ModelsColumnDefs.js | 43 +-- .../table/models/modals/EditModelModal.jsx | 57 ++++ .../models/modals/EditPrefillGroupModal.jsx | 234 +++++++++++++++ .../models/modals/MissingModelsModal.jsx | 8 +- .../models/modals/PrefillGroupManagement.jsx | 271 ++++++++++++++++++ .../table/models/ui/RenderUtils.jsx | 60 ++++ 13 files changed, 803 insertions(+), 45 deletions(-) create mode 100644 controller/prefill_group.go create mode 100644 model/prefill_group.go create mode 100644 web/src/components/table/models/modals/EditPrefillGroupModal.jsx create mode 100644 web/src/components/table/models/modals/PrefillGroupManagement.jsx create mode 100644 web/src/components/table/models/ui/RenderUtils.jsx diff --git a/controller/prefill_group.go b/controller/prefill_group.go new file mode 100644 index 00000000..e37082e6 --- /dev/null +++ b/controller/prefill_group.go @@ -0,0 +1,72 @@ +package controller + +import ( + "strconv" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤 +func GetPrefillGroups(c *gin.Context) { + groupType := c.Query("type") + groups, err := model.GetAllPrefillGroups(groupType) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, groups) +} + +// CreatePrefillGroup 创建新的预填组 +func CreatePrefillGroup(c *gin.Context) { + var g model.PrefillGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + if g.Name == "" || g.Type == "" { + common.ApiErrorMsg(c, "组名称和类型不能为空") + return + } + if err := g.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &g) +} + +// UpdatePrefillGroup 更新预填组 +func UpdatePrefillGroup(c *gin.Context) { + var g model.PrefillGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + if g.Id == 0 { + common.ApiErrorMsg(c, "缺少组 ID") + return + } + if err := g.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &g) +} + +// DeletePrefillGroup 删除预填组 +func DeletePrefillGroup(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DeletePrefillGroupByID(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/go.mod b/go.mod index 94873c88..fd787a07 100644 --- a/go.mod +++ b/go.mod @@ -34,12 +34,13 @@ require ( golang.org/x/image v0.23.0 golang.org/x/net v0.35.0 golang.org/x/sync v0.11.0 - gorm.io/driver/mysql v1.4.3 + gorm.io/driver/mysql v1.5.6 gorm.io/driver/postgres v1.5.2 - gorm.io/gorm v1.25.2 + gorm.io/gorm v1.30.0 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect @@ -59,7 +60,7 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/gorilla/context v1.1.1 // indirect @@ -91,6 +92,7 @@ require ( golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/datatypes v1.2.6 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 74eecd4c..8203949a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= @@ -86,6 +88,8 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= @@ -274,13 +278,20 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.6 h1:KafLdXvFUhzNeL2ncm03Gl3eTLONQfNKZ+wJ+9Y4Nck= +gorm.io/datatypes v1.2.6/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k= gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= diff --git a/model/main.go b/model/main.go index 5be43703..1577c50a 100644 --- a/model/main.go +++ b/model/main.go @@ -252,6 +252,7 @@ func migrateDB() error { &Task{}, &Model{}, &Vendor{}, + &PrefillGroup{}, &Setup{}, ) if err != nil { @@ -280,6 +281,7 @@ func migrateDBFast() error { {&Task{}, "Task"}, {&Model{}, "Model"}, {&Vendor{}, "Vendor"}, + {&PrefillGroup{}, "PrefillGroup"}, {&Setup{}, "Setup"}, } // 动态计算migration数量,确保errChan缓冲区足够大 diff --git a/model/prefill_group.go b/model/prefill_group.go new file mode 100644 index 00000000..7a3a6673 --- /dev/null +++ b/model/prefill_group.go @@ -0,0 +1,56 @@ +package model + +import ( + "one-api/common" + + "gorm.io/datatypes" +) + +// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。 +// Name 字段保持唯一,用于在前端下拉框中展示。 +// Type 字段用于区分组的类别,可选值如:model、tag、endpoint。 +// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例: +// ["gpt-4o", "gpt-3.5-turbo"] +// 设计遵循 3NF,避免冗余,提供灵活扩展能力。 + +type PrefillGroup struct { + Id int `json:"id"` + Name string `json:"name" gorm:"uniqueIndex;size:64;not null"` + Type string `json:"type" gorm:"size:32;index;not null"` + Items datatypes.JSON `json:"items" gorm:"type:json"` + Description string `json:"description,omitempty" gorm:"type:varchar(255)"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` +} + +// Insert 新建组 +func (g *PrefillGroup) Insert() error { + now := common.GetTimestamp() + g.CreatedTime = now + g.UpdatedTime = now + return DB.Create(g).Error +} + +// Update 更新组 +func (g *PrefillGroup) Update() error { + g.UpdatedTime = common.GetTimestamp() + return DB.Save(g).Error +} + +// DeleteByID 根据 ID 删除组 +func DeletePrefillGroupByID(id int) error { + return DB.Delete(&PrefillGroup{}, id).Error +} + +// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部) +func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) { + var groups []*PrefillGroup + query := DB.Model(&PrefillGroup{}) + if groupType != "" { + query = query.Where("type = ?", groupType) + } + if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil { + return nil, err + } + return groups, nil +} diff --git a/router/api-router.go b/router/api-router.go index a70c2ad4..3baaef14 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -166,6 +166,16 @@ func SetApiRouter(router *gin.Engine) { { groupRoute.GET("/", controller.GetGroups) } + + prefillGroupRoute := apiRouter.Group("/prefill_group") + prefillGroupRoute.Use(middleware.AdminAuth()) + { + prefillGroupRoute.GET("/", controller.GetPrefillGroups) + prefillGroupRoute.POST("/", controller.CreatePrefillGroup) + prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup) + prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup) + } + mjRoute := apiRouter.Group("/mj") mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index b27d51e4..cb91ed29 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React, { useState } from 'react'; import MissingModelsModal from './modals/MissingModelsModal.jsx'; +import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx'; import { Button, Space, Modal } from '@douyinfe/semi-ui'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; import { showError } from '../../../helpers'; @@ -35,6 +36,7 @@ const ModelsActions = ({ // Modal states const [showDeleteModal, setShowDeleteModal] = useState(false); const [showMissingModal, setShowMissingModal] = useState(false); + const [showGroupManagement, setShowGroupManagement] = useState(false); // Handle delete selected models with confirmation const handleDeleteSelectedModels = () => { @@ -86,6 +88,15 @@ const ModelsActions = ({ {t('未配置模型')} + + + + setShowGroupManagement(false)} + /> ); }; diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index e02090d8..a2af1c95 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -23,14 +23,14 @@ import { Space, Tag, Typography, - Modal, - Popover + Modal } from '@douyinfe/semi-ui'; import { timestamp2string, getLobeHubIcon, stringToColor } from '../../../helpers'; +import { renderLimitedItems, renderDescription } from './ui/RenderUtils.jsx'; const { Text } = Typography; @@ -39,34 +39,6 @@ function renderTimestamp(timestamp) { return <>{timestamp2string(timestamp)}; } -// Generic renderer for list-style tags with limit and popover -function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) { - if (!items || items.length === 0) return '-'; - const displayItems = items.slice(0, maxDisplay); - const remainingItems = items.slice(maxDisplay); - return ( - - {displayItems.map((item, idx) => renderItem(item, idx))} - {remainingItems.length > 0 && ( - - - {remainingItems.map((item, idx) => renderItem(item, idx))} - - - } - position='top' - > - - +{remainingItems.length} - - - )} - - ); -} - // Render vendor column with icon const renderVendorTag = (vendorId, vendorMap, t) => { if (!vendorId || !vendorMap[vendorId]) return '-'; @@ -82,15 +54,6 @@ const renderVendorTag = (vendorId, vendorMap, t) => { ); }; -// Render description with ellipsis -const renderDescription = (text) => { - return ( - - {text || '-'} - - ); -}; - // Render groups (enable_groups) const renderGroups = (groups) => { if (!groups || groups.length === 0) return '-'; @@ -223,7 +186,7 @@ export const getModelsColumns = ({ { title: t('描述'), dataIndex: 'description', - render: renderDescription, + render: (text) => renderDescription(text, 200), }, { title: t('供应商'), diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index eeff5d2b..1a1c9787 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -61,6 +61,10 @@ const EditModelModal = (props) => { // 供应商列表 const [vendors, setVendors] = useState([]); + // 预填组(标签、端点) + const [tagGroups, setTagGroups] = useState([]); + const [endpointGroups, setEndpointGroups] = useState([]); + // 获取供应商列表 const fetchVendors = async () => { try { @@ -74,9 +78,28 @@ const EditModelModal = (props) => { } }; + // 获取预填组(标签、端点) + const fetchPrefillGroups = async () => { + try { + const [tagRes, endpointRes] = await Promise.all([ + API.get('/api/prefill_group?type=tag'), + API.get('/api/prefill_group?type=endpoint'), + ]); + if (tagRes?.data?.success) { + setTagGroups(tagRes.data.data || []); + } + if (endpointRes?.data?.success) { + setEndpointGroups(endpointRes.data.data || []); + } + } catch (error) { + // ignore + } + }; + useEffect(() => { if (props.visiable) { fetchVendors(); + fetchPrefillGroups(); } }, [props.visiable]); @@ -287,6 +310,23 @@ const EditModelModal = (props) => { showClear /> + + ({ label: g.name, value: g.id }))} + showClear + style={{ width: '100%' }} + onChange={(value) => { + const g = tagGroups.find(item => item.id === value); + if (g && formApiRef.current) { + formApiRef.current.setValue('tags', g.items || []); + } + }} + /> + + { + + ({ label: g.name, value: g.id }))} + showClear + style={{ width: '100%' }} + onChange={(value) => { + const g = endpointGroups.find(item => item.id === value); + if (g && formApiRef.current) { + formApiRef.current.setValue('endpoints', g.items || []); + } + }} + /> + + . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useRef } from 'react'; +import { + SideSheet, + Button, + Form, + Typography, + Space, + Tag, + Row, + Col, + Card, + Avatar, + Spin, +} from '@douyinfe/semi-ui'; +import { + IconLayers, + IconSave, + IconClose, +} from '@douyinfe/semi-icons'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; + +const { Text, Title } = Typography; + +const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + const [loading, setLoading] = useState(false); + const formRef = useRef(null); + const isEdit = editingGroup && editingGroup.id !== undefined; + + const typeOptions = [ + { label: t('模型组'), value: 'model' }, + { label: t('标签组'), value: 'tag' }, + { label: t('端点组'), value: 'endpoint' }, + ]; + + // 提交表单 + const handleSubmit = async (values) => { + setLoading(true); + try { + const submitData = { + ...values, + items: Array.isArray(values.items) ? values.items : [], + }; + + if (editingGroup.id) { + submitData.id = editingGroup.id; + const res = await API.put('/api/prefill_group', submitData); + if (res.data.success) { + showSuccess(t('更新成功')); + onSuccess(); + } else { + showError(res.data.message || t('更新失败')); + } + } else { + const res = await API.post('/api/prefill_group', submitData); + if (res.data.success) { + showSuccess(t('创建成功')); + onSuccess(); + } else { + showError(res.data.message || t('创建失败')); + } + } + } catch (error) { + showError(t('操作失败')); + } + setLoading(false); + }; + + return ( + + {isEdit ? ( + + {t('更新')} + + ) : ( + + {t('新建')} + + )} + + {isEdit ? t('更新预填组') : t('创建新的预填组')} + + + } + visible={visible} + onCancel={onClose} + width={isMobile ? '100%' : 600} + bodyStyle={{ padding: '0' }} + footer={ +
    + + + + +
    + } + closeIcon={null} + > + +
    (formRef.current = api)} + initValues={{ + name: editingGroup?.name || '', + type: editingGroup?.type || 'tag', + description: editingGroup?.description || '', + items: (() => { + try { + return typeof editingGroup?.items === 'string' + ? JSON.parse(editingGroup.items) + : editingGroup?.items || []; + } catch { + return []; + } + })(), + }} + onSubmit={handleSubmit} + > +
    + {/* 基本信息 */} + +
    + + + +
    + {t('基本信息')} +
    {t('设置预填组的基本信息')}
    +
    +
    + +
    + + + + + + + + + + + + {/* 内容配置 */} + +
    + + + +
    + {t('内容配置')} +
    {t('配置组内包含的项目')}
    +
    +
    + +
    + + + + + + + + + ); +}; + +export default EditPrefillGroupModal; \ No newline at end of file diff --git a/web/src/components/table/models/modals/MissingModelsModal.jsx b/web/src/components/table/models/modals/MissingModelsModal.jsx index 5bd53944..41ff9d13 100644 --- a/web/src/components/table/models/modals/MissingModelsModal.jsx +++ b/web/src/components/table/models/modals/MissingModelsModal.jsx @@ -22,7 +22,8 @@ import { Modal, Table, Spin, Button, Typography, Empty, Input } from '@douyinfe/ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import { IconSearch } from '@douyinfe/semi-icons'; import { API, showError } from '../../../../helpers'; -import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants/index.js'; +import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; const MissingModelsModal = ({ visible, @@ -34,6 +35,7 @@ const MissingModelsModal = ({ const [missingModels, setMissingModels] = useState([]); const [searchKeyword, setSearchKeyword] = useState(''); const [currentPage, setCurrentPage] = useState(1); + const isMobile = useIsMobile(); const fetchMissing = async () => { setLoading(true); @@ -87,6 +89,8 @@ const MissingModelsModal = ({ { title: '', dataIndex: 'operate', + fixed: 'right', + width: 100, render: (text, record) => ( + deleteGroup(record.id)} + > + + + + ), + }, + ]; + + useEffect(() => { + if (visible) { + loadGroups(); + } + }, [visible]); + + return ( + <> + + + {t('管理')} + + + {t('预填组管理')} + + + } + visible={visible} + onCancel={onClose} + width={isMobile ? '100%' : 800} + bodyStyle={{ padding: '0' }} + closeIcon={null} + > + +
    + +
    + + + +
    + {t('组列表')} +
    {t('管理模型、标签、端点等预填组')}
    +
    +
    +
    + +
    + {groups.length > 0 ? ( + + ) : ( + } + darkModeImage={} + description={t('暂无预填组')} + style={{ padding: 30 }} + /> + )} +
    +
    +
    +
    + + {/* 编辑组件 */} + + + ); +}; + +export default PrefillGroupManagement; \ No newline at end of file diff --git a/web/src/components/table/models/ui/RenderUtils.jsx b/web/src/components/table/models/ui/RenderUtils.jsx new file mode 100644 index 00000000..26a72e16 --- /dev/null +++ b/web/src/components/table/models/ui/RenderUtils.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 { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui'; + +const { Text } = Typography; + +// 通用渲染函数:限制项目数量显示,支持popover展开 +export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) { + if (!items || items.length === 0) return '-'; + const displayItems = items.slice(0, maxDisplay); + const remainingItems = items.slice(maxDisplay); + return ( + + {displayItems.map((item, idx) => renderItem(item, idx))} + {remainingItems.length > 0 && ( + + + {remainingItems.map((item, idx) => renderItem(item, idx))} + + + } + position='top' + > + + +{remainingItems.length} + + + )} + + ); +} + +// 渲染描述字段,长文本支持tooltip +export const renderDescription = (text, maxWidth = 200) => { + return ( + + {text || '-'} + + ); +}; \ No newline at end of file From bb08de0b117cdd3a71d360e6eda49dcea1f35c83 Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Mon, 4 Aug 2025 09:06:57 +0800 Subject: [PATCH 163/582] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dgemini2openai?= =?UTF-8?q?=20=E6=B2=A1=E6=9C=89=E8=BF=94=E5=9B=9E=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/gemini/relay-gemini.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 3fe41600..adc771e2 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -814,7 +814,7 @@ func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.Ch if err != nil { return fmt.Errorf("failed to marshal stream response: %w", err) } - openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, info.ShouldIncludeUsage) + openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, false) return nil } From c504c9af5df1039b48257b404e3302184b8b2a99 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 4 Aug 2025 15:38:01 +0800 Subject: [PATCH 164/582] =?UTF-8?q?=F0=9F=92=B0=20feat:=20Add=20model=20bi?= =?UTF-8?q?lling=20type=20(`quota=5Ftype`)=20support=20across=20backend=20?= =?UTF-8?q?&=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary • Backend 1. model/model_meta.go – Added `QuotaType` field to `Model` struct (JSON only, gorm `-`). 2. model/model_groups.go – Implemented `GetModelQuotaType(modelName)` leveraging cached pricing map. 3. controller/model_meta.go – Enhanced `fillModelExtra` to populate `QuotaType` using new helper. • Frontend 1. web/src/components/table/models/ModelsColumnDefs.js – Introduced `renderQuotaType` helper that visualises billing mode with coloured tags (`teal = per-call`, `violet = per-token`). – Added “计费类型” column (`quota_type`) to models table. Why Providing the billing mode alongside existing pricing/group information gives administrators instant visibility into whether each model is priced per call or per token, aligning UI with new backend metadata. Notes No database migration required – `quota_type` is transient, delivered via API. Frontend labels/colours can be adjusted via i18n or theme tokens if necessary. --- controller/model_meta.go | 2 ++ model/model_extra.go | 24 +++++++++++++++++++ model/model_groups.go | 12 ---------- model/model_meta.go | 1 + .../table/models/ModelsColumnDefs.js | 24 +++++++++++++++++++ 5 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 model/model_extra.go delete mode 100644 model/model_groups.go diff --git a/controller/model_meta.go b/controller/model_meta.go index 3ba09240..24329555 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -147,5 +147,7 @@ func fillModelExtra(m *model.Model) { } // 填充启用分组 m.EnableGroups = model.GetModelEnableGroups(m.ModelName) + // 填充计费类型 + m.QuotaType = model.GetModelQuotaType(m.ModelName) } diff --git a/model/model_extra.go b/model/model_extra.go new file mode 100644 index 00000000..3724346e --- /dev/null +++ b/model/model_extra.go @@ -0,0 +1,24 @@ +package model + +// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。 +// 复用缓存的定价映射,避免额外的数据库查询。 +func GetModelEnableGroups(modelName string) []string { + for _, p := range GetPricing() { + if p.ModelName == modelName { + return p.EnableGroup + } + } + return make([]string, 0) +} + +// GetModelQuotaType 返回指定模型的计费类型(quota_type)。 +// 复用缓存的定价映射,避免额外数据库查询。 +// 如果未找到对应模型,默认返回 0。 +func GetModelQuotaType(modelName string) int { + for _, p := range GetPricing() { + if p.ModelName == modelName { + return p.QuotaType + } + } + return 0 +} diff --git a/model/model_groups.go b/model/model_groups.go deleted file mode 100644 index 3957b909..00000000 --- a/model/model_groups.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。 -// 复用缓存的定价映射,避免额外的数据库查询。 -func GetModelEnableGroups(modelName string) []string { - for _, p := range GetPricing() { - if p.ModelName == modelName { - return p.EnableGroup - } - } - return make([]string, 0) -} diff --git a/model/model_meta.go b/model/model_meta.go index 84742288..3598cb7c 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -39,6 +39,7 @@ type Model struct { BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` + QuotaType int `json:"quota_type" gorm:"-"` } // Insert 创建新的模型元数据记录 diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index a2af1c95..f71686fc 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -98,6 +98,25 @@ const renderEndpoints = (text) => { }); }; +// Render quota type +const renderQuotaType = (qt, t) => { + if (qt === 1) { + return ( + + {t('按次计费')} + + ); + } + if (qt === 0) { + return ( + + {t('按量计费')} + + ); + } + return qt ?? '-'; +}; + // Render bound channels const renderBoundChannels = (channels) => { if (!channels || channels.length === 0) return '-'; @@ -213,6 +232,11 @@ export const getModelsColumns = ({ dataIndex: 'enable_groups', render: renderGroups, }, + { + title: t('计费类型'), + dataIndex: 'quota_type', + render: (qt) => renderQuotaType(qt, t), + }, { title: t('创建时间'), dataIndex: 'created_time', From 12b1893287bc920339507eb925753f045a589633 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 4 Aug 2025 16:01:56 +0800 Subject: [PATCH 165/582] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20model=20name?= =?UTF-8?q?=20matching=20rules=20with=20priority-based=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add flexible model name matching system to support different matching patterns: Backend changes: - Add `name_rule` field to Model struct with 4 matching types: * 0: Exact match (default) * 1: Prefix match * 2: Contains match * 3: Suffix match - Implement `FindModelByNameWithRule` function with priority order: exact > prefix > suffix > contains - Add database migration for new `name_rule` column Frontend changes: - Add "Match Type" column in models table with colored tags - Add name rule selector in create/edit modal with validation - Auto-set exact match and disable selection for preconfigured models - Add explanatory text showing priority order - Support i18n for all new UI elements This enables users to define model patterns once and reuse configurations across similar models, reducing repetitive setup while maintaining exact match priority for specific overrides. Closes: #[issue-number] --- model/model_meta.go | 56 +++++++++++++++++++ .../table/models/ModelsColumnDefs.js | 22 ++++++++ .../table/models/modals/EditModelModal.jsx | 22 ++++++++ 3 files changed, 100 insertions(+) diff --git a/model/model_meta.go b/model/model_meta.go index 3598cb7c..4faf7a84 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -3,6 +3,7 @@ package model import ( "one-api/common" "strconv" + "strings" "gorm.io/gorm" ) @@ -20,6 +21,14 @@ import ( // 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列) // 这样既保证了数据一致性,也方便后期扩展 +// 模型名称匹配规则 +const ( + NameRuleExact = iota // 0 精确匹配 + NameRulePrefix // 1 前缀匹配 + NameRuleContains // 2 包含匹配 + NameRuleSuffix // 3 后缀匹配 +) + type BoundChannel struct { Name string `json:"name"` Type int `json:"type"` @@ -40,6 +49,7 @@ type Model struct { BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` QuotaType int `json:"quota_type" gorm:"-"` + NameRule int `json:"name_rule" gorm:"default:0"` } // Insert 创建新的模型元数据记录 @@ -93,6 +103,52 @@ func GetBoundChannels(modelName string) ([]BoundChannel, error) { return channels, err } +// FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含 +func FindModelByNameWithRule(name string) (*Model, error) { + // 1. 精确匹配 + if m, err := GetModelByName(name); err == nil { + return m, nil + } + // 2. 规则匹配 + var models []*Model + if err := DB.Where("name_rule <> ?", NameRuleExact).Find(&models).Error; err != nil { + return nil, err + } + var prefixMatch, suffixMatch, containsMatch *Model + for _, m := range models { + switch m.NameRule { + case NameRulePrefix: + if strings.HasPrefix(name, m.ModelName) { + if prefixMatch == nil || len(m.ModelName) > len(prefixMatch.ModelName) { + prefixMatch = m + } + } + case NameRuleSuffix: + if strings.HasSuffix(name, m.ModelName) { + if suffixMatch == nil || len(m.ModelName) > len(suffixMatch.ModelName) { + suffixMatch = m + } + } + case NameRuleContains: + if strings.Contains(name, m.ModelName) { + if containsMatch == nil || len(m.ModelName) > len(containsMatch.ModelName) { + containsMatch = m + } + } + } + } + if prefixMatch != nil { + return prefixMatch, nil + } + if suffixMatch != nil { + return suffixMatch, nil + } + if containsMatch != nil { + return containsMatch, nil + } + return nil, gorm.ErrRecordNotFound +} + // SearchModels 根据关键词和供应商搜索模型,支持分页 func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) { var models []*Model diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index f71686fc..c02201c4 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -184,6 +184,23 @@ const renderOperations = (text, record, setEditingModel, setShowEdit, manageMode ); }; +// 名称匹配类型渲染 +const renderNameRule = (rule, t) => { + const map = { + 0: { color: 'green', label: t('精确') }, + 1: { color: 'blue', label: t('前缀') }, + 2: { color: 'orange', label: t('包含') }, + 3: { color: 'purple', label: t('后缀') }, + }; + const cfg = map[rule]; + if (!cfg) return '-'; + return ( + + {cfg.label} + + ); +}; + export const getModelsColumns = ({ t, manageModel, @@ -202,6 +219,11 @@ export const getModelsColumns = ({ ), }, + { + title: t('匹配类型'), + dataIndex: 'name_rule', + render: (val) => renderNameRule(val, t), + }, { title: t('描述'), dataIndex: 'description', diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index 1a1c9787..bc22d006 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -40,6 +40,13 @@ import { API, showError, showSuccess } from '../../../../helpers'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +const nameRuleOptions = [ + { label: '精确名称匹配', value: 0 }, + { label: '前缀名称匹配', value: 1 }, + { label: '包含名称匹配', value: 2 }, + { label: '后缀名称匹配', value: 3 }, +]; + const endpointOptions = [ { label: 'OpenAI', value: 'openai' }, { label: 'Anthropic', value: 'anthropic' }, @@ -111,6 +118,7 @@ const EditModelModal = (props) => { vendor: '', vendor_icon: '', endpoints: [], + name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配 status: true, }); @@ -301,6 +309,20 @@ const EditModelModal = (props) => { showClear /> + +
    + ({ label: t(o.label), value: o.value }))} + rules={[{ required: true, message: t('请选择名称匹配类型') }]} + disabled={!!props.editingModel?.model_name} // 通过未配置模型过来的禁用选择 + style={{ width: '100%' }} + extraText={t('根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含')} + /> + + Date: Mon, 4 Aug 2025 16:52:31 +0800 Subject: [PATCH 166/582] feat: add multi-key management --- controller/channel.go | 258 ++++++++++++ model/channel.go | 33 +- model/channel_cache.go | 2 +- router/api-router.go | 1 + .../table/channels/ChannelsColumnDefs.js | 105 +++-- .../table/channels/ChannelsTable.jsx | 7 + web/src/components/table/channels/index.jsx | 7 + .../channels/modals/MultiKeyManageModal.jsx | 372 ++++++++++++++++++ web/src/hooks/channels/useChannelsData.js | 10 + 9 files changed, 730 insertions(+), 65 deletions(-) create mode 100644 web/src/components/table/channels/modals/MultiKeyManageModal.jsx diff --git a/controller/channel.go b/controller/channel.go index d9e4d422..a2ee5743 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1030,3 +1030,261 @@ func CopyChannel(c *gin.Context) { // success c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}}) } + +// MultiKeyManageRequest represents the request for multi-key management operations +type MultiKeyManageRequest struct { + ChannelId int `json:"channel_id"` + Action string `json:"action"` // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status" + KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions +} + +// MultiKeyStatusResponse represents the response for key status query +type MultiKeyStatusResponse struct { + Keys []KeyStatus `json:"keys"` +} + +type KeyStatus struct { + Index int `json:"index"` + Status int `json:"status"` // 1: enabled, 2: disabled + DisabledTime int64 `json:"disabled_time,omitempty"` + Reason string `json:"reason,omitempty"` + KeyPreview string `json:"key_preview"` // first 10 chars of key for identification +} + +// ManageMultiKeys handles multi-key management operations +func ManageMultiKeys(c *gin.Context) { + request := MultiKeyManageRequest{} + err := c.ShouldBindJSON(&request) + if err != nil { + common.ApiError(c, err) + return + } + + channel, err := model.GetChannelById(request.ChannelId, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "渠道不存在", + }) + return + } + + if !channel.ChannelInfo.IsMultiKey { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该渠道不是多密钥模式", + }) + return + } + + switch request.Action { + case "get_key_status": + keys := channel.GetKeys() + var keyStatusList []KeyStatus + + for i, key := range keys { + status := 1 // default enabled + var disabledTime int64 + var reason string + + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + if status != 1 { + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i] + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + reason = channel.ChannelInfo.MultiKeyDisabledReason[i] + } + } + + // Create key preview (first 10 chars) + keyPreview := key + if len(key) > 10 { + keyPreview = key[:10] + "..." + } + + keyStatusList = append(keyStatusList, KeyStatus{ + Index: i, + Status: status, + DisabledTime: disabledTime, + Reason: reason, + KeyPreview: keyPreview, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": MultiKeyStatusResponse{Keys: keyStatusList}, + }) + return + + case "disable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要禁用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + + channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled + channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp() + channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = "手动禁用" + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已禁用", + }) + return + + case "enable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要启用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + // 从状态列表中删除该密钥的记录,使其回到默认启用状态 + if channel.ChannelInfo.MultiKeyStatusList != nil { + delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex) + } + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已启用", + }) + return + + case "delete_disabled_keys": + keys := channel.GetKeys() + var remainingKeys []string + var deletedCount int + var newStatusList = make(map[int]int) + var newDisabledTime = make(map[int]int64) + var newDisabledReason = make(map[int]string) + + newIndex := 0 + for i, key := range keys { + status := 1 // default enabled + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + // 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥 + if status == 3 { + deletedCount++ + } else { + remainingKeys = append(remainingKeys, key) + // 保留非自动禁用密钥的状态信息,重新索引 + if status != 1 { + newStatusList[newIndex] = status + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { + newDisabledTime[newIndex] = t + } + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { + newDisabledReason[newIndex] = r + } + } + } + newIndex++ + } + } + + if deletedCount == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "没有需要删除的自动禁用密钥", + }) + return + } + + // Update channel with remaining keys + channel.Key = strings.Join(remainingKeys, "\n") + channel.ChannelInfo.MultiKeySize = len(remainingKeys) + channel.ChannelInfo.MultiKeyStatusList = newStatusList + channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime + channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount), + "data": deletedCount, + }) + return + + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不支持的操作", + }) + return + } +} diff --git a/model/channel.go b/model/channel.go index bcffc102..502171fa 100644 --- a/model/channel.go +++ b/model/channel.go @@ -41,6 +41,7 @@ type Channel struct { Priority *int64 `json:"priority" gorm:"bigint;default:0"` AutoBan *int `json:"auto_ban" gorm:"default:1"` OtherInfo string `json:"other_info"` + Settings string `json:"settings"` Tag *string `json:"tag" gorm:"index"` Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 ParamOverride *string `json:"param_override" gorm:"type:text"` @@ -52,11 +53,13 @@ type Channel struct { } type ChannelInfo struct { - IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 - MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 - MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status - MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 - MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` + IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 + MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 + MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status + MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason"` // key禁用原因列表,key index -> reason + MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time"` // key禁用时间列表,key index -> time + MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 + MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` } // Value implements driver.Valuer interface @@ -70,7 +73,7 @@ func (c *ChannelInfo) Scan(value interface{}) error { return common.Unmarshal(bytesValue, c) } -func (channel *Channel) getKeys() []string { +func (channel *Channel) GetKeys() []string { if channel.Key == "" { return []string{} } @@ -101,7 +104,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { } // Obtain all keys (split by \n) - keys := channel.getKeys() + keys := channel.GetKeys() if len(keys) == 0 { // No keys available, return error, should disable the channel return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey) @@ -528,8 +531,8 @@ func CleanupChannelPollingLocks() { }) } -func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) { - keys := channel.getKeys() +func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) { + keys := channel.GetKeys() if len(keys) == 0 { channel.Status = status } else { @@ -547,6 +550,14 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) { delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) } else { channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason + channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp() } if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize { channel.Status = common.ChannelStatusAutoDisabled @@ -569,7 +580,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri } if channelCache.ChannelInfo.IsMultiKey { // 如果是多Key模式,更新缓存中的状态 - handlerMultiKeyUpdate(channelCache, usingKey, status) + handlerMultiKeyUpdate(channelCache, usingKey, status, reason) //CacheUpdateChannel(channelCache) //return true } else { @@ -600,7 +611,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri if channel.ChannelInfo.IsMultiKey { beforeStatus := channel.Status - handlerMultiKeyUpdate(channel, usingKey, status) + handlerMultiKeyUpdate(channel, usingKey, status, reason) if beforeStatus != channel.Status { shouldUpdateAbilities = true } diff --git a/model/channel_cache.go b/model/channel_cache.go index ecd87607..6ca23cf9 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -70,7 +70,7 @@ func InitChannelCache() { //channelsIDM = newChannelId2channel for i, channel := range newChannelId2channel { if channel.ChannelInfo.IsMultiKey { - channel.Keys = channel.getKeys() + channel.Keys = channel.GetKeys() if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { if oldChannel, ok := channelsIDM[i]; ok { // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 diff --git a/router/api-router.go b/router/api-router.go index bc49803a..12846012 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -120,6 +120,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.GET("/tag/models", controller.GetTagModels) channelRoute.POST("/copy/:id", controller.CopyChannel) + channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) diff --git a/web/src/components/table/channels/ChannelsColumnDefs.js b/web/src/components/table/channels/ChannelsColumnDefs.js index beb5fe55..18cb5700 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.js +++ b/web/src/components/table/channels/ChannelsColumnDefs.js @@ -210,7 +210,9 @@ export const getChannelsColumns = ({ copySelectedChannel, refresh, activePage, - channels + channels, + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel }) => { return [ { @@ -503,47 +505,7 @@ export const getChannelsColumns = ({ /> - {record.channel_info?.is_multi_key ? ( - - { - record.status === 1 ? ( - - ) : ( - - ) - } - manageChannel(record.id, 'enable_all', record), - } - ]} - > - + {record.channel_info?.is_multi_key ? ( + + + { + setCurrentMultiKeyChannel(record); + setShowMultiKeyManageModal(true); + }, + } + ]} + > + + )} { setEditingTag, copySelectedChannel, refresh, + // Multi-key management + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel, } = channelsData; // Get all columns @@ -79,6 +82,8 @@ const ChannelsTable = (channelsData) => { refresh, activePage, channels, + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel, }); }, [ t, @@ -98,6 +103,8 @@ const ChannelsTable = (channelsData) => { refresh, activePage, channels, + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel, ]); // Filter columns based on visibility settings diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index b0106b4e..66e2d72d 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -30,6 +30,7 @@ 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 MultiKeyManageModal from './modals/MultiKeyManageModal.jsx'; import { createCardProPagination } from '../../../helpers/utils'; const ChannelsPage = () => { @@ -54,6 +55,12 @@ const ChannelsPage = () => { /> + channelsData.setShowMultiKeyManageModal(false)} + channel={channelsData.currentMultiKeyChannel} + onRefresh={channelsData.refresh} + /> {/* Main Content */} . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Modal, + Button, + Table, + Tag, + Typography, + Space, + Tooltip, + Popconfirm, + Empty, + Spin, + Banner +} from '@douyinfe/semi-ui'; +import { + IconRefresh, + IconDelete, + IconClose, + IconSave, + IconSetting +} from '@douyinfe/semi-icons'; +import { API, showError, showSuccess, timestamp2string } from '../../../../helpers/index.js'; + +const { Text, Title } = Typography; + +const MultiKeyManageModal = ({ + visible, + onCancel, + channel, + onRefresh +}) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [keyStatusList, setKeyStatusList] = useState([]); + const [operationLoading, setOperationLoading] = useState({}); + + // Load key status data + const loadKeyStatus = async () => { + if (!channel?.id) return; + + setLoading(true); + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'get_key_status' + }); + + if (res.data.success) { + setKeyStatusList(res.data.data.keys || []); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('获取密钥状态失败')); + } finally { + setLoading(false); + } + }; + + // Disable a specific key + const handleDisableKey = async (keyIndex) => { + const operationId = `disable_${keyIndex}`; + setOperationLoading(prev => ({ ...prev, [operationId]: true })); + + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'disable_key', + key_index: keyIndex + }); + + if (res.data.success) { + showSuccess(t('密钥已禁用')); + await loadKeyStatus(); // Reload data + onRefresh && onRefresh(); // Refresh parent component + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('禁用密钥失败')); + } finally { + setOperationLoading(prev => ({ ...prev, [operationId]: false })); + } + }; + + // Enable a specific key + const handleEnableKey = async (keyIndex) => { + const operationId = `enable_${keyIndex}`; + setOperationLoading(prev => ({ ...prev, [operationId]: true })); + + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'enable_key', + key_index: keyIndex + }); + + if (res.data.success) { + showSuccess(t('密钥已启用')); + await loadKeyStatus(); // Reload data + onRefresh && onRefresh(); // Refresh parent component + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('启用密钥失败')); + } finally { + setOperationLoading(prev => ({ ...prev, [operationId]: false })); + } + }; + + // Delete all disabled keys + const handleDeleteDisabledKeys = async () => { + setOperationLoading(prev => ({ ...prev, delete_disabled: true })); + + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'delete_disabled_keys' + }); + + if (res.data.success) { + showSuccess(res.data.message); + await loadKeyStatus(); // Reload data + onRefresh && onRefresh(); // Refresh parent component + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('删除禁用密钥失败')); + } finally { + setOperationLoading(prev => ({ ...prev, delete_disabled: false })); + } + }; + + // Effect to load data when modal opens + useEffect(() => { + if (visible && channel?.id) { + loadKeyStatus(); + } + }, [visible, channel?.id]); + + // Get status tag component + const renderStatusTag = (status) => { + switch (status) { + case 1: + return {t('已启用')}; + case 2: + return {t('已禁用')}; + case 3: + return {t('自动禁用')}; + default: + return {t('未知状态')}; + } + }; + + // Table columns definition + const columns = [ + { + title: t('索引'), + dataIndex: 'index', + render: (text) => `#${text}`, + }, + { + title: t('密钥预览'), + dataIndex: 'key_preview', + render: (text) => ( + + {text} + + ), + }, + { + title: t('状态'), + dataIndex: 'status', + width: 100, + render: (status) => renderStatusTag(status), + }, + { + title: t('禁用原因'), + dataIndex: 'reason', + width: 220, + render: (reason, record) => { + if (record.status === 1 || !reason) { + return -; + } + return ( + + + {reason} + + + ); + }, + }, + { + title: t('禁用时间'), + dataIndex: 'disabled_time', + width: 150, + render: (time, record) => { + if (record.status === 1 || !time) { + return -; + } + return ( + + + {timestamp2string(time)} + + + ); + }, + }, + { + title: t('操作'), + key: 'action', + width: 120, + render: (_, record) => ( + + {record.status === 1 ? ( + handleDisableKey(record.index)} + > + + + ) : ( + handleEnableKey(record.index)} + > + + + )} + + ), + }, + ]; + + // Calculate statistics + const enabledCount = keyStatusList.filter(key => key.status === 1).length; + const manualDisabledCount = keyStatusList.filter(key => key.status === 2).length; + const autoDisabledCount = keyStatusList.filter(key => key.status === 3).length; + const totalCount = keyStatusList.length; + + return ( + + + {t('多密钥管理')} - {channel?.name} + + } + visible={visible} + onCancel={onCancel} + width={800} + height={600} + footer={ + + + + {autoDisabledCount > 0 && ( + + + + )} + + } + > +
    + {/* Statistics Banner */} + + + {t('总共 {{total}} 个密钥,{{enabled}} 个已启用,{{manual}} 个手动禁用,{{auto}} 个自动禁用', { + total: totalCount, + enabled: enabledCount, + manual: manualDisabledCount, + auto: autoDisabledCount + })} + + {channel?.channel_info?.multi_key_mode && ( +
    + + {t('多密钥模式')}: {channel.channel_info.multi_key_mode === 'random' ? t('随机') : t('轮询')} + +
    + )} +
    + } + /> + + {/* Key Status Table */} + + {keyStatusList.length > 0 ? ( +
    + ) : ( + !loading && ( + + ) + )} + + + + ); +}; + +export default MultiKeyManageModal; \ No newline at end of file diff --git a/web/src/hooks/channels/useChannelsData.js b/web/src/hooks/channels/useChannelsData.js index d188c9fe..8f1f8c29 100644 --- a/web/src/hooks/channels/useChannelsData.js +++ b/web/src/hooks/channels/useChannelsData.js @@ -83,6 +83,10 @@ export const useChannelsData = () => { const [isProcessingQueue, setIsProcessingQueue] = useState(false); const [modelTablePage, setModelTablePage] = useState(1); + // Multi-key management states + const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false); + const [currentMultiKeyChannel, setCurrentMultiKeyChannel] = useState(null); + // Refs const requestCounter = useRef(0); const allSelectingRef = useRef(false); @@ -885,6 +889,12 @@ export const useChannelsData = () => { setModelTablePage, allSelectingRef, + // Multi-key management states + showMultiKeyManageModal, + setShowMultiKeyManageModal, + currentMultiKeyChannel, + setCurrentMultiKeyChannel, + // Form formApi, setFormApi, From 1ecd8b41d8ccc07c8b7d42362c101b1682ab3036 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 4 Aug 2025 17:15:32 +0800 Subject: [PATCH 167/582] feat: enhance multi-key management with pagination and statistics --- controller/channel.go | 122 ++++++++++++--- model/channel.go | 12 +- .../channels/modals/MultiKeyManageModal.jsx | 147 +++++++++++++++--- 3 files changed, 228 insertions(+), 53 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index a2ee5743..440815cc 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -71,6 +71,13 @@ func parseStatusFilter(statusParam string) int { } } +func clearChannelInfo(channel *model.Channel) { + if channel.ChannelInfo.IsMultiKey { + channel.ChannelInfo.MultiKeyDisabledReason = nil + channel.ChannelInfo.MultiKeyDisabledTime = nil + } +} + func GetAllChannels(c *gin.Context) { pageInfo := common.GetPageQuery(c) channelData := make([]*model.Channel, 0) @@ -145,6 +152,10 @@ func GetAllChannels(c *gin.Context) { } } + for _, datum := range channelData { + clearChannelInfo(datum) + } + countQuery := model.DB.Model(&model.Channel{}) if statusFilter == common.ChannelStatusEnabled { countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled) @@ -371,6 +382,10 @@ func SearchChannels(c *gin.Context) { pagedData := channelData[startIdx:endIdx] + for _, datum := range pagedData { + clearChannelInfo(datum) + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -394,6 +409,9 @@ func GetChannel(c *gin.Context) { common.ApiError(c, err) return } + if channel != nil { + clearChannelInfo(channel) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -827,6 +845,7 @@ func UpdateChannel(c *gin.Context) { } model.InitChannelCache() channel.Key = "" + clearChannelInfo(&channel.Channel) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -1036,11 +1055,21 @@ type MultiKeyManageRequest struct { ChannelId int `json:"channel_id"` Action string `json:"action"` // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status" KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions + Page int `json:"page,omitempty"` // for get_key_status pagination + PageSize int `json:"page_size,omitempty"` // for get_key_status pagination } // MultiKeyStatusResponse represents the response for key status query type MultiKeyStatusResponse struct { - Keys []KeyStatus `json:"keys"` + Keys []KeyStatus `json:"keys"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` + // Statistics + EnabledCount int `json:"enabled_count"` + ManualDisabledCount int `json:"manual_disabled_count"` + AutoDisabledCount int `json:"auto_disabled_count"` } type KeyStatus struct { @@ -1080,8 +1109,35 @@ func ManageMultiKeys(c *gin.Context) { switch request.Action { case "get_key_status": keys := channel.GetKeys() - var keyStatusList []KeyStatus + total := len(keys) + // Default pagination parameters + page := request.Page + pageSize := request.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 // Default page size + } + + // Calculate pagination + totalPages := (total + pageSize - 1) / pageSize + if page > totalPages && totalPages > 0 { + page = totalPages + } + + // Calculate range + start := (page - 1) * pageSize + end := start + pageSize + if end > total { + end = total + } + + // Statistics for all keys + var enabledCount, manualDisabledCount, autoDisabledCount int + + var keyStatusList []KeyStatus for i, key := range keys { status := 1 // default enabled var disabledTime int64 @@ -1093,34 +1149,56 @@ func ManageMultiKeys(c *gin.Context) { } } - if status != 1 { - if channel.ChannelInfo.MultiKeyDisabledTime != nil { - disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i] - } - if channel.ChannelInfo.MultiKeyDisabledReason != nil { - reason = channel.ChannelInfo.MultiKeyDisabledReason[i] - } + // Count for statistics + switch status { + case 1: + enabledCount++ + case 2: + manualDisabledCount++ + case 3: + autoDisabledCount++ } - // Create key preview (first 10 chars) - keyPreview := key - if len(key) > 10 { - keyPreview = key[:10] + "..." - } + // Only include keys in current page + if i >= start && i < end { + if status != 1 { + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i] + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + reason = channel.ChannelInfo.MultiKeyDisabledReason[i] + } + } - keyStatusList = append(keyStatusList, KeyStatus{ - Index: i, - Status: status, - DisabledTime: disabledTime, - Reason: reason, - KeyPreview: keyPreview, - }) + // Create key preview (first 10 chars) + keyPreview := key + if len(key) > 10 { + keyPreview = key[:10] + "..." + } + + keyStatusList = append(keyStatusList, KeyStatus{ + Index: i, + Status: status, + DisabledTime: disabledTime, + Reason: reason, + KeyPreview: keyPreview, + }) + } } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": MultiKeyStatusResponse{Keys: keyStatusList}, + "data": MultiKeyStatusResponse{ + Keys: keyStatusList, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + EnabledCount: enabledCount, + ManualDisabledCount: manualDisabledCount, + AutoDisabledCount: autoDisabledCount, + }, }) return diff --git a/model/channel.go b/model/channel.go index 502171fa..280781f1 100644 --- a/model/channel.go +++ b/model/channel.go @@ -53,12 +53,12 @@ type Channel struct { } type ChannelInfo struct { - IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 - MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 - MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status - MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason"` // key禁用原因列表,key index -> reason - MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time"` // key禁用时间列表,key index -> time - MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 + IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 + MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 + MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status + MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason,omitempty"` // key禁用原因列表,key index -> reason + MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time,omitempty"` // key禁用时间列表,key index -> time + MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` } diff --git a/web/src/components/table/channels/modals/MultiKeyManageModal.jsx b/web/src/components/table/channels/modals/MultiKeyManageModal.jsx index 9ae46ea3..44f16c03 100644 --- a/web/src/components/table/channels/modals/MultiKeyManageModal.jsx +++ b/web/src/components/table/channels/modals/MultiKeyManageModal.jsx @@ -30,7 +30,9 @@ import { Popconfirm, Empty, Spin, - Banner + Banner, + Select, + Pagination } from '@douyinfe/semi-ui'; import { IconRefresh, @@ -53,24 +55,48 @@ const MultiKeyManageModal = ({ const [loading, setLoading] = useState(false); const [keyStatusList, setKeyStatusList] = useState([]); const [operationLoading, setOperationLoading] = useState({}); + + // Pagination states + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(0); + + // Statistics states + const [enabledCount, setEnabledCount] = useState(0); + const [manualDisabledCount, setManualDisabledCount] = useState(0); + const [autoDisabledCount, setAutoDisabledCount] = useState(0); // Load key status data - const loadKeyStatus = async () => { + const loadKeyStatus = async (page = currentPage, size = pageSize) => { if (!channel?.id) return; setLoading(true); try { const res = await API.post('/api/channel/multi_key/manage', { channel_id: channel.id, - action: 'get_key_status' + action: 'get_key_status', + page: page, + page_size: size }); if (res.data.success) { - setKeyStatusList(res.data.data.keys || []); + const data = res.data.data; + setKeyStatusList(data.keys || []); + setTotal(data.total || 0); + setCurrentPage(data.page || 1); + setPageSize(data.page_size || 50); + setTotalPages(data.total_pages || 0); + + // Update statistics + setEnabledCount(data.enabled_count || 0); + setManualDisabledCount(data.manual_disabled_count || 0); + setAutoDisabledCount(data.auto_disabled_count || 0); } else { showError(res.data.message); } } catch (error) { + console.error(error); showError(t('获取密钥状态失败')); } finally { setLoading(false); @@ -91,7 +117,7 @@ const MultiKeyManageModal = ({ if (res.data.success) { showSuccess(t('密钥已禁用')); - await loadKeyStatus(); // Reload data + await loadKeyStatus(currentPage, pageSize); // Reload current page onRefresh && onRefresh(); // Refresh parent component } else { showError(res.data.message); @@ -117,7 +143,7 @@ const MultiKeyManageModal = ({ if (res.data.success) { showSuccess(t('密钥已启用')); - await loadKeyStatus(); // Reload data + await loadKeyStatus(currentPage, pageSize); // Reload current page onRefresh && onRefresh(); // Refresh parent component } else { showError(res.data.message); @@ -141,7 +167,9 @@ const MultiKeyManageModal = ({ if (res.data.success) { showSuccess(res.data.message); - await loadKeyStatus(); // Reload data + // Reset to first page after deletion as data structure might change + setCurrentPage(1); + await loadKeyStatus(1, pageSize); onRefresh && onRefresh(); // Refresh parent component } else { showError(res.data.message); @@ -153,13 +181,40 @@ const MultiKeyManageModal = ({ } }; + // Handle page change + const handlePageChange = (page) => { + setCurrentPage(page); + loadKeyStatus(page, pageSize); + }; + + // Handle page size change + const handlePageSizeChange = (size) => { + setPageSize(size); + setCurrentPage(1); // Reset to first page + loadKeyStatus(1, size); + }; + // Effect to load data when modal opens useEffect(() => { if (visible && channel?.id) { - loadKeyStatus(); + setCurrentPage(1); // Reset to first page when opening + loadKeyStatus(1, pageSize); } }, [visible, channel?.id]); + // Reset pagination when modal closes + useEffect(() => { + if (!visible) { + setCurrentPage(1); + setKeyStatusList([]); + setTotal(0); + setTotalPages(0); + setEnabledCount(0); + setManualDisabledCount(0); + setAutoDisabledCount(0); + } + }, [visible]); + // Get status tag component const renderStatusTag = (status) => { switch (status) { @@ -270,12 +325,6 @@ const MultiKeyManageModal = ({ }, ]; - // Calculate statistics - const enabledCount = keyStatusList.filter(key => key.status === 1).length; - const manualDisabledCount = keyStatusList.filter(key => key.status === 2).length; - const autoDisabledCount = keyStatusList.filter(key => key.status === 3).length; - const totalCount = keyStatusList.length; - return ( {t('关闭')}
    + <> +
    + + {/* Pagination */} + {total > 0 && ( +
    + + {t('显示第 {{start}}-{{end}} 条,共 {{total}} 条', { + start: (currentPage - 1) * pageSize + 1, + end: Math.min(currentPage * pageSize, total), + total: total + })} + + +
    + + {t('每页显示')}: + + + + + t('第 {{current}} / {{total}} 页', { + current: currentPage, + total: totalPages + }) + } + /> +
    +
    + )} + ) : ( !loading && ( Date: Mon, 4 Aug 2025 17:19:38 +0800 Subject: [PATCH 168/582] feat: allow admin to restrict the minimum linuxdo trust level to register --- common/constants.go | 1 + controller/linuxdo.go | 30 +++++--- controller/misc.go | 81 ++++++++++---------- model/option.go | 2 + web/src/components/settings/SystemSetting.js | 18 ++++- 5 files changed, 79 insertions(+), 53 deletions(-) diff --git a/common/constants.go b/common/constants.go index 30522411..e6d59d10 100644 --- a/common/constants.go +++ b/common/constants.go @@ -83,6 +83,7 @@ var GitHubClientId = "" var GitHubClientSecret = "" var LinuxDOClientId = "" var LinuxDOClientSecret = "" +var LinuxDOMinimumTrustLevel = 0 var WeChatServerAddress = "" var WeChatServerToken = "" diff --git a/controller/linuxdo.go b/controller/linuxdo.go index 65380b65..9fa15615 100644 --- a/controller/linuxdo.go +++ b/controller/linuxdo.go @@ -220,21 +220,29 @@ func LinuxdoOAuth(c *gin.Context) { } } else { if common.RegisterEnabled { - user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1) - user.DisplayName = linuxdoUser.Name - user.Role = common.RoleCommonUser - user.Status = common.UserStatusEnabled + if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel { + user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1) + user.DisplayName = linuxdoUser.Name + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled - affCode := session.Get("aff") - inviterId := 0 - if affCode != nil { - inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) - } + affCode := session.Get("aff") + inviterId := 0 + if affCode != nil { + inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + } - if err := user.Insert(inviterId); err != nil { + if err := user.Insert(inviterId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": err.Error(), + "message": "Linux DO 信任等级未达到管理员设置的最低信任等级", }) return } diff --git a/controller/misc.go b/controller/misc.go index a3ed9be9..f30ab8c7 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -41,46 +41,47 @@ func GetStatus(c *gin.Context) { cs := console_setting.GetConsoleSetting() data := gin.H{ - "version": common.Version, - "start_time": common.StartTime, - "email_verification": common.EmailVerificationEnabled, - "github_oauth": common.GitHubOAuthEnabled, - "github_client_id": common.GitHubClientId, - "linuxdo_oauth": common.LinuxDOOAuthEnabled, - "linuxdo_client_id": common.LinuxDOClientId, - "telegram_oauth": common.TelegramOAuthEnabled, - "telegram_bot_name": common.TelegramBotName, - "system_name": common.SystemName, - "logo": common.Logo, - "footer_html": common.Footer, - "wechat_qrcode": common.WeChatAccountQRCodeImageURL, - "wechat_login": common.WeChatAuthEnabled, - "server_address": setting.ServerAddress, - "price": setting.Price, - "stripe_unit_price": setting.StripeUnitPrice, - "min_topup": setting.MinTopUp, - "stripe_min_topup": setting.StripeMinTopUp, - "turnstile_check": common.TurnstileCheckEnabled, - "turnstile_site_key": common.TurnstileSiteKey, - "top_up_link": common.TopUpLink, - "docs_link": operation_setting.GetGeneralSetting().DocsLink, - "quota_per_unit": common.QuotaPerUnit, - "display_in_currency": common.DisplayInCurrencyEnabled, - "enable_batch_update": common.BatchUpdateEnabled, - "enable_drawing": common.DrawingEnabled, - "enable_task": common.TaskEnabled, - "enable_data_export": common.DataExportEnabled, - "data_export_default_time": common.DataExportDefaultTime, - "default_collapse_sidebar": common.DefaultCollapseSidebar, - "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", - "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", - "mj_notify_enabled": setting.MjNotifyEnabled, - "chats": setting.Chats, - "demo_site_enabled": operation_setting.DemoSiteEnabled, - "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, - "default_use_auto_group": setting.DefaultUseAutoGroup, - "pay_methods": setting.PayMethods, - "usd_exchange_rate": setting.USDExchangeRate, + "version": common.Version, + "start_time": common.StartTime, + "email_verification": common.EmailVerificationEnabled, + "github_oauth": common.GitHubOAuthEnabled, + "github_client_id": common.GitHubClientId, + "linuxdo_oauth": common.LinuxDOOAuthEnabled, + "linuxdo_client_id": common.LinuxDOClientId, + "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, + "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_bot_name": common.TelegramBotName, + "system_name": common.SystemName, + "logo": common.Logo, + "footer_html": common.Footer, + "wechat_qrcode": common.WeChatAccountQRCodeImageURL, + "wechat_login": common.WeChatAuthEnabled, + "server_address": setting.ServerAddress, + "price": setting.Price, + "stripe_unit_price": setting.StripeUnitPrice, + "min_topup": setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "turnstile_check": common.TurnstileCheckEnabled, + "turnstile_site_key": common.TurnstileSiteKey, + "top_up_link": common.TopUpLink, + "docs_link": operation_setting.GetGeneralSetting().DocsLink, + "quota_per_unit": common.QuotaPerUnit, + "display_in_currency": common.DisplayInCurrencyEnabled, + "enable_batch_update": common.BatchUpdateEnabled, + "enable_drawing": common.DrawingEnabled, + "enable_task": common.TaskEnabled, + "enable_data_export": common.DataExportEnabled, + "data_export_default_time": common.DataExportDefaultTime, + "default_collapse_sidebar": common.DefaultCollapseSidebar, + "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "mj_notify_enabled": setting.MjNotifyEnabled, + "chats": setting.Chats, + "demo_site_enabled": operation_setting.DemoSiteEnabled, + "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "default_use_auto_group": setting.DefaultUseAutoGroup, + "pay_methods": setting.PayMethods, + "usd_exchange_rate": setting.USDExchangeRate, // 面板启用开关 "api_info_enabled": cs.ApiInfoEnabled, diff --git a/model/option.go b/model/option.go index 05b99b41..5c84d166 100644 --- a/model/option.go +++ b/model/option.go @@ -336,6 +336,8 @@ func updateOptionMap(key string, value string) (err error) { common.LinuxDOClientId = value case "LinuxDOClientSecret": common.LinuxDOClientSecret = value + case "LinuxDOMinimumTrustLevel": + common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value) case "Footer": common.Footer = value case "SystemName": diff --git a/web/src/components/settings/SystemSetting.js b/web/src/components/settings/SystemSetting.js index ce8ac7a7..a267fbe8 100644 --- a/web/src/components/settings/SystemSetting.js +++ b/web/src/components/settings/SystemSetting.js @@ -85,6 +85,7 @@ const SystemSetting = () => { LinuxDOOAuthEnabled: '', LinuxDOClientId: '', LinuxDOClientSecret: '', + LinuxDOMinimumTrustLevel: '', ServerAddress: '', }); @@ -472,6 +473,12 @@ const SystemSetting = () => { value: inputs.LinuxDOClientSecret, }); } + if (originInputs['LinuxDOMinimumTrustLevel'] !== inputs.LinuxDOMinimumTrustLevel) { + options.push({ + key: 'LinuxDOMinimumTrustLevel', + value: inputs.LinuxDOMinimumTrustLevel, + }); + } if (options.length > 0) { await updateOptions(options); @@ -916,14 +923,14 @@ const SystemSetting = () => { - + - + { placeholder={t('敏感信息不会发送到前端显示')} /> + + + - + {t('禁用')} + ) : ( - handleEnableKey(record.index)} + - + {t('启用')} + )} ), @@ -347,21 +407,48 @@ const MultiKeyManageModal = ({ > {t('刷新')} - {autoDisabledCount > 0 && ( + + + + {enabledCount > 0 && ( )} + + + } > @@ -391,6 +478,28 @@ const MultiKeyManageModal = ({ } /> + {/* Filter Controls */} +
    + {t('状态筛选')}: + + {statusFilter !== null && ( + + {t('当前显示 {{count}} 条筛选结果', { count: total })} + + )} +
    + {/* Key Status Table */} {keyStatusList.length > 0 ? ( From 5e47da1a8e2ea7df9ba8183714a75c948648bcaf Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 4 Aug 2025 20:16:51 +0800 Subject: [PATCH 171/582] feat: improve layout and pagination handling in MultiKeyManageModal --- .../channels/modals/MultiKeyManageModal.jsx | 157 ++++++++++-------- 1 file changed, 84 insertions(+), 73 deletions(-) diff --git a/web/src/components/table/channels/modals/MultiKeyManageModal.jsx b/web/src/components/table/channels/modals/MultiKeyManageModal.jsx index 161da1cc..89ab790f 100644 --- a/web/src/components/table/channels/modals/MultiKeyManageModal.jsx +++ b/web/src/components/table/channels/modals/MultiKeyManageModal.jsx @@ -395,8 +395,7 @@ const MultiKeyManageModal = ({ } visible={visible} onCancel={onCancel} - width={800} - height={600} + width={900} footer={ @@ -452,11 +451,11 @@ const MultiKeyManageModal = ({ } > -
    +
    {/* Statistics Banner */} @@ -479,7 +478,7 @@ const MultiKeyManageModal = ({ /> {/* Filter Controls */} -
    +
    {t('状态筛选')}:
    - - {/* Pagination */} - {total > 0 && ( -
    - - {t('显示第 {{start}}-{{end}} 条,共 {{total}} 条', { - start: (currentPage - 1) * pageSize + 1, - end: Math.min(currentPage * pageSize, total), - total: total - })} - - -
    - - {t('每页显示')}: - - - - - t('第 {{current}} / {{total}} 页', { - current: currentPage, - total: totalPages - }) - } - /> -
    +
    + + {keyStatusList.length > 0 ? ( +
    +
    +
    - )} - - ) : ( - !loading && ( - - ) - )} - + + {/* Pagination */} + {total > 0 && ( +
    + + {t('显示第 {{start}}-{{end}} 条,共 {{total}} 条', { + start: (currentPage - 1) * pageSize + 1, + end: Math.min(currentPage * pageSize, total), + total: total + })} + + +
    + + {t('每页显示')}: + + + + + t('第 {{current}} / {{total}} 页', { + current: currentPage, + total: totalPages + }) + } + /> +
    +
    + )} + + ) : ( + !loading && ( + + ) + )} + + ); From 755acc6191dcea255ded0cd87807ca1aafb05b41 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 4 Aug 2025 20:44:19 +0800 Subject: [PATCH 172/582] feat: implement channel-specific locking for thread-safe polling --- controller/channel.go | 4 ++++ model/channel.go | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 7756e18f..9f46ca35 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1107,6 +1107,10 @@ func ManageMultiKeys(c *gin.Context) { return } + lock := model.GetChannelPollingLock(channel.Id) + lock.Lock() + defer lock.Unlock() + switch request.Action { case "get_key_status": keys := channel.GetKeys() diff --git a/model/channel.go b/model/channel.go index 280781f1..a5fb463e 100644 --- a/model/channel.go +++ b/model/channel.go @@ -141,7 +141,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { return keys[selectedIdx], selectedIdx, nil case constant.MultiKeyModePolling: // Use channel-specific lock to ensure thread-safe polling - lock := getChannelPollingLock(channel.Id) + lock := GetChannelPollingLock(channel.Id) lock.Lock() defer lock.Unlock() @@ -500,8 +500,8 @@ var channelStatusLock sync.Mutex // channelPollingLocks stores locks for each channel.id to ensure thread-safe polling var channelPollingLocks sync.Map -// getChannelPollingLock returns or creates a mutex for the given channel ID -func getChannelPollingLock(channelId int) *sync.Mutex { +// GetChannelPollingLock returns or creates a mutex for the given channel ID +func GetChannelPollingLock(channelId int) *sync.Mutex { if lock, exists := channelPollingLocks.Load(channelId); exists { return lock.(*sync.Mutex) } From ded1cde2ff383e6b8c09d68c8e88db6b5146a4a2 Mon Sep 17 00:00:00 2001 From: antecanis8 <42382878+antecanis8@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:02:57 +0000 Subject: [PATCH 173/582] fix : Gemini embedding model only embeds the first text in a batch --- dto/gemini.go | 10 +++++-- relay/channel/gemini/adaptor.go | 44 ++++++++++++++++------------ relay/channel/gemini/relay-gemini.go | 20 +++++++------ 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/dto/gemini.go b/dto/gemini.go index f7acd355..1bd1fe4c 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -216,10 +216,14 @@ type GeminiEmbeddingRequest struct { OutputDimensionality int `json:"outputDimensionality,omitempty"` } -type GeminiEmbeddingResponse struct { - Embedding ContentEmbedding `json:"embedding"` +type GeminiBatchEmbeddingRequest struct { + Requests []*GeminiEmbeddingRequest `json:"requests"` } -type ContentEmbedding struct { +type GeminiEmbedding struct { Values []float64 `json:"values"` } + +type GeminiBatchEmbeddingResponse struct { + Embeddings []*GeminiEmbedding `json:"embeddings"` +} diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 14fd278d..efa64057 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -114,7 +114,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if strings.HasPrefix(info.UpstreamModelName, "text-embedding") || strings.HasPrefix(info.UpstreamModelName, "embedding") || strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") { - return fmt.Sprintf("%s/%s/models/%s:embedContent", info.BaseUrl, version, info.UpstreamModelName), nil + return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", info.BaseUrl, version, info.UpstreamModelName), nil } action := "generateContent" @@ -156,29 +156,35 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela if len(inputs) == 0 { return nil, errors.New("input is empty") } - - // only process the first input - geminiRequest := dto.GeminiEmbeddingRequest{ - Content: dto.GeminiChatContent{ - Parts: []dto.GeminiPart{ - { - Text: inputs[0], + // process all inputs + geminiRequests := make([]map[string]interface{}, 0, len(inputs)) + for _, input := range inputs { + geminiRequest := map[string]interface{}{ + "model": fmt.Sprintf("models/%s", info.UpstreamModelName), + "content": dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + { + Text: input, + }, }, }, - }, - } - - // set specific parameters for different models - // https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent - switch info.UpstreamModelName { - case "text-embedding-004": - // except embedding-001 supports setting `OutputDimensionality` - if request.Dimensions > 0 { - geminiRequest.OutputDimensionality = request.Dimensions } + + // set specific parameters for different models + // https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent + switch info.UpstreamModelName { + case "text-embedding-004": + // except embedding-001 supports setting `OutputDimensionality` + if request.Dimensions > 0 { + geminiRequest["outputDimensionality"] = request.Dimensions + } + } + geminiRequests = append(geminiRequests, geminiRequest) } - return geminiRequest, nil + return map[string]interface{}{ + "requests": geminiRequests, + }, nil } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index adc771e2..0b6e63a6 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -974,7 +974,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } - var geminiResponse dto.GeminiEmbeddingResponse + var geminiResponse dto.GeminiBatchEmbeddingResponse if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } @@ -982,14 +982,16 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h // convert to openai format response openAIResponse := dto.OpenAIEmbeddingResponse{ Object: "list", - Data: []dto.OpenAIEmbeddingResponseItem{ - { - Object: "embedding", - Embedding: geminiResponse.Embedding.Values, - Index: 0, - }, - }, - Model: info.UpstreamModelName, + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)), + Model: info.UpstreamModelName, + } + + for i, embedding := range geminiResponse.Embeddings { + openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: "embedding", + Embedding: embedding.Values, + Index: i, + }) } // calculate usage From f6b49dce15f58851b42e2fc2d08326d081536ad6 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 4 Aug 2025 21:36:31 +0800 Subject: [PATCH 174/582] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor:=20Rep?= =?UTF-8?q?lace=20model=20categories=20with=20vendor-based=20filtering=20a?= =?UTF-8?q?nd=20optimize=20data=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Backend Changes:** - Refactor pricing API to return separate vendors array with ID-based model references - Remove redundant vendor_name/vendor_icon fields from pricing records, use vendor_id only - Add vendor_description to pricing response for frontend display - Maintain 1-minute cache protection for pricing endpoint security - **Frontend Data Flow:** - Update useModelPricingData hook to build vendorsMap from API response - Enhance model records with vendor info during data processing - Pass vendorsMap through component hierarchy for consistent vendor data access - **UI Component Replacements:** - Replace PricingCategories with PricingVendors component for vendor-based filtering - Replace PricingCategoryIntro with PricingVendorIntro in header section - Remove all model category related components and logic - **Header Improvements:** - Implement vendor intro with real backend data (name, icon, description) - Add text collapsible feature (2-line limit with expand/collapse functionality) - Support carousel animation for "All Vendors" view with vendor icon rotation - **Model Detail Modal Enhancements:** - Update ModelHeader to use real vendor icons via getLobeHubIcon() - Move tags from header to ModelBasicInfo content area to avoid SideSheet title width constraints - Display only custom tags from backend with stringToColor() for consistent styling - Use Space component with wrap property for proper tag layout - **Table View Optimizations:** - Integrate RenderUtils for description and tags columns - Implement renderLimitedItems for tags (max 3 visible, +x popover for overflow) - Use renderDescription for text truncation with tooltip support - **Filter Logic Updates:** - Vendor filter shows disabled options instead of hiding when no models match - Include "Unknown Vendor" category for models without vendor information - Remove all hardcoded vendor descriptions, use real backend data - **Code Quality:** - Fix import paths after component relocation - Remove unused model category utilities and hardcoded mappings - Ensure consistent vendor data usage across all pricing views - Maintain backward compatibility with existing pricing calculation logic This refactor provides a more scalable vendor-based architecture while eliminating data redundancy and improving user experience with real-time backend data integration. --- controller/pricing.go | 1 + model/pricing.go | 95 +++++++ .../models => common}/ui/RenderUtils.jsx | 0 .../filter/PricingCategories.jsx | 45 ---- .../model-pricing/filter/PricingVendors.jsx | 119 +++++++++ .../model-pricing/layout/PricingPage.jsx | 1 + .../model-pricing/layout/PricingSidebar.jsx | 21 +- .../layout/header/PricingCategoryIntro.jsx | 232 ---------------- .../layout/header/PricingTopSection.jsx | 20 +- .../layout/header/PricingVendorIntro.jsx | 247 ++++++++++++++++++ ...ton.jsx => PricingVendorIntroSkeleton.jsx} | 20 +- ...jsx => PricingVendorIntroWithSkeleton.jsx} | 28 +- .../modal/ModelDetailSideSheet.jsx | 5 +- .../modal/PricingFilterModal.jsx | 3 +- .../modal/components/FilterModalContent.jsx | 18 +- .../modal/components/ModelBasicInfo.jsx | 56 +++- .../modal/components/ModelHeader.jsx | 77 +----- .../view/card/PricingCardView.jsx | 109 ++++---- .../view/table/PricingTableColumns.js | 48 +++- .../table/models/ModelsColumnDefs.js | 2 +- .../models/modals/PrefillGroupManagement.jsx | 6 +- web/src/helpers/utils.js | 6 +- .../model-pricing/useModelPricingData.js | 89 +++---- .../model-pricing/usePricingFilterCounts.js | 108 ++++---- 24 files changed, 780 insertions(+), 576 deletions(-) rename web/src/components/{table/models => common}/ui/RenderUtils.jsx (100%) delete mode 100644 web/src/components/table/model-pricing/filter/PricingCategories.jsx create mode 100644 web/src/components/table/model-pricing/filter/PricingVendors.jsx delete mode 100644 web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx create mode 100644 web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx rename web/src/components/table/model-pricing/layout/header/{PricingCategoryIntroSkeleton.jsx => PricingVendorIntroSkeleton.jsx} (85%) rename web/src/components/table/model-pricing/layout/header/{PricingCategoryIntroWithSkeleton.jsx => PricingVendorIntroWithSkeleton.jsx} (65%) diff --git a/controller/pricing.go b/controller/pricing.go index f27336b7..7205cb03 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -41,6 +41,7 @@ func GetPricing(c *gin.Context) { c.JSON(200, gin.H{ "success": true, "data": pricing, + "vendors": model.GetVendors(), "group_ratio": groupRatio, "usable_group": usableGroup, }) diff --git a/model/pricing.go b/model/pricing.go index a280b524..53fd0e89 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "strings" "one-api/common" "one-api/constant" "one-api/setting/ratio_setting" @@ -12,6 +13,9 @@ import ( type Pricing struct { ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` QuotaType int `json:"quota_type"` ModelRatio float64 `json:"model_ratio"` ModelPrice float64 `json:"model_price"` @@ -21,8 +25,16 @@ type Pricing struct { SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` } +type PricingVendor struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` +} + var ( pricingMap []Pricing + vendorsList []PricingVendor lastGetPricingTime time.Time updatePricingLock sync.Mutex ) @@ -46,6 +58,15 @@ func GetPricing() []Pricing { return pricingMap } +// GetVendors 返回当前定价接口使用到的供应商信息 +func GetVendors() []PricingVendor { + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + // 保证先刷新一次 + GetPricing() + } + return vendorsList +} + func GetModelSupportEndpointTypes(model string) []constant.EndpointType { if model == "" { return make([]constant.EndpointType, 0) @@ -65,6 +86,73 @@ func updatePricing() { common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err)) return } + // 预加载模型元数据与供应商一次,避免循环查询 + var allMeta []Model + _ = DB.Find(&allMeta).Error + metaMap := make(map[string]*Model) + prefixList := make([]*Model, 0) + suffixList := make([]*Model, 0) + containsList := make([]*Model, 0) + for i := range allMeta { + m := &allMeta[i] + if m.NameRule == NameRuleExact { + metaMap[m.ModelName] = m + } else { + switch m.NameRule { + case NameRulePrefix: + prefixList = append(prefixList, m) + case NameRuleSuffix: + suffixList = append(suffixList, m) + case NameRuleContains: + containsList = append(containsList, m) + } + } + } + + // 将非精确规则模型匹配到 metaMap + for _, m := range prefixList { + for _, pricingModel := range enableAbilities { + if strings.HasPrefix(pricingModel.Model, m.ModelName) { + metaMap[pricingModel.Model] = m + } + } + } + for _, m := range suffixList { + for _, pricingModel := range enableAbilities { + if strings.HasSuffix(pricingModel.Model, m.ModelName) { + metaMap[pricingModel.Model] = m + } + } + } + for _, m := range containsList { + for _, pricingModel := range enableAbilities { + if strings.Contains(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + + // 预加载供应商 + var vendors []Vendor + _ = DB.Find(&vendors).Error + vendorMap := make(map[int]*Vendor) + for i := range vendors { + vendorMap[vendors[i].Id] = &vendors[i] + } + + // 构建对前端友好的供应商列表 + vendorsList = make([]PricingVendor, 0, len(vendors)) + for _, v := range vendors { + vendorsList = append(vendorsList, PricingVendor{ + ID: v.Id, + Name: v.Name, + Description: v.Description, + Icon: v.Icon, + }) + } + modelGroupsMap := make(map[string]*types.Set[string]) for _, ability := range enableAbilities { @@ -111,6 +199,13 @@ func updatePricing() { EnableGroup: groups.Items(), SupportedEndpointTypes: modelSupportEndpointTypes[model], } + + // 补充模型元数据(描述、标签、供应商等) + if meta, ok := metaMap[model]; ok { + pricing.Description = meta.Description + pricing.Tags = meta.Tags + pricing.VendorID = meta.VendorID + } modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) if findPrice { pricing.ModelPrice = modelPrice diff --git a/web/src/components/table/models/ui/RenderUtils.jsx b/web/src/components/common/ui/RenderUtils.jsx similarity index 100% rename from web/src/components/table/models/ui/RenderUtils.jsx rename to web/src/components/common/ui/RenderUtils.jsx diff --git a/web/src/components/table/model-pricing/filter/PricingCategories.jsx b/web/src/components/table/model-pricing/filter/PricingCategories.jsx deleted file mode 100644 index 7a979508..00000000 --- a/web/src/components/table/model-pricing/filter/PricingCategories.jsx +++ /dev/null @@ -1,45 +0,0 @@ -/* -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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; - -const PricingCategories = ({ activeKey, setActiveKey, modelCategories, categoryCounts, availableCategories, loading = false, t }) => { - const items = Object.entries(modelCategories) - .filter(([key]) => availableCategories.includes(key)) - .map(([key, category]) => ({ - value: key, - label: category.label, - icon: category.icon, - tagCount: categoryCounts[key] || 0, - })); - - return ( - - ); -}; - -export default PricingCategories; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/filter/PricingVendors.jsx b/web/src/components/table/model-pricing/filter/PricingVendors.jsx new file mode 100644 index 00000000..632ddb0c --- /dev/null +++ b/web/src/components/table/model-pricing/filter/PricingVendors.jsx @@ -0,0 +1,119 @@ +/* +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 SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup'; +import { getLobeHubIcon } from '../../../../helpers'; + +/** + * 供应商筛选组件 + * @param {string|'all'} filterVendor 当前值 + * @param {Function} setFilterVendor setter + * @param {Array} models 模型列表 + * @param {Array} allModels 所有模型列表(用于获取全部供应商) + * @param {boolean} loading 是否加载中 + * @param {Function} t i18n + */ +const PricingVendors = ({ filterVendor, setFilterVendor, models = [], allModels = [], loading = false, t }) => { + // 获取系统中所有供应商(基于 allModels,如果未提供则退化为 models) + const getAllVendors = React.useMemo(() => { + const vendors = new Set(); + const vendorIcons = new Map(); + let hasUnknownVendor = false; + + (allModels.length > 0 ? allModels : models).forEach(model => { + if (model.vendor_name) { + vendors.add(model.vendor_name); + if (model.vendor_icon && !vendorIcons.has(model.vendor_name)) { + vendorIcons.set(model.vendor_name, model.vendor_icon); + } + } else { + hasUnknownVendor = true; + } + }); + + return { + vendors: Array.from(vendors).sort(), + vendorIcons, + hasUnknownVendor + }; + }, [allModels, models]); + + // 计算每个供应商的模型数量(基于当前过滤后的 models) + const getVendorCount = React.useCallback((vendor) => { + if (vendor === 'all') { + return models.length; + } + if (vendor === 'unknown') { + return models.filter(model => !model.vendor_name).length; + } + return models.filter(model => model.vendor_name === vendor).length; + }, [models]); + + // 生成供应商选项 + const items = React.useMemo(() => { + const result = [ + { + value: 'all', + label: t('全部供应商'), + tagCount: getVendorCount('all'), + disabled: models.length === 0 + } + ]; + + // 添加所有已知供应商 + getAllVendors.vendors.forEach(vendor => { + const count = getVendorCount(vendor); + const icon = getAllVendors.vendorIcons.get(vendor); + result.push({ + value: vendor, + label: vendor, + icon: icon ? getLobeHubIcon(icon, 16) : null, + tagCount: count, + disabled: count === 0 + }); + }); + + // 如果系统中存在未知供应商,添加"未知供应商"选项 + if (getAllVendors.hasUnknownVendor) { + const count = getVendorCount('unknown'); + result.push({ + value: 'unknown', + label: t('未知供应商'), + tagCount: count, + disabled: count === 0 + }); + } + + return result; + }, [getAllVendors, getVendorCount, t]); + + return ( + + ); +}; + +export default PricingVendors; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/PricingPage.jsx b/web/src/components/table/model-pricing/layout/PricingPage.jsx index 76c31e81..74f47dc0 100644 --- a/web/src/components/table/model-pricing/layout/PricingPage.jsx +++ b/web/src/components/table/model-pricing/layout/PricingPage.jsx @@ -79,6 +79,7 @@ const PricingPage = () => { tokenUnit={pricingData.tokenUnit} displayPrice={pricingData.displayPrice} showRatio={allProps.showRatio} + vendorsMap={pricingData.vendorsMap} t={pricingData.t} /> diff --git a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx index d6b5df79..ea9ab700 100644 --- a/web/src/components/table/model-pricing/layout/PricingSidebar.jsx +++ b/web/src/components/table/model-pricing/layout/PricingSidebar.jsx @@ -19,10 +19,10 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Button } from '@douyinfe/semi-ui'; -import PricingCategories from '../filter/PricingCategories'; import PricingGroups from '../filter/PricingGroups'; import PricingQuotaTypes from '../filter/PricingQuotaTypes'; import PricingEndpointTypes from '../filter/PricingEndpointTypes'; +import PricingVendors from '../filter/PricingVendors'; import PricingDisplaySettings from '../filter/PricingDisplaySettings'; import { resetPricingFilters } from '../../../../helpers/utils'; import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts'; @@ -44,6 +44,8 @@ const PricingSidebar = ({ setFilterQuotaType, filterEndpointType, setFilterEndpointType, + filterVendor, + setFilterVendor, currentPage, setCurrentPage, tokenUnit, @@ -56,23 +58,20 @@ const PricingSidebar = ({ const { quotaTypeModels, endpointTypeModels, - dynamicCategoryCounts, + vendorModels, groupCountModels, } = usePricingFilterCounts({ models: categoryProps.models, - modelCategories: categoryProps.modelCategories, - activeKey: categoryProps.activeKey, filterGroup, filterQuotaType, filterEndpointType, + filterVendor, searchValue: categoryProps.searchValue, }); const handleResetFilters = () => resetPricingFilters({ handleChange, - setActiveKey, - availableCategories: categoryProps.availableCategories, setShowWithRecharge, setCurrency, setShowRatio, @@ -80,6 +79,7 @@ const PricingSidebar = ({ setFilterGroup, setFilterQuotaType, setFilterEndpointType, + setFilterVendor, setCurrentPage, setTokenUnit, }); @@ -115,10 +115,11 @@ const PricingSidebar = ({ t={t} /> - diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx deleted file mode 100644 index 47cac58c..00000000 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntro.jsx +++ /dev/null @@ -1,232 +0,0 @@ -/* -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, Tag, Avatar, AvatarGroup } from '@douyinfe/semi-ui'; - -const PricingCategoryIntro = ({ - activeKey, - modelCategories, - categoryCounts, - availableCategories, - t -}) => { - // 轮播动效状态(只对全部模型生效) - const [currentOffset, setCurrentOffset] = useState(0); - - // 获取除了 'all' 之外的可用分类 - const validCategories = (availableCategories || []).filter(key => key !== 'all'); - - // 设置轮播定时器(只对全部模型且有足够头像时生效) - useEffect(() => { - if (activeKey !== 'all' || validCategories.length <= 3) { - setCurrentOffset(0); // 重置偏移 - return; - } - - const interval = setInterval(() => { - setCurrentOffset(prev => (prev + 1) % validCategories.length); - }, 2000); // 每2秒切换一次 - - return () => clearInterval(interval); - }, [activeKey, validCategories.length]); - - // 如果没有有效的分类键或分类数据,不显示 - if (!activeKey || !modelCategories) { - return null; - } - - const modelCount = categoryCounts[activeKey] || 0; - - // 获取分类描述信息 - const getCategoryDescription = (categoryKey) => { - const descriptions = { - all: t('查看所有可用的AI模型,包括文本生成、图像处理、音频转换等多种类型的模型。'), - openai: t('令牌分发介绍:SSVIP 为纯OpenAI官方。SVIP 为纯Azure。Default 为Azure 消费。VIP为近似的复数。VVIP为近似的书发。'), - anthropic: t('Anthropic Claude系列模型,以安全性和可靠性著称,擅长对话、分析和创作任务。'), - gemini: t('Google Gemini系列模型,具备强大的多模态能力,支持文本、图像和代码理解。'), - moonshot: t('月之暗面Moonshot系列模型,专注于长文本处理和深度理解能力。'), - zhipu: t('智谱AI ChatGLM系列模型,在中文理解和生成方面表现优秀。'), - qwen: t('阿里云通义千问系列模型,覆盖多个领域的智能问答和内容生成。'), - deepseek: t('DeepSeek系列模型,在代码生成和数学推理方面具有出色表现。'), - minimax: t('MiniMax ABAB系列模型,专注于对话和内容创作的AI助手。'), - baidu: t('百度文心一言系列模型,在中文自然语言处理方面具有强大能力。'), - xunfei: t('科大讯飞星火系列模型,在语音识别和自然语言理解方面领先。'), - midjourney: t('Midjourney图像生成模型,专业的AI艺术创作和图像生成服务。'), - tencent: t('腾讯混元系列模型,提供全面的AI能力和企业级服务。'), - cohere: t('Cohere Command系列模型,专注于企业级自然语言处理应用。'), - cloudflare: t('Cloudflare Workers AI模型,提供边缘计算和高性能AI服务。'), - ai360: t('360智脑系列模型,在安全和智能助手方面具有独特优势。'), - yi: t('零一万物Yi系列模型,提供高质量的多语言理解和生成能力。'), - jina: t('Jina AI模型,专注于嵌入和向量搜索的AI解决方案。'), - mistral: t('Mistral AI系列模型,欧洲领先的开源大语言模型。'), - xai: t('xAI Grok系列模型,具有独特的幽默感和实时信息处理能力。'), - llama: t('Meta Llama系列模型,开源的大语言模型,在各种任务中表现优秀。'), - doubao: t('字节跳动豆包系列模型,在内容创作和智能对话方面表现出色。'), - }; - return descriptions[categoryKey] || t('该分类包含多种AI模型,适用于不同的应用场景。'); - }; - - // 为全部模型创建特殊的头像组合 - const renderAllModelsAvatar = () => { - // 重新排列数组,让当前偏移量的头像在第一位 - const rotatedCategories = validCategories.length > 3 ? [ - ...validCategories.slice(currentOffset), - ...validCategories.slice(0, currentOffset) - ] : validCategories; - - // 如果没有有效分类,使用模型分类名称的前两个字符 - if (validCategories.length === 0) { - // 获取所有分类(除了 'all')的名称前两个字符 - const fallbackCategories = Object.entries(modelCategories) - .filter(([key]) => key !== 'all') - .slice(0, 3) - .map(([key, category]) => ({ - key, - label: category.label, - text: category.label.slice(0, 2) || key.slice(0, 2).toUpperCase() - })); - - return ( -
    - - {fallbackCategories.map((item) => ( - - {item.text} - - ))} - -
    - ); - } - - return ( -
    - ( - - {`+${restNumber}`} - - )} - > - {rotatedCategories.map((categoryKey) => { - const category = modelCategories[categoryKey]; - - return ( - - {category?.icon ? - React.cloneElement(category.icon, { size: 20 }) : - (category?.label?.charAt(0) || categoryKey.charAt(0).toUpperCase()) - } - - ); - })} - -
    - ); - }; - - // 为具体分类渲染单个图标 - const renderCategoryAvatar = (category) => ( -
    - {category.icon && React.cloneElement(category.icon, { size: 40 })} -
    - ); - - // 如果是全部模型分类 - if (activeKey === 'all') { - return ( -
    - -
    - {/* 全部模型的头像组合 */} -
    - {renderAllModelsAvatar()} -
    - - {/* 分类信息 */} -
    -
    -

    {modelCategories.all.label}

    - - {t('共 {{count}} 个模型', { count: modelCount })} - -
    -

    - {getCategoryDescription(activeKey)} -

    -
    -
    -
    -
    - ); - } - - // 具体分类 - const currentCategory = modelCategories[activeKey]; - if (!currentCategory) { - return null; - } - - return ( -
    - -
    - {/* 分类图标 */} -
    - {renderCategoryAvatar(currentCategory)} -
    - - {/* 分类信息 */} -
    -
    -

    {currentCategory.label}

    - - {t('共 {{count}} 个模型', { count: modelCount })} - -
    -

    - {getCategoryDescription(activeKey)} -

    -
    -
    -
    -
    - ); -}; - -export default PricingCategoryIntro; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx index dbdee4f9..f50a2ee6 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx @@ -21,7 +21,7 @@ import React, { useMemo, useState } from 'react'; import { Input, Button } from '@douyinfe/semi-ui'; import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons'; import PricingFilterModal from '../../modal/PricingFilterModal'; -import PricingCategoryIntroWithSkeleton from './PricingCategoryIntroWithSkeleton'; +import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton'; const PricingTopSection = ({ selectedRowKeys, @@ -31,10 +31,9 @@ const PricingTopSection = ({ handleCompositionEnd, isMobile, sidebarProps, - activeKey, - modelCategories, - categoryCounts, - availableCategories, + filterVendor, + models, + filteredModels, loading, t }) => { @@ -82,13 +81,12 @@ const PricingTopSection = ({ return ( <> - {/* 分类介绍区域(含骨架屏) */} - diff --git a/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx new file mode 100644 index 00000000..89922384 --- /dev/null +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntro.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, { useState, useEffect, useMemo } from 'react'; +import { Card, Tag, Avatar, AvatarGroup, Typography } from '@douyinfe/semi-ui'; +import { getLobeHubIcon } from '../../../../../helpers'; + +const { Paragraph } = Typography; + +const PricingVendorIntro = ({ + filterVendor, + models = [], + allModels = [], + t +}) => { + // 轮播动效状态(只对全部供应商生效) + const [currentOffset, setCurrentOffset] = useState(0); + + // 获取所有供应商信息 + const vendorInfo = useMemo(() => { + const vendors = new Map(); + let unknownCount = 0; + + (allModels.length > 0 ? allModels : models).forEach(model => { + if (model.vendor_name) { + if (!vendors.has(model.vendor_name)) { + vendors.set(model.vendor_name, { + name: model.vendor_name, + icon: model.vendor_icon, + description: model.vendor_description, + count: 0 + }); + } + vendors.get(model.vendor_name).count++; + } else { + unknownCount++; + } + }); + + const vendorList = Array.from(vendors.values()).sort((a, b) => a.name.localeCompare(b.name)); + + if (unknownCount > 0) { + vendorList.push({ + name: 'unknown', + icon: null, + description: t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'), + count: unknownCount + }); + } + + return vendorList; + }, [allModels, models]); + + // 计算当前过滤器的模型数量 + const currentModelCount = models.length; + + // 设置轮播定时器(只对全部供应商且有足够头像时生效) + useEffect(() => { + if (filterVendor !== 'all' || vendorInfo.length <= 3) { + setCurrentOffset(0); // 重置偏移 + return; + } + + const interval = setInterval(() => { + setCurrentOffset(prev => (prev + 1) % vendorInfo.length); + }, 2000); // 每2秒切换一次 + + return () => clearInterval(interval); + }, [filterVendor, vendorInfo.length]); + + // 获取供应商描述信息(从后端数据中) + const getVendorDescription = (vendorKey) => { + if (vendorKey === 'all') { + return t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。'); + } + if (vendorKey === 'unknown') { + return t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'); + } + const vendor = vendorInfo.find(v => v.name === vendorKey); + return vendor?.description || t('该供应商提供多种AI模型,适用于不同的应用场景。'); + }; + + // 为全部供应商创建特殊的头像组合 + const renderAllVendorsAvatar = () => { + // 重新排列数组,让当前偏移量的头像在第一位 + const rotatedVendors = vendorInfo.length > 3 ? [ + ...vendorInfo.slice(currentOffset), + ...vendorInfo.slice(0, currentOffset) + ] : vendorInfo; + + // 如果没有供应商,显示占位符 + if (vendorInfo.length === 0) { + return ( +
    + + AI + +
    + ); + } + + return ( +
    + ( + + {`+${restNumber}`} + + )} + > + {rotatedVendors.map((vendor) => ( + + {vendor.icon ? + getLobeHubIcon(vendor.icon, 20) : + (vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()) + } + + ))} + +
    + ); + }; + + // 为具体供应商渲染单个图标 + const renderVendorAvatar = (vendor) => ( +
    + {vendor.icon ? + getLobeHubIcon(vendor.icon, 40) : + + {vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()} + + } +
    + ); + + // 如果是全部供应商 + if (filterVendor === 'all') { + return ( +
    + +
    + {/* 全部供应商的头像组合 */} +
    + {renderAllVendorsAvatar()} +
    + + {/* 供应商信息 */} +
    +
    +

    {t('全部供应商')}

    + + {t('共 {{count}} 个模型', { count: currentModelCount })} + +
    + + {getVendorDescription('all')} + +
    +
    +
    +
    + ); + } + + // 具体供应商 + const currentVendor = vendorInfo.find(v => v.name === filterVendor); + if (!currentVendor) { + return null; + } + + const vendorDisplayName = currentVendor.name === 'unknown' ? t('未知供应商') : currentVendor.name; + + return ( +
    + +
    + {/* 供应商图标 */} +
    + {renderVendorAvatar(currentVendor)} +
    + + {/* 供应商信息 */} +
    +
    +

    {vendorDisplayName}

    + + {t('共 {{count}} 个模型', { count: currentModelCount })} + +
    + + {currentVendor.description || getVendorDescription(currentVendor.name)} + +
    +
    +
    +
    + ); +}; + +export default PricingVendorIntro; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx similarity index 85% rename from web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx rename to web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx index 8ae719df..1a0a759a 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroSkeleton.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx @@ -20,26 +20,26 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Card, Skeleton } from '@douyinfe/semi-ui'; -const PricingCategoryIntroSkeleton = ({ - isAllModels = false +const PricingVendorIntroSkeleton = ({ + isAllVendors = false }) => { const placeholder = (
    - {/* 分类图标骨架 */} + {/* 供应商图标骨架 */}
    - {isAllModels ? ( + {isAllVendors ? (
    - {Array.from({ length: 5 }).map((_, index) => ( + {Array.from({ length: 4 }).map((_, index) => ( ))} @@ -49,7 +49,7 @@ const PricingCategoryIntroSkeleton = ({ )}
    - {/* 分类信息骨架 */} + {/* 供应商信息骨架 */}
    @@ -72,4 +72,4 @@ const PricingCategoryIntroSkeleton = ({ ); }; -export default PricingCategoryIntroSkeleton; \ No newline at end of file +export default PricingVendorIntroSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx similarity index 65% rename from web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx rename to web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx index fbb7113a..dc7cba93 100644 --- a/web/src/components/table/model-pricing/layout/header/PricingCategoryIntroWithSkeleton.jsx +++ b/web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx @@ -18,37 +18,35 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import PricingCategoryIntro from './PricingCategoryIntro'; -import PricingCategoryIntroSkeleton from './PricingCategoryIntroSkeleton'; +import PricingVendorIntro from './PricingVendorIntro'; +import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton'; import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; -const PricingCategoryIntroWithSkeleton = ({ +const PricingVendorIntroWithSkeleton = ({ loading = false, - activeKey, - modelCategories, - categoryCounts, - availableCategories, + filterVendor, + models, + allModels, t }) => { const showSkeleton = useMinimumLoadingTime(loading); if (showSkeleton) { return ( - ); } return ( - ); }; -export default PricingCategoryIntroWithSkeleton; \ No newline at end of file +export default PricingVendorIntroWithSkeleton; \ No newline at end of file diff --git a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx index 6723e2f7..372401c0 100644 --- a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx +++ b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx @@ -46,6 +46,7 @@ const ModelDetailSideSheet = ({ displayPrice, showRatio, usableGroup, + vendorsMap, t, }) => { const isMobile = useIsMobile(); @@ -53,7 +54,7 @@ const ModelDetailSideSheet = ({ return ( } + title={} bodyStyle={{ padding: '0', display: 'flex', @@ -80,7 +81,7 @@ const ModelDetailSideSheet = ({ )} {modelData && ( <> - + resetPricingFilters({ handleChange: sidebarProps.handleChange, - setActiveKey: sidebarProps.setActiveKey, - availableCategories: sidebarProps.availableCategories, setShowWithRecharge: sidebarProps.setShowWithRecharge, setCurrency: sidebarProps.setCurrency, setShowRatio: sidebarProps.setShowRatio, @@ -41,6 +39,7 @@ const PricingFilterModal = ({ setFilterGroup: sidebarProps.setFilterGroup, setFilterQuotaType: sidebarProps.setFilterQuotaType, setFilterEndpointType: sidebarProps.setFilterEndpointType, + setFilterVendor: sidebarProps.setFilterVendor, setCurrentPage: sidebarProps.setCurrentPage, setTokenUnit: sidebarProps.setTokenUnit, }); diff --git a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx index e9f3178e..94ab3c04 100644 --- a/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx +++ b/web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx @@ -19,10 +19,10 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import PricingDisplaySettings from '../../filter/PricingDisplaySettings'; -import PricingCategories from '../../filter/PricingCategories'; import PricingGroups from '../../filter/PricingGroups'; import PricingQuotaTypes from '../../filter/PricingQuotaTypes'; import PricingEndpointTypes from '../../filter/PricingEndpointTypes'; +import PricingVendors from '../../filter/PricingVendors'; import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts'; const FilterModalContent = ({ sidebarProps, t }) => { @@ -43,6 +43,8 @@ const FilterModalContent = ({ sidebarProps, t }) => { setFilterQuotaType, filterEndpointType, setFilterEndpointType, + filterVendor, + setFilterVendor, tokenUnit, setTokenUnit, loading, @@ -52,15 +54,14 @@ const FilterModalContent = ({ sidebarProps, t }) => { const { quotaTypeModels, endpointTypeModels, - dynamicCategoryCounts, + vendorModels, groupCountModels, } = usePricingFilterCounts({ models: categoryProps.models, - modelCategories: categoryProps.modelCategories, - activeKey: categoryProps.activeKey, filterGroup, filterQuotaType, filterEndpointType, + filterVendor, searchValue: sidebarProps.searchValue, }); @@ -81,10 +82,11 @@ const FilterModalContent = ({ sidebarProps, t }) => { t={t} /> - diff --git a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx index 662b5616..d33d2766 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx @@ -18,20 +18,43 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Card, Avatar, Typography } from '@douyinfe/semi-ui'; +import { Card, Avatar, Typography, Tag, Space } from '@douyinfe/semi-ui'; import { IconInfoCircle } from '@douyinfe/semi-icons'; +import { stringToColor } from '../../../../../helpers'; const { Text } = Typography; -const ModelBasicInfo = ({ modelData, t }) => { - // 获取模型描述 +const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => { + // 获取模型描述(使用后端真实数据) const getModelDescription = () => { if (!modelData) return t('暂无模型描述'); - // 这里可以根据模型名称返回不同的描述 - if (modelData.model_name?.includes('gpt-4o-image')) { - return t('逆向plus账号的可绘图的gpt-4o模型,由于OpenAI限制绘图数量,并非每次都能绘图成功,由于是逆向模型,只要请求成功,即使绘图失败也会扣费,请谨慎使用。推荐使用正式版的 gpt-image-1模型。'); + + // 优先使用后端提供的描述 + if (modelData.description) { + return modelData.description; } - return modelData.description || t('暂无模型描述'); + + // 如果没有描述但有供应商描述,显示供应商信息 + if (modelData.vendor_description) { + return t('供应商信息:') + modelData.vendor_description; + } + + return t('暂无模型描述'); + }; + + // 获取模型标签 + const getModelTags = () => { + const tags = []; + + if (modelData?.tags) { + const customTags = modelData.tags.split(',').filter(tag => tag.trim()); + customTags.forEach(tag => { + const tagText = tag.trim(); + tags.push({ text: tagText, color: stringToColor(tagText) }); + }); + } + + return tags; }; return ( @@ -46,7 +69,24 @@ const ModelBasicInfo = ({ modelData, t }) => {
    -

    {getModelDescription()}

    +

    {getModelDescription()}

    + {getModelTags().length > 0 && ( +
    + {t('模型标签')} + + {getModelTags().map((tag, index) => ( + + {tag.text} + + ))} + +
    + )}
    ); diff --git a/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx b/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx index 23ae179c..63475819 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelHeader.jsx @@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Tag, Typography, Toast, Avatar } from '@douyinfe/semi-ui'; -import { getModelCategories } from '../../../../../helpers'; +import { Typography, Toast, Avatar } from '@douyinfe/semi-ui'; +import { getLobeHubIcon } from '../../../../../helpers'; const { Paragraph } = Typography; @@ -28,52 +28,22 @@ const CARD_STYLES = { icon: "w-8 h-8 flex items-center justify-center", }; -const ModelHeader = ({ modelData, t }) => { - // 获取模型图标 - const getModelIcon = (modelName) => { - // 如果没有模型名称,直接返回默认头像 - if (!modelName) { - return ( -
    - - AI - -
    - ); - } - - const categories = getModelCategories(t); - let icon = null; - - // 遍历分类,找到匹配的模型图标 - for (const [key, category] of Object.entries(categories)) { - if (key !== 'all' && category.filter({ model_name: modelName })) { - icon = category.icon; - break; - } - } - - // 如果找到了匹配的图标,返回包装后的图标 - if (icon) { +const ModelHeader = ({ modelData, vendorsMap = {}, t }) => { + // 获取模型图标(使用供应商图标) + const getModelIcon = () => { + // 优先使用供应商图标 + if (modelData?.vendor_icon) { return (
    - {React.cloneElement(icon, { size: 32 })} + {getLobeHubIcon(modelData.vendor_icon, 32)}
    ); } - const avatarText = modelName?.slice(0, 2).toUpperCase() || 'AI'; + // 如果没有供应商图标,使用模型名称的前两个字符 + const avatarText = modelData?.model_name?.slice(0, 2).toUpperCase() || 'AI'; return (
    { ); }; - // 获取模型标签 - const getModelTags = () => { - const tags = [ - { text: t('文本对话'), color: 'green' }, - { text: t('图片生成'), color: 'blue' }, - { text: t('图像分析'), color: 'cyan' } - ]; - - return tags; - }; - return (
    - {getModelIcon(modelData?.model_name)} + {getModelIcon()}
    Toast.success({ content: t('已复制模型名称') }) @@ -116,18 +75,6 @@ const ModelHeader = ({ modelData, t }) => { > {modelData?.model_name || t('未知模型')} -
    - {getModelTags().map((tag, index) => ( - - {tag.text} - - ))} -
    ); diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 9d0fbf48..35b84e2e 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -21,9 +21,10 @@ import React from 'react'; import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui'; import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; -import { stringToColor, getModelCategories, calculateModelPrice, formatPriceInfo } from '../../../../../helpers'; +import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } from '../../../../../helpers'; import PricingCardSkeleton from './PricingCardSkeleton'; import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime'; +import { renderLimitedItems } from '../../../../common/ui/RenderUtils'; const CARD_STYLES = { container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md", @@ -52,16 +53,11 @@ const PricingCardView = ({ t, selectedRowKeys = [], setSelectedRowKeys, - activeKey, - availableCategories, openModelDetail, }) => { const showSkeleton = useMinimumLoadingTime(loading); - const startIndex = (currentPage - 1) * pageSize; - const endIndex = startIndex + pageSize; - const paginatedModels = filteredModels.slice(startIndex, endIndex); - + const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize); const getModelKey = (model) => model.key ?? model.model_name ?? model.id; const handleCheckboxChange = (model, checked) => { @@ -75,30 +71,28 @@ const PricingCardView = ({ }; // 获取模型图标 - const getModelIcon = (modelName) => { - const categories = getModelCategories(t); - let icon = null; - - // 遍历分类,找到匹配的模型图标 - for (const [key, category] of Object.entries(categories)) { - if (key !== 'all' && category.filter({ model_name: modelName })) { - icon = category.icon; - break; - } + const getModelIcon = (model) => { + if (!model || !model.model_name) { + return ( +
    + ? +
    + ); } - - // 如果找到了匹配的图标,返回包装后的图标 - if (icon) { + // 优先使用供应商图标 + if (model.vendor_icon) { return (
    - {React.cloneElement(icon, { size: 32 })} + {getLobeHubIcon(model.vendor_icon, 32)}
    ); } - const avatarText = modelName.slice(0, 2).toUpperCase(); + // 如果没有供应商图标,使用模型名称生成头像 + + const avatarText = model.model_name.slice(0, 2).toUpperCase(); return (
    { - return t('高性能AI模型,适用于各种文本生成和理解任务。'); + const getModelDescription = (record) => { + return record.description || ''; }; // 渲染价格信息 @@ -137,47 +131,41 @@ const PricingCardView = ({ // 渲染标签 const renderTags = (record) => { - const tags = []; + const allTags = []; // 计费类型标签 const billingType = record.quota_type === 1 ? 'teal' : 'violet'; const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); - tags.push( - - {billingText} - - ); - - // 热门模型标签 - if (record.model_name.includes('gpt')) { - tags.push( - - {t('热')} + allTags.push({ + key: "billing", + element: ( + + {billingText} - ); - } + ) + }); - // 端点类型标签 - if (record.supported_endpoint_types?.length > 0) { - record.supported_endpoint_types.slice(0, 2).forEach((endpoint, index) => { - tags.push( - - {endpoint} - - ); + // 自定义标签 + if (record.tags) { + const tagArr = record.tags.split(',').filter(Boolean); + tagArr.forEach((tg, idx) => { + allTags.push({ + key: `custom-${idx}`, + element: ( + + {tg} + + ) + }); }); } - // 上下文长度标签 - const contextMatch = record.model_name.match(/(\d+)k/i); - const contextSize = contextMatch ? contextMatch[1] + 'K' : '4K'; - tags.push( - - {contextSize} - - ); - - return tags; + // 使用 renderLimitedItems 渲染标签 + return renderLimitedItems({ + items: allTags, + renderItem: (item, idx) => React.cloneElement(item.element, { key: item.key }), + maxDisplay: 3 + }); }; // 显示骨架屏 @@ -212,15 +200,14 @@ const PricingCardView = ({ return ( openModelDetail && openModelDetail(model)} > {/* 头部:图标 + 模型名称 + 操作按钮 */}
    - {getModelIcon(model.model_name)} + {getModelIcon(model)}

    {model.model_name} @@ -262,12 +249,12 @@ const PricingCardView = ({ className="text-xs line-clamp-2 leading-relaxed" style={{ color: 'var(--semi-color-text-2)' }} > - {getModelDescription(model.model_name)} + {getModelDescription(model)}

    {/* 标签区域 */} -
    +
    {renderTags(model)}
    diff --git a/web/src/components/table/model-pricing/view/table/PricingTableColumns.js b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js index 7ff77a57..e38cde13 100644 --- a/web/src/components/table/model-pricing/view/table/PricingTableColumns.js +++ b/web/src/components/table/model-pricing/view/table/PricingTableColumns.js @@ -20,7 +20,8 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Tag, Space, Tooltip } from '@douyinfe/semi-ui'; import { IconHelpCircle } from '@douyinfe/semi-icons'; -import { renderModelTag, stringToColor, calculateModelPrice } from '../../../../../helpers'; +import { renderModelTag, stringToColor, calculateModelPrice, getLobeHubIcon } from '../../../../../helpers'; +import { renderLimitedItems, renderDescription } from '../../../../common/ui/RenderUtils'; function renderQuotaType(type, t) { switch (type) { @@ -41,6 +42,31 @@ function renderQuotaType(type, t) { } } +// Render vendor name +const renderVendor = (vendorName, vendorIcon, t) => { + if (!vendorName) return '-'; + return ( + + {vendorName} + + ); +}; + +// Render tags list using RenderUtils +const renderTags = (text) => { + if (!text) return '-'; + const tagsArr = text.split(',').filter(tag => tag.trim()); + return renderLimitedItems({ + items: tagsArr, + renderItem: (tag, idx) => ( + + {tag.trim()} + + ), + maxDisplay: 3 + }); +}; + function renderSupportedEndpoints(endpoints) { if (!endpoints || endpoints.length === 0) { return null; @@ -104,7 +130,25 @@ export const getPricingTableColumns = ({ sorter: (a, b) => a.quota_type - b.quota_type, }; - const baseColumns = [modelNameColumn, quotaColumn]; + const descriptionColumn = { + title: t('描述'), + dataIndex: 'description', + render: (text) => renderDescription(text, 200), + }; + + const tagsColumn = { + title: t('标签'), + dataIndex: 'tags', + render: renderTags, + }; + + const vendorColumn = { + title: t('供应商'), + dataIndex: 'vendor_name', + render: (text, record) => renderVendor(text, record.vendor_icon, t), + }; + + const baseColumns = [modelNameColumn, vendorColumn, descriptionColumn, tagsColumn, quotaColumn]; const ratioColumn = { title: () => ( diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index c02201c4..48841e60 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -30,7 +30,7 @@ import { getLobeHubIcon, stringToColor } from '../../../helpers'; -import { renderLimitedItems, renderDescription } from './ui/RenderUtils.jsx'; +import { renderLimitedItems, renderDescription } from '../../common/ui/RenderUtils'; const { Text } = Typography; diff --git a/web/src/components/table/models/modals/PrefillGroupManagement.jsx b/web/src/components/table/models/modals/PrefillGroupManagement.jsx index 569fcdcd..1ce51b9e 100644 --- a/web/src/components/table/models/modals/PrefillGroupManagement.jsx +++ b/web/src/components/table/models/modals/PrefillGroupManagement.jsx @@ -41,9 +41,9 @@ import { import { API, showError, showSuccess, stringToColor } from '../../../../helpers'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; -import CardTable from '../../../common/ui/CardTable.js'; -import EditPrefillGroupModal from './EditPrefillGroupModal.jsx'; -import { renderLimitedItems, renderDescription } from '../ui/RenderUtils.jsx'; +import CardTable from '../../../common/ui/CardTable'; +import EditPrefillGroupModal from './EditPrefillGroupModal'; +import { renderLimitedItems, renderDescription } from '../../../common/ui/RenderUtils'; const { Text, Title } = Typography; diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 27dd7ab9..c226bdd9 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -698,14 +698,13 @@ const DEFAULT_PRICING_FILTERS = { filterGroup: 'all', filterQuotaType: 'all', filterEndpointType: 'all', + filterVendor: 'all', currentPage: 1, }; // 重置模型定价筛选条件 export const resetPricingFilters = ({ handleChange, - setActiveKey, - availableCategories, setShowWithRecharge, setCurrency, setShowRatio, @@ -713,11 +712,11 @@ export const resetPricingFilters = ({ setFilterGroup, setFilterQuotaType, setFilterEndpointType, + setFilterVendor, setCurrentPage, setTokenUnit, }) => { handleChange?.(DEFAULT_PRICING_FILTERS.search); - availableCategories?.length > 0 && setActiveKey?.(availableCategories[0]); setShowWithRecharge?.(DEFAULT_PRICING_FILTERS.showWithRecharge); setCurrency?.(DEFAULT_PRICING_FILTERS.currency); setShowRatio?.(DEFAULT_PRICING_FILTERS.showRatio); @@ -726,5 +725,6 @@ export const resetPricingFilters = ({ setFilterGroup?.(DEFAULT_PRICING_FILTERS.filterGroup); setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType); setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType); + setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor); setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage); }; diff --git a/web/src/hooks/model-pricing/useModelPricingData.js b/web/src/hooks/model-pricing/useModelPricingData.js index 98a8e566..1a8fb719 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.js +++ b/web/src/hooks/model-pricing/useModelPricingData.js @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useState, useEffect, useContext, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { API, copy, showError, showInfo, showSuccess, getModelCategories } from '../../helpers'; +import { API, copy, showError, showInfo, showSuccess } from '../../helpers'; import { Modal } from '@douyinfe/semi-ui'; import { UserContext } from '../../context/User/index.js'; import { StatusContext } from '../../context/Status/index.js'; @@ -34,16 +34,17 @@ export const useModelPricingData = () => { const [selectedGroup, setSelectedGroup] = useState('default'); const [showModelDetail, setShowModelDetail] = useState(false); const [selectedModel, setSelectedModel] = useState(null); - const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,“all” 表示不过滤 + const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤 const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1 - const [activeKey, setActiveKey] = useState('all'); const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string + const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string const [pageSize, setPageSize] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [currency, setCurrency] = useState('USD'); const [showWithRecharge, setShowWithRecharge] = useState(false); const [tokenUnit, setTokenUnit] = useState('M'); const [models, setModels] = useState([]); + const [vendorsMap, setVendorsMap] = useState({}); const [loading, setLoading] = useState(true); const [groupRatio, setGroupRatio] = useState({}); const [usableGroup, setUsableGroup] = useState({}); @@ -55,37 +56,9 @@ export const useModelPricingData = () => { const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]); const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]); - const modelCategories = getModelCategories(t); - - const categoryCounts = useMemo(() => { - const counts = {}; - if (models.length > 0) { - counts['all'] = models.length; - Object.entries(modelCategories).forEach(([key, category]) => { - if (key !== 'all') { - counts[key] = models.filter(model => category.filter(model)).length; - } - }); - } - return counts; - }, [models, modelCategories]); - - const availableCategories = useMemo(() => { - if (!models.length) return ['all']; - return Object.entries(modelCategories).filter(([key, category]) => { - if (key === 'all') return true; - return models.some(model => category.filter(model)); - }).map(([key]) => key); - }, [models]); - const filteredModels = useMemo(() => { let result = models; - // 分类筛选 - if (activeKey !== 'all') { - result = result.filter(model => modelCategories[activeKey].filter(model)); - } - // 分组筛选 if (filterGroup !== 'all') { result = result.filter(model => model.enable_groups.includes(filterGroup)); @@ -104,16 +77,28 @@ export const useModelPricingData = () => { ); } + // 供应商筛选 + if (filterVendor !== 'all') { + if (filterVendor === 'unknown') { + result = result.filter(model => !model.vendor_name); + } else { + result = result.filter(model => model.vendor_name === filterVendor); + } + } + // 搜索筛选 if (searchValue.length > 0) { const searchTerm = searchValue.toLowerCase(); result = result.filter(model => - model.model_name.toLowerCase().includes(searchTerm) + (model.model_name && model.model_name.toLowerCase().includes(searchTerm)) || + (model.description && model.description.toLowerCase().includes(searchTerm)) || + (model.tags && model.tags.toLowerCase().includes(searchTerm)) || + (model.vendor_name && model.vendor_name.toLowerCase().includes(searchTerm)) ); } return result; - }, [activeKey, models, searchValue, filterGroup, filterQuotaType, filterEndpointType]); + }, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]); const rowSelection = useMemo( () => ({ @@ -137,10 +122,18 @@ export const useModelPricingData = () => { return `$${priceInUSD.toFixed(3)}`; }; - const setModelsFormat = (models, groupRatio) => { + const setModelsFormat = (models, groupRatio, vendorMap) => { for (let i = 0; i < models.length; i++) { - models[i].key = models[i].model_name; - models[i].group_ratio = groupRatio[models[i].model_name]; + const m = models[i]; + m.key = m.model_name; + m.group_ratio = groupRatio[m.model_name]; + + if (m.vendor_id && vendorMap[m.vendor_id]) { + const vendor = vendorMap[m.vendor_id]; + m.vendor_name = vendor.name; + m.vendor_icon = vendor.icon; + m.vendor_description = vendor.description; + } } models.sort((a, b) => { return a.quota_type - b.quota_type; @@ -166,12 +159,20 @@ export const useModelPricingData = () => { setLoading(true); let url = '/api/pricing'; const res = await API.get(url); - const { success, message, data, group_ratio, usable_group } = res.data; + const { success, message, data, vendors, group_ratio, usable_group } = res.data; if (success) { setGroupRatio(group_ratio); setUsableGroup(usable_group); setSelectedGroup(userState.user ? userState.user.group : 'default'); - setModelsFormat(data, group_ratio); + // 构建供应商 Map 方便查找 + const vendorMap = {}; + if (Array.isArray(vendors)) { + vendors.forEach(v => { + vendorMap[v.id] = v; + }); + } + setVendorsMap(vendorMap); + setModelsFormat(data, group_ratio, vendorMap); } else { showError(message); } @@ -238,7 +239,7 @@ export const useModelPricingData = () => { // 当筛选条件变化时重置到第一页 useEffect(() => { setCurrentPage(1); - }, [activeKey, filterGroup, filterQuotaType, filterEndpointType, searchValue]); + }, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]); return { // 状态 @@ -262,8 +263,8 @@ export const useModelPricingData = () => { setFilterQuotaType, filterEndpointType, setFilterEndpointType, - activeKey, - setActiveKey, + filterVendor, + setFilterVendor, pageSize, setPageSize, currentPage, @@ -282,12 +283,12 @@ export const useModelPricingData = () => { // 计算属性 priceRate, usdExchangeRate, - modelCategories, - categoryCounts, - availableCategories, filteredModels, rowSelection, + // 供应商 + vendorsMap, + // 用户和状态 userState, statusState, diff --git a/web/src/hooks/model-pricing/usePricingFilterCounts.js b/web/src/hooks/model-pricing/usePricingFilterCounts.js index e23111f3..cd993bd5 100644 --- a/web/src/hooks/model-pricing/usePricingFilterCounts.js +++ b/web/src/hooks/model-pricing/usePricingFilterCounts.js @@ -24,61 +24,18 @@ import { useMemo } from 'react'; export const usePricingFilterCounts = ({ models = [], - modelCategories = {}, - activeKey = 'all', filterGroup = 'all', filterQuotaType = 'all', filterEndpointType = 'all', + filterVendor = 'all', searchValue = '', }) => { - // 根据分类过滤后的模型 - const modelsAfterCategory = useMemo(() => { - if (activeKey === 'all') return models; - const category = modelCategories[activeKey]; - if (category && typeof category.filter === 'function') { - return models.filter(category.filter); - } - return models; - }, [models, activeKey, modelCategories]); - - // 根据除分类外其它过滤条件后的模型 (用于动态分类计数) - const modelsAfterOtherFilters = useMemo(() => { - let result = models; - if (filterGroup !== 'all') { - result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); - } - if (filterQuotaType !== 'all') { - result = result.filter(m => m.quota_type === filterQuotaType); - } - if (filterEndpointType !== 'all') { - result = result.filter(m => - m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) - ); - } - if (searchValue && searchValue.length > 0) { - const term = searchValue.toLowerCase(); - result = result.filter(m => m.model_name.toLowerCase().includes(term)); - } - return result; - }, [models, filterGroup, filterQuotaType, filterEndpointType, searchValue]); - - // 动态分类计数 - const dynamicCategoryCounts = useMemo(() => { - const counts = { all: modelsAfterOtherFilters.length }; - Object.entries(modelCategories).forEach(([key, category]) => { - if (key === 'all') return; - if (typeof category.filter === 'function') { - counts[key] = modelsAfterOtherFilters.filter(category.filter).length; - } else { - counts[key] = 0; - } - }); - return counts; - }, [modelsAfterOtherFilters, modelCategories]); + // 所有模型(不再需要分类过滤) + const allModels = models; // 针对计费类型按钮计数 const quotaTypeModels = useMemo(() => { - let result = modelsAfterCategory; + let result = allModels; if (filterGroup !== 'all') { result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); } @@ -87,24 +44,38 @@ export const usePricingFilterCounts = ({ m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) ); } + if (filterVendor !== 'all') { + if (filterVendor === 'unknown') { + result = result.filter(m => !m.vendor_name); + } else { + result = result.filter(m => m.vendor_name === filterVendor); + } + } return result; - }, [modelsAfterCategory, filterGroup, filterEndpointType]); + }, [allModels, filterGroup, filterEndpointType, filterVendor]); // 针对端点类型按钮计数 const endpointTypeModels = useMemo(() => { - let result = modelsAfterCategory; + let result = allModels; if (filterGroup !== 'all') { result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); } if (filterQuotaType !== 'all') { result = result.filter(m => m.quota_type === filterQuotaType); } + if (filterVendor !== 'all') { + if (filterVendor === 'unknown') { + result = result.filter(m => !m.vendor_name); + } else { + result = result.filter(m => m.vendor_name === filterVendor); + } + } return result; - }, [modelsAfterCategory, filterGroup, filterQuotaType]); + }, [allModels, filterGroup, filterQuotaType, filterVendor]); // === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) === const groupCountModels = useMemo(() => { - let result = modelsAfterCategory; // 已包含分类筛选 + let result = allModels; // 不应用 filterGroup 本身 if (filterQuotaType !== 'all') { @@ -115,17 +86,46 @@ export const usePricingFilterCounts = ({ m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) ); } + if (filterVendor !== 'all') { + if (filterVendor === 'unknown') { + result = result.filter(m => !m.vendor_name); + } else { + result = result.filter(m => m.vendor_name === filterVendor); + } + } if (searchValue && searchValue.length > 0) { const term = searchValue.toLowerCase(); - result = result.filter(m => m.model_name.toLowerCase().includes(term)); + result = result.filter(m => + m.model_name.toLowerCase().includes(term) || + (m.description && m.description.toLowerCase().includes(term)) || + (m.tags && m.tags.toLowerCase().includes(term)) || + (m.vendor_name && m.vendor_name.toLowerCase().includes(term)) + ); } return result; - }, [modelsAfterCategory, filterQuotaType, filterEndpointType, searchValue]); + }, [allModels, filterQuotaType, filterEndpointType, filterVendor, searchValue]); + + // 针对供应商按钮计数 + const vendorModels = useMemo(() => { + let result = allModels; + if (filterGroup !== 'all') { + result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup)); + } + if (filterQuotaType !== 'all') { + result = result.filter(m => m.quota_type === filterQuotaType); + } + if (filterEndpointType !== 'all') { + result = result.filter(m => + m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType) + ); + } + return result; + }, [allModels, filterGroup, filterQuotaType, filterEndpointType]); return { quotaTypeModels, endpointTypeModels, - dynamicCategoryCounts, + vendorModels, groupCountModels, }; }; \ No newline at end of file From 5b55a53b07a5cc960e822306156a5fc839317447 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 4 Aug 2025 21:58:10 +0800 Subject: [PATCH 175/582] =?UTF-8?q?=F0=9F=8D=8E=20chore:=20modify=20the=20?= =?UTF-8?q?`JSONEditor`=20component=20import=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/common/{ => ui}/JSONEditor.js | 0 web/src/components/table/channels/modals/EditChannelModal.jsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename web/src/components/common/{ => ui}/JSONEditor.js (100%) diff --git a/web/src/components/common/JSONEditor.js b/web/src/components/common/ui/JSONEditor.js similarity index 100% rename from web/src/components/common/JSONEditor.js rename to web/src/components/common/ui/JSONEditor.js diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 8c8bdb70..c13aca13 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -48,7 +48,7 @@ import { } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; -import JSONEditor from '../../../common/JSONEditor'; +import JSONEditor from '../../../common/ui/JSONEditor'; import { IconSave, IconClose, From a436f81e1cb2494851dbb6775f2c4f1d4e762321 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 4 Aug 2025 22:03:12 +0800 Subject: [PATCH 176/582] =?UTF-8?q?=F0=9F=92=84=20fix(pricing-card):=20ali?= =?UTF-8?q?gn=20skeleton=20responsive=20grid=20with=20actual=20card=20layo?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update PricingCardSkeleton grid classes from 'sm:grid-cols-2 lg:grid-cols-3' to 'xl:grid-cols-2 2xl:grid-cols-3' to match PricingCardView layout - Ensures consistent column count between skeleton and actual content at same screen sizes - Improves loading state visual consistency across different breakpoints --- .../table/model-pricing/view/card/PricingCardSkeleton.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx b/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx index 13eb5ecc..43535fee 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx @@ -27,7 +27,7 @@ const PricingCardSkeleton = ({ }) => { const placeholder = (
    -
    +
    {Array.from({ length: skeletonCount }).map((_, index) => ( Date: Mon, 4 Aug 2025 22:11:13 +0800 Subject: [PATCH 177/582] =?UTF-8?q?=F0=9F=90=9B=20fix(models):=20eliminate?= =?UTF-8?q?=20vendor=20column=20flicker=20by=20loading=20vendors=20before?= =?UTF-8?q?=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: • The vendor list API is separate from the models API, causing the “Vendor” column in `ModelsTable` to flash (rendering `'-'` first, then updating) after the table finishes loading. • This visual jump degrades the user experience. What: • Updated `web/src/hooks/models/useModelsData.js` – In the initial `useEffect`, vendors are fetched first with `loadVendors()` and awaited. – Only after vendors are ready do we call `loadModels()`, ensuring `vendorMap` is populated before the table renders. Outcome: • The table now renders with complete vendor data on first paint, removing the flicker and providing a smoother UI. --- web/src/hooks/models/useModelsData.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js index da222429..8c17f78d 100644 --- a/web/src/hooks/models/useModelsData.js +++ b/web/src/hooks/models/useModelsData.js @@ -333,8 +333,11 @@ export const useModelsData = () => { // Initial load useEffect(() => { - loadVendors(); - loadModels(); + (async () => { + await loadVendors(); + await loadModels(); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { From 4a5bd6938b02c5c0d876442fe1ef2795aca23313 Mon Sep 17 00:00:00 2001 From: antecanis8 <42382878+antecanis8@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:19:19 +0000 Subject: [PATCH 178/582] feat: add support for configuring output dimensionality for multiple Gemini new models --- relay/channel/gemini/adaptor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index efa64057..0f561023 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -173,8 +173,8 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela // set specific parameters for different models // https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent switch info.UpstreamModelName { - case "text-embedding-004": - // except embedding-001 supports setting `OutputDimensionality` + case "text-embedding-004","gemini-embedding-exp-03-07","gemini-embedding-001": + // Only newer models introduced after 2024 support OutputDimensionality if request.Dimensions > 0 { geminiRequest["outputDimensionality"] = request.Dimensions } From f3a961f071fd80ad155f9235d05b71b579bc25b8 Mon Sep 17 00:00:00 2001 From: CaIon Date: Tue, 5 Aug 2025 20:40:00 +0800 Subject: [PATCH 179/582] fix: reorder request URL handling for relay formats in Adaptor --- relay/channel/openai/adaptor.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index df858ea2..f46af710 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -73,9 +73,6 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if info.RelayFormat == relaycommon.RelayFormatClaude || info.RelayFormat == relaycommon.RelayFormatGemini { - return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil - } if info.RelayMode == relayconstant.RelayModeRealtime { if strings.HasPrefix(info.BaseUrl, "https://") { baseUrl := strings.TrimPrefix(info.BaseUrl, "https://") @@ -122,6 +119,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { url = strings.Replace(url, "{model}", info.UpstreamModelName, -1) return url, nil default: + if info.RelayFormat == relaycommon.RelayFormatClaude || info.RelayFormat == relaycommon.RelayFormatGemini { + return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil + } return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil } } From d7428227f6a84145b63fc9d4bed4985cbe34de6b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 5 Aug 2025 22:26:19 +0800 Subject: [PATCH 180/582] =?UTF-8?q?=E2=9C=A8=20feat:=20enhance=20soft-dele?= =?UTF-8?q?te=20handling=20&=20boost=20pricing=20cache=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ------- This commit unifies soft-delete behaviour across meta tables and introduces an in-memory cache for model pricing look-ups to improve throughput under high concurrency. Details ------- Soft-delete consistency • PrefillGroup / Vendor / Model – Added `gorm.DeletedAt` field with `json:"-" gorm:"index"`. – Replaced plain `uniqueIndex` with partial unique indexes `uniqueIndex:,where:deleted_at IS NULL` allowing duplicate keys after logical deletion while preserving uniqueness for active rows. • Imports updated to include `gorm.io/gorm`. • JSON output now hides `deleted_at`, matching existing tables. High-throughput pricing cache • model/pricing.go – Added thread-safe maps `modelEnableGroups` & `modelQuotaTypeMap` plus RW-mutex for O(1) access. – `updatePricing()` now refreshes these maps alongside `pricingMap`. • model/model_extra.go – Rewrote `GetModelEnableGroups` & `GetModelQuotaType` to read from the new maps, falling back to automatic refresh via `GetPricing()`. Misc • Retained `RefreshPricing()` helper for immediate cache invalidation after admin actions. • All modified files pass linter; no breaking DB migrations required (handled by AutoMigrate). Result ------ – Soft-delete logic is transparent, safe, and allows record “revival”. – Pricing-related queries are now constant-time, reducing CPU usage and latency under load. --- model/model_extra.go | 36 ++++++++++++++--------- model/model_meta.go | 2 +- model/prefill_group.go | 4 ++- model/pricing.go | 66 ++++++++++++++++++++++++++---------------- model/vendor_meta.go | 2 +- 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/model/model_extra.go b/model/model_extra.go index 3724346e..6ade6ff0 100644 --- a/model/model_extra.go +++ b/model/model_extra.go @@ -1,24 +1,34 @@ package model // GetModelEnableGroups 返回指定模型名称可用的用户分组列表。 -// 复用缓存的定价映射,避免额外的数据库查询。 +// 使用在 updatePricing() 中维护的缓存映射,O(1) 读取,适合高并发场景。 func GetModelEnableGroups(modelName string) []string { - for _, p := range GetPricing() { - if p.ModelName == modelName { - return p.EnableGroup - } + // 确保缓存最新 + GetPricing() + + if modelName == "" { + return make([]string, 0) } - return make([]string, 0) + + modelEnableGroupsLock.RLock() + groups, ok := modelEnableGroups[modelName] + modelEnableGroupsLock.RUnlock() + if !ok { + return make([]string, 0) + } + return groups } // GetModelQuotaType 返回指定模型的计费类型(quota_type)。 -// 复用缓存的定价映射,避免额外数据库查询。 -// 如果未找到对应模型,默认返回 0。 +// 同样使用缓存映射,避免每次遍历定价切片。 func GetModelQuotaType(modelName string) int { - for _, p := range GetPricing() { - if p.ModelName == modelName { - return p.QuotaType - } + GetPricing() + + modelEnableGroupsLock.RLock() + quota, ok := modelQuotaTypeMap[modelName] + modelEnableGroupsLock.RUnlock() + if !ok { + return 0 } - return 0 + return quota } diff --git a/model/model_meta.go b/model/model_meta.go index 4faf7a84..f90d4831 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -36,7 +36,7 @@ type BoundChannel struct { type Model struct { Id int `json:"id"` - ModelName string `json:"model_name" gorm:"uniqueIndex;size:128;not null"` + ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"` Description string `json:"description,omitempty" gorm:"type:text"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` VendorID int `json:"vendor_id,omitempty" gorm:"index"` diff --git a/model/prefill_group.go b/model/prefill_group.go index 7a3a6673..6ebe3b04 100644 --- a/model/prefill_group.go +++ b/model/prefill_group.go @@ -4,6 +4,7 @@ import ( "one-api/common" "gorm.io/datatypes" + "gorm.io/gorm" ) // PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。 @@ -15,12 +16,13 @@ import ( type PrefillGroup struct { Id int `json:"id"` - Name string `json:"name" gorm:"uniqueIndex;size:64;not null"` + Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"` Type string `json:"type" gorm:"size:32;index;not null"` Items datatypes.JSON `json:"items" gorm:"type:json"` Description string `json:"description,omitempty" gorm:"type:varchar(255)"` CreatedTime int64 `json:"created_time" gorm:"bigint"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } // Insert 新建组 diff --git a/model/pricing.go b/model/pricing.go index 53fd0e89..5f3939da 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -37,6 +37,11 @@ var ( vendorsList []PricingVendor lastGetPricingTime time.Time updatePricingLock sync.Mutex + + // 缓存映射:模型名 -> 启用分组 / 计费类型 + modelEnableGroups = make(map[string][]string) + modelQuotaTypeMap = make(map[string]int) + modelEnableGroupsLock = sync.RWMutex{} ) var ( @@ -193,30 +198,41 @@ func updatePricing() { } pricingMap = make([]Pricing, 0) - for model, groups := range modelGroupsMap { - pricing := Pricing{ - ModelName: model, - EnableGroup: groups.Items(), - SupportedEndpointTypes: modelSupportEndpointTypes[model], - } + for model, groups := range modelGroupsMap { + pricing := Pricing{ + ModelName: model, + EnableGroup: groups.Items(), + SupportedEndpointTypes: modelSupportEndpointTypes[model], + } - // 补充模型元数据(描述、标签、供应商等) - if meta, ok := metaMap[model]; ok { - pricing.Description = meta.Description - pricing.Tags = meta.Tags - pricing.VendorID = meta.VendorID - } - modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) - if findPrice { - pricing.ModelPrice = modelPrice - pricing.QuotaType = 1 - } else { - modelRatio, _, _ := ratio_setting.GetModelRatio(model) - pricing.ModelRatio = modelRatio - pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model) - pricing.QuotaType = 0 - } - pricingMap = append(pricingMap, pricing) - } - lastGetPricingTime = time.Now() + // 补充模型元数据(描述、标签、供应商等) + if meta, ok := metaMap[model]; ok { + pricing.Description = meta.Description + pricing.Tags = meta.Tags + pricing.VendorID = meta.VendorID + } + modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) + if findPrice { + pricing.ModelPrice = modelPrice + pricing.QuotaType = 1 + } else { + modelRatio, _, _ := ratio_setting.GetModelRatio(model) + pricing.ModelRatio = modelRatio + pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model) + pricing.QuotaType = 0 + } + pricingMap = append(pricingMap, pricing) + } + + // 刷新缓存映射,供高并发快速查询 + modelEnableGroupsLock.Lock() + modelEnableGroups = make(map[string][]string) + modelQuotaTypeMap = make(map[string]int) + for _, p := range pricingMap { + modelEnableGroups[p.ModelName] = p.EnableGroup + modelQuotaTypeMap[p.ModelName] = p.QuotaType + } + modelEnableGroupsLock.Unlock() + + lastGetPricingTime = time.Now() } diff --git a/model/vendor_meta.go b/model/vendor_meta.go index 1dcec351..76bda1f0 100644 --- a/model/vendor_meta.go +++ b/model/vendor_meta.go @@ -14,7 +14,7 @@ import ( type Vendor struct { Id int `json:"id"` - Name string `json:"name" gorm:"uniqueIndex;size:128;not null"` + Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"` Description string `json:"description,omitempty" gorm:"type:text"` Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` Status int `json:"status" gorm:"default:1"` From 2d226a813e8cd40d67af3ef50e7aa5149a82dcd7 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Tue, 5 Aug 2025 22:56:27 +0800 Subject: [PATCH 181/582] =?UTF-8?q?fix:=20responses=20cache=20token=20?= =?UTF-8?q?=E6=9C=AA=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/openai/relay_responses.go | 1 + web/src/helpers/render.js | 10 +++++++--- web/src/hooks/usage-logs/useUsageLogsData.js | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index ef063e7c..420634c0 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -40,6 +40,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http usage.PromptTokens = responsesResponse.Usage.InputTokens usage.CompletionTokens = responsesResponse.Usage.OutputTokens usage.TotalTokens = responsesResponse.Usage.TotalTokens + usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens // 解析 Tools 用量 for _, tool := range responsesResponse.Tools { info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++ diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 1178d5f9..9075164c 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -1156,6 +1156,7 @@ export function renderLogContent( modelPrice = -1, groupRatio, user_group_ratio, + cacheRatio = 1.0, image = false, imageRatio = 1.0, webSearch = false, @@ -1174,9 +1175,10 @@ export function renderLogContent( } else { if (image) { return i18next.t( - '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}', + '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}', { modelRatio: modelRatio, + cacheRatio: cacheRatio, completionRatio: completionRatio, imageRatio: imageRatio, ratioType: ratioLabel, @@ -1185,9 +1187,10 @@ export function renderLogContent( ); } else if (webSearch) { return i18next.t( - '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次', + '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次', { modelRatio: modelRatio, + cacheRatio: cacheRatio, completionRatio: completionRatio, ratioType: ratioLabel, ratio, @@ -1196,9 +1199,10 @@ export function renderLogContent( ); } else { return i18next.t( - '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}', + '模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}', { modelRatio: modelRatio, + cacheRatio: cacheRatio, completionRatio: completionRatio, ratioType: ratioLabel, ratio, diff --git a/web/src/hooks/usage-logs/useUsageLogsData.js b/web/src/hooks/usage-logs/useUsageLogsData.js index 03e09eb8..0c6c4452 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.js +++ b/web/src/hooks/usage-logs/useUsageLogsData.js @@ -366,6 +366,7 @@ export const useLogsData = () => { other.model_price, other.group_ratio, other?.user_group_ratio, + other.cache_ratio || 1.0, false, 1.0, other.web_search || false, From d31027d5c7a7a4a06ef9740d9e002d5eada183fa Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Tue, 5 Aug 2025 22:10:22 +0800 Subject: [PATCH 182/582] feat: support aws bedrock apikey --- go.mod | 14 +++++++------- go.sum | 25 ++++++++++++------------- relay/channel/aws/relay-aws.go | 27 +++++++++++++++++++-------- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/go.mod b/go.mod index 86576bc2..70345b64 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,10 @@ require ( github.com/Calcium-Ion/go-epay v0.0.4 github.com/andybalholm/brotli v1.1.1 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 - github.com/aws/aws-sdk-go-v2 v1.26.1 + github.com/aws/aws-sdk-go-v2 v1.37.2 github.com/aws/aws-sdk-go-v2/credentials v1.17.11 - github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 + github.com/aws/smithy-go v1.22.5 github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 @@ -24,6 +25,7 @@ require ( github.com/gorilla/websocket v1.5.0 github.com/joho/godotenv v1.5.1 github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.5.0 github.com/samber/lo v1.39.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 @@ -41,10 +43,9 @@ require ( require ( github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect - github.com/aws/smithy-go v1.20.2 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect github.com/boombuler/barcode v1.1.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect @@ -80,7 +81,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect - github.com/pquerna/otp v1.5.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect diff --git a/go.sum b/go.sum index a1cc5ece..aebad474 100644 --- a/go.sum +++ b/go.sum @@ -6,21 +6,20 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8= -github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= -github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg= +github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= +github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg= github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 h1:JgHnonzbnA3pbqj76wYsSZIZZQYBxkmMEjvL6GHy8XU= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg= -github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= -github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA= +github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= +github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go index 0df19e07..04427ab8 100644 --- a/relay/channel/aws/relay-aws.go +++ b/relay/channel/aws/relay-aws.go @@ -19,20 +19,31 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" bedrockruntimeTypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "github.com/aws/smithy-go/auth/bearer" ) func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) { awsSecret := strings.Split(info.ApiKey, "|") - if len(awsSecret) != 3 { + var client *bedrockruntime.Client + switch len(awsSecret) { + case 2: + apiKey := awsSecret[0] + region := awsSecret[1] + client = bedrockruntime.New(bedrockruntime.Options{ + Region: region, + BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}}, + }) + case 3: + ak := awsSecret[0] + sk := awsSecret[1] + region := awsSecret[2] + client = bedrockruntime.New(bedrockruntime.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")), + }) + default: return nil, errors.New("invalid aws secret key") } - ak := awsSecret[0] - sk := awsSecret[1] - region := awsSecret[2] - client := bedrockruntime.New(bedrockruntime.Options{ - Region: region, - Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")), - }) return client, nil } From 02fccf0330a37c2e9169d836b8128bc0edf37077 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Tue, 5 Aug 2025 23:08:08 +0800 Subject: [PATCH 183/582] =?UTF-8?q?fix:=20responses=20=E6=B5=81=20cache=20?= =?UTF-8?q?token=20=E6=9C=AA=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/openai/relay_responses.go | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 420634c0..bae6fcb6 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -37,10 +37,14 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http // compute usage usage := dto.Usage{} - usage.PromptTokens = responsesResponse.Usage.InputTokens - usage.CompletionTokens = responsesResponse.Usage.OutputTokens - usage.TotalTokens = responsesResponse.Usage.TotalTokens - usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens + if responsesResponse.Usage != nil { + usage.PromptTokens = responsesResponse.Usage.InputTokens + usage.CompletionTokens = responsesResponse.Usage.OutputTokens + usage.TotalTokens = responsesResponse.Usage.TotalTokens + if responsesResponse.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens + } + } // 解析 Tools 用量 for _, tool := range responsesResponse.Tools { info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++ @@ -65,9 +69,14 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp sendResponsesStreamData(c, streamResponse, data) switch streamResponse.Type { case "response.completed": - usage.PromptTokens = streamResponse.Response.Usage.InputTokens - usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens - usage.TotalTokens = streamResponse.Response.Usage.TotalTokens + if streamResponse.Response.Usage != nil { + usage.PromptTokens = streamResponse.Response.Usage.InputTokens + usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens + usage.TotalTokens = streamResponse.Response.Usage.TotalTokens + if streamResponse.Response.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens + } + } case "response.output_text.delta": // 处理输出文本 responseTextBuilder.WriteString(streamResponse.Delta) From cfb5b6024c0a1a359061f292b5024c2e819efae8 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Tue, 5 Aug 2025 23:18:12 +0800 Subject: [PATCH 184/582] =?UTF-8?q?=F0=9F=9A=80=20refactor:=20refine=20pri?= =?UTF-8?q?cing=20refresh=20logic=20&=20hide=20disabled=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ------- 1. Pricing generation • `model/pricing.go`: skip any model whose `status != 1` when building `pricingMap`, ensuring disabled models are never returned to the front-end. 2. Cache refresh placement • `controller/model_meta.go` – Removed `model.RefreshPricing()` from pure read handlers (`GetAllModelsMeta`, `SearchModelsMeta`). – Kept refresh only in mutating handlers (`Create`, `Update`, `Delete`), guaranteeing data is updated immediately after an admin change while avoiding redundant work on every read. Result ------ Front-end no longer receives information about disabled models, and pricing cache refreshes occur exactly when model data is modified, improving efficiency and consistency. --- controller/model_meta.go | 8 +++----- model/pricing.go | 18 +++++++++++++----- .../table/models/modals/EditModelModal.jsx | 10 ++++------ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/controller/model_meta.go b/controller/model_meta.go index 24329555..ec996555 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -13,8 +13,6 @@ import ( // GetAllModelsMeta 获取模型列表(分页) func GetAllModelsMeta(c *gin.Context) { - model.RefreshPricing() - pageInfo := common.GetPageQuery(c) modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { @@ -35,8 +33,6 @@ func GetAllModelsMeta(c *gin.Context) { // SearchModelsMeta 搜索模型列表 func SearchModelsMeta(c *gin.Context) { - model.RefreshPricing() - keyword := c.Query("keyword") vendor := c.Query("vendor") pageInfo := common.GetPageQuery(c) @@ -87,6 +83,7 @@ func CreateModelMeta(c *gin.Context) { common.ApiError(c, err) return } + model.RefreshPricing() common.ApiSuccess(c, &m) } @@ -116,6 +113,7 @@ func UpdateModelMeta(c *gin.Context) { return } } + model.RefreshPricing() common.ApiSuccess(c, &m) } @@ -131,6 +129,7 @@ func DeleteModelMeta(c *gin.Context) { common.ApiError(c, err) return } + model.RefreshPricing() common.ApiSuccess(c, nil) } @@ -149,5 +148,4 @@ func fillModelExtra(m *model.Model) { m.EnableGroups = model.GetModelEnableGroups(m.ModelName) // 填充计费类型 m.QuotaType = model.GetModelQuotaType(m.ModelName) - } diff --git a/model/pricing.go b/model/pricing.go index 5f3939da..1eaf8c16 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -118,15 +118,19 @@ func updatePricing() { for _, m := range prefixList { for _, pricingModel := range enableAbilities { if strings.HasPrefix(pricingModel.Model, m.ModelName) { - metaMap[pricingModel.Model] = m - } + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } } } for _, m := range suffixList { for _, pricingModel := range enableAbilities { if strings.HasSuffix(pricingModel.Model, m.ModelName) { - metaMap[pricingModel.Model] = m - } + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } } } for _, m := range containsList { @@ -205,8 +209,12 @@ func updatePricing() { SupportedEndpointTypes: modelSupportEndpointTypes[model], } - // 补充模型元数据(描述、标签、供应商等) + // 补充模型元数据(描述、标签、供应商、状态) if meta, ok := metaMap[model]; ok { + // 若模型被禁用(status!=1),则直接跳过,不返回给前端 + if meta.Status != 1 { + continue + } pricing.Description = meta.Description pricing.Tags = meta.Tags pricing.VendorID = meta.VendorID diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index bc22d006..33b2f979 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -305,7 +305,6 @@ const EditModelModal = (props) => { label={t('模型名称')} placeholder={t('请输入模型名称,如:gpt-4')} rules={[{ required: true, message: t('请输入模型名称') }]} - disabled={isEdit || !!props.editingModel?.model_name} showClear /> @@ -317,9 +316,8 @@ const EditModelModal = (props) => { placeholder={t('请选择名称匹配类型')} optionList={nameRuleOptions.map(o => ({ label: t(o.label), value: o.value }))} rules={[{ required: true, message: t('请选择名称匹配类型') }]} - disabled={!!props.editingModel?.model_name} // 通过未配置模型过来的禁用选择 - style={{ width: '100%' }} extraText={t('根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含')} + style={{ width: '100%' }} /> @@ -339,13 +337,13 @@ const EditModelModal = (props) => { placeholder={t('选择标签组后将自动填充标签')} optionList={tagGroups.map(g => ({ label: g.name, value: g.id }))} showClear - style={{ width: '100%' }} onChange={(value) => { const g = tagGroups.find(item => item.id === value); if (g && formApiRef.current) { formApiRef.current.setValue('tags', g.items || []); } }} + style={{ width: '100%' }} /> @@ -356,7 +354,6 @@ const EditModelModal = (props) => { placeholder={t('输入标签或使用","分隔多个标签')} addOnBlur showClear - style={{ width: '100%' }} onChange={(newTags) => { if (!formApiRef.current) return; const normalize = (tags) => { @@ -366,6 +363,7 @@ const EditModelModal = (props) => { const normalized = normalize(newTags); formApiRef.current.setValue('tags', normalized); }} + style={{ width: '100%' }} /> @@ -391,13 +389,13 @@ const EditModelModal = (props) => { optionList={vendors.map(v => ({ label: v.name, value: v.id }))} filter showClear - style={{ width: '100%' }} onChange={(value) => { const vendorInfo = vendors.find(v => v.id === value); if (vendorInfo && formApiRef.current) { formApiRef.current.setValue('vendor', vendorInfo.name); } }} + style={{ width: '100%' }} /> From b0dc31c414db1d9b575c4d430b60b5c747b4eb93 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Tue, 5 Aug 2025 23:18:42 +0800 Subject: [PATCH 185/582] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=80=8D=E7=8E=87=E8=AE=BE=E7=BD=AE=E4=B8=AD=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=B0=E6=A8=A1=E5=9E=8B=E6=97=B6=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E9=94=81=E5=AE=9A=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Setting/Ratio/ModelSettingsVisualEditor.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js index 2aa45ace..1205f6d8 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js @@ -44,6 +44,7 @@ export default function ModelSettingsVisualEditor(props) { const { t } = useTranslation(); const [models, setModels] = useState([]); const [visible, setVisible] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); const [currentModel, setCurrentModel] = useState(null); const [searchText, setSearchText] = useState(''); const [currentPage, setCurrentPage] = useState(1); @@ -386,9 +387,11 @@ export default function ModelSettingsVisualEditor(props) { setCurrentModel(null); setPricingMode('per-token'); setPricingSubMode('ratio'); + setIsEditMode(false); }; const editModel = (record) => { + setIsEditMode(true); // Determine which pricing mode to use based on the model's current configuration let initialPricingMode = 'per-token'; let initialPricingSubMode = 'ratio'; @@ -500,13 +503,7 @@ export default function ModelSettingsVisualEditor(props) { model.name === currentModel.name) - ? t('编辑模型') - : t('添加模型') - } + title={isEditMode ? t('编辑模型') : t('添加模型')} visible={visible} onCancel={() => { resetModalState(); @@ -562,11 +559,7 @@ export default function ModelSettingsVisualEditor(props) { label={t('模型名称')} placeholder='strawberry' required - disabled={ - currentModel && - currentModel.name && - models.some((model) => model.name === currentModel.name) - } + disabled={isEditMode} onChange={(value) => setCurrentModel((prev) => ({ ...prev, name: value })) } From c9bcdc89f0161e2424eb8129332ed756ddd7feab Mon Sep 17 00:00:00 2001 From: neotf <10400594+neotf@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:58:46 +0800 Subject: [PATCH 186/582] feat: add support for claude-opus-4-1 model and update ratios --- relay/channel/aws/constants.go | 4 ++++ relay/channel/claude/constants.go | 2 ++ relay/channel/vertex/adaptor.go | 1 + setting/ratio_setting/cache_ratio.go | 4 ++++ setting/ratio_setting/model_ratio.go | 1 + 5 files changed, 12 insertions(+) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 64c7b747..3f8800b1 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -13,6 +13,7 @@ var awsModelIDMap = map[string]string{ "claude-3-7-sonnet-20250219": "anthropic.claude-3-7-sonnet-20250219-v1:0", "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0", "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", + "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", } var awsModelCanCrossRegionMap = map[string]map[string]bool{ @@ -54,6 +55,9 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ "anthropic.claude-opus-4-20250514-v1:0": { "us": true, }, + "anthropic.claude-opus-4-1-20250805-v1:0": { + "us": true, + }, } var awsRegionCrossModelPrefixMap = map[string]string{ diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index e0e3c421..a23543d2 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -17,6 +17,8 @@ var ModelList = []string{ "claude-sonnet-4-20250514-thinking", "claude-opus-4-20250514", "claude-opus-4-20250514-thinking", + "claude-opus-4-1-20250805", + "claude-opus-4-1-20250805-thinking", } var ChannelName = "claude" diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index fa895de0..ecdb86c4 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -35,6 +35,7 @@ var claudeModelMap = map[string]string{ "claude-3-7-sonnet-20250219": "claude-3-7-sonnet@20250219", "claude-sonnet-4-20250514": "claude-sonnet-4@20250514", "claude-opus-4-20250514": "claude-opus-4@20250514", + "claude-opus-4-1-20250805": "claude-opus-4-1@20250805", } const anthropicVersion = "vertex-2023-10-16" diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index 51d473a8..8b87cb86 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -40,6 +40,8 @@ var defaultCacheRatio = map[string]float64{ "claude-sonnet-4-20250514-thinking": 0.1, "claude-opus-4-20250514": 0.1, "claude-opus-4-20250514-thinking": 0.1, + "claude-opus-4-1-20250805": 0.1, + "claude-opus-4-1-20250805-thinking": 0.1, } var defaultCreateCacheRatio = map[string]float64{ @@ -55,6 +57,8 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-sonnet-4-20250514-thinking": 1.25, "claude-opus-4-20250514": 1.25, "claude-opus-4-20250514-thinking": 1.25, + "claude-opus-4-1-20250805": 1.25, + "claude-opus-4-1-20250805-thinking": 1.25, } //var defaultCreateCacheRatio = map[string]float64{} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 8a1d6aae..be6dd6b9 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -118,6 +118,7 @@ var defaultModelRatio = map[string]float64{ "claude-sonnet-4-20250514": 1.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, + "claude-opus-4-1-20250805": 7.5, "ERNIE-4.0-8K": 0.120 * RMB, "ERNIE-3.5-8K": 0.012 * RMB, "ERNIE-3.5-8K-0205": 0.024 * RMB, From ac0544614b4be15a08a0988a6bd26355abed7c91 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 6 Aug 2025 01:40:08 +0800 Subject: [PATCH 187/582] =?UTF-8?q?=F0=9F=9A=80=20refactor:=20migrate=20ve?= =?UTF-8?q?ndor-count=20aggregation=20to=20model=20layer=20&=20align=20fro?= =?UTF-8?q?ntend=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary • Backend – Moved duplicate-name validation and total vendor-count aggregation from controllers (`controller/model_meta.go`, `controller/vendor_meta.go`, `controller/prefill_group.go`) to model layer (`model/model_meta.go`, `model/vendor_meta.go`, `model/prefill_group.go`). – Added `GetVendorModelCounts()` and `Is*NameDuplicated()` helpers; controllers now call these instead of duplicating queries. – API response for `/api/models` now returns `vendor_counts` with per-vendor totals across all pages, plus `all` summary. – Removed redundant checks and unused imports, eliminating `go vet` warnings. • Frontend – `useModelsData.js` updated to consume backend-supplied `vendor_counts`, calculate the `all` total once, and drop legacy client-side counting logic. – Simplified initial data flow: first render now triggers only one models request. – Deleted obsolete `updateVendorCounts` helper and related comments. – Ensured search flow also sets `vendorCounts`, keeping tab badges accurate. Why This refactor enforces single-responsibility (aggregation in model layer), delivers consistent totals irrespective of pagination, and removes redundant client queries, leading to cleaner code and better performance. --- controller/model_meta.go | 29 ++- controller/prefill_group.go | 18 ++ controller/vendor_meta.go | 18 +- model/model_meta.go | 29 +++ model/prefill_group.go | 10 + model/vendor_meta.go | 10 + .../modal/components/ModelBasicInfo.jsx | 27 +-- .../view/card/PricingCardView.jsx | 218 +++++++++--------- .../components/table/models/ModelsActions.jsx | 15 +- .../components/SelectionNotification.jsx | 76 ++++++ .../table/models/modals/EditModelModal.jsx | 20 +- web/src/hooks/models/useModelsData.js | 39 +--- 12 files changed, 334 insertions(+), 175 deletions(-) create mode 100644 web/src/components/table/models/components/SelectionNotification.jsx diff --git a/controller/model_meta.go b/controller/model_meta.go index ec996555..090ea3c1 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -25,9 +25,19 @@ func GetAllModelsMeta(c *gin.Context) { } var total int64 model.DB.Model(&model.Model{}).Count(&total) + + // 统计供应商计数(全部数据,不受分页影响) + vendorCounts, _ := model.GetVendorModelCounts() + pageInfo.SetTotal(int(total)) pageInfo.SetItems(modelsMeta) - common.ApiSuccess(c, pageInfo) + common.ApiSuccess(c, gin.H{ + "items": modelsMeta, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "vendor_counts": vendorCounts, + }) } // SearchModelsMeta 搜索模型列表 @@ -78,6 +88,14 @@ func CreateModelMeta(c *gin.Context) { common.ApiErrorMsg(c, "模型名称不能为空") return } + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } if err := m.Insert(); err != nil { common.ApiError(c, err) @@ -108,6 +126,15 @@ func UpdateModelMeta(c *gin.Context) { return } } else { + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } + if err := m.Update(); err != nil { common.ApiError(c, err) return diff --git a/controller/prefill_group.go b/controller/prefill_group.go index e37082e6..4e29379b 100644 --- a/controller/prefill_group.go +++ b/controller/prefill_group.go @@ -31,6 +31,15 @@ func CreatePrefillGroup(c *gin.Context) { common.ApiErrorMsg(c, "组名称和类型不能为空") return } + // 创建前检查名称 + if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + if err := g.Insert(); err != nil { common.ApiError(c, err) return @@ -49,6 +58,15 @@ func UpdatePrefillGroup(c *gin.Context) { common.ApiErrorMsg(c, "缺少组 ID") return } + // 名称冲突检查 + if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + if err := g.Update(); err != nil { common.ApiError(c, err) return diff --git a/controller/vendor_meta.go b/controller/vendor_meta.go index 27e4294b..28664dd6 100644 --- a/controller/vendor_meta.go +++ b/controller/vendor_meta.go @@ -65,6 +65,15 @@ func CreateVendorMeta(c *gin.Context) { common.ApiErrorMsg(c, "供应商名称不能为空") return } + // 创建前先检查名称 + if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + if err := v.Insert(); err != nil { common.ApiError(c, err) return @@ -83,10 +92,11 @@ func UpdateVendorMeta(c *gin.Context) { common.ApiErrorMsg(c, "缺少供应商 ID") return } - // 检查名称冲突 - var dup int64 - _ = model.DB.Model(&model.Vendor{}).Where("name = ? AND id <> ?", v.Name, v.Id).Count(&dup).Error - if dup > 0 { + // 名称冲突检查 + if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { common.ApiErrorMsg(c, "供应商名称已存在") return } diff --git a/model/model_meta.go b/model/model_meta.go index f90d4831..5ccd80c5 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -60,6 +60,16 @@ func (mi *Model) Insert() error { return DB.Create(mi).Error } +// IsModelNameDuplicated 检查模型名称是否重复(排除自身 ID) +func IsModelNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + // Update 更新现有模型记录 func (mi *Model) Update() error { // 仅更新需要变更的字段,避免覆盖 CreatedTime @@ -84,6 +94,25 @@ func GetModelByName(name string) (*Model, error) { return &mi, nil } +// GetVendorModelCounts 统计每个供应商下模型数量(不受分页影响) +func GetVendorModelCounts() (map[int64]int64, error) { + var stats []struct { + VendorID int64 + Count int64 + } + if err := DB.Model(&Model{}). + Select("vendor_id as vendor_id, count(*) as count"). + Group("vendor_id"). + Scan(&stats).Error; err != nil { + return nil, err + } + m := make(map[int64]int64, len(stats)) + for _, s := range stats { + m[s.VendorID] = s.Count + } + return m, nil +} + // GetAllModels 分页获取所有模型元数据 func GetAllModels(offset int, limit int) ([]*Model, error) { var models []*Model diff --git a/model/prefill_group.go b/model/prefill_group.go index 6ebe3b04..51e3e7f1 100644 --- a/model/prefill_group.go +++ b/model/prefill_group.go @@ -33,6 +33,16 @@ func (g *PrefillGroup) Insert() error { return DB.Create(g).Error } +// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID) +func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + // Update 更新组 func (g *PrefillGroup) Update() error { g.UpdatedTime = common.GetTimestamp() diff --git a/model/vendor_meta.go b/model/vendor_meta.go index 76bda1f0..fd316156 100644 --- a/model/vendor_meta.go +++ b/model/vendor_meta.go @@ -31,6 +31,16 @@ func (v *Vendor) Insert() error { return DB.Create(v).Error } +// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID) +func IsVendorNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + // Update 更新供应商记录 func (v *Vendor) Update() error { v.UpdatedTime = common.GetTimestamp() diff --git a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx index d33d2766..1944a939 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx @@ -71,21 +71,18 @@ const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {

    {getModelDescription()}

    {getModelTags().length > 0 && ( -
    - {t('模型标签')} - - {getModelTags().map((tag, index) => ( - - {tag.text} - - ))} - -
    + + {getModelTags().map((tag, index) => ( + + {tag.text} + + ))} + )}
    diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 35b84e2e..83814b56 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -131,41 +131,42 @@ const PricingCardView = ({ // 渲染标签 const renderTags = (record) => { - const allTags = []; - - // 计费类型标签 + // 计费类型标签(左边) const billingType = record.quota_type === 1 ? 'teal' : 'violet'; const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); - allTags.push({ - key: "billing", - element: ( - - {billingText} - - ) - }); + const billingTag = ( + + {billingText} + + ); - // 自定义标签 + // 自定义标签(右边) + const customTags = []; if (record.tags) { const tagArr = record.tags.split(',').filter(Boolean); tagArr.forEach((tg, idx) => { - allTags.push({ - key: `custom-${idx}`, - element: ( - - {tg} - - ) - }); + customTags.push( + + {tg} + + ); }); } - // 使用 renderLimitedItems 渲染标签 - return renderLimitedItems({ - items: allTags, - renderItem: (item, idx) => React.cloneElement(item.element, { key: item.key }), - maxDisplay: 3 - }); + return ( +
    +
    + {billingTag} +
    +
    + {renderLimitedItems({ + items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })), + renderItem: (item, idx) => item.element, + maxDisplay: 3 + })} +
    +
    + ); }; // 显示骨架屏 @@ -201,96 +202,101 @@ const PricingCardView = ({ openModelDetail && openModelDetail(model)} > - {/* 头部:图标 + 模型名称 + 操作按钮 */} -
    -
    - {getModelIcon(model)} -
    -

    - {model.model_name} -

    -
    - {renderPriceInfo(model)} +
    + {/* 头部:图标 + 模型名称 + 操作按钮 */} +
    +
    + {getModelIcon(model)} +
    +

    + {model.model_name} +

    +
    + {renderPriceInfo(model)} +
    + +
    + {/* 复制按钮 */} +
    -
    - {/* 复制按钮 */} -
    - - {/* 模型描述 */} -
    -

    - {getModelDescription(model)} -

    -
    - - {/* 标签区域 */} -
    - {renderTags(model)} -
    - - {/* 倍率信息(可选) */} - {showRatio && ( -
    -
    - {t('倍率信息')} - - { - e.stopPropagation(); - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> - -
    -
    -
    - {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')} -
    -
    - {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')} -
    -
    - {t('分组')}: {groupRatio[selectedGroup]} -
    -
    -
    - )} ); })} diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index cb91ed29..9eacab69 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -23,6 +23,7 @@ import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx'; import { Button, Space, Modal } from '@douyinfe/semi-ui'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; import { showError } from '../../../helpers'; +import SelectionNotification from './components/SelectionNotification.jsx'; const ModelsActions = ({ selectedKeys, @@ -70,14 +71,6 @@ const ModelsActions = ({ {t('添加模型')} -
    + + . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect } from 'react'; +import { Notification, Button, Space } from '@douyinfe/semi-ui'; + +// 固定通知 ID,保持同一个实例即可避免闪烁 +const NOTICE_ID = 'models-batch-actions'; + +/** + * SelectionNotification 选择通知组件 + * 1. 当 selectedKeys.length > 0 时,使用固定 id 创建/更新通知 + * 2. 当 selectedKeys 清空时关闭通知 + */ +const SelectionNotification = ({ selectedKeys = [], t, onDelete }) => { + // 根据选中数量决定显示/隐藏或更新通知 + useEffect(() => { + const selectedCount = selectedKeys.length; + + if (selectedCount > 0) { + const content = ( + + {t('已选择 {{count}} 个模型', { count: selectedCount })} + + + ); + + // 使用相同 id 更新通知(若已存在则就地更新,不存在则创建) + Notification.info({ + id: NOTICE_ID, + title: t('批量操作'), + content, + duration: 0, // 不自动关闭 + position: 'bottom', + showClose: false, + }); + } else { + // 取消全部勾选时关闭通知 + Notification.close(NOTICE_ID); + } + }, [selectedKeys, t, onDelete]); + + // 卸载时确保关闭通知 + useEffect(() => { + return () => { + Notification.close(NOTICE_ID); + }; + }, []); + + return null; // 该组件不渲染可见内容 +}; + +export default SelectionNotification; diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index 33b2f979..5cc6124d 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -32,10 +32,12 @@ import { Row, } from '@douyinfe/semi-ui'; import { - IconSave, - IconClose, - IconLayers, -} from '@douyinfe/semi-icons'; + Save, + X, + FileText, + Building, + Settings, +} from 'lucide-react'; import { API, showError, showSuccess } from '../../../../helpers'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; @@ -258,7 +260,7 @@ const EditModelModal = (props) => { theme='solid' className='!rounded-lg' onClick={() => formApiRef.current?.submitForm()} - icon={} + icon={} loading={loading} > {t('提交')} @@ -268,7 +270,7 @@ const EditModelModal = (props) => { className='!rounded-lg' type='primary' onClick={handleCancel} - icon={} + icon={} > {t('取消')} @@ -291,7 +293,7 @@ const EditModelModal = (props) => {
    - +
    {t('基本信息')} @@ -373,7 +375,7 @@ const EditModelModal = (props) => {
    - +
    {t('供应商信息')} @@ -405,7 +407,7 @@ const EditModelModal = (props) => {
    - +
    {t('功能配置')} diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js index 8c17f78d..0195858d 100644 --- a/web/src/hooks/models/useModelsData.js +++ b/web/src/hooks/models/useModelsData.js @@ -135,9 +135,9 @@ export const useModelsData = () => { setModelCount(data.total || newPageData.length); setModelFormat(newPageData); - // Refresh vendor counts only when viewing 'all' to preserve other counts - if (vendorKey === 'all') { - updateVendorCounts(newPageData); + if (data.vendor_counts) { + const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0); + setVendorCounts({ ...data.vendor_counts, all: sumAll }); } } else { showError(message); @@ -151,27 +151,9 @@ export const useModelsData = () => { setLoading(false); }; - // Fetch vendor counts separately to keep tab numbers accurate - const refreshVendorCounts = async () => { - try { - // Load all models (large page_size) to compute counts for every vendor - const res = await API.get('/api/models/?p=1&page_size=100000'); - if (res.data.success) { - const newItems = extractItems(res.data.data); - updateVendorCounts(newItems); - } - } catch (_) { - // ignore count refresh errors - } - }; - // Refresh data const refresh = async (page = activePage) => { await loadModels(page, pageSize); - // When not viewing 'all', tab counts need a separate refresh - if (activeVendorKey !== 'all') { - await refreshVendorCounts(); - } }; // Search models with keyword and vendor @@ -195,6 +177,10 @@ export const useModelsData = () => { setActivePage(data.page || 1); setModelCount(data.total || newPageData.length); setModelFormat(newPageData); + if (data.vendor_counts) { + const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0); + setVendorCounts({ ...data.vendor_counts, all: sumAll }); + } } else { showError(message); setModels([]); @@ -242,16 +228,6 @@ export const useModelsData = () => { } }; - // Update vendor counts - const updateVendorCounts = (models) => { - const counts = { all: models.length }; - models.forEach(model => { - if (model.vendor_id) { - counts[model.vendor_id] = (counts[model.vendor_id] || 0) + 1; - } - }); - setVendorCounts(counts); - }; // Handle page change const handlePageChange = (page) => { @@ -335,7 +311,6 @@ export const useModelsData = () => { useEffect(() => { (async () => { await loadVendors(); - await loadModels(); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 029768e868a1261fd6b70273b822b8bea18f48b2 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Wed, 6 Aug 2025 03:29:45 +0800 Subject: [PATCH 188/582] =?UTF-8?q?=E2=9C=A8=20feat(models):=20Revamp=20Ed?= =?UTF-8?q?itModelModal=20UI=20and=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit significantly refactors the `EditModelModal` component to streamline the user interface and enhance usability, aligning it with the interaction patterns found elsewhere in the application. - **Consolidated Layout:** Merged the "Vendor Information" and "Feature Configuration" sections into a single "Basic Information" card. This simplifies the form, reduces clutter, and makes all settings accessible in one view. - **Improved Prefill Groups:** Replaced the separate `Select` dropdowns for tag and endpoint groups with a more intuitive button-based system within the `extraText` of the `TagInput` components. - **Additive Button Logic:** The prefill group buttons now operate in an additive mode. Users can click multiple group buttons to incrementally add tags or endpoints, with duplicates being automatically handled. - **Clear Functionality:** Added "Clear" buttons for both tags and endpoints, allowing users to easily reset the fields. - **Code Cleanup:** Removed the unused `endpointOptions` constant and unnecessary icon imports (`Building`, `Settings`) to keep the codebase clean. --- .../channels/modals/EditChannelModal.jsx | 40 ++--- .../table/channels/modals/ModelTestModal.jsx | 2 +- .../components/table/models/ModelsActions.jsx | 47 +++++- .../components/SelectionNotification.jsx | 44 +++++- web/src/components/table/models/index.jsx | 2 + .../table/models/modals/EditModelModal.jsx | 149 ++++++++---------- .../models/modals/MissingModelsModal.jsx | 2 +- web/src/hooks/models/useModelsData.js | 1 + 8 files changed, 171 insertions(+), 116 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index c13aca13..259553d0 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -1174,27 +1174,27 @@ const EditChannelModal = (props) => { )} - {isEdit && isMultiKeyChannel && ( - setKeyMode(value)} - extraText={ - - {keyMode === 'replace' - ? t('覆盖模式:将完全替换现有的所有密钥') - : t('追加模式:将新密钥添加到现有密钥列表末尾') - } - + {isEdit && isMultiKeyChannel && ( + setKeyMode(value)} + extraText={ + + {keyMode === 'replace' + ? t('覆盖模式:将完全替换现有的所有密钥') + : t('追加模式:将新密钥添加到现有密钥列表末尾') } - /> + + } + /> )} {batch && multiToSingle && ( <> diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx index 1d159473..7e845a04 100644 --- a/web/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -175,7 +175,7 @@ const ModelTestModal = ({ {currentTestChannel.name} {t('渠道的模型测试')} - + {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
    diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index 9eacab69..b10d0500 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -20,13 +20,15 @@ For commercial licensing, please contact support@quantumnous.com import React, { useState } from 'react'; import MissingModelsModal from './modals/MissingModelsModal.jsx'; import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx'; -import { Button, Space, Modal } from '@douyinfe/semi-ui'; +import EditPrefillGroupModal from './modals/EditPrefillGroupModal.jsx'; +import { Button, Modal } from '@douyinfe/semi-ui'; +import { showSuccess, showError, copy } from '../../../helpers'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; -import { showError } from '../../../helpers'; import SelectionNotification from './components/SelectionNotification.jsx'; const ModelsActions = ({ selectedKeys, + setSelectedKeys, setEditingModel, setShowEdit, batchDeleteModels, @@ -38,13 +40,11 @@ const ModelsActions = ({ const [showDeleteModal, setShowDeleteModal] = useState(false); const [showMissingModal, setShowMissingModal] = useState(false); const [showGroupManagement, setShowGroupManagement] = useState(false); + const [showAddPrefill, setShowAddPrefill] = useState(false); + const [prefillInit, setPrefillInit] = useState({ id: undefined }); // Handle delete selected models with confirmation const handleDeleteSelectedModels = () => { - if (selectedKeys.length === 0) { - showError(t('请至少选择一个模型!')); - return; - } setShowDeleteModal(true); }; @@ -54,6 +54,30 @@ const ModelsActions = ({ setShowDeleteModal(false); }; + // Handle clear selection + const handleClearSelected = () => { + setSelectedKeys([]); + }; + + // Handle add selected models to prefill group + const handleCopyNames = async () => { + const text = selectedKeys.map(m => m.model_name).join(','); + if (!text) return; + const ok = await copy(text); + if (ok) { + showSuccess(t('已复制模型名称')); + } else { + showError(t('复制失败')); + } + }; + + const handleAddToPrefill = () => { + // Prepare initial data + const items = selectedKeys.map((m) => m.model_name); + setPrefillInit({ id: undefined, type: 'model', items }); + setShowAddPrefill(true); + }; + return ( <>
    @@ -71,7 +95,6 @@ const ModelsActions = ({ {t('添加模型')} - + + ); @@ -51,7 +81,7 @@ const SelectionNotification = ({ selectedKeys = [], t, onDelete }) => { // 使用相同 id 更新通知(若已存在则就地更新,不存在则创建) Notification.info({ id: NOTICE_ID, - title: t('批量操作'), + title: titleNode, content, duration: 0, // 不自动关闭 position: 'bottom', @@ -61,7 +91,7 @@ const SelectionNotification = ({ selectedKeys = [], t, onDelete }) => { // 取消全部勾选时关闭通知 Notification.close(NOTICE_ID); } - }, [selectedKeys, t, onDelete]); + }, [selectedKeys, t, onDelete, onAddPrefill, onClear, onCopy]); // 卸载时确保关闭通知 useEffect(() => { diff --git a/web/src/components/table/models/index.jsx b/web/src/components/table/models/index.jsx index 4732e83d..93d63470 100644 --- a/web/src/components/table/models/index.jsx +++ b/web/src/components/table/models/index.jsx @@ -42,6 +42,7 @@ const ModelsPage = () => { // Actions state selectedKeys, + setSelectedKeys, setEditingModel, setShowEdit, batchDeleteModels, @@ -100,6 +101,7 @@ const ModelsPage = () => {
    { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -332,23 +322,6 @@ const EditModelModal = (props) => { showClear /> -
    - ({ label: g.name, value: g.id }))} - showClear - onChange={(value) => { - const g = tagGroups.find(item => item.id === value); - if (g && formApiRef.current) { - formApiRef.current.setValue('tags', g.items || []); - } - }} - style={{ width: '100%' }} - /> - - { formApiRef.current.setValue('tags', normalized); }} style={{ width: '100%' }} + extraText={( + + {tagGroups.map(group => ( + + ))} + + + )} /> - - - - {/* 供应商信息 */} - -
    - - - -
    - {t('供应商信息')} -
    {t('设置模型的供应商相关信息')}
    -
    -
    -
    { style={{ width: '100%' }} /> - - - - {/* 功能配置 */} - -
    - - - -
    - {t('功能配置')} -
    {t('设置模型的功能和状态')}
    -
    -
    -
    - ({ label: g.name, value: g.id }))} - showClear - style={{ width: '100%' }} - onChange={(value) => { - const g = endpointGroups.find(item => item.id === value); - if (g && formApiRef.current) { - formApiRef.current.setValue('endpoints', g.items || []); - } - }} - /> - - - - + {endpointGroups.map(group => ( + + ))} + + + )} /> diff --git a/web/src/components/table/models/modals/MissingModelsModal.jsx b/web/src/components/table/models/modals/MissingModelsModal.jsx index 41ff9d13..f181b112 100644 --- a/web/src/components/table/models/modals/MissingModelsModal.jsx +++ b/web/src/components/table/models/modals/MissingModelsModal.jsx @@ -111,7 +111,7 @@ const MissingModelsModal = ({ {t('未配置的模型列表')} - + {t('共')} {missingModels.length} {t('个未配置模型')} diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js index 0195858d..b41bdfc2 100644 --- a/web/src/hooks/models/useModelsData.js +++ b/web/src/hooks/models/useModelsData.js @@ -328,6 +328,7 @@ export const useModelsData = () => { selectedKeys, rowSelection, handleRow, + setSelectedKeys, // Modal state showEdit, From 4f6d16e365ed4c42d1c1adf52156a39f65e186be Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 6 Aug 2025 12:50:26 +0800 Subject: [PATCH 189/582] feat: add reasoning support for Openrouter requests with "-thinking" suffix --- relay/channel/openai/adaptor.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f46af710..115b7b76 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -9,6 +9,7 @@ import ( "mime/multipart" "net/http" "net/textproto" + "one-api/common" "one-api/constant" "one-api/dto" "one-api/relay/channel" @@ -172,6 +173,23 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if len(request.Usage) == 0 { request.Usage = json.RawMessage(`{"include":true}`) } + if strings.HasSuffix(info.UpstreamModelName, "-thinking") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + request.Model = info.UpstreamModelName + if len(request.Reasoning) == 0 { + reasoning := map[string]any{ + "enabled": true, + } + if request.ReasoningEffort != "" { + reasoning["effort"] = request.ReasoningEffort + } + marshal, err := common.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("error marshalling reasoning: %w", err) + } + request.Reasoning = marshal + } + } } if strings.HasPrefix(request.Model, "o") { if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { From 6960a063226a225926419c8468fcad3600422e5e Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 6 Aug 2025 16:20:38 +0800 Subject: [PATCH 190/582] feat: enhance ThinkingAdaptor with effort-based budget clamping and extra body handling --- relay/channel/gemini/relay-gemini.go | 82 ++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index adc771e2..0f4a54cf 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -49,12 +49,20 @@ const ( flash25LiteMaxBudget = 24576 ) -// clampThinkingBudget 根据模型名称将预算限制在允许的范围内 -func clampThinkingBudget(modelName string, budget int) int { - isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && +func isNew25ProModel(modelName string) bool { + return strings.HasPrefix(modelName, "gemini-2.5-pro") && !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") - is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite") +} + +func is25FlashLiteModel(modelName string) bool { + return strings.HasPrefix(modelName, "gemini-2.5-flash-lite") +} + +// clampThinkingBudget 根据模型名称将预算限制在允许的范围内 +func clampThinkingBudget(modelName string, budget int) int { + isNew25Pro := isNew25ProModel(modelName) + is25FlashLite := is25FlashLiteModel(modelName) if is25FlashLite { if budget < flash25LiteMinBudget { @@ -81,7 +89,34 @@ func clampThinkingBudget(modelName string, budget int) int { return budget } -func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) { +// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens) +// "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens) +// "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens) +func clampThinkingBudgetByEffort(modelName string, effort string) int { + isNew25Pro := isNew25ProModel(modelName) + is25FlashLite := is25FlashLiteModel(modelName) + + maxBudget := 0 + if is25FlashLite { + maxBudget = flash25LiteMaxBudget + } + if isNew25Pro { + maxBudget = pro25MaxBudget + } else { + maxBudget = flash25MaxBudget + } + switch effort { + case "high": + return maxBudget * 80 / 100 + case "medium": + return maxBudget * 50 / 100 + case "low": + return maxBudget * 20 / 100 + } + return maxBudget * 50 / 100 // 默认medium +} + +func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { modelName := info.UpstreamModelName isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && @@ -124,6 +159,11 @@ func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.Rel budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) clampedBudget := clampThinkingBudget(modelName, int(budgetTokens)) geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget) + } else { + if len(oaiRequest) > 0 { + // 如果有reasoningEffort参数,则根据其值设置思考预算 + geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort)) + } } } } else if strings.HasSuffix(modelName, "-nothinking") { @@ -156,7 +196,37 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } } - ThinkingAdaptor(&geminiRequest, info) + adaptorWithExtraBody := false + + if len(textRequest.ExtraBody) > 0 { + if !strings.HasSuffix(info.UpstreamModelName, "-nothinking") { + var extraBody map[string]interface{} + if err := common.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil { + return nil, fmt.Errorf("invalid extra body: %w", err) + } + // eg. {"google":{"thinking_config":{"thinking_budget":5324,"include_thoughts":true}}} + if googleBody, ok := extraBody["google"].(map[string]interface{}); ok { + adaptorWithExtraBody = true + if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok { + if budget, ok := thinkingConfig["thinking_budget"].(float64); ok { + budgetInt := int(budget) + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(budgetInt), + IncludeThoughts: true, + } + } else { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + IncludeThoughts: true, + } + } + } + } + } + } + + if !adaptorWithExtraBody { + ThinkingAdaptor(&geminiRequest, info, textRequest) + } safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList)) for _, category := range SafetySettingList { From 2e41362f2ea12fafc8d2a1732ad389f3bdeb18da Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 6 Aug 2025 16:25:48 +0800 Subject: [PATCH 191/582] fix: update budget calculation logic in relay-gemini to use clamping function --- relay/channel/gemini/relay-gemini.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 0f4a54cf..18524afb 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -107,13 +107,13 @@ func clampThinkingBudgetByEffort(modelName string, effort string) int { } switch effort { case "high": - return maxBudget * 80 / 100 + maxBudget = maxBudget * 80 / 100 case "medium": - return maxBudget * 50 / 100 + maxBudget = maxBudget * 50 / 100 case "low": - return maxBudget * 20 / 100 + maxBudget = maxBudget * 20 / 100 } - return maxBudget * 50 / 100 // 默认medium + return clampThinkingBudget(modelName, maxBudget) } func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { From 423ceae5158e3ac5c5466eaa910495f601cb7baa Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Wed, 6 Aug 2025 19:40:26 +0800 Subject: [PATCH 192/582] =?UTF-8?q?fix:=20error=20code=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- types/error.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/types/error.go b/types/error.go index e7265e21..d3dd29e1 100644 --- a/types/error.go +++ b/types/error.go @@ -189,9 +189,13 @@ func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPI } func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + if errorCode == ErrorCodeDoRequestFailed { + err = errors.New("upstream error: do request failed") + } openaiError := OpenAIError{ Message: err.Error(), Type: string(errorCode), + Code: errorCode, } return WithOpenAIError(openaiError, statusCode, ops...) } @@ -199,6 +203,7 @@ func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAP func InitOpenAIError(errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { openaiError := OpenAIError{ Type: string(errorCode), + Code: errorCode, } return WithOpenAIError(openaiError, statusCode, ops...) } @@ -224,7 +229,11 @@ func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int, ops func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { code, ok := openAIError.Code.(string) if !ok { - code = fmt.Sprintf("%v", openAIError.Code) + if openAIError.Code == nil { + code = fmt.Sprintf("%v", openAIError.Code) + } else { + code = "unknown_error" + } } if openAIError.Type == "" { openAIError.Type = "upstream_error" From 15c11bfe5111fa8277a6e8f4aeb45c7a824913d3 Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Wed, 6 Aug 2025 20:09:22 +0800 Subject: [PATCH 193/582] =?UTF-8?q?refactor:=20=E8=B0=83=E6=95=B4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- constant/context_key.go | 1 - middleware/auth.go | 33 +++++++++++++++++- middleware/distributor.go | 51 +++++++--------------------- model/channel_cache.go | 8 ++--- setting/ratio_setting/model_ratio.go | 37 ++++++++++---------- 5 files changed, 66 insertions(+), 64 deletions(-) diff --git a/constant/context_key.go b/constant/context_key.go index 4eaf3d00..32dd9617 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -11,7 +11,6 @@ const ( ContextKeyTokenKey ContextKey = "token_key" ContextKeyTokenId ContextKey = "token_id" ContextKeyTokenGroup ContextKey = "token_group" - ContextKeyTokenAllowIps ContextKey = "allow_ips" ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id" ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" ContextKeyTokenModelLimit ContextKey = "token_model_limit" diff --git a/middleware/auth.go b/middleware/auth.go index 72900f83..5f6e5d43 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -4,7 +4,10 @@ import ( "fmt" "net/http" "one-api/common" + "one-api/constant" "one-api/model" + "one-api/setting" + "one-api/setting/ratio_setting" "strconv" "strings" @@ -234,6 +237,16 @@ func TokenAuth() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error()) return } + + allowIpsMap := token.GetIpLimitsMap() + if len(allowIpsMap) != 0 { + clientIp := c.ClientIP() + if _, ok := allowIpsMap[clientIp]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") + return + } + } + userCache, err := model.GetUserCache(token.UserId) if err != nil { abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error()) @@ -247,6 +260,25 @@ func TokenAuth() func(c *gin.Context) { userCache.WriteContext(c) + userGroup := userCache.Group + tokenGroup := token.Group + if tokenGroup != "" { + // check common.UserUsableGroups[userGroup] + if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup)) + return + } + // check group in common.GroupRatio + if !ratio_setting.ContainsGroupRatio(tokenGroup) { + if tokenGroup != "auto" { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) + return + } + } + userGroup = tokenGroup + } + common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup) + err = SetupContextForToken(c, token, parts...) if err != nil { return @@ -273,7 +305,6 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e } else { c.Set("token_model_limit_enabled", false) } - c.Set("allow_ips", token.GetIpLimitsMap()) c.Set("token_group", token.Group) if len(parts) > 1 { if model.IsAdmin(token.UserId) { diff --git a/middleware/distributor.go b/middleware/distributor.go index c7a55f4c..5fae6322 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -10,7 +10,6 @@ import ( "one-api/model" relayconstant "one-api/relay/constant" "one-api/service" - "one-api/setting" "one-api/setting/ratio_setting" "one-api/types" "strconv" @@ -27,14 +26,6 @@ type ModelRequest struct { func Distribute() func(c *gin.Context) { return func(c *gin.Context) { - allowIpsMap := common.GetContextKeyStringMap(c, constant.ContextKeyTokenAllowIps) - if len(allowIpsMap) != 0 { - clientIp := c.ClientIP() - if _, ok := allowIpsMap[clientIp]; !ok { - abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") - return - } - } var channel *model.Channel channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId) modelRequest, shouldSelectChannel, err := getModelRequest(c) @@ -42,24 +33,6 @@ func Distribute() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request, "+err.Error()) return } - userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) - if tokenGroup != "" { - // check common.UserUsableGroups[userGroup] - if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok { - abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup)) - return - } - // check group in common.GroupRatio - if !ratio_setting.ContainsGroupRatio(tokenGroup) { - if tokenGroup != "auto" { - abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) - return - } - } - userGroup = tokenGroup - } - common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup) if ok { id, err := strconv.Atoi(channelId.(string)) if err != nil { @@ -81,22 +54,21 @@ func Distribute() func(c *gin.Context) { modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) if modelLimitEnable { s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) - var tokenModelLimit map[string]bool - if ok { - tokenModelLimit = s.(map[string]bool) - } else { - tokenModelLimit = map[string]bool{} - } - if tokenModelLimit != nil { - if _, ok := tokenModelLimit[modelRequest.Model]; !ok { - abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model) - return - } - } else { + if !ok { // token model limit is empty, all models are not allowed abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问任何模型") return } + var tokenModelLimit map[string]bool + tokenModelLimit, ok = s.(map[string]bool) + if !ok { + tokenModelLimit = map[string]bool{} + } + matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-* + if _, ok := tokenModelLimit[matchName]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model) + return + } } if shouldSelectChannel { @@ -105,6 +77,7 @@ func Distribute() func(c *gin.Context) { return } var selectGroup string + userGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0) if err != nil { showGroup := userGroup diff --git a/model/channel_cache.go b/model/channel_cache.go index 6ca23cf9..90bd2ad1 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -7,6 +7,7 @@ import ( "one-api/common" "one-api/constant" "one-api/setting" + "one-api/setting/ratio_setting" "sort" "strings" "sync" @@ -128,12 +129,7 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, } func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { - if strings.HasPrefix(model, "gpt-4-gizmo") { - model = "gpt-4-gizmo-*" - } - if strings.HasPrefix(model, "gpt-4o-gizmo") { - model = "gpt-4o-gizmo-*" - } + model = ratio_setting.FormatMatchingModelName(model) // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index be6dd6b9..647cc1f4 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -335,12 +335,8 @@ func GetModelPrice(name string, printErr bool) (float64, bool) { modelPriceMapMutex.RLock() defer modelPriceMapMutex.RUnlock() - if strings.HasPrefix(name, "gpt-4-gizmo") { - name = "gpt-4-gizmo-*" - } - if strings.HasPrefix(name, "gpt-4o-gizmo") { - name = "gpt-4o-gizmo-*" - } + name = FormatMatchingModelName(name) + price, ok := modelPriceMap[name] if !ok { if printErr { @@ -374,11 +370,8 @@ func GetModelRatio(name string) (float64, bool, string) { modelRatioMapMutex.RLock() defer modelRatioMapMutex.RUnlock() - name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") - name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") - if strings.HasPrefix(name, "gpt-4-gizmo") { - name = "gpt-4-gizmo-*" - } + name = FormatMatchingModelName(name) + ratio, ok := modelRatioMap[name] if !ok { return 37.5, operation_setting.SelfUseModeEnabled, name @@ -429,12 +422,9 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error { func GetCompletionRatio(name string) float64 { CompletionRatioMutex.RLock() defer CompletionRatioMutex.RUnlock() - if strings.HasPrefix(name, "gpt-4-gizmo") { - name = "gpt-4-gizmo-*" - } - if strings.HasPrefix(name, "gpt-4o-gizmo") { - name = "gpt-4o-gizmo-*" - } + + name = FormatMatchingModelName(name) + if strings.Contains(name, "/") { if ratio, ok := CompletionRatio[name]; ok { return ratio @@ -664,3 +654,16 @@ func GetCompletionRatioCopy() map[string]float64 { } return copyMap } + +// 转换模型名,减少渠道必须配置各种带参数模型 +func FormatMatchingModelName(name string) string { + name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + if strings.HasPrefix(name, "gpt-4o-gizmo") { + name = "gpt-4o-gizmo-*" + } + return name +} From 14ee6651b444a85f0851a3197ef41c6ff028d4c0 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 7 Aug 2025 00:54:48 +0800 Subject: [PATCH 194/582] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20Google?= =?UTF-8?q?OpenAI=20=E5=85=BC=E5=AE=B9=E6=A8=A1=E5=9E=8B=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=BD=93=EF=BC=8C=E7=AE=80=E5=8C=96=20FetchU?= =?UTF-8?q?pstreamModels=20=E5=87=BD=E6=95=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/channel.go | 57 ++++++------------------------------------- 1 file changed, 8 insertions(+), 49 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 9f46ca35..3361cbf5 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -36,30 +36,11 @@ type OpenAIModel struct { Parent string `json:"parent"` } -type GoogleOpenAICompatibleModels []struct { - Name string `json:"name"` - Version string `json:"version"` - DisplayName string `json:"displayName"` - Description string `json:"description,omitempty"` - InputTokenLimit int `json:"inputTokenLimit"` - OutputTokenLimit int `json:"outputTokenLimit"` - SupportedGenerationMethods []string `json:"supportedGenerationMethods"` - Temperature float64 `json:"temperature,omitempty"` - TopP float64 `json:"topP,omitempty"` - TopK int `json:"topK,omitempty"` - MaxTemperature int `json:"maxTemperature,omitempty"` -} - type OpenAIModelsResponse struct { Data []OpenAIModel `json:"data"` Success bool `json:"success"` } -type GoogleOpenAICompatibleResponse struct { - Models []GoogleOpenAICompatibleModels `json:"models"` - NextPageToken string `json:"nextPageToken"` -} - func parseStatusFilter(statusParam string) int { switch strings.ToLower(statusParam) { case "enabled", "1": @@ -203,7 +184,7 @@ func FetchUpstreamModels(c *gin.Context) { switch channel.Type { case constant.ChannelTypeGemini: // curl https://example.com/v1beta/models?key=$GEMINI_API_KEY - url = fmt.Sprintf("%s/v1beta/openai/models?key=%s", baseURL, channel.Key) + url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remember key in url since we need to use AuthHeader case constant.ChannelTypeAli: url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) default: @@ -213,7 +194,7 @@ func FetchUpstreamModels(c *gin.Context) { // 获取响应体 - 根据渠道类型决定是否添加 AuthHeader var body []byte if channel.Type == constant.ChannelTypeGemini { - body, err = GetResponseBody("GET", url, channel, nil) // I don't know why, but Gemini requires no AuthHeader + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) // Use AuthHeader since Gemini now forces it } else { body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) } @@ -223,34 +204,12 @@ func FetchUpstreamModels(c *gin.Context) { } var result OpenAIModelsResponse - var parseSuccess bool - - // 适配特殊格式 - switch channel.Type { - case constant.ChannelTypeGemini: - var googleResult GoogleOpenAICompatibleResponse - if err = json.Unmarshal(body, &googleResult); err == nil { - // 转换Google格式到OpenAI格式 - for _, model := range googleResult.Models { - for _, gModel := range model { - result.Data = append(result.Data, OpenAIModel{ - ID: gModel.Name, - }) - } - } - parseSuccess = true - } - } - - // 如果解析失败,尝试OpenAI格式 - if !parseSuccess { - if err = json.Unmarshal(body, &result); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("解析响应失败: %s", err.Error()), - }) - return - } + if err = json.Unmarshal(body, &result); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("解析响应失败: %s", err.Error()), + }) + return } var ids []string From d6dea7d082ff08e2f99c33bd14a6e94520cabc1c Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 7 Aug 2025 01:01:45 +0800 Subject: [PATCH 195/582] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20FetchUpstre?= =?UTF-8?q?amModels=20=E5=87=BD=E6=95=B0=E4=B8=AD=20AuthHeader=20=E7=9A=84?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E5=A4=84=E7=90=86=20=E5=A4=9Akey=E8=81=9A=E5=90=88=E7=9A=84?= =?UTF-8?q?=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/channel.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 3361cbf5..284597c3 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -193,10 +193,11 @@ func FetchUpstreamModels(c *gin.Context) { // 获取响应体 - 根据渠道类型决定是否添加 AuthHeader var body []byte + key := strings.Split(channel.Key, "\n")[0] if channel.Type == constant.ChannelTypeGemini { - body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) // Use AuthHeader since Gemini now forces it + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) // Use AuthHeader since Gemini now forces it } else { - body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) } if err != nil { common.ApiError(c, err) From b2597206f31b91be210166013e99f2d8ded7ce4a Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 7 Aug 2025 01:06:50 +0800 Subject: [PATCH 196/582] fix a typo in comment --- controller/channel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/channel.go b/controller/channel.go index 284597c3..020a3327 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -184,7 +184,7 @@ func FetchUpstreamModels(c *gin.Context) { switch channel.Type { case constant.ChannelTypeGemini: // curl https://example.com/v1beta/models?key=$GEMINI_API_KEY - url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remember key in url since we need to use AuthHeader + url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader case constant.ChannelTypeAli: url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) default: From 342bf59c91334c6412e71ca4899385ef294a629b Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 7 Aug 2025 06:18:22 +0800 Subject: [PATCH 197/582] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4Disable=20Ping?= =?UTF-8?q?=E6=A0=87=E5=BF=97=E7=9A=84=E8=AE=BE=E7=BD=AE=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/gemini/adaptor.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 14fd278d..01dfea2c 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -120,6 +120,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { action := "generateContent" if info.IsStream { action = "streamGenerateContent?alt=sse" + if info.RelayMode == constant.RelayModeGemini { + info.DisablePing = true + } } return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil } @@ -193,7 +196,6 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { if info.RelayMode == constant.RelayModeGemini { if info.IsStream { - info.DisablePing = true return GeminiTextGenerationStreamHandler(c, info, resp) } else { return GeminiTextGenerationHandler(c, info, resp) From 1d28e4ddcb0a2ebd45d997ced824cea696defdfc Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Thu, 7 Aug 2025 10:54:05 +0800 Subject: [PATCH 198/582] =?UTF-8?q?=F0=9F=8E=A8=20feat(models):=20add=20ro?= =?UTF-8?q?w=20styling=20for=20disabled=20models=20in=20ModelsTable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visual distinction for enabled/disabled models by applying different background colors to table rows based on model status. This implementation follows the same pattern used in ChannelsTable for consistent user experience. Changes: - Modified handleRow function in useModelsData.js to include row styling - Disabled models (status !== 1) now display with gray background using --semi-color-disabled-border CSS variable - Enabled models (status === 1) maintain normal background color - Preserved existing row click selection functionality This enhancement improves the visual feedback for users to quickly identify which models are active vs inactive in the models management interface. --- .../components/SelectionNotification.jsx | 4 +- .../table/models/modals/EditModelModal.jsx | 110 ++++++++---------- web/src/hooks/models/useModelsData.js | 9 +- 3 files changed, 56 insertions(+), 67 deletions(-) diff --git a/web/src/components/table/models/components/SelectionNotification.jsx b/web/src/components/table/models/components/SelectionNotification.jsx index d886a7c0..571c8948 100644 --- a/web/src/components/table/models/components/SelectionNotification.jsx +++ b/web/src/components/table/models/components/SelectionNotification.jsx @@ -45,7 +45,7 @@ const SelectionNotification = ({ selectedKeys = [], t, onDelete, onAddPrefill, o - ))} - - - )} + {...(tagGroups.length > 0 && { + extraText: ( + + {tagGroups.map(group => ( + + ))} + + ) + })} /> @@ -398,38 +389,29 @@ const EditModelModal = (props) => { addOnBlur showClear style={{ width: '100%' }} - extraText={( - - {endpointGroups.map(group => ( - - ))} - - - )} + {...(endpointGroups.length > 0 && { + extraText: ( + + {endpointGroups.map(group => ( + + ))} + + ) + })} /> diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js index b41bdfc2..febc934e 100644 --- a/web/src/hooks/models/useModelsData.js +++ b/web/src/hooks/models/useModelsData.js @@ -247,9 +247,16 @@ export const useModelsData = () => { await loadModels(1, size, activeVendorKey); }; - // Handle row click + // Handle row click and styling const handleRow = (record, index) => { + const rowStyle = record.status !== 1 ? { + style: { + background: 'var(--semi-color-disabled-border)', + }, + } : {}; + return { + ...rowStyle, onClick: (event) => { // Don't trigger row selection when clicking on buttons if (event.target.closest('button, .semi-button')) { From ea7fd9875b558454cf94ee06a98c6348d3cafdc6 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Wed, 6 Aug 2025 22:58:36 +0800 Subject: [PATCH 199/582] feat: enable thinking mode on ali thinking model --- controller/channel-test.go | 2 +- relay/channel/ali/adaptor.go | 12 +++++++++--- relay/common/relay_info.go | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 3a7c582b..1be36808 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -275,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string) testResult { Quota: quota, Content: "模型测试", UseTimeSeconds: int(consumedTime), - IsStream: false, + IsStream: info.IsStream, Group: info.UsingGroup, Other: other, }) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 067fac37..35fe73c2 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -3,6 +3,7 @@ package ali import ( "errors" "fmt" + "github.com/gin-gonic/gin" "io" "net/http" "one-api/dto" @@ -11,8 +12,7 @@ import ( relaycommon "one-api/relay/common" "one-api/relay/constant" "one-api/types" - - "github.com/gin-gonic/gin" + "strings" ) type Adaptor struct { @@ -65,7 +65,13 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if request == nil { return nil, errors.New("request is nil") } - + // docs: https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2712216 + // fix: InternalError.Algo.InvalidParameter: The value of the enable_thinking parameter is restricted to True. + if strings.Contains(request.Model, "thinking") { + request.EnableThinking = true + request.Stream = true + info.IsStream = true + } // fix: ali parameter.enable_thinking must be set to false for non-streaming calls if !info.IsStream { request.EnableThinking = false diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 266486c4..743070ca 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -225,6 +225,9 @@ func GenRelayInfo(c *gin.Context) *RelayInfo { userId := common.GetContextKeyInt(c, constant.ContextKeyUserId) tokenUnlimited := common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited) startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) + if startTime.IsZero() { + startTime = time.Now() + } // firstResponseTime = time.Now() - 1 second apiType, _ := common.ChannelType2APIType(channelType) From 97b8d7de9ee2f94480c74ed6c51c16552f13ea99 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 7 Aug 2025 15:40:12 +0800 Subject: [PATCH 200/582] feat: update Usage struct to support dynamic token handling with ceil function #1503 --- dto/openai_response.go | 119 +++++++++++++++++++++++- relay/channel/openai/relay-openai.go | 8 +- relay/channel/openai/relay_responses.go | 8 +- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/dto/openai_response.go b/dto/openai_response.go index b050cd03..7e6ee584 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -3,6 +3,8 @@ package dto import ( "encoding/json" "fmt" + "math" + "one-api/common" "one-api/types" ) @@ -202,13 +204,124 @@ type Usage struct { PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"` CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + InputTokens any `json:"input_tokens"` + OutputTokens any `json:"output_tokens"` + //CacheReadInputTokens any `json:"cache_read_input_tokens,omitempty"` + InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` // OpenRouter Params Cost any `json:"cost,omitempty"` } +func (u *Usage) UnmarshalJSON(data []byte) error { + // first normal unmarshal + if err := common.Unmarshal(data, u); err != nil { + return fmt.Errorf("unmarshal Usage failed: %w", err) + } + + // then ceil the input and output tokens + ceil := func(val any) int { + switch v := val.(type) { + case float64: + return int(math.Ceil(v)) + case int: + return v + case string: + var intVal int + _, err := fmt.Sscanf(v, "%d", &intVal) + if err != nil { + return 0 // or handle error appropriately + } + return intVal + default: + return 0 // or handle error appropriately + } + } + + // input_tokens must be int + if u.InputTokens != nil { + u.InputTokens = ceil(u.InputTokens) + } + if u.OutputTokens != nil { + u.OutputTokens = ceil(u.OutputTokens) + } + return nil +} + +func (u *Usage) GetInputTokens() int { + if u.InputTokens == nil { + return 0 + } + + switch v := u.InputTokens.(type) { + case int: + return v + case float64: + return int(math.Ceil(v)) + case string: + var intVal int + _, err := fmt.Sscanf(v, "%d", &intVal) + if err != nil { + return 0 // or handle error appropriately + } + return intVal + default: + return 0 // or handle error appropriately + } +} + +func (u *Usage) GetOutputTokens() int { + if u.OutputTokens == nil { + return 0 + } + + switch v := u.OutputTokens.(type) { + case int: + return v + case float64: + return int(math.Ceil(v)) + case string: + var intVal int + _, err := fmt.Sscanf(v, "%d", &intVal) + if err != nil { + return 0 // or handle error appropriately + } + return intVal + default: + return 0 // or handle error appropriately + } +} + +//func (u *Usage) MarshalJSON() ([]byte, error) { +// ceil := func(val any) int { +// switch v := val.(type) { +// case float64: +// return int(math.Ceil(v)) +// case int: +// return v +// case string: +// var intVal int +// _, err := fmt.Sscanf(v, "%d", &intVal) +// if err != nil { +// return 0 // or handle error appropriately +// } +// return intVal +// default: +// return 0 // or handle error appropriately +// } +// } +// +// // input_tokens must be int +// if u.InputTokens != nil { +// u.InputTokens = ceil(u.InputTokens) +// } +// if u.OutputTokens != nil { +// u.OutputTokens = ceil(u.OutputTokens) +// } +// +// // done +// return common.Marshal(u) +//} + type InputTokenDetails struct { CachedTokens int `json:"cached_tokens"` CachedCreationTokens int `json:"-"` diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index 9ae0a200..f5e29209 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -570,11 +570,11 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h // because the upstream has already consumed resources and returned content // We should still perform billing even if parsing fails // format - if usageResp.InputTokens > 0 { - usageResp.PromptTokens += usageResp.InputTokens + if usageResp.GetInputTokens() > 0 { + usageResp.PromptTokens += usageResp.GetInputTokens() } - if usageResp.OutputTokens > 0 { - usageResp.CompletionTokens += usageResp.OutputTokens + if usageResp.GetOutputTokens() > 0 { + usageResp.CompletionTokens += usageResp.GetOutputTokens() } if usageResp.InputTokensDetails != nil { usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index bae6fcb6..2c996f91 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -38,8 +38,8 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http // compute usage usage := dto.Usage{} if responsesResponse.Usage != nil { - usage.PromptTokens = responsesResponse.Usage.InputTokens - usage.CompletionTokens = responsesResponse.Usage.OutputTokens + usage.PromptTokens = responsesResponse.Usage.GetInputTokens() + usage.CompletionTokens = responsesResponse.Usage.GetOutputTokens() usage.TotalTokens = responsesResponse.Usage.TotalTokens if responsesResponse.Usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens @@ -70,8 +70,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp switch streamResponse.Type { case "response.completed": if streamResponse.Response.Usage != nil { - usage.PromptTokens = streamResponse.Response.Usage.InputTokens - usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens + usage.PromptTokens = streamResponse.Response.Usage.GetInputTokens() + usage.CompletionTokens = streamResponse.Response.Usage.GetOutputTokens() usage.TotalTokens = streamResponse.Response.Usage.TotalTokens if streamResponse.Response.Usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens From a4b02107dd40cc62cc729ce7c44d160f7213dd5d Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 7 Aug 2025 16:15:59 +0800 Subject: [PATCH 201/582] feat: update MaxTokens handling --- controller/channel-test.go | 2 +- dto/openai_request.go | 7 ++++-- relay/channel/baidu/relay-baidu.go | 6 ++--- relay/channel/claude/relay-claude.go | 2 +- relay/channel/cloudflare/dto.go | 2 +- relay/channel/cohere/dto.go | 2 +- relay/channel/gemini/relay-gemini.go | 2 +- relay/channel/mistral/text.go | 2 +- relay/channel/ollama/relay-ollama.go | 2 +- relay/channel/palm/relay-palm.go | 24 -------------------- relay/channel/perplexity/relay-perplexity.go | 2 +- relay/channel/xunfei/relay-xunfei.go | 2 +- relay/channel/zhipu_4v/relay-zhipu_v4.go | 2 +- 13 files changed, 18 insertions(+), 39 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 3a7c582b..a83d7d2a 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -161,7 +161,7 @@ func testChannel(channel *model.Channel, testModel string) testResult { logInfo.ApiKey = "" common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, logInfo)) - priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.MaxTokens)) + priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.GetMaxTokens())) if err != nil { return testResult{ context: c, diff --git a/dto/openai_request.go b/dto/openai_request.go index 29076ef6..fcd47d07 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -99,8 +99,11 @@ type StreamOptions struct { IncludeUsage bool `json:"include_usage,omitempty"` } -func (r *GeneralOpenAIRequest) GetMaxTokens() int { - return int(r.MaxTokens) +func (r *GeneralOpenAIRequest) GetMaxTokens() uint { + if r.MaxCompletionTokens != 0 { + return r.MaxCompletionTokens + } + return r.MaxTokens } func (r *GeneralOpenAIRequest) ParseInput() []string { diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go index 06b48c20..a7cd5996 100644 --- a/relay/channel/baidu/relay-baidu.go +++ b/relay/channel/baidu/relay-baidu.go @@ -34,9 +34,9 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest { EnableCitation: false, UserId: request.User, } - if request.MaxTokens != 0 { - maxTokens := int(request.MaxTokens) - if request.MaxTokens == 1 { + if request.GetMaxTokens() != 0 { + maxTokens := int(request.GetMaxTokens()) + if request.GetMaxTokens() == 1 { maxTokens = 2 } baiduRequest.MaxOutputTokens = &maxTokens diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 64739aa9..2cbac7b7 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -149,7 +149,7 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla claudeRequest := dto.ClaudeRequest{ Model: textRequest.Model, - MaxTokens: textRequest.MaxTokens, + MaxTokens: textRequest.GetMaxTokens(), StopSequences: nil, Temperature: textRequest.Temperature, TopP: textRequest.TopP, diff --git a/relay/channel/cloudflare/dto.go b/relay/channel/cloudflare/dto.go index 62a45c40..72b40615 100644 --- a/relay/channel/cloudflare/dto.go +++ b/relay/channel/cloudflare/dto.go @@ -5,7 +5,7 @@ import "one-api/dto" type CfRequest struct { Messages []dto.Message `json:"messages,omitempty"` Lora string `json:"lora,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` Prompt string `json:"prompt,omitempty"` Raw bool `json:"raw,omitempty"` Stream bool `json:"stream,omitempty"` diff --git a/relay/channel/cohere/dto.go b/relay/channel/cohere/dto.go index 410540c0..d5127963 100644 --- a/relay/channel/cohere/dto.go +++ b/relay/channel/cohere/dto.go @@ -7,7 +7,7 @@ type CohereRequest struct { ChatHistory []ChatHistory `json:"chat_history"` Message string `json:"message"` Stream bool `json:"stream"` - MaxTokens int `json:"max_tokens"` + MaxTokens uint `json:"max_tokens"` SafetyMode string `json:"safety_mode,omitempty"` } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 18524afb..698a972c 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -184,7 +184,7 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon GenerationConfig: dto.GeminiChatGenerationConfig{ Temperature: textRequest.Temperature, TopP: textRequest.TopP, - MaxOutputTokens: textRequest.MaxTokens, + MaxOutputTokens: textRequest.GetMaxTokens(), Seed: int64(textRequest.Seed), }, } diff --git a/relay/channel/mistral/text.go b/relay/channel/mistral/text.go index e26c6101..aa925781 100644 --- a/relay/channel/mistral/text.go +++ b/relay/channel/mistral/text.go @@ -71,7 +71,7 @@ func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAI Messages: messages, Temperature: request.Temperature, TopP: request.TopP, - MaxTokens: request.MaxTokens, + MaxTokens: request.GetMaxTokens(), Tools: request.Tools, ToolChoice: request.ToolChoice, } diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index f98dfc73..d4686ce3 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -60,7 +60,7 @@ func requestOpenAI2Ollama(request *dto.GeneralOpenAIRequest) (*OllamaRequest, er TopK: request.TopK, Stop: Stop, Tools: request.Tools, - MaxTokens: request.MaxTokens, + MaxTokens: request.GetMaxTokens(), ResponseFormat: request.ResponseFormat, FrequencyPenalty: request.FrequencyPenalty, PresencePenalty: request.PresencePenalty, diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index cbd60f5e..9b8bce7d 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -18,30 +18,6 @@ import ( // https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body // https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body -func requestOpenAI2PaLM(textRequest dto.GeneralOpenAIRequest) *PaLMChatRequest { - palmRequest := PaLMChatRequest{ - Prompt: PaLMPrompt{ - Messages: make([]PaLMChatMessage, 0, len(textRequest.Messages)), - }, - Temperature: textRequest.Temperature, - CandidateCount: textRequest.N, - TopP: textRequest.TopP, - TopK: textRequest.MaxTokens, - } - for _, message := range textRequest.Messages { - palmMessage := PaLMChatMessage{ - Content: message.StringContent(), - } - if message.Role == "user" { - palmMessage.Author = "0" - } else { - palmMessage.Author = "1" - } - palmRequest.Prompt.Messages = append(palmRequest.Prompt.Messages, palmMessage) - } - return &palmRequest -} - func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse { fullTextResponse := dto.OpenAITextResponse{ Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), diff --git a/relay/channel/perplexity/relay-perplexity.go b/relay/channel/perplexity/relay-perplexity.go index 9772aead..7ebadd0f 100644 --- a/relay/channel/perplexity/relay-perplexity.go +++ b/relay/channel/perplexity/relay-perplexity.go @@ -16,6 +16,6 @@ func requestOpenAI2Perplexity(request dto.GeneralOpenAIRequest) *dto.GeneralOpen Messages: messages, Temperature: request.Temperature, TopP: request.TopP, - MaxTokens: request.MaxTokens, + MaxTokens: request.GetMaxTokens(), } } diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go index 373ad605..1a426d50 100644 --- a/relay/channel/xunfei/relay-xunfei.go +++ b/relay/channel/xunfei/relay-xunfei.go @@ -48,7 +48,7 @@ func requestOpenAI2Xunfei(request dto.GeneralOpenAIRequest, xunfeiAppId string, xunfeiRequest.Parameter.Chat.Domain = domain xunfeiRequest.Parameter.Chat.Temperature = request.Temperature xunfeiRequest.Parameter.Chat.TopK = request.N - xunfeiRequest.Parameter.Chat.MaxTokens = request.MaxTokens + xunfeiRequest.Parameter.Chat.MaxTokens = request.GetMaxTokens() xunfeiRequest.Payload.Message.Text = messages return &xunfeiRequest } diff --git a/relay/channel/zhipu_4v/relay-zhipu_v4.go b/relay/channel/zhipu_4v/relay-zhipu_v4.go index 271dda8f..98a852f5 100644 --- a/relay/channel/zhipu_4v/relay-zhipu_v4.go +++ b/relay/channel/zhipu_4v/relay-zhipu_v4.go @@ -105,7 +105,7 @@ func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReq Messages: messages, Temperature: request.Temperature, TopP: request.TopP, - MaxTokens: request.MaxTokens, + MaxTokens: request.GetMaxTokens(), Stop: Stop, Tools: request.Tools, ToolChoice: request.ToolChoice, From c4666934be30427c8cf09a424b126f225d39a273 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 7 Aug 2025 16:22:40 +0800 Subject: [PATCH 202/582] Revert "feat: update Usage struct to support dynamic token handling with ceil function #1503" This reverts commit 97b8d7de9ee2f94480c74ed6c51c16552f13ea99. --- dto/openai_response.go | 119 +----------------------- relay/channel/openai/relay-openai.go | 8 +- relay/channel/openai/relay_responses.go | 8 +- 3 files changed, 11 insertions(+), 124 deletions(-) diff --git a/dto/openai_response.go b/dto/openai_response.go index 7e6ee584..b050cd03 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -3,8 +3,6 @@ package dto import ( "encoding/json" "fmt" - "math" - "one-api/common" "one-api/types" ) @@ -204,124 +202,13 @@ type Usage struct { PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"` CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"` - InputTokens any `json:"input_tokens"` - OutputTokens any `json:"output_tokens"` - //CacheReadInputTokens any `json:"cache_read_input_tokens,omitempty"` - InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` // OpenRouter Params Cost any `json:"cost,omitempty"` } -func (u *Usage) UnmarshalJSON(data []byte) error { - // first normal unmarshal - if err := common.Unmarshal(data, u); err != nil { - return fmt.Errorf("unmarshal Usage failed: %w", err) - } - - // then ceil the input and output tokens - ceil := func(val any) int { - switch v := val.(type) { - case float64: - return int(math.Ceil(v)) - case int: - return v - case string: - var intVal int - _, err := fmt.Sscanf(v, "%d", &intVal) - if err != nil { - return 0 // or handle error appropriately - } - return intVal - default: - return 0 // or handle error appropriately - } - } - - // input_tokens must be int - if u.InputTokens != nil { - u.InputTokens = ceil(u.InputTokens) - } - if u.OutputTokens != nil { - u.OutputTokens = ceil(u.OutputTokens) - } - return nil -} - -func (u *Usage) GetInputTokens() int { - if u.InputTokens == nil { - return 0 - } - - switch v := u.InputTokens.(type) { - case int: - return v - case float64: - return int(math.Ceil(v)) - case string: - var intVal int - _, err := fmt.Sscanf(v, "%d", &intVal) - if err != nil { - return 0 // or handle error appropriately - } - return intVal - default: - return 0 // or handle error appropriately - } -} - -func (u *Usage) GetOutputTokens() int { - if u.OutputTokens == nil { - return 0 - } - - switch v := u.OutputTokens.(type) { - case int: - return v - case float64: - return int(math.Ceil(v)) - case string: - var intVal int - _, err := fmt.Sscanf(v, "%d", &intVal) - if err != nil { - return 0 // or handle error appropriately - } - return intVal - default: - return 0 // or handle error appropriately - } -} - -//func (u *Usage) MarshalJSON() ([]byte, error) { -// ceil := func(val any) int { -// switch v := val.(type) { -// case float64: -// return int(math.Ceil(v)) -// case int: -// return v -// case string: -// var intVal int -// _, err := fmt.Sscanf(v, "%d", &intVal) -// if err != nil { -// return 0 // or handle error appropriately -// } -// return intVal -// default: -// return 0 // or handle error appropriately -// } -// } -// -// // input_tokens must be int -// if u.InputTokens != nil { -// u.InputTokens = ceil(u.InputTokens) -// } -// if u.OutputTokens != nil { -// u.OutputTokens = ceil(u.OutputTokens) -// } -// -// // done -// return common.Marshal(u) -//} - type InputTokenDetails struct { CachedTokens int `json:"cached_tokens"` CachedCreationTokens int `json:"-"` diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index f5e29209..9ae0a200 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -570,11 +570,11 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h // because the upstream has already consumed resources and returned content // We should still perform billing even if parsing fails // format - if usageResp.GetInputTokens() > 0 { - usageResp.PromptTokens += usageResp.GetInputTokens() + if usageResp.InputTokens > 0 { + usageResp.PromptTokens += usageResp.InputTokens } - if usageResp.GetOutputTokens() > 0 { - usageResp.CompletionTokens += usageResp.GetOutputTokens() + if usageResp.OutputTokens > 0 { + usageResp.CompletionTokens += usageResp.OutputTokens } if usageResp.InputTokensDetails != nil { usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 2c996f91..bae6fcb6 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -38,8 +38,8 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http // compute usage usage := dto.Usage{} if responsesResponse.Usage != nil { - usage.PromptTokens = responsesResponse.Usage.GetInputTokens() - usage.CompletionTokens = responsesResponse.Usage.GetOutputTokens() + usage.PromptTokens = responsesResponse.Usage.InputTokens + usage.CompletionTokens = responsesResponse.Usage.OutputTokens usage.TotalTokens = responsesResponse.Usage.TotalTokens if responsesResponse.Usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens @@ -70,8 +70,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp switch streamResponse.Type { case "response.completed": if streamResponse.Response.Usage != nil { - usage.PromptTokens = streamResponse.Response.Usage.GetInputTokens() - usage.CompletionTokens = streamResponse.Response.Usage.GetOutputTokens() + usage.PromptTokens = streamResponse.Response.Usage.InputTokens + usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens usage.TotalTokens = streamResponse.Response.Usage.TotalTokens if streamResponse.Response.Usage.InputTokensDetails != nil { usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens From d37af13b33d3d7a5da0f3e2d5a6a3e436edec03a Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 7 Aug 2025 18:32:31 +0800 Subject: [PATCH 203/582] feat: support qwen claude format --- relay/channel/ali/adaptor.go | 62 +++++++++++++++++----------- relay/channel/claude/adaptor.go | 2 +- relay/channel/claude/relay-claude.go | 2 +- relay/channel/vertex/adaptor.go | 2 +- service/error.go | 3 ++ 5 files changed, 44 insertions(+), 27 deletions(-) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 35fe73c2..f3c5cee6 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -8,6 +8,7 @@ import ( "net/http" "one-api/dto" "one-api/relay/channel" + "one-api/relay/channel/claude" "one-api/relay/channel/openai" relaycommon "one-api/relay/common" "one-api/relay/constant" @@ -23,10 +24,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt return nil, errors.New("not implemented") } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - panic("implement me") - return nil, nil +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + return req, nil } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { @@ -34,18 +33,24 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { var fullRequestURL string - switch info.RelayMode { - case constant.RelayModeEmbeddings: - fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.BaseUrl) - case constant.RelayModeRerank: - fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl) - case constant.RelayModeImagesGenerations: - fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl) - case constant.RelayModeCompletions: - fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.BaseUrl) + switch info.RelayFormat { + case relaycommon.RelayFormatClaude: + fullRequestURL = fmt.Sprintf("%s/api/v2/apps/claude-code-proxy/v1/messages", info.BaseUrl) default: - fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.BaseUrl) + switch info.RelayMode { + case constant.RelayModeEmbeddings: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.BaseUrl) + case constant.RelayModeRerank: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl) + case constant.RelayModeImagesGenerations: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl) + case constant.RelayModeCompletions: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.BaseUrl) + default: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.BaseUrl) + } } + return fullRequestURL, nil } @@ -112,18 +117,27 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - switch info.RelayMode { - case constant.RelayModeImagesGenerations: - err, usage = aliImageHandler(c, resp, info) - case constant.RelayModeEmbeddings: - err, usage = aliEmbeddingHandler(c, resp) - case constant.RelayModeRerank: - err, usage = RerankHandler(c, resp, info) - default: + switch info.RelayFormat { + case relaycommon.RelayFormatClaude: if info.IsStream { - usage, err = openai.OaiStreamHandler(c, info, resp) + err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) } else { - usage, err = openai.OpenaiHandler(c, info, resp) + err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + switch info.RelayMode { + case constant.RelayModeImagesGenerations: + err, usage = aliImageHandler(c, resp, info) + case constant.RelayModeEmbeddings: + err, usage = aliEmbeddingHandler(c, resp) + case constant.RelayModeRerank: + err, usage = RerankHandler(c, resp, info) + default: + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } } } return diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 0f7a9414..39b8ce2f 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -104,7 +104,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom if info.IsStream { err, usage = ClaudeStreamHandler(c, resp, info, a.RequestMode) } else { - err, usage = ClaudeHandler(c, resp, a.RequestMode, info) + err, usage = ClaudeHandler(c, resp, info, a.RequestMode) } return } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 2cbac7b7..e4d3975e 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -740,7 +740,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud return nil } -func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { +func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) { defer common.CloseResponseBodyGracefully(resp) claudeInfo := &ClaudeResponseInfo{ diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 4648a384..35e4490b 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -238,7 +238,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom } else { switch a.RequestMode { case RequestModeClaude: - err, usage = claude.ClaudeHandler(c, resp, claude.RequestModeMessage, info) + err, usage = claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) case RequestModeGemini: if info.RelayMode == constant.RelayModeGemini { usage, err = gemini.GeminiTextGenerationHandler(c, info, resp) diff --git a/service/error.go b/service/error.go index ad28c90f..9672402d 100644 --- a/service/error.go +++ b/service/error.go @@ -93,6 +93,9 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *t if showBodyWhenFail { newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) } else { + if common.DebugEnabled { + println(fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) + } newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) } return From b8b59a134e6fcf96061b86cdbbb2fd476c7a84d2 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 7 Aug 2025 19:01:49 +0800 Subject: [PATCH 204/582] feat: support deepseek claude format (convert) --- dto/claude.go | 2 +- relay/channel/deepseek/adaptor.go | 7 +++---- service/convert.go | 6 +++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/dto/claude.go b/dto/claude.go index ea099df4..7b5f348e 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -361,7 +361,7 @@ type ClaudeUsage struct { CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` OutputTokens int `json:"output_tokens"` - ServerToolUse *ClaudeServerToolUse `json:"server_tool_use"` + ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"` } type ClaudeServerToolUse struct { diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index ac8ea18f..be8de0c8 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -24,10 +24,9 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt return nil, errors.New("not implemented") } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - panic("implement me") - return nil, nil +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { diff --git a/service/convert.go b/service/convert.go index ee8ecee5..967e4682 100644 --- a/service/convert.go +++ b/service/convert.go @@ -283,7 +283,9 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon if chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" { // should be done info.FinishReason = *chosenChoice.FinishReason - return claudeResponses + if !info.Done { + return claudeResponses + } } if info.Done { claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) @@ -432,6 +434,8 @@ func stopReasonOpenAI2Claude(reason string) string { return "end_turn" case "stop_sequence": return "stop_sequence" + case "length": + fallthrough case "max_tokens": return "max_tokens" case "tool_calls": From deb6fbbe2109aa10d373e3b31f01e5e90386a3f7 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 7 Aug 2025 19:19:59 +0800 Subject: [PATCH 205/582] feat: implement ConvertClaudeRequest method in baidu_v2 Adaptor --- relay/channel/baidu_v2/adaptor.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go index b8a4ac2f..c0ea0e60 100644 --- a/relay/channel/baidu_v2/adaptor.go +++ b/relay/channel/baidu_v2/adaptor.go @@ -23,10 +23,9 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt return nil, errors.New("not implemented") } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - panic("implement me") - return nil, nil +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { From ef2bed2fe7c8a92231773dbf5a4216b2eb3d556a Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 7 Aug 2025 19:30:42 +0800 Subject: [PATCH 206/582] feat: enhance Adaptor to support multiple relay modes in request handling --- relay/channel/baidu_v2/adaptor.go | 23 +++++++++++++++++------ relay/channel/volcengine/adaptor.go | 25 +++++++++---------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go index c0ea0e60..ba59e307 100644 --- a/relay/channel/baidu_v2/adaptor.go +++ b/relay/channel/baidu_v2/adaptor.go @@ -9,6 +9,7 @@ import ( "one-api/relay/channel" "one-api/relay/channel/openai" relaycommon "one-api/relay/common" + "one-api/relay/constant" "one-api/types" "strings" @@ -42,7 +43,20 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - return fmt.Sprintf("%s/v2/chat/completions", info.BaseUrl), nil + switch info.RelayMode { + case constant.RelayModeChatCompletions: + return fmt.Sprintf("%s/v2/chat/completions", info.BaseUrl), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/v2/embeddings", info.BaseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/v2/images/generations", info.BaseUrl), nil + case constant.RelayModeImagesEdits: + return fmt.Sprintf("%s/v2/images/edits", info.BaseUrl), nil + case constant.RelayModeRerank: + return fmt.Sprintf("%s/v2/rerank", info.BaseUrl), nil + default: + } + return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { @@ -98,11 +112,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - if info.IsStream { - usage, err = openai.OaiStreamHandler(c, info, resp) - } else { - usage, err = openai.OpenaiHandler(c, info, resp) - } + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) return } diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index 225b3895..2cc4f663 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -28,10 +28,9 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt return nil, errors.New("not implemented") } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - panic("implement me") - return nil, nil +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { @@ -196,6 +195,10 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil case constant.RelayModeImagesGenerations: return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil + case constant.RelayModeImagesEdits: + return fmt.Sprintf("%s/api/v3/images/edits", info.BaseUrl), nil + case constant.RelayModeRerank: + return fmt.Sprintf("%s/api/v3/rerank", info.BaseUrl), nil default: } return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) @@ -232,18 +235,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - switch info.RelayMode { - case constant.RelayModeChatCompletions: - if info.IsStream { - usage, err = openai.OaiStreamHandler(c, info, resp) - } else { - usage, err = openai.OpenaiHandler(c, info, resp) - } - case constant.RelayModeEmbeddings: - usage, err = openai.OpenaiHandler(c, info, resp) - case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: - usage, err = openai.OpenaiHandlerWithUsage(c, info, resp) - } + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) return } From 2ef6e340a8a1651ff50d21049b82f0a5ef4500e6 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 7 Aug 2025 21:37:08 +0800 Subject: [PATCH 207/582] feat: update FormatMatchingModelName to handle gemini-2.5-flash-lite model prefix --- setting/ratio_setting/model_ratio.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 647cc1f4..d47b86db 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -657,8 +657,15 @@ func GetCompletionRatioCopy() map[string]float64 { // 转换模型名,减少渠道必须配置各种带参数模型 func FormatMatchingModelName(name string) string { - name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") - name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") + + if strings.HasPrefix(name, "gemini-2.5-flash-lite") { + name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*") + } else if strings.HasPrefix(name, "gemini-2.5-flash") { + name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + } else if strings.HasPrefix(name, "gemini-2.5-pro") { + name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") + } + if strings.HasPrefix(name, "gpt-4-gizmo") { name = "gpt-4-gizmo-*" } From 87cfcf1190856064e1dc26f69c24730c5fd27809 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 7 Aug 2025 21:39:11 +0800 Subject: [PATCH 208/582] feat: add default model ratio for gemini-2.5-flash-lite-preview-thinking model --- setting/ratio_setting/model_ratio.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index d47b86db..c0cbe190 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -150,6 +150,7 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-preview-05-20-nothinking": 0.075, "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率 "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率 + "gemini-2.5-flash-lite-preview-thinking-*": 0.05, "gemini-2.5-flash-lite-preview-06-17": 0.05, "gemini-2.5-flash": 0.15, "text-embedding-004": 0.001, @@ -503,9 +504,6 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { return 3.5 / 0.15, false } if strings.HasPrefix(name, "gemini-2.5-flash-lite") { - if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") { - return 4, false - } return 4, false } return 2.5 / 0.3, true From 037cc47354cbef34d97b82e173083e848695bf5c Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 7 Aug 2025 21:58:15 +0800 Subject: [PATCH 209/582] feat: optimize channel retrieval by respecting original model names --- model/channel_cache.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/model/channel_cache.go b/model/channel_cache.go index 90bd2ad1..86866e40 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -129,8 +129,6 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, } func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { - model = ratio_setting.FormatMatchingModelName(model) - // if memory cache is disabled, get channel directly from database if !common.MemoryCacheEnabled { return GetRandomSatisfiedChannel(group, model, retry) @@ -138,8 +136,16 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, channelSyncLock.RLock() defer channelSyncLock.RUnlock() + + // First, try to find channels with the exact model name. channels := group2model2channels[group][model] + // If no channels found, try to find channels with the normalized model name. + if len(channels) == 0 { + normalizedModel := ratio_setting.FormatMatchingModelName(model) + channels = group2model2channels[group][normalizedModel] + } + if len(channels) == 0 { return nil, nil } From 5b268d855e1d2970dc3d1ad193b2915469bd447d Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 8 Aug 2025 02:34:15 +0800 Subject: [PATCH 210/582] =?UTF-8?q?=E2=9C=A8=20feat(pricing+endpoints+ui):?= =?UTF-8?q?=20wire=20custom=20endpoint=20mapping=20end=E2=80=91to=E2=80=91?= =?UTF-8?q?end=20and=20overhaul=20visual=20JSON=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (Go) - Include custom endpoints in each model’s SupportedEndpointTypes by parsing Model.Endpoints (JSON) and appending keys alongside native endpoint types. - Build a global supportedEndpointMap map[string]EndpointInfo{path, method} by: - Seeding with native defaults. - Overriding/adding from models.endpoints (accepts string path → default POST, or {path, method}). - Expose supported_endpoint at the top level of /api/pricing (vendors-like), removing per-model duplication. - Fix default path for EndpointTypeOpenAIResponse to /v1/responses. - Keep concurrency/caching for pricing retrieval intact. Frontend (React) - Fetch supported_endpoint in useModelPricingData and propagate to PricingPage → ModelDetailSideSheet → ModelEndpoints. - ModelEndpoints - Resolve path+method via endpointMap; replace {model} with actual model name. - Fix mobile visibility; always show path and HTTP method. - JSONEditor - Wrap with Form.Slot to inherit form layout; simplify visual styles. - Use Tabs for “Visual” / “Manual” modes. - Unify editors: key-value editor now supports nested JSON: - “+” to convert a primitive into an object and add nested fields. - Add “Convert to value” for two‑way toggle back from object. - Stable key rename without reordering rows; new rows append at bottom. - Use Row/Col grid for clean alignment; region editor uses Form.Slot + grid. - Editing flows - EditModelModal / EditPrefillGroupModal use JSONEditor (editorType='object') for endpoint mappings. - PrefillGroupManagement renders endpoint group items by JSON keys. Data expectations / compatibility - models.endpoints should be a JSON object mapping endpoint type → string path or {path, method}. Strings default to POST. - No schema changes; existing TEXT field continues to store JSON. QA - /api/pricing now returns custom endpoint types and global supported_endpoint. - UI shows both native and custom endpoints; paths/methods render on mobile; nested editing works and preserves order. --- common/endpoint_defaults.go | 32 + controller/pricing.go | 7 +- model/pricing.go | 139 +++- web/src/components/common/ui/JSONEditor.js | 698 +++++++++--------- .../model-pricing/layout/PricingPage.jsx | 1 + .../modal/ModelDetailSideSheet.jsx | 3 +- .../modal/components/ModelEndpoints.jsx | 58 +- .../table/models/modals/EditModelModal.jsx | 54 +- .../models/modals/EditPrefillGroupModal.jsx | 59 +- .../models/modals/PrefillGroupManagement.jsx | 16 +- .../model-pricing/useModelPricingData.js | 5 +- 11 files changed, 614 insertions(+), 458 deletions(-) create mode 100644 common/endpoint_defaults.go diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go new file mode 100644 index 00000000..1dfe1dc9 --- /dev/null +++ b/common/endpoint_defaults.go @@ -0,0 +1,32 @@ +package common + +import "one-api/constant" + +// EndpointInfo 描述单个端点的默认请求信息 +// path: 上游路径 +// method: HTTP 请求方式,例如 POST/GET +// 目前均为 POST,后续可扩展 +// +// json 标签用于直接序列化到 API 输出 +// 例如:{"path":"/v1/chat/completions","method":"POST"} + +type EndpointInfo struct { + Path string `json:"path"` + Method string `json:"method"` +} + +// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method +var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ + constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"}, + constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, + constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, + constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, + constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"}, + constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, +} + +// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 +func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) { + info, ok := defaultEndpointInfoMap[et] + return info, ok +} diff --git a/controller/pricing.go b/controller/pricing.go index 7205cb03..e1719cf3 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -42,9 +42,10 @@ func GetPricing(c *gin.Context) { "success": true, "data": pricing, "vendors": model.GetVendors(), - "group_ratio": groupRatio, - "usable_group": usableGroup, - }) + "group_ratio": groupRatio, + "usable_group": usableGroup, + "supported_endpoint": model.GetSupportedEndpointMap(), + }) } func ResetModelRatio(c *gin.Context) { diff --git a/model/pricing.go b/model/pricing.go index 1eaf8c16..2b3920ba 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -1,28 +1,30 @@ package model import ( - "fmt" - "strings" - "one-api/common" - "one-api/constant" - "one-api/setting/ratio_setting" - "one-api/types" - "sync" - "time" + "encoding/json" + "fmt" + "strings" + + "one-api/common" + "one-api/constant" + "one-api/setting/ratio_setting" + "one-api/types" + "sync" + "time" ) type Pricing struct { - ModelName string `json:"model_name"` - Description string `json:"description,omitempty"` - Tags string `json:"tags,omitempty"` - VendorID int `json:"vendor_id,omitempty"` - QuotaType int `json:"quota_type"` - ModelRatio float64 `json:"model_ratio"` - ModelPrice float64 `json:"model_price"` - OwnerBy string `json:"owner_by"` - CompletionRatio float64 `json:"completion_ratio"` - EnableGroup []string `json:"enable_groups"` - SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` + ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + OwnerBy string `json:"owner_by"` + CompletionRatio float64 `json:"completion_ratio"` + EnableGroup []string `json:"enable_groups"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` } type PricingVendor struct { @@ -33,10 +35,11 @@ type PricingVendor struct { } var ( - pricingMap []Pricing - vendorsList []PricingVendor - lastGetPricingTime time.Time - updatePricingLock sync.Mutex + pricingMap []Pricing + vendorsList []PricingVendor + supportedEndpointMap map[string]common.EndpointInfo + lastGetPricingTime time.Time + updatePricingLock sync.Mutex // 缓存映射:模型名 -> 启用分组 / 计费类型 modelEnableGroups = make(map[string][]string) @@ -176,20 +179,34 @@ func updatePricing() { //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 modelSupportEndpointsStr := make(map[string][]string) - for _, ability := range enableAbilities { - endpoints, ok := modelSupportEndpointsStr[ability.Model] - if !ok { - endpoints = make([]string, 0) - modelSupportEndpointsStr[ability.Model] = endpoints - } - channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) - for _, channelType := range channelTypes { - if !common.StringsContains(endpoints, string(channelType)) { - endpoints = append(endpoints, string(channelType)) - } - } - modelSupportEndpointsStr[ability.Model] = endpoints - } + // 先根据已有能力填充原生端点 + for _, ability := range enableAbilities { + endpoints := modelSupportEndpointsStr[ability.Model] + channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) + for _, channelType := range channelTypes { + if !common.StringsContains(endpoints, string(channelType)) { + endpoints = append(endpoints, string(channelType)) + } + } + modelSupportEndpointsStr[ability.Model] = endpoints + } + + // 再补充模型自定义端点 + for modelName, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + endpoints := modelSupportEndpointsStr[modelName] + for k := range raw { + if !common.StringsContains(endpoints, k) { + endpoints = append(endpoints, k) + } + } + modelSupportEndpointsStr[modelName] = endpoints + } + } modelSupportEndpointTypes = make(map[string][]constant.EndpointType) for model, endpoints := range modelSupportEndpointsStr { @@ -199,9 +216,48 @@ func updatePricing() { supportedEndpoints = append(supportedEndpoints, endpointType) } modelSupportEndpointTypes[model] = supportedEndpoints - } + } - pricingMap = make([]Pricing, 0) + // 构建全局 supportedEndpointMap(默认 + 自定义覆盖) + supportedEndpointMap = make(map[string]common.EndpointInfo) + // 1. 默认端点 + for _, endpoints := range modelSupportEndpointTypes { + for _, et := range endpoints { + if info, ok := common.GetDefaultEndpointInfo(et); ok { + if _, exists := supportedEndpointMap[string(et)]; !exists { + supportedEndpointMap[string(et)] = info + } + } + } + } + // 2. 自定义端点(models 表)覆盖默认 + for _, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + for k, v := range raw { + switch val := v.(type) { + case string: + supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"} + case map[string]interface{}: + ep := common.EndpointInfo{Method: "POST"} + if p, ok := val["path"].(string); ok { + ep.Path = p + } + if m, ok := val["method"].(string); ok { + ep.Method = strings.ToUpper(m) + } + supportedEndpointMap[k] = ep + default: + // ignore unsupported types + } + } + } + } + + pricingMap = make([]Pricing, 0) for model, groups := range modelGroupsMap { pricing := Pricing{ ModelName: model, @@ -244,3 +300,8 @@ func updatePricing() { lastGetPricingTime = time.Now() } + +// GetSupportedEndpointMap 返回全局端点到路径的映射 +func GetSupportedEndpointMap() map[string]common.EndpointInfo { + return supportedEndpointMap +} diff --git a/web/src/components/common/ui/JSONEditor.js b/web/src/components/common/ui/JSONEditor.js index 649d5a58..fd4064dd 100644 --- a/web/src/components/common/ui/JSONEditor.js +++ b/web/src/components/common/ui/JSONEditor.js @@ -1,25 +1,25 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { - Space, Button, Form, - Card, Typography, Banner, - Row, - Col, + Tabs, + TabPane, + Card, + Input, InputNumber, Switch, - Select, - Input, + TextArea, + Row, + Col, } from '@douyinfe/semi-ui'; import { IconCode, - IconEdit, IconPlus, IconDelete, - IconSetting, + IconRefresh, } from '@douyinfe/semi-icons'; const { Text } = Typography; @@ -34,18 +34,17 @@ const JSONEditor = ({ showClear = true, template, templateLabel, - editorType = 'keyValue', // keyValue, object, region - autosize = true, + editorType = 'keyValue', rules = [], formApi = null, ...props }) => { const { t } = useTranslation(); - + // 初始化JSON数据 const [jsonData, setJsonData] = useState(() => { // 初始化时解析JSON数据 - if (value && value.trim()) { + if (typeof value === 'string' && value.trim()) { try { const parsed = JSON.parse(value); return parsed; @@ -53,13 +52,16 @@ const JSONEditor = ({ return {}; } } + if (typeof value === 'object' && value !== null) { + return value; + } return {}; }); - + // 根据键数量决定默认编辑模式 const [editMode, setEditMode] = useState(() => { // 如果初始JSON数据的键数量大于10个,则默认使用手动模式 - if (value && value.trim()) { + if (typeof value === 'string' && value.trim()) { try { const parsed = JSON.parse(value); const keyCount = Object.keys(parsed).length; @@ -76,7 +78,12 @@ const JSONEditor = ({ // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效) useEffect(() => { try { - const parsed = value && value.trim() ? JSON.parse(value) : {}; + let parsed = {}; + if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } setJsonData(parsed); setJsonError(''); } catch (error) { @@ -86,18 +93,17 @@ const JSONEditor = ({ } }, [value]); - // 处理可视化编辑的数据变化 const handleVisualChange = useCallback((newData) => { setJsonData(newData); setJsonError(''); const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2); - + // 通过formApi设置值(如果提供的话) if (formApi && field) { formApi.setValue(field, jsonString); } - + onChange?.(jsonString); }, [onChange, formApi, field]); @@ -127,7 +133,12 @@ const JSONEditor = ({ } else { // 从手动模式切换到可视化模式,需要验证JSON try { - const parsed = value && value.trim() ? JSON.parse(value) : {}; + let parsed = {}; + if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } setJsonData(parsed); setJsonError(''); setEditMode('visual'); @@ -143,11 +154,11 @@ const JSONEditor = ({ const addKeyValue = useCallback(() => { const newData = { ...jsonData }; const keys = Object.keys(newData); - let newKey = 'key'; let counter = 1; + let newKey = `field_${counter}`; while (newData.hasOwnProperty(newKey)) { - newKey = `key${counter}`; - counter++; + counter += 1; + newKey = `field_${counter}`; } newData[newKey] = ''; handleVisualChange(newData); @@ -162,11 +173,15 @@ const JSONEditor = ({ // 更新键名 const updateKey = useCallback((oldKey, newKey) => { - if (oldKey === newKey) return; - const newData = { ...jsonData }; - const value = newData[oldKey]; - delete newData[oldKey]; - newData[newKey] = value; + if (oldKey === newKey || !newKey) return; + const newData = {}; + Object.entries(jsonData).forEach(([k, v]) => { + if (k === oldKey) { + newData[newKey] = v; + } else { + newData[k] = v; + } + }); handleVisualChange(newData); }, [jsonData, handleVisualChange]); @@ -181,20 +196,20 @@ const JSONEditor = ({ const fillTemplate = useCallback(() => { if (template) { const templateString = JSON.stringify(template, null, 2); - + // 通过formApi设置值(如果提供的话) if (formApi && field) { formApi.setValue(field, templateString); } - + // 无论哪种模式都要更新值 onChange?.(templateString); - + // 如果是可视化模式,同时更新jsonData if (editMode === 'visual') { setJsonData(template); } - + // 清除错误状态 setJsonError(''); } @@ -215,69 +230,47 @@ const JSONEditor = ({ ); } const entries = Object.entries(jsonData); - + return (
    {entries.length === 0 && (
    -
    - -
    {t('暂无数据,点击下方按钮添加键值对')}
    )} - + {entries.map(([key, value], index) => ( - - -
    -
    - {t('键名')} - updateKey(key, newKey)} - size="small" - /> -
    - - -
    - {t('值')} - updateValue(key, newValue)} - size="small" - /> -
    - - -
    -
    - - - + + + updateKey(key, newKey)} + /> + + + {renderValueInput(key, value)} + + + @@ -286,100 +279,61 @@ const JSONEditor = ({ ); }; - // 渲染对象编辑器(用于复杂JSON) - const renderObjectEditor = () => { - const entries = Object.entries(jsonData); - - return ( -
    - {entries.length === 0 && ( -
    -
    - -
    - - {t('暂无参数,点击下方按钮添加请求参数')} - -
    - )} - - {entries.map(([key, value], index) => ( - - -
    -
    - {t('参数名')} - updateKey(key, newKey)} - size="small" - /> -
    - - -
    - {t('参数值')} ({typeof value}) - {renderValueInput(key, value)} -
    - - -
    -
    - - - - ))} - -
    - -
    - - ); - }; + // 添加嵌套对象 + const flattenObject = useCallback((parentKey) => { + const newData = { ...jsonData }; + let primitive = ''; + const obj = newData[parentKey]; + if (obj && typeof obj === 'object') { + const firstKey = Object.keys(obj)[0]; + if (firstKey !== undefined) { + const firstVal = obj[firstKey]; + if (typeof firstVal !== 'object') primitive = firstVal; + } + } + newData[parentKey] = primitive; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); - // 渲染参数值输入控件 + const addNestedObject = useCallback((parentKey) => { + const newData = { ...jsonData }; + if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) { + newData[parentKey] = {}; + } + const existingKeys = Object.keys(newData[parentKey]); + let counter = 1; + let newKey = `field_${counter}`; + while (newData[parentKey].hasOwnProperty(newKey)) { + counter += 1; + newKey = `field_${counter}`; + } + newData[parentKey][newKey] = ''; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 渲染参数值输入控件(支持嵌套) const renderValueInput = (key, value) => { const valueType = typeof value; - + if (valueType === 'boolean') { return (
    updateValue(key, newValue)} - size="small" /> - + {value ? t('true') : t('false')}
    ); } - + if (valueType === 'number') { return ( updateValue(key, newValue)} - size="small" style={{ width: '100%' }} step={key === 'temperature' ? 0.1 : 1} precision={key === 'temperature' ? 2 : 0} @@ -387,25 +341,137 @@ const JSONEditor = ({ /> ); } - - // 字符串类型或其他类型 + + if (valueType === 'object' && value !== null) { + // 渲染嵌套对象 + const entries = Object.entries(value); + return ( + + {entries.length === 0 && ( + + {t('空对象,点击下方加号添加字段')} + + )} + + {entries.map(([nestedKey, nestedValue], index) => ( + + + { + const newData = { ...jsonData }; + const oldValue = newData[key][nestedKey]; + delete newData[key][nestedKey]; + newData[key][newKey] = oldValue; + handleVisualChange(newData); + }} + /> + + + {typeof nestedValue === 'object' && nestedValue !== null ? ( +