feat(channel): 模型映射 + 分组搜索 + 卡片折叠 + 冲突校验
- 渠道模型映射:新增 model_mapping JSONB 字段,在账号映射之前执行 - 分组选择:添加搜索过滤 + 平台图标 - 定价卡片:支持折叠/展开,已有数据默认折叠 - 模型冲突校验:前后端均禁止同一渠道内重复模型 - 迁移 083: channels 表添加 model_mapping 列
This commit is contained in:
@@ -24,18 +24,20 @@ func NewChannelHandler(channelService *service.ChannelService) *ChannelHandler {
|
||||
// --- Request / Response types ---
|
||||
|
||||
type createChannelRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Description string `json:"description"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ModelPricing []channelModelPricingRequest `json:"model_pricing"`
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Description string `json:"description"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ModelPricing []channelModelPricingRequest `json:"model_pricing"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
}
|
||||
|
||||
type updateChannelRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,max=100"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
ModelPricing *[]channelModelPricingRequest `json:"model_pricing"`
|
||||
Name string `json:"name" binding:"omitempty,max=100"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
ModelPricing *[]channelModelPricingRequest `json:"model_pricing"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
}
|
||||
|
||||
type channelModelPricingRequest struct {
|
||||
@@ -62,14 +64,15 @@ type pricingIntervalRequest struct {
|
||||
}
|
||||
|
||||
type channelResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ModelPricing []channelModelPricingResponse `json:"model_pricing"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ModelPricing []channelModelPricingResponse `json:"model_pricing"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type channelModelPricingResponse struct {
|
||||
@@ -106,13 +109,17 @@ func channelToResponse(ch *service.Channel) *channelResponse {
|
||||
Name: ch.Name,
|
||||
Description: ch.Description,
|
||||
Status: ch.Status,
|
||||
GroupIDs: ch.GroupIDs,
|
||||
GroupIDs: ch.GroupIDs,
|
||||
ModelMapping: ch.ModelMapping,
|
||||
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: ch.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if resp.GroupIDs == nil {
|
||||
resp.GroupIDs = []int64{}
|
||||
}
|
||||
if resp.ModelMapping == nil {
|
||||
resp.ModelMapping = map[string]string{}
|
||||
}
|
||||
|
||||
resp.ModelPricing = make([]channelModelPricingResponse, 0, len(ch.ModelPricing))
|
||||
for _, p := range ch.ModelPricing {
|
||||
@@ -246,6 +253,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
|
||||
Description: req.Description,
|
||||
GroupIDs: req.GroupIDs,
|
||||
ModelPricing: pricingRequestToService(req.ModelPricing),
|
||||
ModelMapping: req.ModelMapping,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -271,10 +279,11 @@ func (h *ChannelHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
input := &service.UpdateChannelInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Status: req.Status,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Status: req.Status,
|
||||
GroupIDs: req.GroupIDs,
|
||||
ModelMapping: req.ModelMapping,
|
||||
}
|
||||
if req.ModelPricing != nil {
|
||||
pricing := pricingRequestToService(*req.ModelPricing)
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -36,10 +37,14 @@ func (r *channelRepository) runInTx(ctx context.Context, fn func(tx *sql.Tx) err
|
||||
|
||||
func (r *channelRepository) Create(ctx context.Context, channel *service.Channel) error {
|
||||
return r.runInTx(ctx, func(tx *sql.Tx) error {
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`INSERT INTO channels (name, description, status) VALUES ($1, $2, $3)
|
||||
modelMappingJSON, err := marshalModelMapping(channel.ModelMapping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`INSERT INTO channels (name, description, status, model_mapping) VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
channel.Name, channel.Description, channel.Status,
|
||||
channel.Name, channel.Description, channel.Status, modelMappingJSON,
|
||||
).Scan(&channel.ID, &channel.CreatedAt, &channel.UpdatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -68,16 +73,18 @@ func (r *channelRepository) Create(ctx context.Context, channel *service.Channel
|
||||
|
||||
func (r *channelRepository) GetByID(ctx context.Context, id int64) (*service.Channel, error) {
|
||||
ch := &service.Channel{}
|
||||
var modelMappingJSON []byte
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, description, status, created_at, updated_at
|
||||
`SELECT id, name, description, status, model_mapping, created_at, updated_at
|
||||
FROM channels WHERE id = $1`, id,
|
||||
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &ch.CreatedAt, &ch.UpdatedAt)
|
||||
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.CreatedAt, &ch.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, service.ErrChannelNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get channel: %w", err)
|
||||
}
|
||||
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
|
||||
|
||||
groupIDs, err := r.GetGroupIDs(ctx, id)
|
||||
if err != nil {
|
||||
@@ -96,10 +103,14 @@ func (r *channelRepository) GetByID(ctx context.Context, id int64) (*service.Cha
|
||||
|
||||
func (r *channelRepository) Update(ctx context.Context, channel *service.Channel) error {
|
||||
return r.runInTx(ctx, func(tx *sql.Tx) error {
|
||||
modelMappingJSON, err := marshalModelMapping(channel.ModelMapping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx,
|
||||
`UPDATE channels SET name = $1, description = $2, status = $3, updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
channel.Name, channel.Description, channel.Status, channel.ID,
|
||||
`UPDATE channels SET name = $1, description = $2, status = $3, model_mapping = $4, updated_at = NOW()
|
||||
WHERE id = $5`,
|
||||
channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -176,7 +187,7 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
|
||||
|
||||
// 查询 channel 列表
|
||||
dataQuery := fmt.Sprintf(
|
||||
`SELECT c.id, c.name, c.description, c.status, c.created_at, c.updated_at
|
||||
`SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.created_at, c.updated_at
|
||||
FROM channels c WHERE %s ORDER BY c.id DESC LIMIT $%d OFFSET $%d`,
|
||||
whereClause, argIdx, argIdx+1,
|
||||
)
|
||||
@@ -192,9 +203,11 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
|
||||
var channelIDs []int64
|
||||
for rows.Next() {
|
||||
var ch service.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
||||
var modelMappingJSON []byte
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
||||
return nil, nil, fmt.Errorf("scan channel: %w", err)
|
||||
}
|
||||
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
|
||||
channels = append(channels, ch)
|
||||
channelIDs = append(channelIDs, ch.ID)
|
||||
}
|
||||
@@ -235,7 +248,7 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
|
||||
|
||||
func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, name, description, status, created_at, updated_at FROM channels ORDER BY id`,
|
||||
`SELECT id, name, description, status, model_mapping, created_at, updated_at FROM channels ORDER BY id`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query all channels: %w", err)
|
||||
@@ -246,9 +259,11 @@ func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, err
|
||||
var channelIDs []int64
|
||||
for rows.Next() {
|
||||
var ch service.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
||||
var modelMappingJSON []byte
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan channel: %w", err)
|
||||
}
|
||||
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
|
||||
channels = append(channels, ch)
|
||||
channelIDs = append(channelIDs, ch.ID)
|
||||
}
|
||||
@@ -390,3 +405,27 @@ func (r *channelRepository) GetGroupsInOtherChannels(ctx context.Context, channe
|
||||
}
|
||||
return conflicting, nil
|
||||
}
|
||||
|
||||
// marshalModelMapping 将 model mapping 序列化为 JSON 字节,nil/空 map 返回 '{}'
|
||||
func marshalModelMapping(m map[string]string) ([]byte, error) {
|
||||
if len(m) == 0 {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal model_mapping: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// unmarshalModelMapping 将 JSON 字节反序列化为 model mapping
|
||||
func unmarshalModelMapping(data []byte) map[string]string {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ type Channel struct {
|
||||
GroupIDs []int64
|
||||
// 模型定价列表
|
||||
ModelPricing []ChannelModelPricing
|
||||
// 渠道级模型映射
|
||||
ModelMapping map[string]string
|
||||
}
|
||||
|
||||
// ChannelModelPricing 渠道模型定价条目
|
||||
@@ -71,6 +73,33 @@ type PricingInterval struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ResolveMappedModel 解析渠道级模型映射,返回映射后的模型名。
|
||||
// 支持通配符(如 "claude-*" → "claude-sonnet-4")。
|
||||
// 如果没有匹配的映射规则,返回原始模型名。
|
||||
func (c *Channel) ResolveMappedModel(requestedModel string) string {
|
||||
if len(c.ModelMapping) == 0 {
|
||||
return requestedModel
|
||||
}
|
||||
lower := strings.ToLower(requestedModel)
|
||||
// 精确匹配优先
|
||||
for src, dst := range c.ModelMapping {
|
||||
if strings.ToLower(src) == lower {
|
||||
return dst
|
||||
}
|
||||
}
|
||||
// 通配符匹配
|
||||
for src, dst := range c.ModelMapping {
|
||||
srcLower := strings.ToLower(src)
|
||||
if strings.HasSuffix(srcLower, "*") {
|
||||
prefix := strings.TrimSuffix(srcLower, "*")
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return dst
|
||||
}
|
||||
}
|
||||
}
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// IsActive 判断渠道是否启用
|
||||
func (c *Channel) IsActive() bool {
|
||||
return c.Status == StatusActive
|
||||
@@ -168,5 +197,11 @@ func (c *Channel) Clone() *Channel {
|
||||
cp.ModelPricing[i] = c.ModelPricing[i].Clone()
|
||||
}
|
||||
}
|
||||
if c.ModelMapping != nil {
|
||||
cp.ModelMapping = make(map[string]string, len(c.ModelMapping))
|
||||
for k, v := range c.ModelMapping {
|
||||
cp.ModelMapping[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -213,6 +214,11 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
|
||||
Status: StatusActive,
|
||||
GroupIDs: input.GroupIDs,
|
||||
ModelPricing: input.ModelPricing,
|
||||
ModelMapping: input.ModelMapping,
|
||||
}
|
||||
|
||||
if err := validateNoDuplicateModels(channel.ModelPricing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, channel); err != nil {
|
||||
@@ -270,6 +276,14 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan
|
||||
channel.ModelPricing = *input.ModelPricing
|
||||
}
|
||||
|
||||
if input.ModelMapping != nil {
|
||||
channel.ModelMapping = input.ModelMapping
|
||||
}
|
||||
|
||||
if err := validateNoDuplicateModels(channel.ModelPricing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, channel); err != nil {
|
||||
return nil, fmt.Errorf("update channel: %w", err)
|
||||
}
|
||||
@@ -318,6 +332,21 @@ func (s *ChannelService) List(ctx context.Context, params pagination.PaginationP
|
||||
return s.repo.List(ctx, params, status, search)
|
||||
}
|
||||
|
||||
// validateNoDuplicateModels 检查定价列表中是否有重复模型
|
||||
func validateNoDuplicateModels(pricingList []ChannelModelPricing) error {
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range pricingList {
|
||||
for _, model := range p.Models {
|
||||
lower := strings.ToLower(model)
|
||||
if seen[lower] {
|
||||
return infraerrors.BadRequest("DUPLICATE_MODEL", fmt.Sprintf("model '%s' appears in multiple pricing entries", model))
|
||||
}
|
||||
seen[lower] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Input types ---
|
||||
|
||||
// CreateChannelInput 创建渠道输入
|
||||
@@ -326,6 +355,7 @@ type CreateChannelInput struct {
|
||||
Description string
|
||||
GroupIDs []int64
|
||||
ModelPricing []ChannelModelPricing
|
||||
ModelMapping map[string]string
|
||||
}
|
||||
|
||||
// UpdateChannelInput 更新渠道输入
|
||||
@@ -335,4 +365,5 @@ type UpdateChannelInput struct {
|
||||
Status string
|
||||
GroupIDs *[]int64
|
||||
ModelPricing *[]ChannelModelPricing
|
||||
ModelMapping map[string]string
|
||||
}
|
||||
|
||||
5
backend/migrations/083_channel_model_mapping.sql
Normal file
5
backend/migrations/083_channel_model_mapping.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '10min';
|
||||
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS model_mapping JSONB DEFAULT '{}';
|
||||
COMMENT ON COLUMN channels.model_mapping IS '渠道级模型映射,在账号映射之前执行。格式:{"source_model": "target_model"}';
|
||||
@@ -1,148 +1,209 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<!-- Header: Models + Billing Mode + Remove -->
|
||||
<div class="mb-3 flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.models', '模型列表') }}
|
||||
</label>
|
||||
<ModelTagInput
|
||||
:models="entry.models"
|
||||
@update:models="emit('update', { ...entry, models: $event })"
|
||||
:placeholder="t('admin.channels.form.modelsPlaceholder', '输入模型名后按回车添加,支持通配符 *')"
|
||||
class="mt-1"
|
||||
/>
|
||||
<!-- Collapsed summary header (clickable) -->
|
||||
<div
|
||||
class="flex cursor-pointer select-none items-center gap-2"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<Icon
|
||||
:name="collapsed ? 'chevronRight' : 'chevronDown'"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="flex-shrink-0 text-gray-400 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
<!-- Summary: model tags + billing badge -->
|
||||
<div v-if="collapsed" class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<!-- Compact model tags (show first 3) -->
|
||||
<div class="flex min-w-0 flex-shrink items-center gap-1 overflow-hidden">
|
||||
<span
|
||||
v-for="(m, i) in entry.models.slice(0, 3)"
|
||||
:key="i"
|
||||
class="inline-flex max-w-[120px] truncate rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-300"
|
||||
>
|
||||
{{ m }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.models.length > 3"
|
||||
class="whitespace-nowrap text-xs text-gray-400"
|
||||
>
|
||||
+{{ entry.models.length - 3 }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.models.length === 0"
|
||||
class="text-xs italic text-gray-400"
|
||||
>
|
||||
{{ t('admin.channels.form.noModels', '未添加模型') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Billing mode badge -->
|
||||
<span
|
||||
class="flex-shrink-0 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ billingModeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.billingMode', '计费模式') }}
|
||||
</label>
|
||||
<Select
|
||||
:modelValue="entry.billing_mode"
|
||||
@update:modelValue="emit('update', { ...entry, billing_mode: $event as BillingMode, intervals: [] })"
|
||||
:options="billingModeOptions"
|
||||
class="mt-1"
|
||||
/>
|
||||
|
||||
<!-- Expanded: show the label "Pricing Entry" or similar -->
|
||||
<div v-else class="flex-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.pricingEntry', '定价配置') }}
|
||||
</div>
|
||||
|
||||
<!-- Remove button (always visible, stop propagation) -->
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('remove')"
|
||||
class="mt-5 rounded p-1 text-gray-400 hover:text-red-500"
|
||||
@click.stop="emit('remove')"
|
||||
class="flex-shrink-0 rounded p-1 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Token mode -->
|
||||
<div v-if="entry.billing_mode === 'token'">
|
||||
<!-- Default prices (fallback when no interval matches) -->
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.defaultPrices', '默认价格(未命中区间时使用)') }}
|
||||
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
|
||||
</label>
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }}</label>
|
||||
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
<!-- Expandable content with transition -->
|
||||
<div
|
||||
class="collapsible-content"
|
||||
:class="{ 'collapsible-content--collapsed': collapsed }"
|
||||
>
|
||||
<div class="collapsible-inner">
|
||||
<!-- Header: Models + Billing Mode -->
|
||||
<div class="mt-3 flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.models', '模型列表') }}
|
||||
</label>
|
||||
<ModelTagInput
|
||||
:models="entry.models"
|
||||
@update:models="emit('update', { ...entry, models: $event })"
|
||||
:placeholder="t('admin.channels.form.modelsPlaceholder', '输入模型名后按回车添加,支持通配符 *')"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.billingMode', '计费模式') }}
|
||||
</label>
|
||||
<Select
|
||||
:modelValue="entry.billing_mode"
|
||||
@update:modelValue="emit('update', { ...entry, billing_mode: $event as BillingMode, intervals: [] })"
|
||||
:options="billingModeOptions"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }}</label>
|
||||
<input :value="entry.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存写入') }}</label>
|
||||
<input :value="entry.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存读取') }}</label>
|
||||
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice', '图片输出') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token intervals -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.intervals', '上下文区间定价(可选)') }}
|
||||
<span class="ml-1 font-normal text-gray-400">(min, max]</span>
|
||||
<!-- Token mode -->
|
||||
<div v-if="entry.billing_mode === 'token'">
|
||||
<!-- Default prices (fallback when no interval matches) -->
|
||||
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.defaultPrices', '默认价格(未命中区间时使用)') }}
|
||||
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
|
||||
</label>
|
||||
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addInterval', '添加区间') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
<IntervalRow
|
||||
v-for="(iv, idx) in entry.intervals"
|
||||
:key="idx"
|
||||
:interval="iv"
|
||||
:mode="entry.billing_mode"
|
||||
@update="updateInterval(idx, $event)"
|
||||
@remove="removeInterval(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }}</label>
|
||||
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }}</label>
|
||||
<input :value="entry.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存写入') }}</label>
|
||||
<input :value="entry.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存读取') }}</label>
|
||||
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice', '图片输出') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-request mode -->
|
||||
<div v-else-if="entry.billing_mode === 'per_request'">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.requestTiers', '按次计费层级') }}
|
||||
</label>
|
||||
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addTier', '添加层级') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
<IntervalRow
|
||||
v-for="(iv, idx) in entry.intervals"
|
||||
:key="idx"
|
||||
:interval="iv"
|
||||
:mode="entry.billing_mode"
|
||||
@update="updateInterval(idx, $event)"
|
||||
@remove="removeInterval(idx)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mt-2 rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400 dark:border-dark-500">
|
||||
{{ t('admin.channels.form.noTiersYet', '暂无层级,点击添加配置按次计费价格') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Token intervals -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.intervals', '上下文区间定价(可选)') }}
|
||||
<span class="ml-1 font-normal text-gray-400">(min, max]</span>
|
||||
</label>
|
||||
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addInterval', '添加区间') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
<IntervalRow
|
||||
v-for="(iv, idx) in entry.intervals"
|
||||
:key="idx"
|
||||
:interval="iv"
|
||||
:mode="entry.billing_mode"
|
||||
@update="updateInterval(idx, $event)"
|
||||
@remove="removeInterval(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image mode (legacy per-request) -->
|
||||
<div v-else-if="entry.billing_mode === 'image'">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.imageTiers', '图片计费层级(按次)') }}
|
||||
</label>
|
||||
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addTier', '添加层级') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
<IntervalRow
|
||||
v-for="(iv, idx) in entry.intervals"
|
||||
:key="idx"
|
||||
:interval="iv"
|
||||
:mode="entry.billing_mode"
|
||||
@update="updateInterval(idx, $event)"
|
||||
@remove="removeInterval(idx)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageOutputPrice', '图片输出价格') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
<!-- Per-request mode -->
|
||||
<div v-else-if="entry.billing_mode === 'per_request'">
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.requestTiers', '按次计费层级') }}
|
||||
</label>
|
||||
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addTier', '添加层级') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
<IntervalRow
|
||||
v-for="(iv, idx) in entry.intervals"
|
||||
:key="idx"
|
||||
:interval="iv"
|
||||
:mode="entry.billing_mode"
|
||||
@update="updateInterval(idx, $event)"
|
||||
@remove="removeInterval(idx)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mt-2 rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400 dark:border-dark-500">
|
||||
{{ t('admin.channels.form.noTiersYet', '暂无层级,点击添加配置按次计费价格') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image mode (legacy per-request) -->
|
||||
<div v-else-if="entry.billing_mode === 'image'">
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.imageTiers', '图片计费层级(按次)') }}
|
||||
</label>
|
||||
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
|
||||
+ {{ t('admin.channels.form.addTier', '添加层级') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
|
||||
<IntervalRow
|
||||
v-for="(iv, idx) in entry.intervals"
|
||||
:key="idx"
|
||||
:interval="iv"
|
||||
:mode="entry.billing_mode"
|
||||
@update="updateInterval(idx, $event)"
|
||||
@remove="removeInterval(idx)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageOutputPrice', '图片输出价格') }}</label>
|
||||
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +212,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -171,12 +232,20 @@ const emit = defineEmits<{
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
// Collapse state: entries with existing models default to collapsed
|
||||
const collapsed = ref(props.entry.models.length > 0)
|
||||
|
||||
const billingModeOptions = computed(() => [
|
||||
{ value: 'token', label: 'Token' },
|
||||
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest', '按次') },
|
||||
{ value: 'image', label: t('admin.channels.billingMode.image', '图片(按次)') }
|
||||
])
|
||||
|
||||
const billingModeLabel = computed(() => {
|
||||
const opt = billingModeOptions.value.find(o => o.value === props.entry.billing_mode)
|
||||
return opt ? opt.label : props.entry.billing_mode
|
||||
})
|
||||
|
||||
function emitField(field: keyof PricingFormEntry, value: string) {
|
||||
emit('update', { ...props.entry, [field]: value === '' ? null : value })
|
||||
}
|
||||
@@ -216,3 +285,19 @@ function removeInterval(idx: number) {
|
||||
emit('update', { ...props.entry, intervals })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.collapsible-content {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
|
||||
.collapsible-content--collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.collapsible-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1743,6 +1743,7 @@ export default {
|
||||
updateError: 'Failed to update channel',
|
||||
deleteError: 'Failed to delete channel',
|
||||
nameRequired: 'Please enter a channel name',
|
||||
duplicateModels: 'Model "{0}" appears in multiple pricing entries',
|
||||
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
|
||||
@@ -1823,6 +1823,7 @@ export default {
|
||||
updateError: '更新渠道失败',
|
||||
deleteError: '删除渠道失败',
|
||||
nameRequired: '请输入渠道名称',
|
||||
duplicateModels: '模型「{0}」在多个定价条目中重复',
|
||||
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
|
||||
columns: {
|
||||
name: '名称',
|
||||
|
||||
@@ -176,6 +176,19 @@
|
||||
<!-- Group Association -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.channels.form.groups', 'Associated Groups') }}</label>
|
||||
<div class="relative mb-2">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="groupSearchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.channels.form.searchGroups', 'Search groups...')"
|
||||
class="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-48 overflow-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
@@ -185,8 +198,11 @@
|
||||
<div v-else-if="allGroups.length === 0" class="py-4 text-center text-sm text-gray-500">
|
||||
{{ t('admin.channels.form.noGroupsAvailable', 'No groups available') }}
|
||||
</div>
|
||||
<div v-else-if="filteredGroups.length === 0" class="py-4 text-center text-sm text-gray-500">
|
||||
{{ t('admin.channels.form.noGroupsMatch', 'No groups match your search') }}
|
||||
</div>
|
||||
<label
|
||||
v-for="group in allGroups"
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': isGroupInOtherChannel(group.id) }"
|
||||
@@ -198,6 +214,7 @@
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
@change="toggleGroup(group.id)"
|
||||
/>
|
||||
<PlatformIcon :platform="group.platform" size="xs" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ group.name }}</span>
|
||||
<span
|
||||
v-if="isGroupInOtherChannel(group.id)"
|
||||
@@ -205,12 +222,6 @@
|
||||
>
|
||||
{{ getGroupInOtherChannelLabel(group.id) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.platform"
|
||||
class="ml-auto text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{{ group.platform }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,6 +310,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
|
||||
@@ -348,6 +360,7 @@ const deletingChannel = ref<Channel | null>(null)
|
||||
// Groups
|
||||
const allGroups = ref<AdminGroup[]>([])
|
||||
const groupsLoading = ref(false)
|
||||
const groupSearchQuery = ref('')
|
||||
|
||||
// Form data
|
||||
const form = reactive({
|
||||
@@ -367,6 +380,12 @@ function formatDate(value: string): string {
|
||||
}
|
||||
|
||||
// ── Group helpers ──
|
||||
const filteredGroups = computed(() => {
|
||||
const query = groupSearchQuery.value.trim().toLowerCase()
|
||||
if (!query) return allGroups.value
|
||||
return allGroups.value.filter(g => g.name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
const groupToChannelMap = computed(() => {
|
||||
const map = new Map<number, Channel>()
|
||||
for (const ch of channels.value) {
|
||||
@@ -525,6 +544,7 @@ function resetForm() {
|
||||
form.status = 'active'
|
||||
form.group_ids = []
|
||||
form.model_pricing = []
|
||||
groupSearchQuery.value = ''
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
@@ -558,6 +578,14 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查模型重复
|
||||
const allModels = form.model_pricing.flatMap(e => e.models.map(m => m.toLowerCase()))
|
||||
const duplicates = allModels.filter((m, i) => allModels.indexOf(m) !== i)
|
||||
if (duplicates.length > 0) {
|
||||
appStore.showError(t('admin.channels.duplicateModels', `模型 "${duplicates[0]}" 在多个定价条目中重复`))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingChannel.value) {
|
||||
|
||||
Reference in New Issue
Block a user