- Channel 新增 homepage 字段(渠道主页)
- 后端 /admin/channels/:id/favicon 代理抓取 {homepage}/favicon.ico, 内存缓存 1h;
服务端抓取可解析 localhost/内网主页, 避免浏览器跨域
- 前端渠道表单加"渠道主页"输入; 表格新增主页列(favicon + 域名), 加载失败回退灰色地球图标
- mock 上游提供 /favicon.ico 演示
Co-Authored-By: Claude <noreply@anthropic.com>
269 lines
9.6 KiB
Vue
269 lines
9.6 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { http, errMsg } from '@/api/client'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import { PROTOCOL_OPTIONS, protocolName } 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 } 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[],
|
||
homepage: '',
|
||
base_url: '',
|
||
api_key: '',
|
||
weight: 1,
|
||
priority: 0,
|
||
timeout_ms: 120000,
|
||
max_concurrency: 16,
|
||
enabled: true,
|
||
})
|
||
|
||
// favicon 兜底:灰色地球
|
||
const FAV_FALLBACK =
|
||
'data:image/svg+xml;utf8,' +
|
||
encodeURIComponent(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.5" fill="none" stroke="#a1a1aa" stroke-width="1.4"/><ellipse cx="8" cy="8" rx="3" ry="6.5" fill="none" stroke="#a1a1aa" stroke-width="1.4"/><path d="M1.8 8h12.4" stroke="#a1a1aa" stroke-width="1.4"/></svg>',
|
||
)
|
||
function onFavError(e: Event) {
|
||
;(e.target as HTMLImageElement).src = FAV_FALLBACK
|
||
}
|
||
function hostOf(u?: string): string {
|
||
if (!u) return '-'
|
||
try {
|
||
return new URL(u).host
|
||
} catch {
|
||
return u
|
||
}
|
||
}
|
||
|
||
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'], homepage: '', 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'])],
|
||
homepage: ch.homepage || '',
|
||
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">主页</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-wrap gap-1">
|
||
<span
|
||
v-for="f in ch.formats || []"
|
||
:key="f"
|
||
class="rounded-full bg-surface2 px-2 py-0.5 text-[10px] leading-4 text-muted"
|
||
>{{ protocolName(f) }}</span>
|
||
</div>
|
||
</td>
|
||
<td class="px-4 py-2.5">
|
||
<div class="flex items-center gap-2">
|
||
<img
|
||
:src="`/api/v1/admin/channels/${ch.id}/favicon`"
|
||
:alt="ch.name"
|
||
class="size-5 shrink-0 rounded-sm"
|
||
loading="lazy"
|
||
@error="onFavError"
|
||
/>
|
||
<span class="text-xs text-muted">{{ hostOf(ch.homepage) }}</span>
|
||
</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="8" 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" />
|
||
<Input v-model="form.homepage" label="渠道主页" placeholder="https://openai.com(用于展示 favicon)" />
|
||
<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>
|