feat(channel): 模型映射 + 分组搜索 + 卡片折叠 + 冲突校验

- 渠道模型映射:新增 model_mapping JSONB 字段,在账号映射之前执行
- 分组选择:添加搜索过滤 + 平台图标
- 定价卡片:支持折叠/展开,已有数据默认折叠
- 模型冲突校验:前后端均禁止同一渠道内重复模型
- 迁移 083: channels 表添加 model_mapping 列
This commit is contained in:
erio
2026-03-30 02:36:04 +08:00
parent dca0054e93
commit 29d58f2414
9 changed files with 405 additions and 171 deletions

View File

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

View File

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

View File

@@ -1823,6 +1823,7 @@ export default {
updateError: '更新渠道失败',
deleteError: '删除渠道失败',
nameRequired: '请输入渠道名称',
duplicateModels: '模型「{0}」在多个定价条目中重复',
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
columns: {
name: '名称',

View File

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