- 修 nav 激活态残留 text-zinc-50 导致浅色下白字白底 - @theme 令牌改实色字面量, 浅色用 [data-theme=light] 覆盖同一批 --color-* - 新增 soft 令牌(accent/ok/warn/err, color-mix 运行时解析)替代烘焙的透明度修饰符, 修复 badge 实色底、focus ring 实线等问题 - ThemeToggle 改三选下拉(浅色/深色/跟随系统), 系统自动为显式选项 - 浅色强调色调深保证 WCAG 对比度, 全量复查无残留硬编码颜色类 Co-Authored-By: Claude <noreply@anthropic.com>
216 lines
7.5 KiB
Vue
216 lines
7.5 KiB
Vue
<script setup lang="ts">
|
||
import { 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 { Channel, Model } from '@/types'
|
||
|
||
const toast = useToastStore()
|
||
const models = ref<Model[]>([])
|
||
const channels = ref<Channel[]>([])
|
||
const editOpen = ref(false)
|
||
const editing = ref<Model | null>(null)
|
||
const saving = ref(false)
|
||
|
||
const bindOpen = ref(false)
|
||
const bindModel = ref<Model | null>(null)
|
||
const binding = reactive({ channel_id: 0, upstream_model: '', weight: 1 })
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
display_name: '',
|
||
input_price: 0,
|
||
output_price: 0,
|
||
cache_read_price: 0,
|
||
enabled: true,
|
||
})
|
||
|
||
async function load() {
|
||
try {
|
||
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
||
models.value = m.data.data.items
|
||
channels.value = c.data.data.items
|
||
} 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))
|
||
}
|
||
}
|
||
|
||
function openBind(m: Model) {
|
||
bindModel.value = m
|
||
Object.assign(binding, { channel_id: channels.value[0]?.id ?? 0, upstream_model: m.name, weight: 1 })
|
||
bindOpen.value = true
|
||
}
|
||
|
||
async function saveBinding() {
|
||
if (!bindModel.value) return
|
||
try {
|
||
await http.post(`/admin/models/${bindModel.value.id}/bindings`, {
|
||
...binding,
|
||
weight: Number(binding.weight),
|
||
})
|
||
toast.ok('绑定已添加')
|
||
bindOpen.value = false
|
||
await load()
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
}
|
||
}
|
||
|
||
async function removeBinding(m: Model, bid: number) {
|
||
try {
|
||
await http.delete(`/admin/models/${m.id}/bindings/${bid}`)
|
||
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 items-center justify-between">
|
||
<div>
|
||
<h1 class="text-lg font-semibold">模型与定价</h1>
|
||
<p class="text-sm text-muted">价格按每百万 token (USD),历史用量按当时价格入账</p>
|
||
</div>
|
||
<Button @click="openCreate">添加模型</Button>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<div v-for="m in models" :key="m.id" class="card">
|
||
<div class="flex items-center justify-between px-4 py-3">
|
||
<div class="flex items-center gap-3">
|
||
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
||
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
||
</div>
|
||
<div class="flex 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-accent" @click="openBind(m)">绑定渠道</button>
|
||
<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">
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="b in m.channels"
|
||
:key="b.id"
|
||
class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
|
||
>
|
||
{{ b.channel_name }} → {{ b.upstream_model }}
|
||
<button class="text-muted hover:text-err" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<p v-else class="border-t border-edge px-4 py-2 text-xs text-muted">
|
||
未绑定渠道,客户端无法调用该模型
|
||
</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-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>
|
||
|
||
<!-- 绑定渠道 -->
|
||
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
|
||
<div class="space-y-4">
|
||
<p class="text-xs text-muted">模型 <span class="font-mono text-accent">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
|
||
<label class="block">
|
||
<span class="mb-1.5 block text-xs font-medium text-muted">渠道</span>
|
||
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
||
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
|
||
</select>
|
||
</label>
|
||
<Input v-model="binding.upstream_model" label="上游模型名" placeholder="与渠道侧一致" />
|
||
<Input v-model="binding.weight" label="权重" type="number" />
|
||
</div>
|
||
<template #footer>
|
||
<Button variant="ghost" @click="bindOpen = false">取消</Button>
|
||
<Button @click="saveBinding">绑定</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|