From 7975cdf3bf6dd19e4db18c90861148bd5ada1877 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Thu, 19 Jun 2025 08:57:34 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=9A=80=20feat(ratio-sync):=20major=20?= =?UTF-8?q?refactor=20&=20UX=20overhaul=20for=20Upstream=20Ratio=20Sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/utils.go | 18 + controller/ratio_config.go | 24 + controller/ratio_sync.go | 290 +++++++++ dto/ratio_sync.go | 50 ++ model/option.go | 3 + router/api-router.go | 7 + setting/ratio_setting/cache_ratio.go | 16 +- setting/ratio_setting/expose_ratio.go | 17 + setting/ratio_setting/exposed_cache.go | 55 ++ setting/ratio_setting/model_ratio.go | 48 +- .../settings/ChannelSelectorModal.js | 154 +++++ web/src/components/settings/RatioSetting.js | 18 +- web/src/helpers/ratio.js | 20 + web/src/pages/Detail/index.js | 2 - .../pages/Setting/Ratio/ModelRatioSettings.js | 12 + .../pages/Setting/Ratio/UpstreamRatioSync.js | 596 ++++++++++++++++++ 16 files changed, 1319 insertions(+), 11 deletions(-) create mode 100644 controller/ratio_config.go create mode 100644 controller/ratio_sync.go create mode 100644 dto/ratio_sync.go create mode 100644 setting/ratio_setting/expose_ratio.go create mode 100644 setting/ratio_setting/exposed_cache.go create mode 100644 web/src/components/settings/ChannelSelectorModal.js create mode 100644 web/src/helpers/ratio.js create mode 100644 web/src/pages/Setting/Ratio/UpstreamRatioSync.js diff --git a/common/utils.go b/common/utils.go index d9db67d0..17aecd95 100644 --- a/common/utils.go +++ b/common/utils.go @@ -13,6 +13,7 @@ import ( "math/big" "math/rand" "net" + "net/url" "os" "os/exec" "runtime" @@ -284,3 +285,20 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64 } return strconv.ParseFloat(durationStr, 64) } + +// BuildURL concatenates base and endpoint, returns the complete url string +func BuildURL(base string, endpoint string) string { + u, err := url.Parse(base) + if err != nil { + return base + endpoint + } + end := endpoint + if end == "" { + end = "/" + } + ref, err := url.Parse(end) + if err != nil { + return base + endpoint + } + return u.ResolveReference(ref).String() +} diff --git a/controller/ratio_config.go b/controller/ratio_config.go new file mode 100644 index 00000000..6ddc3d9e --- /dev/null +++ b/controller/ratio_config.go @@ -0,0 +1,24 @@ +package controller + +import ( + "net/http" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetRatioConfig(c *gin.Context) { + if !ratio_setting.IsExposeRatioEnabled() { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "倍率配置接口未启用", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ratio_setting.GetExposedData(), + }) +} \ No newline at end of file diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go new file mode 100644 index 00000000..c7494b5b --- /dev/null +++ b/controller/ratio_sync.go @@ -0,0 +1,290 @@ +package controller + +import ( + "encoding/json" + "net/http" + "one-api/model" + "one-api/setting/ratio_setting" + "one-api/dto" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type upstreamResult struct { + Name string `json:"name"` + Data map[string]any `json:"data,omitempty"` + Err string `json:"err,omitempty"` +} + +type TestResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +type DifferenceItem struct { + Current interface{} `json:"current"` // 当前本地值,可能为null + Upstreams map[string]interface{} `json:"upstreams"` // 上游值:具体值/"same"/null +} + +// SyncableChannel 可同步的渠道信息 +type SyncableChannel struct { + ID int `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Status int `json:"status"` +} + +// FetchUpstreamRatios 后端并发拉取上游倍率 +func FetchUpstreamRatios(c *gin.Context) { + var req dto.UpstreamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + + if req.Timeout <= 0 { + req.Timeout = 10 + } + + // build upstream list from ids + custom + var upstreams []dto.UpstreamDTO + if len(req.ChannelIDs) > 0 { + // convert []int64 -> []int for model function + intIds := make([]int, 0, len(req.ChannelIDs)) + for _, id64 := range req.ChannelIDs { + intIds = append(intIds, int(id64)) + } + dbChannels, _ := model.GetChannelsByIds(intIds) + for _, ch := range dbChannels { + upstreams = append(upstreams, dto.UpstreamDTO{ + Name: ch.Name, + BaseURL: ch.GetBaseURL(), + Endpoint: "", // assume default endpoint + }) + } + } + upstreams = append(upstreams, req.CustomChannels...) + + var wg sync.WaitGroup + ch := make(chan upstreamResult, len(upstreams)) + + for _, chn := range upstreams { + wg.Add(1) + go func(chItem dto.UpstreamDTO) { + defer wg.Done() + endpoint := chItem.Endpoint + if endpoint == "" { + endpoint = "/api/ratio_config" + } + url := chItem.BaseURL + endpoint + client := http.Client{Timeout: time.Duration(req.Timeout) * time.Second} + resp, err := client.Get(url) + if err != nil { + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + ch <- upstreamResult{Name: chItem.Name, Err: resp.Status} + return + } + var body struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + Message string `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + if !body.Success { + ch <- upstreamResult{Name: chItem.Name, Err: body.Message} + return + } + ch <- upstreamResult{Name: chItem.Name, Data: body.Data} + }(chn) + } + + wg.Wait() + close(ch) + + // 本地倍率配置 + localData := ratio_setting.GetExposedData() + + var testResults []dto.TestResult + var successfulChannels []struct { + name string + data map[string]any + } + + for r := range ch { + if r.Err != "" { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "error", + Error: r.Err, + }) + } else { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "success", + }) + successfulChannels = append(successfulChannels, struct { + name string + data map[string]any + }{name: r.Name, data: r.Data}) + } + } + + // 构建差异化数据 + differences := buildDifferences(localData, successfulChannels) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "differences": differences, + "test_results": testResults, + }, + }) +} + +// buildDifferences 构建差异化数据,只返回有意义的差异 +func buildDifferences(localData map[string]any, successfulChannels []struct { + name string + data map[string]any +}) map[string]map[string]dto.DifferenceItem { + differences := make(map[string]map[string]dto.DifferenceItem) + ratioTypes := []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} + + // 收集所有模型名称 + allModels := make(map[string]struct{}) + + // 从本地数据收集模型名称 + for _, ratioType := range ratioTypes { + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + for modelName := range localRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + // 从上游数据收集模型名称 + for _, channel := range successfulChannels { + for _, ratioType := range ratioTypes { + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + for modelName := range upstreamRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + // 对每个模型和每个比率类型进行分析 + for modelName := range allModels { + for _, ratioType := range ratioTypes { + // 获取本地值 + var localValue interface{} = nil + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + if val, exists := localRatio[modelName]; exists { + localValue = val + } + } + } + + // 收集上游值 + upstreamValues := make(map[string]interface{}) + hasUpstreamValue := false + hasDifference := false + + for _, channel := range successfulChannels { + var upstreamValue interface{} = nil + + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + if val, exists := upstreamRatio[modelName]; exists { + upstreamValue = val + hasUpstreamValue = true + + // 检查是否与本地值不同 + if localValue != nil && localValue != val { + hasDifference = true + } else if localValue == val { + upstreamValue = "same" + } + } + } + + // 如果本地值为空但上游有值,这也是差异 + if localValue == nil && upstreamValue != nil && upstreamValue != "same" { + hasDifference = true + } + + upstreamValues[channel.name] = upstreamValue + } + + // 应用过滤逻辑 + shouldInclude := false + + if localValue != nil { + // 规则1: 本地值存在,至少有一个上游与本地值不同 + if hasDifference { + shouldInclude = true + } + // 规则2: 本地值存在,但所有上游都未设置 - 不包含 + } else { + // 规则3: 本地值不存在,至少有一个上游设置了值 + if hasUpstreamValue { + shouldInclude = true + } + } + + if shouldInclude { + if differences[modelName] == nil { + differences[modelName] = make(map[string]dto.DifferenceItem) + } + differences[modelName][ratioType] = dto.DifferenceItem{ + Current: localValue, + Upstreams: upstreamValues, + } + } + } + } + + return differences +} + +// GetSyncableChannels 获取可用于倍率同步的渠道(base_url 不为空的渠道) +func GetSyncableChannels(c *gin.Context) { + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var syncableChannels []dto.SyncableChannel + for _, channel := range channels { + // 只返回 base_url 不为空的渠道 + if channel.GetBaseURL() != "" { + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: channel.Id, + Name: channel.Name, + BaseURL: channel.GetBaseURL(), + Status: channel.Status, + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": syncableChannels, + }) +} \ No newline at end of file diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go new file mode 100644 index 00000000..4f2fe06d --- /dev/null +++ b/dto/ratio_sync.go @@ -0,0 +1,50 @@ +package dto + +// UpstreamDTO 提交到后端同步倍率的上游渠道信息 +// Endpoint 可以为空,后端会默认使用 /api/ratio_config +// BaseURL 必须以 http/https 开头,不要以 / 结尾 +// 例如: https://api.example.com +// Endpoint: /api/ratio_config +// 提交示例: +// { +// "name": "openai", +// "base_url": "https://api.openai.com", +// "endpoint": "/ratio_config" +// } + +type UpstreamDTO struct { + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + Endpoint string `json:"endpoint"` +} + +type UpstreamRequest struct { + ChannelIDs []int64 `json:"channel_ids"` + CustomChannels []UpstreamDTO `json:"custom_channels"` + Timeout int `json:"timeout"` +} + +// TestResult 上游测试连通性结果 +type TestResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// DifferenceItem 差异项 +// Current 为本地值,可能为 nil +// Upstreams 为各渠道的上游值,具体数值 / "same" / nil + +type DifferenceItem struct { + Current interface{} `json:"current"` + Upstreams map[string]interface{} `json:"upstreams"` +} + +// SyncableChannel 可同步的渠道信息(base_url 不为空) + +type SyncableChannel struct { + ID int `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Status int `json:"status"` +} \ No newline at end of file diff --git a/model/option.go b/model/option.go index 43c0a644..97f7baae 100644 --- a/model/option.go +++ b/model/option.go @@ -126,6 +126,7 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) // 自动添加所有注册的模型配置 modelConfigs := config.GlobalConfig.ExportAllConfigs() @@ -266,6 +267,8 @@ func updateOptionMap(key string, value string) (err error) { setting.WorkerAllowHttpImageRequestEnabled = boolValue case "DefaultUseAutoGroup": setting.DefaultUseAutoGroup = boolValue + case "ExposeRatioEnabled": + ratio_setting.SetExposeRatioEnabled(boolValue) } } switch key { diff --git a/router/api-router.go b/router/api-router.go index 45930246..badfa7bf 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -36,6 +36,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind) apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) + apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) userRoute := apiRouter.Group("/user") { @@ -83,6 +84,12 @@ func SetApiRouter(router *gin.Engine) { optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 } + ratioSyncRoute := apiRouter.Group("/ratio_sync") + ratioSyncRoute.Use(middleware.RootAuth()) + { + ratioSyncRoute.GET("/channels", controller.GetSyncableChannels) + ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios) + } channelRoute := apiRouter.Group("/channel") channelRoute.Use(middleware.AdminAuth()) { diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index aa934b22..51d473a8 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -85,7 +85,11 @@ func UpdateCacheRatioByJSONString(jsonStr string) error { cacheRatioMapMutex.Lock() defer cacheRatioMapMutex.Unlock() cacheRatioMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + err := json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // GetCacheRatio returns the cache ratio for a model @@ -106,3 +110,13 @@ func GetCreateCacheRatio(name string) (float64, bool) { } return ratio, true } + +func GetCacheRatioCopy() map[string]float64 { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(cacheRatioMap)) + for k, v := range cacheRatioMap { + copyMap[k] = v + } + return copyMap +} diff --git a/setting/ratio_setting/expose_ratio.go b/setting/ratio_setting/expose_ratio.go new file mode 100644 index 00000000..8fca0bcb --- /dev/null +++ b/setting/ratio_setting/expose_ratio.go @@ -0,0 +1,17 @@ +package ratio_setting + +import "sync/atomic" + +var exposeRatioEnabled atomic.Bool + +func init() { + exposeRatioEnabled.Store(false) +} + +func SetExposeRatioEnabled(enabled bool) { + exposeRatioEnabled.Store(enabled) +} + +func IsExposeRatioEnabled() bool { + return exposeRatioEnabled.Load() +} \ No newline at end of file diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go new file mode 100644 index 00000000..9e5b6c30 --- /dev/null +++ b/setting/ratio_setting/exposed_cache.go @@ -0,0 +1,55 @@ +package ratio_setting + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +const exposedDataTTL = 30 * time.Second + +type exposedCache struct { + data gin.H + expiresAt time.Time +} + +var ( + exposedData atomic.Value + rebuildMu sync.Mutex +) + +func InvalidateExposedDataCache() { + exposedData.Store((*exposedCache)(nil)) +} + +func cloneGinH(src gin.H) gin.H { + dst := make(gin.H, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func GetExposedData() gin.H { + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + rebuildMu.Lock() + defer rebuildMu.Unlock() + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + newData := gin.H{ + "model_ratio": GetModelRatioCopy(), + "completion_ratio": GetCompletionRatioCopy(), + "cache_ratio": GetCacheRatioCopy(), + "model_price": GetModelPriceCopy(), + } + exposedData.Store(&exposedCache{ + data: newData, + expiresAt: time.Now().Add(exposedDataTTL), + }) + return cloneGinH(newData) +} \ No newline at end of file diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 3102dfe9..1eaf25b1 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -317,7 +317,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error { modelPriceMapMutex.Lock() defer modelPriceMapMutex.Unlock() modelPriceMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &modelPriceMap) + err := json.Unmarshal([]byte(jsonStr), &modelPriceMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false @@ -345,7 +349,11 @@ func UpdateModelRatioByJSONString(jsonStr string) error { modelRatioMapMutex.Lock() defer modelRatioMapMutex.Unlock() modelRatioMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &modelRatioMap) + err := json.Unmarshal([]byte(jsonStr), &modelRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // 处理带有思考预算的模型名称,方便统一定价 @@ -405,7 +413,11 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error { CompletionRatioMutex.Lock() defer CompletionRatioMutex.Unlock() CompletionRatio = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &CompletionRatio) + err := json.Unmarshal([]byte(jsonStr), &CompletionRatio) + if err == nil { + InvalidateExposedDataCache() + } + return err } func GetCompletionRatio(name string) float64 { @@ -609,3 +621,33 @@ func GetImageRatio(name string) (float64, bool) { } return ratio, true } + +func GetModelRatioCopy() map[string]float64 { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelRatioMap)) + for k, v := range modelRatioMap { + copyMap[k] = v + } + return copyMap +} + +func GetModelPriceCopy() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelPriceMap)) + for k, v := range modelPriceMap { + copyMap[k] = v + } + return copyMap +} + +func GetCompletionRatioCopy() map[string]float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + copyMap := make(map[string]float64, len(CompletionRatio)) + for k, v := range CompletionRatio { + copyMap[k] = v + } + return copyMap +} diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js new file mode 100644 index 00000000..c393d97f --- /dev/null +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -0,0 +1,154 @@ +import React from 'react'; +import { + Modal, + Transfer, + Input, + Card, + Space, + Button, + Checkbox, +} from '@douyinfe/semi-ui'; +import { IconPlus, IconClose } from '@douyinfe/semi-icons'; + +/** + * ChannelSelectorModal + * 负责选择同步渠道、测试与批量测试等 UI,纯展示组件。 + * 业务状态与动作通过 props 注入,保持可复用与可测试。 + */ +export default function ChannelSelectorModal({ + t, + visible, + onCancel, + onOk, + // 渠道与选择 + allChannels = [], + selectedChannelIds = [], + setSelectedChannelIds, + // 自定义渠道 + customUrl, + setCustomUrl, + customEndpoint, + setCustomEndpoint, + customChannelTesting, + addCustomChannel, + // 渠道端点 + channelEndpoints, + updateChannelEndpoint, + // 测试相关 +}) { + // Transfer 自定义渲染 + const renderSourceItem = (item) => { + const channelId = item.key || item.value; + const currentEndpoint = channelEndpoints[channelId]; + const baseUrl = item._originalData?.base_url || ''; + + return ( +
+
+
+ + {item.label} + +
+
+ + {baseUrl} + + updateChannelEndpoint(channelId, value)} + placeholder="/api/ratio_config" + className="flex-1 text-xs" + style={{ fontSize: '12px' }} + /> +
+
+
+ ); + }; + + const renderSelectedItem = (item) => { + const channelId = item.key || item.value; + const currentEndpoint = channelEndpoints[channelId]; + const baseUrl = item._originalData?.base_url || ''; + + return ( +
+
+
+ {item.label} + +
+
+ + {baseUrl} + + + {currentEndpoint} + +
+
+
+ ); + }; + + const channelFilter = (input, item) => item.label.toLowerCase().includes(input.toLowerCase()); + + return ( + {t('选择同步渠道')}} + width={1000} + > + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/web/src/components/settings/RatioSetting.js b/web/src/components/settings/RatioSetting.js index bf97282c..1d87c6de 100644 --- a/web/src/components/settings/RatioSetting.js +++ b/web/src/components/settings/RatioSetting.js @@ -6,6 +6,7 @@ import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings.js' import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings.js'; import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor.js'; import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js'; +import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync.js'; import { API, showError } from '../../helpers'; @@ -21,6 +22,7 @@ const RatioSetting = () => { GroupGroupRatio: '', AutoGroups: '', DefaultUseAutoGroup: false, + ExposeRatioEnabled: false, UserUsableGroups: '', }); @@ -48,7 +50,7 @@ const RatioSetting = () => { // 如果后端返回的不是合法 JSON,直接展示 } } - if (['DefaultUseAutoGroup'].includes(item.key)) { + if (['DefaultUseAutoGroup', 'ExposeRatioEnabled'].includes(item.key)) { newInputs[item.key] = item.value === 'true' ? true : false; } else { newInputs[item.key] = item.value; @@ -78,10 +80,6 @@ const RatioSetting = () => { return ( - {/* 分组倍率设置 */} - - - {/* 模型倍率设置以及可视化编辑器 */} @@ -100,8 +98,18 @@ const RatioSetting = () => { refresh={onRefresh} /> + + + + {/* 分组倍率设置 */} + + + ); }; diff --git a/web/src/helpers/ratio.js b/web/src/helpers/ratio.js new file mode 100644 index 00000000..fb293c80 --- /dev/null +++ b/web/src/helpers/ratio.js @@ -0,0 +1,20 @@ +export const DEFAULT_ENDPOINT = '/api/ratio_config'; + +/** + * buildEndpointUrl: 拼接 baseUrl 与 endpoint,确保不会出现双斜杠或缺失斜杠问题。 + * 使用 URL 构造函数保证协议/域名安全;若 baseUrl 非标准 URL,则退回字符串拼接。 + * @param {string} baseUrl - 基础地址,例如 https://api.example.com + * @param {string} endpoint - 接口路径,例如 /api/ratio_config + * @returns {string} + */ +export const buildEndpointUrl = (baseUrl, endpoint) => { + if (!baseUrl) return endpoint; + try { + return new URL(endpoint, baseUrl).toString(); + } catch (_) { + // fallback 处理不规范的 baseUrl + const cleanedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const cleanedEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; + return `${cleanedBase}/${cleanedEndpoint}`; + } +}; \ No newline at end of file diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 15c02abf..0fd18d16 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -1112,7 +1112,6 @@ const Detail = (props) => { @@ -1389,7 +1388,6 @@ const Detail = (props) => { ) : ( + + + + setInputs({ ...inputs, ExposeRatioEnabled: value }) + } + /> + + diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js new file mode 100644 index 00000000..518c6468 --- /dev/null +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -0,0 +1,596 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { + Button, + Table, + Tag, + Empty, + Checkbox, + Form, +} from '@douyinfe/semi-ui'; +import { + RefreshCcw, + CheckSquare, +} from 'lucide-react'; +import { + DEFAULT_ENDPOINT, + buildEndpointUrl, +} from '../../../helpers/ratio'; +import { API, showError, showSuccess, showWarning } from '../../../helpers'; +import { useTranslation } from 'react-i18next'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal'; + +export default function UpstreamRatioSync(props) { + const { t } = useTranslation(); + const [modalVisible, setModalVisible] = useState(false); + const [loading, setLoading] = useState(false); + const [syncLoading, setSyncLoading] = useState(false); + + // 渠道选择相关 + const [allChannels, setAllChannels] = useState([]); + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + + // 自定义渠道 + const [customUrl, setCustomUrl] = useState(''); + const [customEndpoint, setCustomEndpoint] = useState(DEFAULT_ENDPOINT); + const [customChannelTesting, setCustomChannelTesting] = useState(false); + + // 渠道端点配置 + const [channelEndpoints, setChannelEndpoints] = useState({}); // { channelId: endpoint } + + // 差异数据和测试结果 + const [differences, setDifferences] = useState({}); + const [testResults, setTestResults] = useState([]); + const [resolutions, setResolutions] = useState({}); + + // 分页相关状态 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + // 当前倍率快照 + const currentRatiosSnapshot = useMemo(() => ({ + model_ratio: JSON.parse(props.options.ModelRatio || '{}'), + completion_ratio: JSON.parse(props.options.CompletionRatio || '{}'), + cache_ratio: JSON.parse(props.options.CacheRatio || '{}'), + model_price: JSON.parse(props.options.ModelPrice || '{}'), + }), [props.options]); + + // 获取所有渠道 + const fetchAllChannels = async () => { + setLoading(true); + try { + const res = await API.get('/api/ratio_sync/channels'); + + if (res.data.success) { + const channels = res.data.data || []; + + // 转换为Transfer组件所需格式 + const transferData = channels.map(channel => ({ + key: channel.id, + label: channel.name, + value: channel.id, + disabled: false, // 所有渠道都可以选择 + _originalData: channel, + })); + + setAllChannels(transferData); + + // 初始化端点配置 + const initialEndpoints = {}; + transferData.forEach(channel => { + initialEndpoints[channel.key] = DEFAULT_ENDPOINT; + }); + setChannelEndpoints(initialEndpoints); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('获取渠道失败:') + error.message); + } finally { + setLoading(false); + } + }; + + // 测试自定义渠道 + const testCustomChannel = async () => { + if (!customUrl) { + showWarning(t('请输入渠道地址')); + return false; + } + + setCustomChannelTesting(true); + + try { + const url = buildEndpointUrl(customUrl, customEndpoint); + const client = { timeout: 10000 }; + + const response = await fetch(url, { + method: 'GET', + signal: AbortSignal.timeout(client.timeout) + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + return true; + } else { + showError(t('测试失败') + `: ${data.message || t('响应格式错误')}`); + return false; + } + } else { + showError(t('测试失败') + `: HTTP ${response.status}`); + return false; + } + } catch (error) { + showError(t('测试失败') + `: ${error.message || t('请求超时')}`); + return false; + } finally { + setCustomChannelTesting(false); + } + }; + + // 添加自定义渠道 + const addCustomChannel = async () => { + if (!customUrl) { + showWarning(t('请输入渠道地址')); + return; + } + + // 先测试渠道 + const testResult = await testCustomChannel(); + if (!testResult) { + return; + } + + let hostname; + try { + hostname = new URL(customUrl).hostname; + } catch (e) { + hostname = customUrl; + } + + const customId = `custom_${Date.now()}`; + const newChannel = { + key: customId, + label: hostname, + value: customId, + disabled: false, + _originalData: { + id: customId, + name: hostname, + base_url: customUrl.endsWith('/') ? customUrl.slice(0, -1) : customUrl, + status: 1, + is_custom: true, + }, + }; + + setAllChannels([...allChannels, newChannel]); + setSelectedChannelIds([...selectedChannelIds, customId]); + setChannelEndpoints(prev => ({ ...prev, [customId]: customEndpoint })); + setCustomUrl(''); + showSuccess(t('测试成功,渠道添加成功')); + }; + + // 确认选择渠道 + const confirmChannelSelection = () => { + const selected = allChannels + .filter(ch => selectedChannelIds.includes(ch.value)) + .map(ch => ch._originalData); + + if (selected.length === 0) { + showWarning(t('请至少选择一个渠道')); + return; + } + + setModalVisible(false); + fetchRatiosFromChannels(selected); + }; + + // 从选定渠道获取倍率 + const fetchRatiosFromChannels = async (channelList) => { + setSyncLoading(true); + + // 分离数据库渠道和自定义渠道 + const dbChannels = channelList.filter(ch => !ch.is_custom); + const customChannels = channelList.filter(ch => ch.is_custom); + + const payload = { + channel_ids: dbChannels.map(ch => parseInt(ch.id)), + custom_channels: customChannels.map(ch => ({ + name: ch.name, + base_url: ch.base_url, + endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT, + })), + timeout: 10 + }; + + try { + const res = await API.post('/api/ratio_sync/fetch', payload); + + if (!res.data.success) { + showError(res.data.message || t('后端请求失败')); + setSyncLoading(false); + return; + } + + const { differences = {}, test_results = [] } = res.data.data; + + // 显示测试结果 + const errorResults = test_results.filter(r => r.status === 'error'); + if (errorResults.length > 0) { + showWarning(t('部分渠道测试失败:') + errorResults.map(r => `${r.name}: ${r.error}`).join(', ')); + } + + setDifferences(differences); + setTestResults(test_results); + setResolutions({}); + + // 判断是否有差异 + if (Object.keys(differences).length === 0) { + showSuccess(t('已与上游倍率完全一致,无需同步')); + } + } catch (e) { + showError(t('请求后端接口失败:') + e.message); + } finally { + setSyncLoading(false); + } + }; + + // 解决冲突/选择值 + const selectValue = (model, ratioType, value) => { + setResolutions(prev => ({ + ...prev, + [model]: { + ...prev[model], + [ratioType]: value, + }, + })); + }; + + // 应用同步 + const applySync = async () => { + const currentRatios = { + ModelRatio: JSON.parse(props.options.ModelRatio || '{}'), + CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'), + CacheRatio: JSON.parse(props.options.CacheRatio || '{}'), + ModelPrice: JSON.parse(props.options.ModelPrice || '{}'), + }; + + // 应用已选择的值 + Object.entries(resolutions).forEach(([model, ratios]) => { + Object.entries(ratios).forEach(([ratioType, value]) => { + const optionKey = ratioType + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + currentRatios[optionKey][model] = parseFloat(value); + }); + }); + + // 保存到后端 + setLoading(true); + try { + const updates = Object.entries(currentRatios).map(([key, value]) => + API.put('/api/option/', { + key, + value: JSON.stringify(value, null, 2), + }) + ); + + const results = await Promise.all(updates); + + if (results.every(res => res.data.success)) { + showSuccess(t('同步成功')); + props.refresh(); + // 清空状态 + setDifferences({}); + setTestResults([]); + setResolutions({}); + setSelectedChannelIds([]); + } else { + showError(t('部分保存失败')); + } + } catch (error) { + showError(t('保存失败')); + } finally { + setLoading(false); + } + }; + + // 计算当前页显示的数据 + const getCurrentPageData = (dataSource) => { + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return dataSource.slice(startIndex, endIndex); + }; + + // 渲染表格头部 + const renderHeader = () => ( +
+
+
+ + + {(() => { + // 检查是否有选择可应用的值 + const hasSelections = Object.keys(resolutions).length > 0; + + return ( + + ); + })()} +
+
+
+ ); + + // 渲染差异表格 + const renderDifferenceTable = () => { + // 构建数据源 + const dataSource = useMemo(() => { + const tmp = []; + + Object.entries(differences).forEach(([model, ratioTypes]) => { + Object.entries(ratioTypes).forEach(([ratioType, diff]) => { + tmp.push({ + key: `${model}_${ratioType}`, + model, + ratioType, + current: diff.current, + upstreams: diff.upstreams, + }); + }); + }); + + return tmp; + }, [differences]); + + // 收集所有上游渠道名称 + const upstreamNames = useMemo(() => { + const set = new Set(); + dataSource.forEach((row) => { + Object.keys(row.upstreams || {}).forEach((name) => set.add(name)); + }); + return Array.from(set); + }, [dataSource]); + + if (dataSource.length === 0) { + return ( + } + darkModeImage={} + description={Object.keys(differences).length === 0 ? t('已与上游倍率完全一致') : t('请先选择同步渠道')} + style={{ padding: 30 }} + /> + ); + } + + // 列定义 + const columns = [ + { + title: t('模型'), + dataIndex: 'model', + fixed: 'left', + width: 160, + }, + { + title: t('倍率类型'), + dataIndex: 'ratioType', + width: 140, + render: (text) => { + const typeMap = { + model_ratio: t('模型倍率'), + completion_ratio: t('补全倍率'), + cache_ratio: t('缓存倍率'), + model_price: t('固定价格'), + }; + return {typeMap[text] || text}; + }, + }, + { + title: t('当前值'), + dataIndex: 'current', + width: 100, + render: (text) => ( + + {text !== null && text !== undefined ? text : t('未设置')} + + ), + }, + // 动态上游列 + ...upstreamNames.map((upName) => { + // 计算该渠道的全选状态 + const channelStats = (() => { + let selectableCount = 0; // 可选择的项目数量 + let selectedCount = 0; // 已选择的项目数量 + + dataSource.forEach((row) => { + const upstreamVal = row.upstreams?.[upName]; + // 只有具体数值的才是可选择的(不是null、undefined或"same") + if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { + selectableCount++; + const isSelected = resolutions[row.model]?.[row.ratioType] === upstreamVal; + if (isSelected) { + selectedCount++; + } + } + }); + + return { + selectableCount, + selectedCount, + allSelected: selectableCount > 0 && selectedCount === selectableCount, + partiallySelected: selectedCount > 0 && selectedCount < selectableCount, + hasSelectableItems: selectableCount > 0 + }; + })(); + + // 处理全选/取消全选 + const handleBulkSelect = (checked) => { + setResolutions((prev) => { + const newRes = { ...prev }; + + dataSource.forEach((row) => { + const upstreamVal = row.upstreams?.[upName]; + if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { + if (checked) { + // 选择该值 + if (!newRes[row.model]) newRes[row.model] = {}; + newRes[row.model][row.ratioType] = upstreamVal; + } else { + // 取消选择该值 + if (newRes[row.model]) { + delete newRes[row.model][row.ratioType]; + if (Object.keys(newRes[row.model]).length === 0) { + delete newRes[row.model]; + } + } + } + } + }); + + return newRes; + }); + }; + + return { + title: channelStats.hasSelectableItems ? ( + handleBulkSelect(e.target.checked)} + > + {upName} + + ) : ( + {upName} + ), + dataIndex: upName, + width: 140, + render: (_, record) => { + const upstreamVal = record.upstreams?.[upName]; + + if (upstreamVal === null || upstreamVal === undefined) { + return {t('未设置')}; + } + + if (upstreamVal === 'same') { + return {t('与本地相同')}; + } + + // 有具体值,可以选择 + const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal; + + return ( + { + const isChecked = e.target.checked; + if (isChecked) { + selectValue(record.model, record.ratioType, upstreamVal); + } else { + setResolutions((prev) => { + const newRes = { ...prev }; + if (newRes[record.model]) { + delete newRes[record.model][record.ratioType]; + if (Object.keys(newRes[record.model]).length === 0) { + delete newRes[record.model]; + } + } + return newRes; + }); + } + }} + > + {upstreamVal} + + ); + }, + }; + }), + ]; + + return ( + t('第 {{start}} - {{end}} 条,共 {{total}} 条', { + start: page.currentStart, + end: page.currentEnd, + total: dataSource.length, + }), + pageSizeOptions: ['5', '10', '20', '50'], + onChange: (page, size) => { + setCurrentPage(page); + setPageSize(size); + }, + onShowSizeChange: (current, size) => { + setCurrentPage(1); + setPageSize(size); + } + }} + scroll={{ x: 'max-content' }} + size='middle' + loading={loading || syncLoading} + className="rounded-xl overflow-hidden" + /> + ); + }; + + // 更新渠道端点 + const updateChannelEndpoint = useCallback((channelId, endpoint) => { + setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint })); + }, []); + + return ( + <> + + {renderDifferenceTable()} + + + setModalVisible(false)} + onOk={confirmChannelSelection} + allChannels={allChannels} + selectedChannelIds={selectedChannelIds} + setSelectedChannelIds={setSelectedChannelIds} + customUrl={customUrl} + setCustomUrl={setCustomUrl} + customEndpoint={customEndpoint} + setCustomEndpoint={setCustomEndpoint} + customChannelTesting={customChannelTesting} + addCustomChannel={addCustomChannel} + channelEndpoints={channelEndpoints} + updateChannelEndpoint={updateChannelEndpoint} + /> + + ); +} \ No newline at end of file From fb4ff63bad031cce6742a9a3efe19d2193e9305b Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Thu, 19 Jun 2025 15:17:05 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20chore(custom=20chan?= =?UTF-8?q?nel):=20Remove=20custom=20channel=20support=20from=20upstream?= =?UTF-8?q?=20ratio=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all custom channel functionality from the upstream ratio sync feature to simplify the codebase and focus on database-stored channels only. Changes: - Remove custom channel UI components and related state management - Remove custom channel testing and validation logic - Simplify ChannelSelectorModal by removing custom channel input fields - Update API payload to only include channel_ids, removing custom_channels - Remove custom channel processing logic from backend controller - Update import path for DEFAULT_ENDPOINT constant Files modified: - web/src/pages/Setting/Ratio/UpstreamRatioSync.js - web/src/components/settings/ChannelSelectorModal.js - controller/ratio_sync.go This change streamlines the ratio synchronization workflow by focusing solely on pre-configured database channels, reducing complexity and potential maintenance overhead. --- controller/ratio_sync.go | 1 - .../settings/ChannelSelectorModal.js | 40 +------ web/src/constants/common.constant.js | 2 + web/src/helpers/ratio.js | 20 ---- .../pages/Setting/Ratio/UpstreamRatioSync.js | 113 +----------------- 5 files changed, 7 insertions(+), 169 deletions(-) delete mode 100644 web/src/helpers/ratio.js diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index c7494b5b..fae0c59c 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -66,7 +66,6 @@ func FetchUpstreamRatios(c *gin.Context) { }) } } - upstreams = append(upstreams, req.CustomChannels...) var wg sync.WaitGroup ch := make(chan upstreamResult, len(upstreams)) diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js index c393d97f..35059473 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -3,12 +3,10 @@ import { Modal, Transfer, Input, - Card, Space, - Button, Checkbox, } from '@douyinfe/semi-ui'; -import { IconPlus, IconClose } from '@douyinfe/semi-icons'; +import { IconClose } from '@douyinfe/semi-icons'; /** * ChannelSelectorModal @@ -20,21 +18,13 @@ export default function ChannelSelectorModal({ visible, onCancel, onOk, - // 渠道与选择 + // 渠道选择 allChannels = [], selectedChannelIds = [], setSelectedChannelIds, - // 自定义渠道 - customUrl, - setCustomUrl, - customEndpoint, - setCustomEndpoint, - customChannelTesting, - addCustomChannel, // 渠道端点 channelEndpoints, updateChannelEndpoint, - // 测试相关 }) { // Transfer 自定义渲染 const renderSourceItem = (item) => { @@ -107,32 +97,6 @@ export default function ChannelSelectorModal({ width={1000} > - - - - - - - - { - if (!baseUrl) return endpoint; - try { - return new URL(endpoint, baseUrl).toString(); - } catch (_) { - // fallback 处理不规范的 baseUrl - const cleanedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; - const cleanedEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; - return `${cleanedBase}/${cleanedEndpoint}`; - } -}; \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 518c6468..ecf5a1b9 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -11,11 +11,8 @@ import { RefreshCcw, CheckSquare, } from 'lucide-react'; -import { - DEFAULT_ENDPOINT, - buildEndpointUrl, -} from '../../../helpers/ratio'; import { API, showError, showSuccess, showWarning } from '../../../helpers'; +import { DEFAULT_ENDPOINT } from '../../../constants'; import { useTranslation } from 'react-i18next'; import { IllustrationNoResult, @@ -33,11 +30,6 @@ export default function UpstreamRatioSync(props) { const [allChannels, setAllChannels] = useState([]); const [selectedChannelIds, setSelectedChannelIds] = useState([]); - // 自定义渠道 - const [customUrl, setCustomUrl] = useState(''); - const [customEndpoint, setCustomEndpoint] = useState(DEFAULT_ENDPOINT); - const [customChannelTesting, setCustomChannelTesting] = useState(false); - // 渠道端点配置 const [channelEndpoints, setChannelEndpoints] = useState({}); // { channelId: endpoint } @@ -94,86 +86,6 @@ export default function UpstreamRatioSync(props) { } }; - // 测试自定义渠道 - const testCustomChannel = async () => { - if (!customUrl) { - showWarning(t('请输入渠道地址')); - return false; - } - - setCustomChannelTesting(true); - - try { - const url = buildEndpointUrl(customUrl, customEndpoint); - const client = { timeout: 10000 }; - - const response = await fetch(url, { - method: 'GET', - signal: AbortSignal.timeout(client.timeout) - }); - - if (response.ok) { - const data = await response.json(); - if (data.success) { - return true; - } else { - showError(t('测试失败') + `: ${data.message || t('响应格式错误')}`); - return false; - } - } else { - showError(t('测试失败') + `: HTTP ${response.status}`); - return false; - } - } catch (error) { - showError(t('测试失败') + `: ${error.message || t('请求超时')}`); - return false; - } finally { - setCustomChannelTesting(false); - } - }; - - // 添加自定义渠道 - const addCustomChannel = async () => { - if (!customUrl) { - showWarning(t('请输入渠道地址')); - return; - } - - // 先测试渠道 - const testResult = await testCustomChannel(); - if (!testResult) { - return; - } - - let hostname; - try { - hostname = new URL(customUrl).hostname; - } catch (e) { - hostname = customUrl; - } - - const customId = `custom_${Date.now()}`; - const newChannel = { - key: customId, - label: hostname, - value: customId, - disabled: false, - _originalData: { - id: customId, - name: hostname, - base_url: customUrl.endsWith('/') ? customUrl.slice(0, -1) : customUrl, - status: 1, - is_custom: true, - }, - }; - - setAllChannels([...allChannels, newChannel]); - setSelectedChannelIds([...selectedChannelIds, customId]); - setChannelEndpoints(prev => ({ ...prev, [customId]: customEndpoint })); - setCustomUrl(''); - showSuccess(t('测试成功,渠道添加成功')); - }; - // 确认选择渠道 const confirmChannelSelection = () => { const selected = allChannels @@ -193,18 +105,9 @@ export default function UpstreamRatioSync(props) { const fetchRatiosFromChannels = async (channelList) => { setSyncLoading(true); - // 分离数据库渠道和自定义渠道 - const dbChannels = channelList.filter(ch => !ch.is_custom); - const customChannels = channelList.filter(ch => ch.is_custom); - const payload = { - channel_ids: dbChannels.map(ch => parseInt(ch.id)), - custom_channels: customChannels.map(ch => ({ - name: ch.name, - base_url: ch.base_url, - endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT, - })), - timeout: 10 + channel_ids: channelList.map(ch => parseInt(ch.id)), + timeout: 10, }; try { @@ -391,12 +294,10 @@ export default function UpstreamRatioSync(props) { title: t('模型'), dataIndex: 'model', fixed: 'left', - width: 160, }, { title: t('倍率类型'), dataIndex: 'ratioType', - width: 140, render: (text) => { const typeMap = { model_ratio: t('模型倍率'), @@ -410,7 +311,6 @@ export default function UpstreamRatioSync(props) { { title: t('当前值'), dataIndex: 'current', - width: 100, render: (text) => ( {text !== null && text !== undefined ? text : t('未设置')} @@ -486,7 +386,6 @@ export default function UpstreamRatioSync(props) { {upName} ), dataIndex: upName, - width: 140, render: (_, record) => { const upstreamVal = record.upstreams?.[upName]; @@ -582,12 +481,6 @@ export default function UpstreamRatioSync(props) { allChannels={allChannels} selectedChannelIds={selectedChannelIds} setSelectedChannelIds={setSelectedChannelIds} - customUrl={customUrl} - setCustomUrl={setCustomUrl} - customEndpoint={customEndpoint} - setCustomEndpoint={setCustomEndpoint} - customChannelTesting={customChannelTesting} - addCustomChannel={addCustomChannel} channelEndpoints={channelEndpoints} updateChannelEndpoint={updateChannelEndpoint} /> From 67546f4b2adad40ed38f51d177d73daa728fd666 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Thu, 19 Jun 2025 16:05:50 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20chore(ui):=20enhance=20channel?= =?UTF-8?q?=20selector=20with=20status=20avatars=20and=20UI=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visual status indicators and improve user experience for the upstream ratio sync channel selector modal. Features: - Add status-based avatar indicators for channels (enabled/disabled/auto-disabled) - Implement search functionality with text highlighting - Add endpoint configuration input for each channel - Optimize component structure with reusable ChannelInfo component UI Improvements: - Custom styling for transfer component items - Hide scrollbars for cleaner appearance in transfer lists - Responsive layout adjustments for channel information display - Color-coded avatars: green (enabled), red (disabled), amber (auto-disabled), grey (unknown) Code Quality: - Extract channel status configuration to constants - Create reusable ChannelInfo component to reduce code duplication - Implement proper search filtering for both channel names and URLs - Add consistent styling classes for transfer demo components Files modified: - web/src/components/settings/ChannelSelectorModal.js - web/src/pages/Setting/Ratio/UpstreamRatioSync.js - web/src/index.css This enhancement provides better visual feedback for channel status and improves the overall user experience when selecting channels for ratio synchronization. --- controller/ratio_sync.go | 2 +- .../settings/ChannelSelectorModal.js | 125 +++++++++++------- web/src/index.css | 68 ++++++++++ .../pages/Setting/Ratio/UpstreamRatioSync.js | 8 -- 4 files changed, 144 insertions(+), 59 deletions(-) diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index fae0c59c..490a2a74 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -49,7 +49,7 @@ func FetchUpstreamRatios(c *gin.Context) { req.Timeout = 10 } - // build upstream list from ids + custom + // build upstream list from ids var upstreams []dto.UpstreamDTO if len(req.ChannelIDs) > 0 { // convert []int64 -> []int for model function diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js index 35059473..573329b3 100644 --- a/web/src/components/settings/ChannelSelectorModal.js +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -1,92 +1,116 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Modal, Transfer, Input, Space, Checkbox, + Avatar, + Highlight, } from '@douyinfe/semi-ui'; import { IconClose } from '@douyinfe/semi-icons'; -/** - * ChannelSelectorModal - * 负责选择同步渠道、测试与批量测试等 UI,纯展示组件。 - * 业务状态与动作通过 props 注入,保持可复用与可测试。 - */ +const CHANNEL_STATUS_CONFIG = { + 1: { color: 'green', text: '启用' }, + 2: { color: 'red', text: '禁用' }, + 3: { color: 'amber', text: '自禁' }, + default: { color: 'grey', text: '未知' } +}; + +const getChannelStatusConfig = (status) => { + return CHANNEL_STATUS_CONFIG[status] || CHANNEL_STATUS_CONFIG.default; +}; + export default function ChannelSelectorModal({ t, visible, onCancel, onOk, - // 渠道选择 allChannels = [], selectedChannelIds = [], setSelectedChannelIds, - // 渠道端点 channelEndpoints, updateChannelEndpoint, }) { - // Transfer 自定义渲染 - const renderSourceItem = (item) => { + const [searchText, setSearchText] = useState(''); + + const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => { const channelId = item.key || item.value; const currentEndpoint = channelEndpoints[channelId]; const baseUrl = item._originalData?.base_url || ''; + const status = item._originalData?.status || 0; + const statusConfig = getChannelStatusConfig(status); return ( -
-
-
- - {item.label} - + <> + + {statusConfig.text} + +
+
+ {isSelected ? ( + item.label + ) : ( + + )}
-
- - {baseUrl} +
+ + {isSelected ? ( + baseUrl + ) : ( + + )} - updateChannelEndpoint(channelId, value)} - placeholder="/api/ratio_config" - className="flex-1 text-xs" - style={{ fontSize: '12px' }} - /> + {showEndpoint && ( + updateChannelEndpoint(channelId, value)} + placeholder="/api/ratio_config" + className="flex-1 text-xs" + style={{ fontSize: '12px' }} + /> + )} + {isSelected && !showEndpoint && ( + + {currentEndpoint} + + )}
+ + ); + }; + + const renderSourceItem = (item) => { + return ( +
+ + +
); }; const renderSelectedItem = (item) => { - const channelId = item.key || item.value; - const currentEndpoint = channelEndpoints[channelId]; - const baseUrl = item._originalData?.base_url || ''; - return ( -
-
-
- {item.label} - -
-
- - {baseUrl} - - - {currentEndpoint} - -
-
+
+ +
); }; - const channelFilter = (input, item) => item.label.toLowerCase().includes(input.toLowerCase()); + const channelFilter = (input, item) => { + const searchLower = input.toLowerCase(); + return item.label.toLowerCase().includes(searchLower) || + (item._originalData?.base_url || '').toLowerCase().includes(searchLower); + }; return ( .semi-table-row { border-bottom: 1px solid rgba(0, 0, 0, 0.1); } +} + +/* ==================== 同步倍率 - 渠道选择器 ==================== */ + +.components-transfer-source-item, +.components-transfer-selected-item { + display: flex; + align-items: center; + padding: 8px; +} + +.semi-transfer-left-list, +.semi-transfer-right-list { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.semi-transfer-left-list::-webkit-scrollbar, +.semi-transfer-right-list::-webkit-scrollbar { + display: none; +} + +.components-transfer-source-item .semi-checkbox, +.components-transfer-selected-item .semi-checkbox { + display: flex; + align-items: center; + width: 100%; +} + +.components-transfer-source-item .semi-avatar, +.components-transfer-selected-item .semi-avatar { + margin-right: 12px; + flex-shrink: 0; +} + +.components-transfer-source-item .info, +.components-transfer-selected-item .info { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; +} + +.components-transfer-source-item .name, +.components-transfer-selected-item .name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.components-transfer-source-item .email, +.components-transfer-selected-item .email { + font-size: 12px; + color: var(--semi-color-text-2); + display: flex; + align-items: center; +} + +.components-transfer-selected-item .semi-icon-close { + margin-left: 8px; + cursor: pointer; + color: var(--semi-color-text-2); +} + +.components-transfer-selected-item .semi-icon-close:hover { + color: var(--semi-color-text-0); } \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index ecf5a1b9..2e12fd3b 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -42,14 +42,6 @@ export default function UpstreamRatioSync(props) { const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); - // 当前倍率快照 - const currentRatiosSnapshot = useMemo(() => ({ - model_ratio: JSON.parse(props.options.ModelRatio || '{}'), - completion_ratio: JSON.parse(props.options.CompletionRatio || '{}'), - cache_ratio: JSON.parse(props.options.CacheRatio || '{}'), - model_price: JSON.parse(props.options.ModelPrice || '{}'), - }), [props.options]); - // 获取所有渠道 const fetchAllChannels = async () => { setLoading(true); From a9f98c5d392799096c4bd6904f39b66425d68cea Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Thu, 19 Jun 2025 18:38:43 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20chore(ratio-sync):?= =?UTF-8?q?=20improve=20upstream=20ratio=20comparison=20&=20output=20clean?= =?UTF-8?q?liness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary 1. Consider “both unset” as identical • When both localValue and upstreamValue are nil, mark upstreamValue as "same" to avoid showing “Not set”. 2. Exclude fully-synced upstream channels from result • Scan `differences` to detect channels that contain at least one divergent value. • Remove channels whose every ratio is either `"same"` or `nil`, so the frontend only receives actionable discrepancies. Why These changes reduce visual noise in the Upstream Ratio Sync table, making it easier for admins to focus on models requiring attention. No functional regressions or breaking API changes are introduced. --- controller/ratio_sync.go | 48 +++++++------- web/src/i18n/locales/en.json | 24 ++++++- .../pages/Setting/Ratio/UpstreamRatioSync.js | 63 ++++++++----------- 3 files changed, 74 insertions(+), 61 deletions(-) diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index 490a2a74..368f92dd 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -29,7 +29,6 @@ type DifferenceItem struct { Upstreams map[string]interface{} `json:"upstreams"` // 上游值:具体值/"same"/null } -// SyncableChannel 可同步的渠道信息 type SyncableChannel struct { ID int `json:"id"` Name string `json:"name"` @@ -37,7 +36,6 @@ type SyncableChannel struct { Status int `json:"status"` } -// FetchUpstreamRatios 后端并发拉取上游倍率 func FetchUpstreamRatios(c *gin.Context) { var req dto.UpstreamRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -49,10 +47,8 @@ func FetchUpstreamRatios(c *gin.Context) { req.Timeout = 10 } - // build upstream list from ids var upstreams []dto.UpstreamDTO if len(req.ChannelIDs) > 0 { - // convert []int64 -> []int for model function intIds := make([]int, 0, len(req.ChannelIDs)) for _, id64 := range req.ChannelIDs { intIds = append(intIds, int(id64)) @@ -62,7 +58,7 @@ func FetchUpstreamRatios(c *gin.Context) { upstreams = append(upstreams, dto.UpstreamDTO{ Name: ch.Name, BaseURL: ch.GetBaseURL(), - Endpoint: "", // assume default endpoint + Endpoint: "", }) } } @@ -110,7 +106,6 @@ func FetchUpstreamRatios(c *gin.Context) { wg.Wait() close(ch) - // 本地倍率配置 localData := ratio_setting.GetExposedData() var testResults []dto.TestResult @@ -138,7 +133,6 @@ func FetchUpstreamRatios(c *gin.Context) { } } - // 构建差异化数据 differences := buildDifferences(localData, successfulChannels) c.JSON(http.StatusOK, gin.H{ @@ -150,7 +144,6 @@ func FetchUpstreamRatios(c *gin.Context) { }) } -// buildDifferences 构建差异化数据,只返回有意义的差异 func buildDifferences(localData map[string]any, successfulChannels []struct { name string data map[string]any @@ -158,10 +151,8 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { differences := make(map[string]map[string]dto.DifferenceItem) ratioTypes := []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} - // 收集所有模型名称 allModels := make(map[string]struct{}) - // 从本地数据收集模型名称 for _, ratioType := range ratioTypes { if localRatioAny, ok := localData[ratioType]; ok { if localRatio, ok := localRatioAny.(map[string]float64); ok { @@ -172,7 +163,6 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } } - // 从上游数据收集模型名称 for _, channel := range successfulChannels { for _, ratioType := range ratioTypes { if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { @@ -183,10 +173,8 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } } - // 对每个模型和每个比率类型进行分析 for modelName := range allModels { for _, ratioType := range ratioTypes { - // 获取本地值 var localValue interface{} = nil if localRatioAny, ok := localData[ratioType]; ok { if localRatio, ok := localRatioAny.(map[string]float64); ok { @@ -196,7 +184,6 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } } - // 收集上游值 upstreamValues := make(map[string]interface{}) hasUpstreamValue := false hasDifference := false @@ -209,7 +196,6 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { upstreamValue = val hasUpstreamValue = true - // 检查是否与本地值不同 if localValue != nil && localValue != val { hasDifference = true } else if localValue == val { @@ -217,8 +203,10 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } } } + if upstreamValue == nil && localValue == nil { + upstreamValue = "same" + } - // 如果本地值为空但上游有值,这也是差异 if localValue == nil && upstreamValue != nil && upstreamValue != "same" { hasDifference = true } @@ -226,17 +214,13 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { upstreamValues[channel.name] = upstreamValue } - // 应用过滤逻辑 shouldInclude := false if localValue != nil { - // 规则1: 本地值存在,至少有一个上游与本地值不同 if hasDifference { shouldInclude = true } - // 规则2: 本地值存在,但所有上游都未设置 - 不包含 } else { - // 规则3: 本地值不存在,至少有一个上游设置了值 if hasUpstreamValue { shouldInclude = true } @@ -254,10 +238,31 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { } } + channelHasDiff := make(map[string]bool) + for _, ratioMap := range differences { + for _, item := range ratioMap { + for chName, val := range item.Upstreams { + if val != nil && val != "same" { + channelHasDiff[chName] = true + } + } + } + } + + for modelName, ratioMap := range differences { + for ratioType, item := range ratioMap { + for chName := range item.Upstreams { + if !channelHasDiff[chName] { + delete(item.Upstreams, chName) + } + } + differences[modelName][ratioType] = item + } + } + return differences } -// GetSyncableChannels 获取可用于倍率同步的渠道(base_url 不为空的渠道) func GetSyncableChannels(c *gin.Context) { channels, err := model.GetAllChannels(0, 0, true, false) if err != nil { @@ -270,7 +275,6 @@ func GetSyncableChannels(c *gin.Context) { var syncableChannels []dto.SyncableChannel for _, channel := range channels { - // 只返回 base_url 不为空的渠道 if channel.GetBaseURL() != "" { syncableChannels = append(syncableChannels, dto.SyncableChannel{ ID: channel.Id, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index fc80f9c1..b8e1afd8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1665,5 +1665,27 @@ "确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?", "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.", "选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)", - "请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)" + "请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)", + "上游倍率同步": "Upstream ratio synchronization", + "获取渠道失败:": "Failed to get channels: ", + "请至少选择一个渠道": "Please select at least one channel", + "获取倍率失败:": "Failed to get ratios: ", + "后端请求失败": "Backend request failed", + "部分渠道测试失败:": "Some channels failed to test: ", + "已与上游倍率完全一致,无需同步": "The upstream ratio is completely consistent, no synchronization is required", + "请求后端接口失败:": "Failed to request the backend interface: ", + "同步成功": "Synchronization successful", + "部分保存失败": "Some settings failed to save", + "保存失败": "Save failed", + "选择同步渠道": "Select synchronization channel", + "应用同步": "Apply synchronization", + "倍率类型": "Ratio type", + "当前值": "Current value", + "上游值": "Upstream value", + "差异": "Difference", + "搜索渠道名称或地址": "Search channel name or address", + "缓存倍率": "Cache ratio", + "暂无差异化倍率显示": "No differential ratio display", + "请先选择同步渠道": "Please select the synchronization channel first", + "与本地相同": "Same as local" } \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index 2e12fd3b..aae6d9f3 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -11,7 +11,7 @@ import { RefreshCcw, CheckSquare, } from 'lucide-react'; -import { API, showError, showSuccess, showWarning } from '../../../helpers'; +import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; import { DEFAULT_ENDPOINT } from '../../../constants'; import { useTranslation } from 'react-i18next'; import { @@ -35,14 +35,12 @@ export default function UpstreamRatioSync(props) { // 差异数据和测试结果 const [differences, setDifferences] = useState({}); - const [testResults, setTestResults] = useState([]); const [resolutions, setResolutions] = useState({}); // 分页相关状态 const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); - // 获取所有渠道 const fetchAllChannels = async () => { setLoading(true); try { @@ -51,18 +49,16 @@ export default function UpstreamRatioSync(props) { if (res.data.success) { const channels = res.data.data || []; - // 转换为Transfer组件所需格式 const transferData = channels.map(channel => ({ key: channel.id, label: channel.name, value: channel.id, - disabled: false, // 所有渠道都可以选择 + disabled: false, _originalData: channel, })); setAllChannels(transferData); - // 初始化端点配置 const initialEndpoints = {}; transferData.forEach(channel => { initialEndpoints[channel.key] = DEFAULT_ENDPOINT; @@ -78,7 +74,6 @@ export default function UpstreamRatioSync(props) { } }; - // 确认选择渠道 const confirmChannelSelection = () => { const selected = allChannels .filter(ch => selectedChannelIds.includes(ch.value)) @@ -93,7 +88,6 @@ export default function UpstreamRatioSync(props) { fetchRatiosFromChannels(selected); }; - // 从选定渠道获取倍率 const fetchRatiosFromChannels = async (channelList) => { setSyncLoading(true); @@ -113,17 +107,14 @@ export default function UpstreamRatioSync(props) { const { differences = {}, test_results = [] } = res.data.data; - // 显示测试结果 const errorResults = test_results.filter(r => r.status === 'error'); if (errorResults.length > 0) { showWarning(t('部分渠道测试失败:') + errorResults.map(r => `${r.name}: ${r.error}`).join(', ')); } setDifferences(differences); - setTestResults(test_results); setResolutions({}); - // 判断是否有差异 if (Object.keys(differences).length === 0) { showSuccess(t('已与上游倍率完全一致,无需同步')); } @@ -134,7 +125,6 @@ export default function UpstreamRatioSync(props) { } }; - // 解决冲突/选择值 const selectValue = (model, ratioType, value) => { setResolutions(prev => ({ ...prev, @@ -145,7 +135,6 @@ export default function UpstreamRatioSync(props) { })); }; - // 应用同步 const applySync = async () => { const currentRatios = { ModelRatio: JSON.parse(props.options.ModelRatio || '{}'), @@ -154,7 +143,6 @@ export default function UpstreamRatioSync(props) { ModelPrice: JSON.parse(props.options.ModelPrice || '{}'), }; - // 应用已选择的值 Object.entries(resolutions).forEach(([model, ratios]) => { Object.entries(ratios).forEach(([ratioType, value]) => { const optionKey = ratioType @@ -165,7 +153,6 @@ export default function UpstreamRatioSync(props) { }); }); - // 保存到后端 setLoading(true); try { const updates = Object.entries(currentRatios).map(([key, value]) => @@ -180,11 +167,26 @@ export default function UpstreamRatioSync(props) { if (results.every(res => res.data.success)) { showSuccess(t('同步成功')); props.refresh(); - // 清空状态 - setDifferences({}); - setTestResults([]); + + setDifferences(prevDifferences => { + const newDifferences = { ...prevDifferences }; + + Object.entries(resolutions).forEach(([model, ratios]) => { + Object.keys(ratios).forEach(ratioType => { + if (newDifferences[model] && newDifferences[model][ratioType]) { + delete newDifferences[model][ratioType]; + + if (Object.keys(newDifferences[model]).length === 0) { + delete newDifferences[model]; + } + } + }); + }); + + return newDifferences; + }); + setResolutions({}); - setSelectedChannelIds([]); } else { showError(t('部分保存失败')); } @@ -195,14 +197,12 @@ export default function UpstreamRatioSync(props) { } }; - // 计算当前页显示的数据 const getCurrentPageData = (dataSource) => { const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; return dataSource.slice(startIndex, endIndex); }; - // 渲染表格头部 const renderHeader = () => (
@@ -219,7 +219,6 @@ export default function UpstreamRatioSync(props) { {(() => { - // 检查是否有选择可应用的值 const hasSelections = Object.keys(resolutions).length > 0; return ( @@ -239,9 +238,7 @@ export default function UpstreamRatioSync(props) {
); - // 渲染差异表格 const renderDifferenceTable = () => { - // 构建数据源 const dataSource = useMemo(() => { const tmp = []; @@ -260,7 +257,6 @@ export default function UpstreamRatioSync(props) { return tmp; }, [differences]); - // 收集所有上游渠道名称 const upstreamNames = useMemo(() => { const set = new Set(); dataSource.forEach((row) => { @@ -274,13 +270,12 @@ export default function UpstreamRatioSync(props) { } darkModeImage={} - description={Object.keys(differences).length === 0 ? t('已与上游倍率完全一致') : t('请先选择同步渠道')} + description={Object.keys(differences).length === 0 ? t('暂无差异化倍率显示') : t('请先选择同步渠道')} style={{ padding: 30 }} /> ); } - // 列定义 const columns = [ { title: t('模型'), @@ -297,7 +292,7 @@ export default function UpstreamRatioSync(props) { cache_ratio: t('缓存倍率'), model_price: t('固定价格'), }; - return {typeMap[text] || text}; + return {typeMap[text] || text}; }, }, { @@ -309,16 +304,13 @@ export default function UpstreamRatioSync(props) { ), }, - // 动态上游列 ...upstreamNames.map((upName) => { - // 计算该渠道的全选状态 const channelStats = (() => { - let selectableCount = 0; // 可选择的项目数量 - let selectedCount = 0; // 已选择的项目数量 + let selectableCount = 0; + let selectedCount = 0; dataSource.forEach((row) => { const upstreamVal = row.upstreams?.[upName]; - // 只有具体数值的才是可选择的(不是null、undefined或"same") if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { selectableCount++; const isSelected = resolutions[row.model]?.[row.ratioType] === upstreamVal; @@ -337,7 +329,6 @@ export default function UpstreamRatioSync(props) { }; })(); - // 处理全选/取消全选 const handleBulkSelect = (checked) => { setResolutions((prev) => { const newRes = { ...prev }; @@ -346,11 +337,9 @@ export default function UpstreamRatioSync(props) { const upstreamVal = row.upstreams?.[upName]; if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { if (checked) { - // 选择该值 if (!newRes[row.model]) newRes[row.model] = {}; newRes[row.model][row.ratioType] = upstreamVal; } else { - // 取消选择该值 if (newRes[row.model]) { delete newRes[row.model][row.ratioType]; if (Object.keys(newRes[row.model]).length === 0) { @@ -389,7 +378,6 @@ export default function UpstreamRatioSync(props) { return {t('与本地相同')}; } - // 有具体值,可以选择 const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal; return ( @@ -454,7 +442,6 @@ export default function UpstreamRatioSync(props) { ); }; - // 更新渠道端点 const updateChannelEndpoint = useCallback((channelId, endpoint) => { setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint })); }, []); From 458472f3e2e3bb84946ee27aaa92d1fd63b6713a Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Thu, 19 Jun 2025 18:54:46 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=94=8D=20feat(ratio-sync):=20add=20fu?= =?UTF-8?q?zzy=20model=20search=20&=20enhance=20empty-state=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary 1. Add model name search box • Introduce Semi UI `Input` with `IconSearch` prefix next to the “Apply Sync” button. • Support case-insensitive fuzzy matching of model names. • Real-time filtering, pagination and bulk-select logic now work on filtered data. 2. Improve empty state handling • Add `hasSynced` flag to distinguish “not synced yet” from “synced with no differences”. • Display messages: – “Please select sync channels” when no sync has been performed. – “No differences found” when a sync completed with zero discrepancies. – “No matching model found” when search yields no results. 3. UI tweaks • Replace lucide-react `Search` icon with Semi UI `IconSearch` for visual consistency. • Keep responsive width and clearable input for better usability. Why These changes allow admins to quickly locate specific models and provide accurate feedback on the sync status, greatly improving the usability of the Upstream Ratio Sync page. --- web/src/i18n/locales/en.json | 3 +- .../pages/Setting/Ratio/UpstreamRatioSync.js | 53 +++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b8e1afd8..ab793364 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1687,5 +1687,6 @@ "缓存倍率": "Cache ratio", "暂无差异化倍率显示": "No differential ratio display", "请先选择同步渠道": "Please select the synchronization channel first", - "与本地相同": "Same as local" + "与本地相同": "Same as local", + "未找到匹配的模型": "No matching model found" } \ No newline at end of file diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js index aae6d9f3..f83e0cdc 100644 --- a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -6,7 +6,9 @@ import { Empty, Checkbox, Form, + Input, } from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; import { RefreshCcw, CheckSquare, @@ -37,10 +39,16 @@ export default function UpstreamRatioSync(props) { const [differences, setDifferences] = useState({}); const [resolutions, setResolutions] = useState({}); + // 是否已经执行过同步 + const [hasSynced, setHasSynced] = useState(false); + // 分页相关状态 const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); + // 搜索相关状态 + const [searchKeyword, setSearchKeyword] = useState(''); + const fetchAllChannels = async () => { setLoading(true); try { @@ -114,6 +122,7 @@ export default function UpstreamRatioSync(props) { setDifferences(differences); setResolutions({}); + setHasSynced(true); if (Object.keys(differences).length === 0) { showSuccess(t('已与上游倍率完全一致,无需同步')); @@ -233,6 +242,15 @@ export default function UpstreamRatioSync(props) { ); })()} + + } + placeholder={t('搜索模型名称')} + value={searchKeyword} + onChange={setSearchKeyword} + className="!rounded-full w-full md:w-64 mt-2" + showClear + />
@@ -257,20 +275,37 @@ export default function UpstreamRatioSync(props) { return tmp; }, [differences]); + const filteredDataSource = useMemo(() => { + if (!searchKeyword.trim()) { + return dataSource; + } + + const keyword = searchKeyword.toLowerCase().trim(); + return dataSource.filter(item => + item.model.toLowerCase().includes(keyword) + ); + }, [dataSource, searchKeyword]); + const upstreamNames = useMemo(() => { const set = new Set(); - dataSource.forEach((row) => { + filteredDataSource.forEach((row) => { Object.keys(row.upstreams || {}).forEach((name) => set.add(name)); }); return Array.from(set); - }, [dataSource]); + }, [filteredDataSource]); - if (dataSource.length === 0) { + if (filteredDataSource.length === 0) { return ( } darkModeImage={} - description={Object.keys(differences).length === 0 ? t('暂无差异化倍率显示') : t('请先选择同步渠道')} + description={ + searchKeyword.trim() + ? t('未找到匹配的模型') + : (Object.keys(differences).length === 0 ? + (hasSynced ? t('暂无差异化倍率显示') : t('请先选择同步渠道')) + : t('请先选择同步渠道')) + } style={{ padding: 30 }} /> ); @@ -309,7 +344,7 @@ export default function UpstreamRatioSync(props) { let selectableCount = 0; let selectedCount = 0; - dataSource.forEach((row) => { + filteredDataSource.forEach((row) => { const upstreamVal = row.upstreams?.[upName]; if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { selectableCount++; @@ -333,7 +368,7 @@ export default function UpstreamRatioSync(props) { setResolutions((prev) => { const newRes = { ...prev }; - dataSource.forEach((row) => { + filteredDataSource.forEach((row) => { const upstreamVal = row.upstreams?.[upName]; if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { if (checked) { @@ -412,17 +447,17 @@ export default function UpstreamRatioSync(props) { return (
t('第 {{start}} - {{end}} 条,共 {{total}} 条', { start: page.currentStart, end: page.currentEnd, - total: dataSource.length, + total: filteredDataSource.length, }), pageSizeOptions: ['5', '10', '20', '50'], onChange: (page, size) => { From 150c506ece9cee198f396e7059f5a4746883ec13 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Thu, 19 Jun 2025 19:55:51 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=9A=80=20chore(controller,=20dto):=20?= =?UTF-8?q?elevate=20ratio-sync=20feature=20to=20production=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT’S NEW • controller/ratio_sync.go – Deleted unused local structs (TestResult, DifferenceItem, SyncableChannel). – Centralised config with constants: defaultTimeoutSeconds, defaultEndpoint, maxConcurrentFetches, ratioTypes. – Replaced magic numbers; added semaphore-based concurrency limit and shared http.Client (with TLS & Expect-Continue timeouts). – Added comprehensive error handling and context-aware logging via common.Log* helpers. – Checked DB errors from GetChannelsByIds; early-return on failures or empty upstream list. – Removed custom-channel support; logic now relies solely on ChannelIDs. – Minor clean-ups: import grouping, string trimming, endpoint normalisation. • dto/ratio_sync.go – Simplified UpstreamRequest: dropped unused CustomChannels field. WHY These improvements harden the ratio-sync endpoint for production use by preventing silent failures, controlling resource usage, and making behaviour configurable and observable. HOW No business logic change—only structural refactor, logging, and safeguards—so existing API contracts (aside from removed custom_channels) remain intact. --- controller/ratio_sync.go | 97 ++++++++++++++++++++++++++-------------- dto/ratio_sync.go | 5 +-- 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index 368f92dd..f749f384 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -1,41 +1,35 @@ package controller import ( + "context" "encoding/json" "net/http" - "one-api/model" - "one-api/setting/ratio_setting" - "one-api/dto" + "strings" "sync" "time" + "one-api/common" + "one-api/dto" + "one-api/model" + "one-api/setting/ratio_setting" + "github.com/gin-gonic/gin" ) +const ( + defaultTimeoutSeconds = 10 + defaultEndpoint = "/api/ratio_config" + maxConcurrentFetches = 8 +) + +var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} + type upstreamResult struct { Name string `json:"name"` Data map[string]any `json:"data,omitempty"` Err string `json:"err,omitempty"` } -type TestResult struct { - Name string `json:"name"` - Status string `json:"status"` - Error string `json:"error,omitempty"` -} - -type DifferenceItem struct { - Current interface{} `json:"current"` // 当前本地值,可能为null - Upstreams map[string]interface{} `json:"upstreams"` // 上游值:具体值/"same"/null -} - -type SyncableChannel struct { - ID int `json:"id"` - Name string `json:"name"` - BaseURL string `json:"base_url"` - Status int `json:"status"` -} - func FetchUpstreamRatios(c *gin.Context) { var req dto.UpstreamRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -44,45 +38,80 @@ func FetchUpstreamRatios(c *gin.Context) { } if req.Timeout <= 0 { - req.Timeout = 10 + req.Timeout = defaultTimeoutSeconds } var upstreams []dto.UpstreamDTO + if len(req.ChannelIDs) > 0 { intIds := make([]int, 0, len(req.ChannelIDs)) for _, id64 := range req.ChannelIDs { intIds = append(intIds, int(id64)) } - dbChannels, _ := model.GetChannelsByIds(intIds) - for _, ch := range dbChannels { - upstreams = append(upstreams, dto.UpstreamDTO{ - Name: ch.Name, - BaseURL: ch.GetBaseURL(), - Endpoint: "", - }) + dbChannels, err := model.GetChannelsByIds(intIds) + if err != nil { + common.LogError(c.Request.Context(), "failed to query channels: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"}) + return } + for _, ch := range dbChannels { + if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") { + upstreams = append(upstreams, dto.UpstreamDTO{ + Name: ch.Name, + BaseURL: strings.TrimRight(base, "/"), + Endpoint: "", + }) + } + } + } + + if len(upstreams) == 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"}) + return } var wg sync.WaitGroup ch := make(chan upstreamResult, len(upstreams)) + sem := make(chan struct{}, maxConcurrentFetches) + + client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}} + for _, chn := range upstreams { wg.Add(1) go func(chItem dto.UpstreamDTO) { defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + endpoint := chItem.Endpoint if endpoint == "" { - endpoint = "/api/ratio_config" + endpoint = defaultEndpoint + } else if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint } - url := chItem.BaseURL + endpoint - client := http.Client{Timeout: time.Duration(req.Timeout) * time.Second} - resp, err := client.Get(url) + fullURL := chItem.BaseURL + endpoint + + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) if err != nil { + common.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + + resp, err := client.Do(httpReq) + if err != nil { + common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status) ch <- upstreamResult{Name: chItem.Name, Err: resp.Status} return } @@ -92,6 +121,7 @@ func FetchUpstreamRatios(c *gin.Context) { Message string `json:"message"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} return } @@ -149,7 +179,6 @@ func buildDifferences(localData map[string]any, successfulChannels []struct { data map[string]any }) map[string]map[string]dto.DifferenceItem { differences := make(map[string]map[string]dto.DifferenceItem) - ratioTypes := []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} allModels := make(map[string]struct{}) diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go index 4f2fe06d..55a89025 100644 --- a/dto/ratio_sync.go +++ b/dto/ratio_sync.go @@ -19,9 +19,8 @@ type UpstreamDTO struct { } type UpstreamRequest struct { - ChannelIDs []int64 `json:"channel_ids"` - CustomChannels []UpstreamDTO `json:"custom_channels"` - Timeout int `json:"timeout"` + ChannelIDs []int64 `json:"channel_ids"` + Timeout int `json:"timeout"` } // TestResult 上游测试连通性结果