Files
openteam/web/src/views/admin/ChannelsView.vue
T
Sakurasan 75c8ecd519 fix: 列表页卡片与表格的切换点抬到 lg,补上用户管理的移动端视图
手机上"溢出屏幕"的两个真因(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 仍是单列。
2026-10-19 09:25:52 +08:00

469 lines
19 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 } 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>