- 无渠道绑定的模型以「悬空」琥珀色徽标 + 卡片着色标记 - 新增「清除悬空」一键删除未绑定渠道的模型(DELETE /models/unused) - 移除页面内「绑定渠道」弹窗与 chips 的解除按钮,渠道支持列表只在渠道抽屉管理 - 缺失提示带上渠道真实模型名(upstream_model) Co-Authored-By: Claude <noreply@anthropic.com>
224 lines
8.2 KiB
Vue
224 lines
8.2 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { http, errMsg } from '@/api/client'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import Button from '@/components/ui/Button.vue'
|
||
import Input from '@/components/ui/Input.vue'
|
||
import Modal from '@/components/ui/Modal.vue'
|
||
import Badge from '@/components/ui/Badge.vue'
|
||
import type { Model, ModelSummary } from '@/types'
|
||
|
||
const toast = useToastStore()
|
||
const models = ref<Model[]>([])
|
||
const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
|
||
const editOpen = ref(false)
|
||
const editing = ref<Model | null>(null)
|
||
const saving = ref(false)
|
||
const quickName = ref('')
|
||
const clearing = ref(false)
|
||
|
||
// 未绑定任何渠道的模型:渠道选中的后来被取消,客户端无法调用
|
||
const unused = computed(() => models.value.filter((m) => m.channels.length === 0))
|
||
|
||
async function clearUnused() {
|
||
if (!unused.value.length) {
|
||
toast.push('没有未绑定渠道的模型')
|
||
return
|
||
}
|
||
const names = unused.value.map((m) => m.name)
|
||
if (!confirm(`确定删除 ${names.length} 个未绑定渠道的模型?\n\n${names.join('\n')}`)) return
|
||
clearing.value = true
|
||
try {
|
||
const { data } = await http.delete('/admin/models/unused')
|
||
toast.ok(`已清除 ${data.data.count} 个模型`)
|
||
await load()
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
} finally {
|
||
clearing.value = false
|
||
}
|
||
}
|
||
|
||
function quickAdd() {
|
||
openCreate()
|
||
if (quickName.value) form.name = quickName.value.trim()
|
||
}
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
display_name: '',
|
||
input_price: 0,
|
||
output_price: 0,
|
||
cache_read_price: 0,
|
||
enabled: true,
|
||
})
|
||
|
||
async function load() {
|
||
try {
|
||
const { data } = await http.get('/admin/models')
|
||
models.value = data.data.items
|
||
summary.value = data.data.summary
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
editing.value = null
|
||
Object.assign(form, { name: '', display_name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
|
||
editOpen.value = true
|
||
}
|
||
|
||
function openEdit(m: Model) {
|
||
editing.value = m
|
||
Object.assign(form, {
|
||
name: m.name, display_name: m.display_name,
|
||
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
|
||
enabled: m.enabled,
|
||
})
|
||
editOpen.value = true
|
||
}
|
||
|
||
async function save() {
|
||
saving.value = true
|
||
const payload = {
|
||
display_name: form.display_name || form.name,
|
||
input_price: Number(form.input_price),
|
||
output_price: Number(form.output_price),
|
||
cache_read_price: Number(form.cache_read_price),
|
||
enabled: form.enabled,
|
||
}
|
||
try {
|
||
if (editing.value) {
|
||
await http.put(`/admin/models/${editing.value.id}`, payload)
|
||
toast.ok('模型已更新')
|
||
} else {
|
||
await http.post('/admin/models', { name: form.name, ...payload })
|
||
toast.ok('模型已创建')
|
||
}
|
||
editOpen.value = false
|
||
await load()
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function removeModel(m: Model) {
|
||
if (!confirm(`删除模型 ${m.name}?`)) return
|
||
try {
|
||
await http.delete(`/admin/models/${m.id}`)
|
||
toast.ok('模型已删除')
|
||
await load()
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mx-auto max-w-6xl">
|
||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h1 class="text-lg font-semibold">模型与定价</h1>
|
||
<p class="text-sm text-muted">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
|
||
</div>
|
||
<div class="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
||
<input
|
||
v-model="quickName"
|
||
placeholder="模型名,如 glm-4.7-flash"
|
||
class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent sm:w-52 sm:flex-none"
|
||
@keyup.enter="quickAdd"
|
||
/>
|
||
<Button class="shrink-0" @click="quickAdd">添加模型</Button>
|
||
<Button
|
||
size="md"
|
||
variant="danger"
|
||
class="shrink-0 px-1!"
|
||
:loading="clearing"
|
||
:disabled="!unused.length"
|
||
@click="clearUnused"
|
||
>
|
||
清除悬空{{ unused.length ? `(${unused.length})` : '' }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 提示:定价目录 = 渠道选中的模型 + 手动添加的模型 -->
|
||
<div v-if="summary.missing.length" class="card border-err/50 p-4">
|
||
<p class="text-sm font-medium text-err">以下渠道选中的模型不在定价目录</p>
|
||
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-muted">
|
||
{{ x.channel }} → {{ x.upstream_model || '模型 #' + x.model_id }}(请到渠道抽屉重新选中,或手动添加)
|
||
</p>
|
||
</div>
|
||
<p v-else-if="summary.unpriced > 0" class="text-xs text-muted">
|
||
有 <span class="mono-num text-warn">{{ summary.unpriced }}</span> 个渠道允许的模型未定价,网关将按示例价计费
|
||
</p>
|
||
<p v-else class="text-xs text-muted">定价目录中渠道允许的模型均已定价</p>
|
||
|
||
<div class="space-y-3">
|
||
<div v-for="m in models" :key="m.id" :class="m.channels.length ? 'card' : 'card border-warn/60 bg-warn-soft'">
|
||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
||
<Badge v-if="m.channels.length" variant="neutral">渠道允许</Badge>
|
||
<Badge v-else variant="warn">悬空</Badge>
|
||
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
||
<Badge v-if="m.denied" variant="err">已禁止</Badge>
|
||
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
||
<span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
|
||
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
|
||
<button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="m.channels.length" class="border-t border-edge px-4 py-2">
|
||
<p class="mb-1.5 text-[11px] font-medium text-muted">允许渠道(渠道抽屉中管理)</p>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="b in m.channels"
|
||
:key="b.id"
|
||
class="inline-flex items-center rounded-md border border-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
|
||
>
|
||
{{ b.channel_name }} → {{ b.upstream_model }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<p v-else class="border-t border-edge px-4 py-2 text-xs text-warn">
|
||
悬空模型:无任何渠道提供,客户端无法调用
|
||
</p>
|
||
</div>
|
||
|
||
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
|
||
还没有模型,点击「添加模型」或到渠道页「导入模型」
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 模型编辑 -->
|
||
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
|
||
<div class="space-y-4">
|
||
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
||
<Input v-model="form.display_name" label="展示名" />
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
||
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
||
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|