为 Channel 模型新增 public_name 字段,让管理员可以为每个渠道 设置用户可见的友好名称(如"标准通道"、"高速通道"),替代前端 硬编码的"通道一/二/三"。Playground 通道选择器展示对外名称, "渠道"统一改为"通道"。新建渠道时对外名称必填,编辑时可选。 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -584,6 +584,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error { | |||||
| return fmt.Errorf("channel cannot be empty") | return fmt.Errorf("channel cannot be empty") | ||||
| } | } | ||||
| if strings.TrimSpace(channel.PublicName) == "" { | |||||
| return fmt.Errorf("public name cannot be empty") | |||||
| } | |||||
| // 检查模型名称长度是否超过 255 | // 检查模型名称长度是否超过 255 | ||||
| for _, m := range channel.GetModels() { | for _, m := range channel.GetModels() { | ||||
| if len(m) > 255 { | if len(m) > 255 { | ||||
| @@ -2110,6 +2114,7 @@ func GetUserChannelsForBinding(c *gin.Context) { | |||||
| result = append(result, gin.H{ | result = append(result, gin.H{ | ||||
| "id": ch.Id, | "id": ch.Id, | ||||
| "name": ch.Name, | "name": ch.Name, | ||||
| "public_name": ch.PublicName, | |||||
| "type": ch.Type, | "type": ch.Type, | ||||
| "remark": ch.Remark, | "remark": ch.Remark, | ||||
| }) | }) | ||||
| @@ -84,13 +84,14 @@ func GetModelChannelsForGroup(modelName string, group string) ([]map[string]any, | |||||
| } | } | ||||
| type channelInfo struct { | type channelInfo struct { | ||||
| Id int `json:"id"` | |||||
| Name string `json:"name"` | |||||
| Id int `json:"id"` | |||||
| Name string `json:"name"` | |||||
| PublicName string `json:"public_name"` | |||||
| } | } | ||||
| var channels []channelInfo | var channels []channelInfo | ||||
| err = DB.Table("channels"). | err = DB.Table("channels"). | ||||
| Where("id IN ? AND status = ?", channelIds, common.ChannelStatusEnabled). | Where("id IN ? AND status = ?", channelIds, common.ChannelStatusEnabled). | ||||
| Select("id, name"). | |||||
| Select("id, name, public_name"). | |||||
| Find(&channels).Error | Find(&channels).Error | ||||
| if err != nil { | if err != nil { | ||||
| return nil, 0, err | return nil, 0, err | ||||
| @@ -109,8 +110,9 @@ func GetModelChannelsForGroup(modelName string, group string) ([]map[string]any, | |||||
| result := make([]map[string]any, 0, len(channels)) | result := make([]map[string]any, 0, len(channels)) | ||||
| for _, ch := range channels { | for _, ch := range channels { | ||||
| result = append(result, map[string]any{ | result = append(result, map[string]any{ | ||||
| "id": ch.Id, | |||||
| "name": ch.Name, | |||||
| "id": ch.Id, | |||||
| "name": ch.Name, | |||||
| "public_name": ch.PublicName, | |||||
| }) | }) | ||||
| } | } | ||||
| @@ -26,6 +26,7 @@ type Channel struct { | |||||
| TestModel *string `json:"test_model"` | TestModel *string `json:"test_model"` | ||||
| Status int `json:"status" gorm:"default:1"` | Status int `json:"status" gorm:"default:1"` | ||||
| Name string `json:"name" gorm:"index"` | Name string `json:"name" gorm:"index"` | ||||
| PublicName string `json:"public_name" gorm:"size:255;default:''"` | |||||
| Weight *uint `json:"weight" gorm:"default:0"` | Weight *uint `json:"weight" gorm:"default:0"` | ||||
| CreatedTime int64 `json:"created_time" gorm:"bigint"` | CreatedTime int64 `json:"created_time" gorm:"bigint"` | ||||
| TestTime int64 `json:"test_time" gorm:"bigint"` | TestTime int64 `json:"test_time" gorm:"bigint"` | ||||
| @@ -72,6 +73,13 @@ func (c ChannelInfo) Value() (driver.Value, error) { | |||||
| return common.Marshal(&c) | return common.Marshal(&c) | ||||
| } | } | ||||
| func ChannelDisplayName(publicName, name string) string { | |||||
| if publicName != "" { | |||||
| return publicName | |||||
| } | |||||
| return name | |||||
| } | |||||
| // Scan implements sql.Scanner interface | // Scan implements sql.Scanner interface | ||||
| func (c *ChannelInfo) Scan(value interface{}) error { | func (c *ChannelInfo) Scan(value interface{}) error { | ||||
| bytesValue, _ := value.([]byte) | bytesValue, _ := value.([]byte) | ||||
| @@ -279,7 +287,7 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan | |||||
| // 只返回 id, name, type, remark,不包含敏感信息 | // 只返回 id, name, type, remark,不包含敏感信息 | ||||
| func GetAllChannelsForBinding() ([]*Channel, error) { | func GetAllChannelsForBinding() ([]*Channel, error) { | ||||
| var channels []*Channel | var channels []*Channel | ||||
| err := DB.Select("id, name, type, remark"). | |||||
| err := DB.Select("id, name, public_name, type, remark"). | |||||
| Where("status = ?", common.ChannelStatusEnabled). | Where("status = ?", common.ChannelStatusEnabled). | ||||
| Order("priority desc"). | Order("priority desc"). | ||||
| Find(&channels).Error | Find(&channels).Error | ||||
| @@ -252,6 +252,7 @@ type ChannelPricingWithChannel struct { | |||||
| Id int `json:"id"` | Id int `json:"id"` | ||||
| ChannelId int `json:"channel_id"` | ChannelId int `json:"channel_id"` | ||||
| ChannelName string `json:"channel_name"` | ChannelName string `json:"channel_name"` | ||||
| ChannelPublicName string `json:"channel_public_name"` | |||||
| ChannelType int `json:"channel_type"` | ChannelType int `json:"channel_type"` | ||||
| TagIds string `json:"tag_ids" gorm:"column:tag_ids"` // 渠道定价的标签ID列表(逗号分隔) | TagIds string `json:"tag_ids" gorm:"column:tag_ids"` // 渠道定价的标签ID列表(逗号分隔) | ||||
| Tags []*PricingTag `json:"tags" gorm:"-"` // 渠道定价的标签详情(不参与数据库扫描) | Tags []*PricingTag `json:"tags" gorm:"-"` // 渠道定价的标签详情(不参与数据库扫描) | ||||
| @@ -296,7 +297,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| // 查询所有支持该模型的渠道,左连接渠道定价表 | // 查询所有支持该模型的渠道,左连接渠道定价表 | ||||
| // 高级字段(cache/image/audio)不回退全局值,直接返回 0 | // 高级字段(cache/image/audio)不回退全局值,直接返回 0 | ||||
| err := DB.Table("abilities"). | err := DB.Table("abilities"). | ||||
| Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type, | |||||
| Select(`abilities.channel_id, channels.name as channel_name, channels.public_name as channel_public_name, channels.type as channel_type, | |||||
| COALESCE(channel_pricings.quota_type, ?) as quota_type, | COALESCE(channel_pricings.quota_type, ?) as quota_type, | ||||
| COALESCE(channel_pricings.model_ratio, ?) as model_ratio, | COALESCE(channel_pricings.model_ratio, ?) as model_ratio, | ||||
| COALESCE(channel_pricings.completion_ratio, ?) as completion_ratio, | COALESCE(channel_pricings.completion_ratio, ?) as completion_ratio, | ||||
| @@ -316,7 +317,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| Where("abilities.model = ?", modelName). | Where("abilities.model = ?", modelName). | ||||
| Where("abilities.enabled = ?", true). | Where("abilities.enabled = ?", true). | ||||
| Where("channels.status = ?", 1). // 只显示启用的渠道 | 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, channel_pricings.is_default"). | |||||
| Group("abilities.channel_id, channels.name, channels.public_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 | Scan(&results).Error | ||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| @@ -304,6 +304,8 @@ func migrateDB() error { | |||||
| // 将现有 sort_order=0 的模型和供应商更新为默认大数 | // 将现有 sort_order=0 的模型和供应商更新为默认大数 | ||||
| DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999) | DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999) | ||||
| DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999) | DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999) | ||||
| migrateChannelPublicName() | |||||
| return nil | return nil | ||||
| } | } | ||||
| @@ -693,3 +695,14 @@ func PingDB() error { | |||||
| common.SysLog("Database pinged successfully") | common.SysLog("Database pinged successfully") | ||||
| return nil | return nil | ||||
| } | } | ||||
| func migrateChannelPublicName() { | |||||
| result := DB.Model(&Channel{}). | |||||
| Where("public_name = '' OR public_name IS NULL"). | |||||
| Update("public_name", gorm.Expr("name")) | |||||
| if result.Error != nil { | |||||
| common.SysError("[Migration] migrateChannelPublicName failed: " + result.Error.Error()) | |||||
| } else if result.RowsAffected > 0 { | |||||
| common.SysLog(fmt.Sprintf("[Migration] migrateChannelPublicName: backfilled %d channels", result.RowsAffected)) | |||||
| } | |||||
| } | |||||
| @@ -289,10 +289,11 @@ func updatePricing() { | |||||
| // 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称 | // 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称 | ||||
| var allCPs []struct { | var allCPs []struct { | ||||
| ChannelPricing | ChannelPricing | ||||
| ChannelName string | |||||
| ChannelName string | |||||
| ChannelPublicName string | |||||
| } | } | ||||
| DB.Table("channel_pricings"). | DB.Table("channel_pricings"). | ||||
| Select("channel_pricings.*, channels.name as channel_name"). | |||||
| Select("channel_pricings.*, channels.name as channel_name, channels.public_name as channel_public_name"). | |||||
| Joins("JOIN channels ON channel_pricings.channel_id = channels.id"). | Joins("JOIN channels ON channel_pricings.channel_id = channels.id"). | ||||
| Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL"). | Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL"). | ||||
| Find(&allCPs) | Find(&allCPs) | ||||
| @@ -300,7 +301,7 @@ func updatePricing() { | |||||
| channelNameMap := make(map[int]string) | channelNameMap := make(map[int]string) | ||||
| for i := range allCPs { | for i := range allCPs { | ||||
| cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing) | cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing) | ||||
| channelNameMap[allCPs[i].ChannelId] = allCPs[i].ChannelName | |||||
| channelNameMap[allCPs[i].ChannelId] = ChannelDisplayName(allCPs[i].ChannelPublicName, allCPs[i].ChannelName) | |||||
| } | } | ||||
| pricingMap = make([]Pricing, 0) | pricingMap = make([]Pricing, 0) | ||||
| @@ -177,11 +177,11 @@ const SettingsPanel = ({ | |||||
| /> | /> | ||||
| </div> | </div> | ||||
| {/* 渠道选择 */} | |||||
| {/* 通道选择 */} | |||||
| <div className={customRequestMode ? 'opacity-50' : ''}> | <div className={customRequestMode ? 'opacity-50' : ''}> | ||||
| <div className='flex items-center gap-2 mb-2'> | <div className='flex items-center gap-2 mb-2'> | ||||
| <Typography.Text strong className='text-sm'> | <Typography.Text strong className='text-sm'> | ||||
| {t('渠道')} | |||||
| {t('通道')} | |||||
| </Typography.Text> | </Typography.Text> | ||||
| {customRequestMode && ( | {customRequestMode && ( | ||||
| <Typography.Text className='text-xs text-orange-600'> | <Typography.Text className='text-xs text-orange-600'> | ||||
| @@ -190,7 +190,7 @@ const SettingsPanel = ({ | |||||
| )} | )} | ||||
| </div> | </div> | ||||
| <Select | <Select | ||||
| placeholder={t('请选择渠道')} | |||||
| placeholder={t('请选择通道')} | |||||
| name='channelId' | name='channelId' | ||||
| selection | selection | ||||
| filter={selectFilter} | filter={selectFilter} | ||||
| @@ -200,7 +200,7 @@ const SettingsPanel = ({ | |||||
| autoComplete='new-password' | autoComplete='new-password' | ||||
| optionList={channels.map((ch) => ({ | optionList={channels.map((ch) => ({ | ||||
| value: ch.id, | value: ch.id, | ||||
| label: ch.name, | |||||
| label: ch.public_name || ch.name, | |||||
| }))} | }))} | ||||
| style={{ width: '100%' }} | style={{ width: '100%' }} | ||||
| dropdownStyle={{ width: '100%', maxWidth: '100%' }} | dropdownStyle={{ width: '100%', maxWidth: '100%' }} | ||||
| @@ -141,6 +141,7 @@ const EditChannelModal = (props) => { | |||||
| }; | }; | ||||
| const originInputs = { | const originInputs = { | ||||
| name: '', | name: '', | ||||
| public_name: '', | |||||
| type: 1, | type: 1, | ||||
| key: '', | key: '', | ||||
| openai_organization: '', | openai_organization: '', | ||||
| @@ -1326,8 +1327,8 @@ const EditChannelModal = (props) => { | |||||
| } | } | ||||
| delete localInputs.vertex_files; | delete localInputs.vertex_files; | ||||
| if (!isEdit && (!localInputs.name || !localInputs.key)) { | |||||
| showInfo(t('请填写渠道名称和渠道密钥!')); | |||||
| if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) { | |||||
| showInfo(t('请填写渠道名称、对外名称和渠道密钥!')); | |||||
| return; | return; | ||||
| } | } | ||||
| if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { | if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { | ||||
| @@ -1979,6 +1980,16 @@ const EditChannelModal = (props) => { | |||||
| autoComplete='new-password' | autoComplete='new-password' | ||||
| /> | /> | ||||
| <Form.Input | |||||
| field='public_name' | |||||
| label={t('对外名称')} | |||||
| placeholder={t('用户看到的渠道名称,如「标准通道」「高速通道」')} | |||||
| rules={!isEdit ? [{ required: true, message: t('请填写对外名称') }] : []} | |||||
| showClear | |||||
| onChange={(value) => handleInputChange('public_name', value)} | |||||
| autoComplete='new-password' | |||||
| /> | |||||
| {inputs.type === 33 && ( | {inputs.type === 33 && ( | ||||
| <> | <> | ||||
| <Form.Select | <Form.Select | ||||
| @@ -104,7 +104,7 @@ const ChannelPricingCard = ({ | |||||
| const tableData = channelPricingData.map((item, index) => ({ | const tableData = channelPricingData.map((item, index) => ({ | ||||
| key: item.channel_id || index, | key: item.channel_id || index, | ||||
| channelId: item.channel_id, | channelId: item.channel_id, | ||||
| channelName: `通道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`, | |||||
| channelName: item.channel_public_name || ('通道' + (index + 1)), | |||||
| channelTags: item.tags || [], // 渠道定价的标签列表 | channelTags: item.tags || [], // 渠道定价的标签列表 | ||||
| channelType: item.channel_type, | channelType: item.channel_type, | ||||
| quotaType: item.quota_type, | quotaType: item.quota_type, | ||||
| @@ -2471,6 +2471,11 @@ | |||||
| "请上传密钥文件": "Please upload the key file", | "请上传密钥文件": "Please upload the key file", | ||||
| "请上传密钥文件!": "Please upload the key file!", | "请上传密钥文件!": "Please upload the key file!", | ||||
| "请为渠道命名": "Please name the channel", | "请为渠道命名": "Please name the channel", | ||||
| "对外名称": "Public Name", | |||||
| "用户看到的渠道名称,如「标准通道」「高速通道」": "User-facing channel name, e.g. \"Standard\", \"Fast\"", | |||||
| "请填写对外名称": "Please enter a public name", | |||||
| "请填写渠道名称、对外名称和渠道密钥!": "Please enter channel name, public name and key!", | |||||
| "请选择通道": "Please select a channel", | |||||
| "请使用 Project 为 io.cloud 的密钥": "Please use a key with Project set to io.cloud", | "请使用 Project 为 io.cloud 的密钥": "Please use a key with Project set to io.cloud", | ||||
| "请先在设置中启用图片功能": "Please enable image feature in settings first", | "请先在设置中启用图片功能": "Please enable image feature in settings first", | ||||
| "请先填写 API Key": "Please fill in API Key first", | "请先填写 API Key": "Please fill in API Key first", | ||||
| @@ -2452,6 +2452,11 @@ | |||||
| "请上传密钥文件": "请上传密钥文件", | "请上传密钥文件": "请上传密钥文件", | ||||
| "请上传密钥文件!": "请上传密钥文件!", | "请上传密钥文件!": "请上传密钥文件!", | ||||
| "请为渠道命名": "请为渠道命名", | "请为渠道命名": "请为渠道命名", | ||||
| "对外名称": "对外名称", | |||||
| "用户看到的渠道名称,如「标准通道」「高速通道」": "用户看到的渠道名称,如「标准通道」「高速通道」", | |||||
| "请填写对外名称": "请填写对外名称", | |||||
| "请填写渠道名称、对外名称和渠道密钥!": "请填写渠道名称、对外名称和渠道密钥!", | |||||
| "请选择通道": "请选择通道", | |||||
| "请使用 Project 为 io.cloud 的密钥": "请使用 Project 为 io.cloud 的密钥", | "请使用 Project 为 io.cloud 的密钥": "请使用 Project 为 io.cloud 的密钥", | ||||
| "请先在设置中启用图片功能": "请先在设置中启用图片功能", | "请先在设置中启用图片功能": "请先在设置中启用图片功能", | ||||
| "请先填写 API Key": "请先填写 API Key", | "请先填写 API Key": "请先填写 API Key", | ||||