手机上"溢出屏幕"的两个真因(375 / 769 / 1100 三档实测): - 卡片↔表格切换卡在 md(768px),而侧栏也在 768 出现,吃掉 224px 加 48px 内边距后 内容区只剩 495px,渠道表自然宽 1097px —— 横屏手机、小平板、分屏(768~1023) 整张表被塞进 495px 的盒子里横向滚。改为 lg(1024px) 起才用表格;769px 实测 可见表格数为 0、docSW 等于视口宽。 - 用户管理页只有 720px 的表格、没有卡片回退,手机上只能横向滑整张表。 补移动端卡片(用户名/邮箱、角色与状态、ID/余额/注册时间、编辑与调余额), 表格改 hidden lg:block;1100px 实测 client=826 / scroll=826,铺满不滚。 - 顺带修掉上次改动留下的挤列问题:渠道表"近N小时"列被表格自动布局压到 95px (min-w 只约束整表、不保证单列),四个指标折成 4 行。该列加 min-w-[190px]、 指标拆两行,并把 Key 并入名称行、启用并入健康行,8 列减到 6 列 (colspan 同步 7→6),自然宽 1125 → 1053。 编辑渠道弹窗 375px 下无溢出元素。遗留:渠道表在 1024~1325 视口仍需容器内横向滚动 (6 列数据表的固有行为,不动整页),卡片列表在 640~1023 仍是单列。
469 lines
19 KiB
Vue
469 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack, PhArrowsClockwise } from '@phosphor-icons/vue'
|
||
import { http, errMsg } from '@/api/client'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
||
import { fmtCost } from '@/lib/format'
|
||
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
|
||
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, ChannelStat } 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 expandedId = ref<number | null>(null)
|
||
|
||
// 近况:按渠道聚合的成功率/延迟分位/成本,来自 usage_logs
|
||
const statWindows = [
|
||
{ v: '5m', label: '5 分钟' },
|
||
{ v: '1h', label: '1 小时' },
|
||
{ v: '6h', label: '6 小时' },
|
||
{ v: '24h', label: '24 小时' },
|
||
{ v: '7d', label: '7 天' },
|
||
]
|
||
const statWindow = ref('1h')
|
||
const stats = ref<Record<number, ChannelStat>>({})
|
||
|
||
// 渠道 + 近况合成一份列表,两套布局共用(也省掉模板里的非空断言)
|
||
const rows = computed(() => channels.value.map((ch) => ({ ch, st: stats.value[ch.id] ?? null })))
|
||
|
||
function successRate(s: ChannelStat): string {
|
||
return `${((1 - s.err_rate) * 100).toFixed(s.err_rate > 0 ? 1 : 0)}%`
|
||
}
|
||
|
||
// 分位延迟取自成功样本,没有样本时显示 -(0ms 是真实值,不能也显示成 -)
|
||
function fmtMs(n: number, has: boolean): string {
|
||
if (!has) return '-'
|
||
return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}s` : `${n}ms`
|
||
}
|
||
|
||
function toggleDrawer(ch: Channel) {
|
||
expandedId.value = expandedId.value === ch.id ? null : ch.id
|
||
}
|
||
|
||
const form = reactive({
|
||
name: '',
|
||
formats: ['chat'] as string[],
|
||
base_url: '',
|
||
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
|
||
api_key: '',
|
||
weight: 1,
|
||
priority: 0,
|
||
timeout_ms: 300000,
|
||
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))
|
||
}
|
||
await loadStats()
|
||
}
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const { data } = await http.get('/admin/channels/stats', { params: { window: statWindow.value } })
|
||
const map: Record<number, ChannelStat> = {}
|
||
for (const s of data.data.items as ChannelStat[]) map[s.channel_id] = s
|
||
stats.value = map
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
}
|
||
}
|
||
|
||
async function switchWindow(v: string) {
|
||
if (statWindow.value === v) return
|
||
statWindow.value = v
|
||
await loadStats()
|
||
}
|
||
|
||
function openCreate() {
|
||
editing.value = null
|
||
Object.assign(form, {
|
||
name: '', formats: ['chat'], base_url: '',
|
||
base_urls: { chat: '', responses: '', messages: '' },
|
||
api_key: '',
|
||
weight: 1, priority: 0, timeout_ms: 300000, 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,
|
||
base_urls: {
|
||
chat: ch.base_urls?.chat ?? '',
|
||
responses: ch.base_urls?.responses ?? '',
|
||
messages: ch.base_urls?.messages ?? '',
|
||
},
|
||
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()
|
||
}
|
||
}
|
||
|
||
// 运行期冷却是网关进程内的状态,探测写的 health_status 看不到它,所以单独提示
|
||
function runtimeHint(ch: Channel): string {
|
||
if (ch.cooldown_seconds > 0) return `冷却中 ${ch.cooldown_seconds}s`
|
||
if (ch.runtime_fails > 0) return `连续失败 ${ch.runtime_fails}/3`
|
||
return ''
|
||
}
|
||
|
||
function needRecover(ch: Channel): boolean {
|
||
return ch.cooldown_seconds > 0 || ch.health_status !== 'healthy'
|
||
}
|
||
|
||
async function recoverChannel(ch: Channel) {
|
||
busyId.value = ch.id
|
||
try {
|
||
await http.post(`/admin/channels/${ch.id}/recover`)
|
||
toast.ok(`渠道 ${ch.name} 已恢复`)
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
} finally {
|
||
busyId.value = null
|
||
await load()
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mx-auto max-w-6xl">
|
||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h1 class="text-lg font-semibold">渠道</h1>
|
||
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<div class="flex overflow-hidden rounded-md border border-edge2" role="group" aria-label="近况统计窗口">
|
||
<button
|
||
v-for="w in statWindows"
|
||
:key="w.v"
|
||
class="px-2 py-1.5 text-[11px] transition"
|
||
:class="statWindow === w.v ? 'bg-accent text-white' : 'bg-surface text-muted hover:text-ink'"
|
||
@click="switchWindow(w.v)"
|
||
>{{ w.label }}</button>
|
||
</div>
|
||
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 移动端:卡片列表 -->
|
||
<div class="space-y-3 lg:hidden">
|
||
<div v-for="{ ch, st } in rows" :key="ch.id" class="card p-4" :class="ch.enabled ? 'bg-accent-soft' : ''">
|
||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
|
||
<div class="mt-1.5 flex flex-wrap gap-1">
|
||
<code
|
||
v-for="f in ch.formats || []"
|
||
:key="f"
|
||
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
|
||
>{{ protocolShort(f) }}</code>
|
||
</div>
|
||
</div>
|
||
<div class="flex shrink-0 gap-1.5">
|
||
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||
{{ ch.health_status }}
|
||
</Badge>
|
||
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
|
||
<Badge v-if="runtimeHint(ch)" :variant="ch.cooldown_seconds > 0 ? 'err' : 'warn'">
|
||
{{ runtimeHint(ch) }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<p class="mt-2 truncate font-mono text-[11px] text-muted">{{ ch.base_url }}</p>
|
||
<div v-if="st" class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted">
|
||
<span class="mono-num">{{ st.requests }} 次</span>
|
||
<span class="mono-num" :class="st.err_rate > 0.05 ? 'text-err' : ''">成功 {{ successRate(st) }}</span>
|
||
<span class="mono-num">P50 {{ fmtMs(st.p50_ms, st.success > 0) }}</span>
|
||
<span class="mono-num">P95 {{ fmtMs(st.p95_ms, st.success > 0) }}</span>
|
||
<span class="mono-num">{{ fmtCost(st.cost) }}</span>
|
||
<span
|
||
v-for="e in st.top_errors"
|
||
:key="e.code"
|
||
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-err"
|
||
>{{ e.code }}×{{ e.count }}</span>
|
||
</div>
|
||
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-edge pt-3">
|
||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||
<PhPulse :size="13" />
|
||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||
</button>
|
||
<button
|
||
v-if="needRecover(ch)"
|
||
class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong"
|
||
:disabled="busyId === ch.id"
|
||
@click="recoverChannel(ch)"
|
||
>
|
||
<PhArrowsClockwise :size="13" />
|
||
立即恢复
|
||
</button>
|
||
<button class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong" @click="toggleDrawer(ch)">
|
||
<PhStack :size="13" />
|
||
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
|
||
</button>
|
||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||
<PhNotePencil :size="13" />
|
||
编辑
|
||
</button>
|
||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||
<PhTrash :size="13" />
|
||
删除
|
||
</button>
|
||
</div>
|
||
<div v-if="expandedId === ch.id" class="mt-3 border-t border-edge pt-3">
|
||
<ChannelModelsDrawer :channel="ch" />
|
||
</div>
|
||
</div>
|
||
<p v-if="channels.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
|
||
还没有渠道,点击「添加渠道」
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 桌面端:表格(lg 起才显示:md 断点下侧栏一出现内容区只剩 ~495px,表格会被挤成横向滚动) -->
|
||
<div class="card hidden lg:block">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm min-w-[860px]">
|
||
<thead>
|
||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||
<th scope="col" class="px-4 py-2.5 font-medium">名称 / Key</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">健康 / 状态</th>
|
||
<th scope="col" class="min-w-[190px] px-4 py-2.5 font-medium">近{{ statWindows.find((w) => w.v === statWindow)?.label }}</th>
|
||
<th scope="col" class="px-4 py-2.5" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-for="{ ch, st } in rows" :key="ch.id">
|
||
<tr class="table-row" :style="ch.enabled ? { borderLeft: '2px solid var(--color-accent)' } : {}">
|
||
<td class="max-w-[240px] px-4 py-2.5">
|
||
<button class="flex items-center gap-1.5 text-left text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
|
||
<span class="truncate">{{ ch.name }}</span>
|
||
<PhCaretDown :size="12" class="shrink-0 text-muted transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
|
||
</button>
|
||
<p class="mt-0.5 truncate font-mono text-[11px] text-muted">{{ ch.api_key_masked || '****' }}</p>
|
||
</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="max-w-[200px] truncate px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
|
||
<td class="px-4 py-2.5">
|
||
<div class="flex flex-wrap items-center gap-1">
|
||
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||
{{ ch.health_status }}
|
||
</Badge>
|
||
<Badge :variant="ch.enabled ? 'neutral' : 'err'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
|
||
</div>
|
||
<div
|
||
v-if="runtimeHint(ch)"
|
||
class="mt-1 font-mono text-[11px]"
|
||
:class="ch.cooldown_seconds > 0 ? 'text-err' : 'text-warn'"
|
||
>{{ runtimeHint(ch) }}</div>
|
||
</td>
|
||
<td class="px-4 py-2.5">
|
||
<div v-if="st" class="flex flex-col gap-0.5 text-[11px] leading-4 text-muted">
|
||
<span class="mono-num">
|
||
{{ st.requests }} 次 ·
|
||
<span :class="st.err_rate > 0.05 ? 'text-err' : ''">成功 {{ successRate(st) }}</span>
|
||
</span>
|
||
<span class="mono-num">P95 {{ fmtMs(st.p95_ms, st.success > 0) }} · {{ fmtCost(st.cost) }}</span>
|
||
<span v-if="st.top_errors.length" class="font-mono text-[10px] text-err">
|
||
{{ st.top_errors.map((e) => `${e.code}×${e.count}`).join(' ') }}
|
||
</span>
|
||
</div>
|
||
<span v-else class="text-[11px] text-muted">-</span>
|
||
</td>
|
||
<td class="px-4 py-2.5 text-right">
|
||
<div class="flex justify-end gap-2">
|
||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||
<PhPulse :size="13" />
|
||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||
</button>
|
||
<button
|
||
v-if="needRecover(ch)"
|
||
class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong"
|
||
:disabled="busyId === ch.id"
|
||
@click="recoverChannel(ch)"
|
||
>
|
||
<PhArrowsClockwise :size="13" />
|
||
立即恢复
|
||
</button>
|
||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||
<PhNotePencil :size="13" />
|
||
编辑
|
||
</button>
|
||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||
<PhTrash :size="13" />
|
||
删除
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="expandedId === ch.id" class="bg-surface/40">
|
||
<td colspan="6" class="px-4 py-3">
|
||
<ChannelModelsDrawer :channel="ch" />
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
<tr v-if="channels.length === 0">
|
||
<td colspan="6" 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"
|
||
:maxlength="255"
|
||
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
|
||
/>
|
||
<div class="space-y-3 rounded-md border border-edge p-3">
|
||
<p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p>
|
||
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" :maxlength="255" />
|
||
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" :maxlength="255" />
|
||
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" :maxlength="255" />
|
||
<p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
|
||
</div>
|
||
<Input
|
||
v-model="form.api_key"
|
||
label="上游 API Key"
|
||
:placeholder="editing ? '留空则不修改' : 'sk-...'"
|
||
/>
|
||
<div class="grid grid-cols-1 sm: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 class="flex items-center justify-between rounded-md border border-edge p-3">
|
||
<div>
|
||
<p class="text-sm font-medium text-ink">启用渠道</p>
|
||
<p class="text-xs text-muted">禁用后该渠道不会被用于请求转发</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
:aria-checked="form.enabled"
|
||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||
:class="form.enabled ? 'bg-accent' : 'bg-surface2'"
|
||
@click="form.enabled = !form.enabled"
|
||
>
|
||
<span
|
||
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
|
||
:class="form.enabled ? 'translate-x-6' : 'translate-x-1'"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|