为 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") | |||
| } | |||
| if strings.TrimSpace(channel.PublicName) == "" { | |||
| return fmt.Errorf("public name cannot be empty") | |||
| } | |||
| // 检查模型名称长度是否超过 255 | |||
| for _, m := range channel.GetModels() { | |||
| if len(m) > 255 { | |||
| @@ -2110,6 +2114,7 @@ func GetUserChannelsForBinding(c *gin.Context) { | |||
| result = append(result, gin.H{ | |||
| "id": ch.Id, | |||
| "name": ch.Name, | |||
| "public_name": ch.PublicName, | |||
| "type": ch.Type, | |||
| "remark": ch.Remark, | |||
| }) | |||
| @@ -84,13 +84,14 @@ func GetModelChannelsForGroup(modelName string, group string) ([]map[string]any, | |||
| } | |||
| 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 | |||
| err = DB.Table("channels"). | |||
| Where("id IN ? AND status = ?", channelIds, common.ChannelStatusEnabled). | |||
| Select("id, name"). | |||
| Select("id, name, public_name"). | |||
| Find(&channels).Error | |||
| if err != nil { | |||
| 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)) | |||
| for _, ch := range channels { | |||
| 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"` | |||
| Status int `json:"status" gorm:"default:1"` | |||
| Name string `json:"name" gorm:"index"` | |||
| PublicName string `json:"public_name" gorm:"size:255;default:''"` | |||
| Weight *uint `json:"weight" gorm:"default:0"` | |||
| CreatedTime int64 `json:"created_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) | |||
| } | |||
| func ChannelDisplayName(publicName, name string) string { | |||
| if publicName != "" { | |||
| return publicName | |||
| } | |||
| return name | |||
| } | |||
| // Scan implements sql.Scanner interface | |||
| func (c *ChannelInfo) Scan(value interface{}) error { | |||
| bytesValue, _ := value.([]byte) | |||
| @@ -279,7 +287,7 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan | |||
| // 只返回 id, name, type, remark,不包含敏感信息 | |||
| func GetAllChannelsForBinding() ([]*Channel, error) { | |||
| var channels []*Channel | |||
| err := DB.Select("id, name, type, remark"). | |||
| err := DB.Select("id, name, public_name, type, remark"). | |||
| Where("status = ?", common.ChannelStatusEnabled). | |||
| Order("priority desc"). | |||
| Find(&channels).Error | |||
| @@ -252,6 +252,7 @@ type ChannelPricingWithChannel struct { | |||
| Id int `json:"id"` | |||
| ChannelId int `json:"channel_id"` | |||
| ChannelName string `json:"channel_name"` | |||
| ChannelPublicName string `json:"channel_public_name"` | |||
| ChannelType int `json:"channel_type"` | |||
| TagIds string `json:"tag_ids" gorm:"column:tag_ids"` // 渠道定价的标签ID列表(逗号分隔) | |||
| Tags []*PricingTag `json:"tags" gorm:"-"` // 渠道定价的标签详情(不参与数据库扫描) | |||
| @@ -296,7 +297,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||
| // 查询所有支持该模型的渠道,左连接渠道定价表 | |||
| // 高级字段(cache/image/audio)不回退全局值,直接返回 0 | |||
| 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.model_ratio, ?) as model_ratio, | |||
| COALESCE(channel_pricings.completion_ratio, ?) as completion_ratio, | |||
| @@ -316,7 +317,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, 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 | |||
| if err != nil { | |||
| return nil, err | |||
| @@ -304,6 +304,8 @@ func migrateDB() error { | |||
| // 将现有 sort_order=0 的模型和供应商更新为默认大数 | |||
| DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999) | |||
| DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999) | |||
| migrateChannelPublicName() | |||
| return nil | |||
| } | |||
| @@ -693,3 +695,14 @@ func PingDB() error { | |||
| common.SysLog("Database pinged successfully") | |||
| 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 { | |||
| ChannelPricing | |||
| ChannelName string | |||
| ChannelName string | |||
| ChannelPublicName string | |||
| } | |||
| 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"). | |||
| Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL"). | |||
| Find(&allCPs) | |||
| @@ -300,7 +301,7 @@ func updatePricing() { | |||
| channelNameMap := make(map[int]string) | |||
| for i := range allCPs { | |||
| 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) | |||
| @@ -177,11 +177,11 @@ const SettingsPanel = ({ | |||
| /> | |||
| </div> | |||
| {/* 渠道选择 */} | |||
| {/* 通道选择 */} | |||
| <div className={customRequestMode ? 'opacity-50' : ''}> | |||
| <div className='flex items-center gap-2 mb-2'> | |||
| <Typography.Text strong className='text-sm'> | |||
| {t('渠道')} | |||
| {t('通道')} | |||
| </Typography.Text> | |||
| {customRequestMode && ( | |||
| <Typography.Text className='text-xs text-orange-600'> | |||
| @@ -190,7 +190,7 @@ const SettingsPanel = ({ | |||
| )} | |||
| </div> | |||
| <Select | |||
| placeholder={t('请选择渠道')} | |||
| placeholder={t('请选择通道')} | |||
| name='channelId' | |||
| selection | |||
| filter={selectFilter} | |||
| @@ -200,7 +200,7 @@ const SettingsPanel = ({ | |||
| autoComplete='new-password' | |||
| optionList={channels.map((ch) => ({ | |||
| value: ch.id, | |||
| label: ch.name, | |||
| label: ch.public_name || ch.name, | |||
| }))} | |||
| style={{ width: '100%' }} | |||
| dropdownStyle={{ width: '100%', maxWidth: '100%' }} | |||
| @@ -141,6 +141,7 @@ const EditChannelModal = (props) => { | |||
| }; | |||
| const originInputs = { | |||
| name: '', | |||
| public_name: '', | |||
| type: 1, | |||
| key: '', | |||
| openai_organization: '', | |||
| @@ -1326,8 +1327,8 @@ const EditChannelModal = (props) => { | |||
| } | |||
| delete localInputs.vertex_files; | |||
| if (!isEdit && (!localInputs.name || !localInputs.key)) { | |||
| showInfo(t('请填写渠道名称和渠道密钥!')); | |||
| if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) { | |||
| showInfo(t('请填写渠道名称、对外名称和渠道密钥!')); | |||
| return; | |||
| } | |||
| if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { | |||
| @@ -1979,6 +1980,16 @@ const EditChannelModal = (props) => { | |||
| 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 && ( | |||
| <> | |||
| <Form.Select | |||
| @@ -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: item.channel_public_name || ('通道' + (index + 1)), | |||
| channelTags: item.tags || [], // 渠道定价的标签列表 | |||
| channelType: item.channel_type, | |||
| quotaType: item.quota_type, | |||
| @@ -2471,6 +2471,11 @@ | |||
| "请上传密钥文件": "Please upload the key file", | |||
| "请上传密钥文件!": "Please upload the key file!", | |||
| "请为渠道命名": "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", | |||
| "请先在设置中启用图片功能": "Please enable image feature in settings first", | |||
| "请先填写 API Key": "Please fill in API Key first", | |||
| @@ -2452,6 +2452,11 @@ | |||
| "请上传密钥文件": "请上传密钥文件", | |||
| "请上传密钥文件!": "请上传密钥文件!", | |||
| "请为渠道命名": "请为渠道命名", | |||
| "对外名称": "对外名称", | |||
| "用户看到的渠道名称,如「标准通道」「高速通道」": "用户看到的渠道名称,如「标准通道」「高速通道」", | |||
| "请填写对外名称": "请填写对外名称", | |||
| "请填写渠道名称、对外名称和渠道密钥!": "请填写渠道名称、对外名称和渠道密钥!", | |||
| "请选择通道": "请选择通道", | |||
| "请使用 Project 为 io.cloud 的密钥": "请使用 Project 为 io.cloud 的密钥", | |||
| "请先在设置中启用图片功能": "请先在设置中启用图片功能", | |||
| "请先填写 API Key": "请先填写 API Key", | |||