Files
openteam/web/src/views/admin/ChannelsView.vue
T
Sakurasan e7f8a5e5ad fix(web): 渠道冷却是按原因分类的,提示不再写死「连续失败 X/3」
后端冷却策略已改成按失败原因区分(余额/配额类首次失败即冷却 30 分钟,其余按连续
进冷却次数指数退避 30s→60s→120s 封顶 10 分钟),前端还停留在「满 3 次进冷却 + 原始
秒数」的旧假设上,管理员看到的提示与真实状态对不上。

- Channel 类型补 cooldown_reason(network | server_error | rate_limited | quota_exhausted)
- 冷却提示带失败原因;配额类额外提示「需充值或换 key」——唯一重试不会自愈的场景,
  应当让管理员直接处理而不是等 30 分钟再看
- 连续失败提示去掉硬编码的 /3(触发阈值随原因变化)
- 新增 fmtDur:1800 → 30m、5400 → 1h30m,替代直接显示秒数(附 4 组用例)
2026-10-30 02:48:59 +08:00

504 lines
21 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 { 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, fmtDur } 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
// 数字输入被清空时 v-model 是 ''(Number('')==0):直接提交会把权重写成 0(永远选不中)。
// 省略该字段:创建走后端默认值,更新端点是指针字段不动原值。
const numOrOmit = (v: unknown) => {
if (v === '' || v === null || v === undefined) return undefined
const n = Number(v)
return Number.isNaN(n) ? undefined : n
}
const payload = {
...form,
weight: numOrOmit(form.weight),
priority: numOrOmit(form.priority),
timeout_ms: numOrOmit(form.timeout_ms),
max_concurrency: numOrOmit(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 看不到它,所以单独提示。
// 冷却时长与触发条件都随失败原因变化(余额/配额类首次失败即 30 分钟,其余指数退避),
// 不能再写死「/3」,也不能只给秒数。
const cooldownReasonLabel: Record<string, string> = {
network: '网络/超时',
server_error: '上游 5xx',
rate_limited: '上游限流',
quota_exhausted: '余额/配额耗尽',
}
function cooldownReason(ch: Channel): string {
const r = ch.cooldown_reason ?? ''
return cooldownReasonLabel[r] ?? r
}
function runtimeHint(ch: Channel): string {
if (ch.cooldown_seconds > 0) {
const reason = cooldownReason(ch)
const base = `冷却中 ${fmtDur(ch.cooldown_seconds)}`
if (isQuotaCooldown(ch)) return `${base} · ${reason},需充值或换 key`
return reason ? `${base} · ${reason}` : base
}
if (ch.runtime_fails > 0) {
const reason = cooldownReason(ch)
return reason ? `连续失败 ${ch.runtime_fails} 次 · ${reason}` : `连续失败 ${ch.runtime_fails} 次`
}
return ''
}
// 余额/配额耗尽是唯一的「重试不会自愈」场景:冷却结束也经常还是坏的,值得到管理员面前
// 直接点出来(充值或换 key),而不是让他等 30 分钟再看。
function isQuotaCooldown(ch: Channel): boolean {
return ch.cooldown_seconds > 0 && ch.cooldown_reason === 'quota_exhausted'
}
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>