Files
openteam/web/src/views/admin/ModelsView.vue
T
SakurasanandClaude 6adadebaf0 前端: 修复浅色模式白字 + 主题优化
- 修 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>
2026-08-15 16:30:11 +08:00

216 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>