Просмотр исходного кода

feat(channel): 添加渠道"对外名称"(public_name)字段

为 Channel 模型新增 public_name 字段,让管理员可以为每个渠道
设置用户可见的友好名称(如"标准通道"、"高速通道"),替代前端
硬编码的"通道一/二/三"。Playground 通道选择器展示对外名称,
"渠道"统一改为"通道"。新建渠道时对外名称必填,编辑时可选。

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 1 неделю назад
Родитель
Сommit
d8272e7707
11 измененных файлов: 69 добавлений и 18 удалений
  1. +5
    -0
      controller/channel.go
  2. +7
    -5
      model/ability.go
  3. +9
    -1
      model/channel.go
  4. +3
    -2
      model/channel_pricing.go
  5. +13
    -0
      model/main.go
  6. +4
    -3
      model/pricing.go
  7. +4
    -4
      web/src/components/playground/SettingsPanel.jsx
  8. +13
    -2
      web/src/components/table/channels/modals/EditChannelModal.jsx
  9. +1
    -1
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  10. +5
    -0
      web/src/i18n/locales/en.json
  11. +5
    -0
      web/src/i18n/locales/zh-CN.json

+ 5
- 0
controller/channel.go Просмотреть файл

@@ -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,
}) })


+ 7
- 5
model/ability.go Просмотреть файл

@@ -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,
}) })
} }




+ 9
- 1
model/channel.go Просмотреть файл

@@ -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


+ 3
- 2
model/channel_pricing.go Просмотреть файл

@@ -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


+ 13
- 0
model/main.go Просмотреть файл

@@ -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))
}
}

+ 4
- 3
model/pricing.go Просмотреть файл

@@ -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)


+ 4
- 4
web/src/components/playground/SettingsPanel.jsx Просмотреть файл

@@ -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%' }}


+ 13
- 2
web/src/components/table/channels/modals/EditChannelModal.jsx Просмотреть файл

@@ -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


+ 1
- 1
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx Просмотреть файл

@@ -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,


+ 5
- 0
web/src/i18n/locales/en.json Просмотреть файл

@@ -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",


+ 5
- 0
web/src/i18n/locales/zh-CN.json Просмотреть файл

@@ -2452,6 +2452,11 @@
"请上传密钥文件": "请上传密钥文件", "请上传密钥文件": "请上传密钥文件",
"请上传密钥文件!": "请上传密钥文件!", "请上传密钥文件!": "请上传密钥文件!",
"请为渠道命名": "请为渠道命名", "请为渠道命名": "请为渠道命名",
"对外名称": "对外名称",
"用户看到的渠道名称,如「标准通道」「高速通道」": "用户看到的渠道名称,如「标准通道」「高速通道」",
"请填写对外名称": "请填写对外名称",
"请填写渠道名称、对外名称和渠道密钥!": "请填写渠道名称、对外名称和渠道密钥!",
"请选择通道": "请选择通道",
"请使用 Project 为 io.cloud 的密钥": "请使用 Project 为 io.cloud 的密钥", "请使用 Project 为 io.cloud 的密钥": "请使用 Project 为 io.cloud 的密钥",
"请先在设置中启用图片功能": "请先在设置中启用图片功能", "请先在设置中启用图片功能": "请先在设置中启用图片功能",
"请先填写 API Key": "请先填写 API Key", "请先填写 API Key": "请先填写 API Key",


Загрузка…
Отмена
Сохранить