M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)
- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、 API Key(sk- 48位, 仅存 SHA-256 哈希) - 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回 - 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先) - 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合 - 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置 - 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台, 自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查 - mock 上游: OpenAI+Anthropic 双协议模拟(含流式) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<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 } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const channels = ref<Channel[]>([])
|
||||
const editOpen = ref(false)
|
||||
const editing = ref<Channel | null>(null)
|
||||
const saving = ref(false)
|
||||
const busyId = ref<number | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
provider: 'openai' as 'openai' | 'anthropic' | 'compatible',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
weight: 1,
|
||||
priority: 0,
|
||||
timeout_ms: 120000,
|
||||
max_concurrency: 16,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const providerMap: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
compatible: '兼容',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await http.get('/admin/channels')
|
||||
channels.value = data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', provider: 'openai', base_url: '', api_key: '',
|
||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(ch: Channel) {
|
||||
editing.value = ch
|
||||
Object.assign(form, {
|
||||
name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '',
|
||||
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
|
||||
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
const payload = {
|
||||
...form,
|
||||
weight: Number(form.weight),
|
||||
priority: Number(form.priority),
|
||||
timeout_ms: Number(form.timeout_ms),
|
||||
max_concurrency: Number(form.max_concurrency),
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await http.put(`/admin/channels/${editing.value.id}`, payload)
|
||||
toast.ok('渠道已更新')
|
||||
} else {
|
||||
await http.post('/admin/channels', payload)
|
||||
toast.ok('渠道已创建')
|
||||
}
|
||||
editOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(ch: Channel) {
|
||||
if (!confirm(`删除渠道 ${ch.name}?关联的模型绑定也会清除。`)) return
|
||||
try {
|
||||
await http.delete(`/admin/channels/${ch.id}`)
|
||||
toast.ok('渠道已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel(ch: Channel) {
|
||||
busyId.value = ch.id
|
||||
try {
|
||||
await http.post(`/admin/channels/${ch.id}/test`)
|
||||
toast.ok(`渠道 ${ch.name} 连接正常`)
|
||||
} catch (e) {
|
||||
toast.err(`连接失败: ${errMsg(e)}`)
|
||||
} finally {
|
||||
busyId.value = null
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
async function importModels(ch: Channel) {
|
||||
busyId.value = ch.id
|
||||
try {
|
||||
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
|
||||
toast.ok(`已导入 ${data.data.imported} 个模型`)
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
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-zinc-500">接入上游服务,API Key 加密存储</p>
|
||||
</div>
|
||||
<Button @click="openCreate">添加渠道</Button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">类型</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">Key</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">启用</th>
|
||||
<th scope="col" class="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ch in channels" :key="ch.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-zinc-200">{{ ch.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ providerMap[ch.provider] }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ ch.base_url }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600">{{ ch.api_key_masked || '****' }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||||
{{ ch.health_status }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ ch.enabled ? '是' : '否' }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" @click="importModels(ch)">导入模型</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(ch)">编辑</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-red-400" @click="remove(ch)">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="channels.length === 0">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">还没有渠道,点击「添加渠道」</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input v-model="form.name" label="名称" placeholder="openai" />
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">API 类型</span>
|
||||
<select v-model="form.provider" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="compatible">兼容</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" />
|
||||
<Input
|
||||
v-model="form.api_key"
|
||||
label="上游 API Key"
|
||||
:placeholder="editing ? '留空则不修改' : 'sk-...'"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input v-model="form.weight" label="权重" type="number" />
|
||||
<Input v-model="form.priority" label="优先级" type="number" />
|
||||
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
||||
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user