Files
openteam/web/src/views/admin/ChannelsView.vue
T
SakurasanandClaude 8d9940ad5a 渠道: Base URL 智能识别前缀/完整端点 + API 格式垂直堆叠
- upstreamURL 按内容智能拼接: 完整端点直接用 / 含 /v1 只拼资源路径 / 纯域名拼全路径
- resolveBaseURL 不再去 /v1, 交网关识别
- 渠道表 API 格式改垂直堆叠, 显示短标识 chat/completions / responses / messages
- 前端 Base URL 提示支持前缀或完整端点

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 00:08:52 +08:00

240 lines
8.4 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 { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
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, ChannelModelMapping } 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: '',
formats: ['chat'] as string[],
base_url: '',
api_key: '',
weight: 1,
priority: 0,
timeout_ms: 120000,
max_concurrency: 16,
enabled: true,
})
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: '', formats: ['chat'], 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, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
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() {
if (form.formats.length === 0) {
toast.err('请至少选择一种 API 格式')
return
}
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-muted">接入上游服务,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-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</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-ink">{{ ch.name }}</td>
<td class="px-4 py-2.5">
<div class="flex flex-col gap-0.5">
<code
v-for="f in ch.formats || []"
:key="f"
class="font-mono text-[11px] leading-4 text-muted"
>{{ protocolShort(f) }}</code>
</div>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ 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-muted">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="text-xs text-muted hover:text-accent" @click="importModels(ch)">导入模型</button>
<button class="text-xs text-muted hover:text-ink" @click="openEdit(ch)">编辑</button>
<button class="text-xs text-muted hover:text-err" @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-muted">还没有渠道,点击「添加渠道」</td>
</tr>
</tbody>
</table>
</div>
</div>
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="名称" placeholder="openai" />
<div>
<span class="mb-1.5 block text-xs font-medium text-muted">支持的 API 格式</span>
<div class="flex flex-wrap gap-2">
<label
v-for="opt in PROTOCOL_OPTIONS"
:key="opt.value"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="form.formats.includes(opt.value) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
>
<input
v-model="form.formats"
type="checkbox"
:value="opt.value"
class="size-3.5 rounded accent-[var(--color-accent)]"
/>
{{ opt.label }}
</label>
</div>
<p class="mt-1.5 text-xs text-muted">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div>
<Input
v-model="form.base_url"
label="Base URL(可选)"
placeholder="https://api.openai.com/v1"
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
/>
<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>