From 3a29f3772f4db6818db3056860c8e123b6f9cb07 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 22 Apr 2026 16:10:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=BB=98=E8=AE=A4=E9=80=9A=E9=81=93=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BC=98=E5=85=88=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=92=8C=E5=8D=A1=E7=89=87=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 is_default 字段和缓存层,支持管理员为模型指定默认通道 - Distribute 中间件优先级调整:Token 指定 → 默认通道 → 亲和性 → 随机 - 模型定价卡片和详情弹窗展示默认通道 amber 标识 - 管理后台定价页面新增星标切换默认通道 - 新增 set_default / clear_default API 和 6 个单元测试 - 简化卡片价格显示(移除内联缓存价格,改为详情弹窗展示) Co-Authored-By: Claude --- controller/channel_pricing.go | 48 +++++++ middleware/distributor.go | 56 ++++++-- model/channel_pricing.go | 129 +++++++++++++++++- model/channel_pricing_test.go | 121 ++++++++++++++++ model/pricing.go | 20 ++- router/api-router.go | 2 + .../modal/components/ChannelPricingCard.jsx | 18 ++- .../view/card/PricingCardView.jsx | 14 +- web/src/helpers/api.js | 8 ++ web/src/helpers/utils.jsx | 52 +------ web/src/i18n/locales/en.json | 8 +- web/src/i18n/locales/zh-CN.json | 8 +- .../Setting/Ratio/ChannelPricingView.jsx | 38 ++++++ 13 files changed, 451 insertions(+), 71 deletions(-) diff --git a/controller/channel_pricing.go b/controller/channel_pricing.go index 3bfdb45..aa424b1 100644 --- a/controller/channel_pricing.go +++ b/controller/channel_pricing.go @@ -340,3 +340,51 @@ func GetChannelPricingWithTags(c *gin.Context) { "items": result, }) } + +// SetDefaultChannelRequest 设置默认通道请求 +type SetDefaultChannelRequest struct { + ModelName string `json:"model_name" binding:"required"` + ChannelId int `json:"channel_id" binding:"required"` +} + +// SetDefaultChannel 设置指定模型的默认通道 +func SetDefaultChannel(c *gin.Context) { + var req SetDefaultChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + // 验证渠道定价记录存在 + existing, err := model.GetChannelPricing(req.ModelName, req.ChannelId) + if err != nil || existing == nil { + common.ApiErrorMsg(c, "channel pricing not found for this model and channel") + return + } + + if err := model.SetDefaultChannel(req.ModelName, req.ChannelId); err != nil { + common.ApiError(c, err) + return + } + + common.SysLog(fmt.Sprintf("[ChannelPricing] set default: model=%s channel=%d", req.ModelName, req.ChannelId)) + common.ApiSuccess(c, nil) +} + +// ClearDefaultChannel 清除指定模型的默认通道 +func ClearDefaultChannel(c *gin.Context) { + modelName := c.Param("name") + modelName = strings.TrimPrefix(modelName, "/") + if modelName == "" { + common.ApiErrorMsg(c, "model name is required") + return + } + + if err := model.ClearDefaultChannel(modelName); err != nil { + common.ApiError(c, err) + return + } + + common.SysLog(fmt.Sprintf("[ChannelPricing] cleared default: model=%s", modelName)) + common.ApiSuccess(c, nil) +} diff --git a/middleware/distributor.go b/middleware/distributor.go index 0c2211b..59a76dd 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -107,25 +107,61 @@ func Distribute() func(c *gin.Context) { } } - if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { - preferred, err := model.CacheGetChannel(preferredChannelID) - if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { - if usingGroup == "auto" { + // 默认通道检查(管理员配置的模型默认通道) + if channel == nil { + if defaultChannelId, ok := model.GetDefaultChannelId(modelRequest.Model); ok { + defaultCh, err := model.CacheGetChannel(defaultChannelId) + if err != nil || defaultCh == nil { + common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not found in cache, fallback", modelRequest.Model, defaultChannelId)) + } else if defaultCh.Status != common.ChannelStatusEnabled { + common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d disabled(status=%d), fallback", modelRequest.Model, defaultChannelId, defaultCh.Status)) + } else if usingGroup == "auto" { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) autoGroups := service.GetUserAutoGroup(userGroup) for _, g := range autoGroups { - if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { + if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, defaultCh.Id) { + channel = defaultCh selectGroup = g common.SetContextKey(c, constant.ContextKeyAutoGroup, g) - channel = preferred - service.MarkChannelAffinityUsed(c, g, preferred.Id) + common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (auto group=%s)", modelRequest.Model, defaultChannelId, g)) break } } - } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) { - channel = preferred + if channel == nil { + common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for any auto group, fallback", modelRequest.Model, defaultChannelId)) + } + } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, defaultCh.Id) { + channel = defaultCh selectGroup = usingGroup - service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id) + common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (group=%s)", modelRequest.Model, defaultChannelId, usingGroup)) + } else { + common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for group=%s, fallback", modelRequest.Model, defaultChannelId, usingGroup)) + } + } + } + + // 通道亲和性检查(仅在未选中默认通道时生效) + if channel == nil { + if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { + preferred, err := model.CacheGetChannel(preferredChannelID) + if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { + if usingGroup == "auto" { + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + autoGroups := service.GetUserAutoGroup(userGroup) + for _, g := range autoGroups { + if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { + selectGroup = g + common.SetContextKey(c, constant.ContextKeyAutoGroup, g) + channel = preferred + service.MarkChannelAffinityUsed(c, g, preferred.Id) + break + } + } + } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) { + channel = preferred + selectGroup = usingGroup + service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id) + } } } } diff --git a/model/channel_pricing.go b/model/channel_pricing.go index 7eacfd9..67f861e 100644 --- a/model/channel_pricing.go +++ b/model/channel_pricing.go @@ -16,6 +16,10 @@ import ( var ( channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId" channelPricingCacheLock sync.RWMutex + + // 默认通道缓存:modelName → channelId + defaultChannelCache = make(map[string]int) + defaultChannelCacheLock sync.RWMutex ) // QuotaType 计费类型 @@ -44,6 +48,7 @@ type ChannelPricing struct { ImageRatio float64 `json:"image_ratio" gorm:"default:0"` AudioRatio float64 `json:"audio_ratio" gorm:"default:0"` AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"` + IsDefault bool `json:"is_default" gorm:"default:false;index"` } // setCache 写穿透缓存 @@ -80,6 +85,9 @@ func (cp *ChannelPricing) Insert() error { err := DB.Create(cp).Error if err == nil { setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) + if cp.IsDefault { + setDefaultChannelCache(cp.ModelName, cp.ChannelId) + } } return err } @@ -89,10 +97,15 @@ func (cp *ChannelPricing) Update() error { err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id). Select("quota_type", "model_ratio", "completion_ratio", "model_price", "tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio", - "audio_ratio", "audio_completion_ratio", "updated_time"). + "audio_ratio", "audio_completion_ratio", "is_default", "updated_time"). Updates(cp).Error if err == nil { setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) + if cp.IsDefault { + setDefaultChannelCache(cp.ModelName, cp.ChannelId) + } else { + clearDefaultChannelCacheIfMatch(cp.ModelName, cp.Id) + } } return err } @@ -105,6 +118,9 @@ func (cp *ChannelPricing) Delete() error { err := DB.Delete(cp).Error if err == nil { removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)) + if existing.IsDefault { + clearDefaultChannelCache(existing.ModelName) + } } return err } @@ -226,6 +242,8 @@ func LoadChannelPricingCache() { channelPricingCache[key] = cp } channelPricingCacheLock.Unlock() + + rebuildDefaultChannelCache(pricings) common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings))) } @@ -247,6 +265,7 @@ type ChannelPricingWithChannel struct { ImageRatio float64 `json:"image_ratio"` AudioRatio float64 `json:"audio_ratio"` AudioCompletionRatio float64 `json:"audio_completion_ratio"` + IsDefault bool `json:"is_default"` } // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) @@ -289,6 +308,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin COALESCE(channel_pricings.image_ratio, 0) as image_ratio, COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio, COALESCE(channel_pricings.audio_completion_ratio, 0) as audio_completion_ratio, + COALESCE(channel_pricings.is_default, false) as is_default, (channel_pricings.id IS NOT NULL) as has_custom_pricing`, defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice). Joins("LEFT JOIN channels ON abilities.channel_id = channels.id"). @@ -296,7 +316,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin Where("abilities.model = ?", modelName). Where("abilities.enabled = ?", true). Where("channels.status = ?", 1). // 只显示启用的渠道 - Group("abilities.channel_id, channels.name, channels.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids, channel_pricings.cache_ratio, channel_pricings.cache_creation_ratio, channel_pricings.image_ratio, channel_pricings.audio_ratio, channel_pricings.audio_completion_ratio"). + Group("abilities.channel_id, channels.name, channels.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids, channel_pricings.cache_ratio, channel_pricings.cache_creation_ratio, channel_pricings.image_ratio, channel_pricings.audio_ratio, channel_pricings.audio_completion_ratio, channel_pricings.is_default"). Scan(&results).Error if err != nil { return nil, err @@ -321,3 +341,108 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin return results, nil } + +// === 默认通道缓存 === + +// syncIsDefaultToCache 将默认标记的变更同步到 channelPricingCache +func syncIsDefaultToCache(modelName string, channelId int, isDefault bool) { + channelPricingCacheLock.Lock() + for key, cp := range channelPricingCache { + if cp.ModelName == modelName { + if isDefault { + cp.IsDefault = cp.ChannelId == channelId + } else { + cp.IsDefault = false + } + } + channelPricingCache[key] = cp + } + channelPricingCacheLock.Unlock() +} + +// setDefaultChannelCache 设置默认通道缓存 +func setDefaultChannelCache(modelName string, channelId int) { + defaultChannelCacheLock.Lock() + defaultChannelCache[modelName] = channelId + defaultChannelCacheLock.Unlock() +} + +// clearDefaultChannelCache 清除指定模型的默认通道缓存 +func clearDefaultChannelCache(modelName string) { + defaultChannelCacheLock.Lock() + delete(defaultChannelCache, modelName) + defaultChannelCacheLock.Unlock() +} + +// clearDefaultChannelCacheIfMatch 如果默认通道的定价记录 ID 匹配则清除 +func clearDefaultChannelCacheIfMatch(modelName string, pricingId int) { + defaultChannelCacheLock.RLock() + cachedId, ok := defaultChannelCache[modelName] + defaultChannelCacheLock.RUnlock() + if !ok { + return + } + // 需要通过缓存找到对应的 pricing 来比对 + key := getChannelPricingCacheKey(modelName, cachedId) + channelPricingCacheLock.RLock() + cp, exists := channelPricingCache[key] + channelPricingCacheLock.RUnlock() + if exists && cp.Id == pricingId { + clearDefaultChannelCache(modelName) + } +} + +// rebuildDefaultChannelCache 从全量数据构建默认通道缓存(启动时调用) +func rebuildDefaultChannelCache(pricings []*ChannelPricing) { + defaultChannelCacheLock.Lock() + defaultChannelCache = make(map[string]int) + for _, cp := range pricings { + if cp.IsDefault { + defaultChannelCache[cp.ModelName] = cp.ChannelId + } + } + defaultChannelCacheLock.Unlock() + common.SysLog(fmt.Sprintf("[ChannelPricing] default channel cache loaded %d records", len(defaultChannelCache))) +} + +// GetDefaultChannelId 获取指定模型的默认通道 ID(纯内存读) +func GetDefaultChannelId(modelName string) (int, bool) { + defaultChannelCacheLock.RLock() + id, ok := defaultChannelCache[modelName] + defaultChannelCacheLock.RUnlock() + return id, ok +} + +// SetDefaultChannel 设置指定模型的默认通道(事务保证互斥) +func SetDefaultChannel(modelName string, channelId int) error { + return DB.Transaction(func(tx *gorm.DB) error { + // 清除该模型所有现有的默认标记 + if err := tx.Model(&ChannelPricing{}). + Where("model_name = ? AND is_default = ?", modelName, true). + Update("is_default", false).Error; err != nil { + return err + } + // 设置新的默认 + if err := tx.Model(&ChannelPricing{}). + Where("model_name = ? AND channel_id = ?", modelName, channelId). + Update("is_default", true).Error; err != nil { + return err + } + // 更新缓存 + setDefaultChannelCache(modelName, channelId) + syncIsDefaultToCache(modelName, channelId, true) + return nil + }) +} + +// ClearDefaultChannel 清除指定模型的默认通道标记 +func ClearDefaultChannel(modelName string) error { + err := DB.Model(&ChannelPricing{}). + Where("model_name = ? AND is_default = ?", modelName, true). + Update("is_default", false).Error + if err == nil { + clearDefaultChannelCache(modelName) + syncIsDefaultToCache(modelName, 0, false) + } + return err +} diff --git a/model/channel_pricing_test.go b/model/channel_pricing_test.go index 9ed8a9f..bb58d3e 100644 --- a/model/channel_pricing_test.go +++ b/model/channel_pricing_test.go @@ -119,3 +119,124 @@ func TestGetEffectivePricing_NotFound(t *testing.T) { assert.False(t, ok) assert.Nil(t, found) } + +// === 默认通道测试 === + +func TestDefaultChannel_SetAndGet(t *testing.T) { + setupChannelPricingDB(t) + + // 创建两条定价记录 + cp1 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 100, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0} + cp2 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 200, QuotaType: QuotaTypeByTokens, ModelRatio: 2.0} + require.NoError(t, cp1.Insert()) + require.NoError(t, cp2.Insert()) + t.Cleanup(func() { cp1.Delete(); cp2.Delete() }) + + // 初始没有默认 + _, ok := GetDefaultChannelId("default-test-model") + assert.False(t, ok) + + // 设置通道 100 为默认 + require.NoError(t, SetDefaultChannel("default-test-model", 100)) + id, ok := GetDefaultChannelId("default-test-model") + assert.True(t, ok) + assert.Equal(t, 100, id) + + // 切换默认到通道 200 + require.NoError(t, SetDefaultChannel("default-test-model", 200)) + id, ok = GetDefaultChannelId("default-test-model") + assert.True(t, ok) + assert.Equal(t, 200, id) + + // 验证旧的默认标记被清除 + found, _ := GetEffectivePricing("default-test-model", 100) + assert.False(t, found.IsDefault) + found, _ = GetEffectivePricing("default-test-model", 200) + assert.True(t, found.IsDefault) +} + +func TestDefaultChannel_Clear(t *testing.T) { + setupChannelPricingDB(t) + + cp := &ChannelPricing{ModelName: "clear-test-model", ChannelId: 300, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0} + require.NoError(t, cp.Insert()) + t.Cleanup(func() { cp.Delete() }) + + require.NoError(t, SetDefaultChannel("clear-test-model", 300)) + _, ok := GetDefaultChannelId("clear-test-model") + assert.True(t, ok) + + require.NoError(t, ClearDefaultChannel("clear-test-model")) + _, ok = GetDefaultChannelId("clear-test-model") + assert.False(t, ok) +} + +func TestDefaultChannel_DeleteClearsCache(t *testing.T) { + setupChannelPricingDB(t) + + cp := &ChannelPricing{ModelName: "delete-test-model", ChannelId: 400, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0, IsDefault: true} + require.NoError(t, cp.Insert()) + + id, ok := GetDefaultChannelId("delete-test-model") + assert.True(t, ok) + assert.Equal(t, 400, id) + + // 删除记录后缓存应清除 + require.NoError(t, cp.Delete()) + _, ok = GetDefaultChannelId("delete-test-model") + assert.False(t, ok) +} + +func TestDefaultChannel_LoadCache(t *testing.T) { + db := setupChannelPricingDB(t) + + // 直接插入数据(绕过缓存) + db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 500, ModelRatio: 1.0, IsDefault: true}) + db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 501, ModelRatio: 2.0, IsDefault: false}) + db.Create(&ChannelPricing{ModelName: "other-model", ChannelId: 502, ModelRatio: 1.0, IsDefault: true}) + + // 全量加载缓存 + LoadChannelPricingCache() + + id, ok := GetDefaultChannelId("load-model") + assert.True(t, ok) + assert.Equal(t, 500, id) + + id, ok = GetDefaultChannelId("other-model") + assert.True(t, ok) + assert.Equal(t, 502, id) + + // 无默认的模型 + _, ok = GetDefaultChannelId("nonexistent") + assert.False(t, ok) +} + +func TestDefaultChannel_InsertWithDefault(t *testing.T) { + setupChannelPricingDB(t) + + cp := &ChannelPricing{ModelName: "insert-default-model", ChannelId: 600, ModelRatio: 1.0, IsDefault: true} + require.NoError(t, cp.Insert()) + t.Cleanup(func() { cp.Delete() }) + + id, ok := GetDefaultChannelId("insert-default-model") + assert.True(t, ok) + assert.Equal(t, 600, id) +} + +func TestDefaultChannel_UpdateWithDefault(t *testing.T) { + setupChannelPricingDB(t) + + cp1 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 700, ModelRatio: 1.0, IsDefault: true} + cp2 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 701, ModelRatio: 2.0} + require.NoError(t, cp1.Insert()) + require.NoError(t, cp2.Insert()) + t.Cleanup(func() { cp1.Delete(); cp2.Delete() }) + + // cp1 是默认,通过 Update 把 cp2 设为默认 + cp2.IsDefault = true + require.NoError(t, cp2.Update()) + + id, ok := GetDefaultChannelId("update-default-model") + assert.True(t, ok) + assert.Equal(t, 701, id) +} diff --git a/model/pricing.go b/model/pricing.go index 183c183..b67c11a 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -31,6 +31,7 @@ type Pricing struct { SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` PricingVersion string `json:"pricing_version,omitempty"` Type int `json:"type"` + DefaultChannelName string `json:"default_channel_name,omitempty"` } type PricingVendor struct { @@ -271,16 +272,21 @@ func updatePricing() { } } - // 从渠道定价表加载实际定价数据(仅启用渠道) - var allCPs []ChannelPricing + // 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称 + var allCPs []struct { + ChannelPricing + ChannelName string + } DB.Table("channel_pricings"). - Select("channel_pricings.*"). + Select("channel_pricings.*, channels.name as channel_name"). Joins("JOIN channels ON channel_pricings.channel_id = channels.id"). Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL"). Find(&allCPs) cpMap := make(map[string][]ChannelPricing) + channelNameMap := make(map[int]string) for i := range allCPs { - cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i]) + cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing) + channelNameMap[allCPs[i].ChannelId] = allCPs[i].ChannelName } pricingMap = make([]Pricing, 0) @@ -306,6 +312,12 @@ func updatePricing() { // 使用渠道定价表中的实际数据,选取最便宜的渠道 applyBestChannelPricing(&pricing, cpMap[model], model) + // 填充默认通道名称 + if chId, ok := GetDefaultChannelId(model); ok { + if name, found := channelNameMap[chId]; found { + pricing.DefaultChannelName = name + } + } pricingMap = append(pricingMap, pricing) } diff --git a/router/api-router.go b/router/api-router.go index fb7ea7c..9d56d21 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -194,6 +194,8 @@ func SetApiRouter(router *gin.Engine) { channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) + channelPricingRoute.POST("/set_default", controller.SetDefaultChannel) + channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel) } // 定价标签路由(管理员权限) diff --git a/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx b/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx index 45afe75..0125c47 100644 --- a/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx +++ b/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx @@ -104,7 +104,7 @@ const ChannelPricingCard = ({ const tableData = channelPricingData.map((item, index) => ({ key: item.channel_id || index, channelId: item.channel_id, - channelName: `渠道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`, + channelName: `通道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`, channelTags: item.tags || [], // 渠道定价的标签列表 channelType: item.channel_type, quotaType: item.quota_type, @@ -114,6 +114,7 @@ const ChannelPricingCard = ({ hasCustomPricing: item.has_custom_pricing, cacheRatio: item.cache_ratio, cacheCreationRatio: item.cache_creation_ratio, + isDefault: item.is_default || false, })); // 判断是否存在按次计费的渠道 @@ -124,11 +125,11 @@ const ChannelPricingCard = ({ // 定义基础列 const baseColumns = [ { - title: t('渠道'), + title: t('通道'), dataIndex: 'channelName', render: (text, record) => (
- + ))} + {record.isDefault && ( + + {t('默认')} + + )}
), }, @@ -282,15 +288,15 @@ const ChannelPricingCard = ({
- {t('渠道价格')} + {t('通道价格')}
- {t('所有支持该模型的渠道价格(自定义定价已标记)')} + {t('所有支持该模型的通道价格(自定义定价已标记)')}
{getModelIcon(model)}
-

+

{model.model_name}

@@ -312,6 +312,18 @@ const PricingCardView = ({ {/* 标签区域 */} {renderTags(model)} + {/* 默认通道标识 */} + {model.default_channel_name && ( + + {t('默认')}: {model.default_channel_name} + + )} + {/* 倍率信息(可选) */} {showRatio && (
diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index da7e310..5116e55 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -401,6 +401,14 @@ export const channelPricingApi = { // 复制全局定价 copyGlobal: (channelId, overwrite = false) => API.post(`/api/channel_pricing/copy_global/${channelId}`, { overwrite }), + + // 设置默认通道 + setDefault: (modelName, channelId) => + API.post('/api/channel_pricing/set_default', { model_name: modelName, channel_id: channelId }), + + // 清除默认通道 + clearDefault: (modelName) => + API.delete(`/api/channel_pricing/default/${encodeURIComponent(modelName)}`), }; // 定价标签 API diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index 6215da4..4b78335 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -680,26 +680,9 @@ export const calculateModelPrice = ({ } } - const cacheReadRatio = record.cache_ratio || 0; - const cacheCreationRatio = record.cache_creation_ratio || 0; - let cacheReadPrice = null; - let cacheCreationPrice = null; - if (cacheReadRatio > 0) { - const cacheReadUSD = record.model_ratio * cacheReadRatio * 2 * usedGroupRatio; - const numCacheRead = parseFloat(displayPrice(cacheReadUSD).replace(/[^0-9.]/g, '')) / unitDivisor; - cacheReadPrice = `${symbol}${numCacheRead.toFixed(precision)}`; - } - if (cacheCreationRatio > 0) { - const cacheCreationUSD = record.model_ratio * cacheCreationRatio * 2 * usedGroupRatio; - const numCacheCreation = parseFloat(displayPrice(cacheCreationUSD).replace(/[^0-9.]/g, '')) / unitDivisor; - cacheCreationPrice = `${symbol}${numCacheCreation.toFixed(precision)}`; - } - return { inputPrice: `${symbol}${numInput.toFixed(precision)}`, completionPrice: `${symbol}${numCompletion.toFixed(precision)}`, - cacheReadPrice, - cacheCreationPrice, unitLabel, isPerToken: true, usedGroup, @@ -733,39 +716,16 @@ export const calculateModelPrice = ({ export const formatPriceInfo = (priceData, t) => { if (priceData.isPerToken) { return ( -
-
- - {t('输入')} {priceData.inputPrice}/{priceData.unitLabel} - - - {t('输出')} {priceData.completionPrice}/{priceData.unitLabel} - -
- {(priceData.cacheReadPrice || priceData.cacheCreationPrice) && ( -
- {priceData.cacheReadPrice && ( - - {t('缓存读取')} {priceData.cacheReadPrice}/{priceData.unitLabel} - - )} - {priceData.cacheCreationPrice && ( - - {t('缓存创建')} {priceData.cacheCreationPrice}/{priceData.unitLabel} - - )} -
- )} -
+ + {t('输入')} {priceData.inputPrice} / {t('输出')} {priceData.completionPrice} / {priceData.unitLabel} tokens + ); } return ( - <> - - {t('模型价格')} {priceData.price} - - + + {t('模型价格')} {priceData.price} + ); }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d184454..9ff9301 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2230,9 +2230,15 @@ "统一的": "The Unified", "统一的大模型接口网关": "Unified LLM API Gateway", "链接顶级 AI 能力": "Connect Top-Tier AI", - "点击复制渠道 ID": "Click to copy channel ID", + "点击复制通道 ID": "Click to copy channel ID", "统一监控": "Unified Monitoring", "统计Tokens": "Statistical Tokens", + "通道": "Channel", + "通道价格": "Channel Pricing", + "已设为默认通道": "Set as default channel", + "已取消默认通道": "Removed default channel", + "所有支持该模型的通道价格(自定义定价已标记)": "Pricing from all channels supporting this model (custom pricing marked)", + "在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1": "Append \":ChannelID\" to the API Key to use a specific channel, e.g. sk-xxxx:1", "统计已重置": "Statistics reset", "统计次数": "Statistical count", "统计额度": "Statistical quota", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index d02076e..4b5800f 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2212,9 +2212,15 @@ "统一的": "统一的", "统一的大模型接口网关": "统一的大模型接口网关", "链接顶级 AI 能力": "链接顶级 AI 能力", - "点击复制渠道 ID": "点击复制渠道 ID", + "点击复制通道 ID": "点击复制通道 ID", "统一监控": "统一监控", "统计Tokens": "统计Tokens", + "通道": "通道", + "通道价格": "通道价格", + "已设为默认通道": "已设为默认通道", + "已取消默认通道": "已取消默认通道", + "所有支持该模型的通道价格(自定义定价已标记)": "所有支持该模型的通道价格(自定义定价已标记)", + "在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1": "在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1", "统计已重置": "统计已重置", "统计次数": "统计次数", "统计额度": "统计额度", diff --git a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx index f35eb16..28d2e66 100644 --- a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx +++ b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx @@ -33,6 +33,7 @@ import { RadioGroup, Radio, TreeSelect, + Tooltip, } from '@douyinfe/semi-ui'; import { IconEdit, @@ -40,6 +41,7 @@ import { IconChevronDown, IconChevronRight, IconSearch, + IconStar, } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; import { channelPricingApi } from '../../../helpers/api'; @@ -315,6 +317,42 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { )); }, }, + { + title: t('默认'), + key: 'isDefault', + width: 70, + render: (_, record) => { + const isDefault = record.pricing?.is_default === true; + return ( + +
+
+
+ ); + }, + }, { title: t('操作'), key: 'action',