新增 Spinner 组件,接入时间轴/AI对话历史/人物管理/统计图表/模型统计/ 服务状态 6 个页面。People.vue 特殊处理:命名或合并后触发的刷新不重新 显示整页 Spinner(此时列表已有数据,会闪一下丢失滚动位置),只有首次 加载(列表为空)才转圈。ServiceStatus.vue 同理,手动点刷新只变按钮文 字,不重新蒙一层 Spinner。
135 lines
5.7 KiB
Vue
135 lines
5.7 KiB
Vue
<script setup>
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { api, fmtDateTime } from '../api.js'
|
||
import PageHeader from '../components/PageHeader.vue'
|
||
import EmptyState from '../components/EmptyState.vue'
|
||
import Spinner from '../components/Spinner.vue'
|
||
import Badge from '../components/Badge.vue'
|
||
|
||
const aggregate = ref([])
|
||
const calls = ref([])
|
||
const loadError = ref('')
|
||
const loading = ref(true)
|
||
|
||
// Gemini 适配器把实际用的 key 编号拼进 model 字段(如 "gemini-flash-latest·key2"),
|
||
// 这里拆出来单独显示成一个小徽章,而不是让用户在一长串字符串里自己找。
|
||
function splitKey(model) {
|
||
const idx = (model || '').lastIndexOf('·key')
|
||
if (idx === -1) return { model, key: '' }
|
||
return { model: model.slice(0, idx), key: model.slice(idx + 1) }
|
||
}
|
||
|
||
const aggRows = computed(() => aggregate.value.map(r => {
|
||
const total = (r.ok_cnt || 0) + (r.fail_cnt || 0)
|
||
const rate = total ? ((r.ok_cnt || 0) / total * 100) : 0
|
||
const { model, key } = splitKey(r.model)
|
||
return {
|
||
provider: r.provider,
|
||
model, key,
|
||
ok: r.ok_cnt || 0,
|
||
fail: r.fail_cnt || 0,
|
||
rate: rate.toFixed(1),
|
||
avgDur: r.avg_duration,
|
||
lastCall: r.last_call ? fmtDateTime(r.last_call) : '—',
|
||
}
|
||
}))
|
||
|
||
const callRows = computed(() => calls.value.map(c => {
|
||
const { model, key } = splitKey(c.model)
|
||
return {
|
||
time: c.started_at ? fmtDateTime(c.started_at) : '—',
|
||
provider: c.provider, model, key,
|
||
duration: (c.duration_sec || 0).toFixed(1),
|
||
success: !!c.success,
|
||
error: (c.error || '').slice(0, 60),
|
||
filename: (c.filename || '').slice(0, 40),
|
||
}
|
||
}))
|
||
|
||
onMounted(async () => {
|
||
loading.value = true
|
||
try {
|
||
const data = await api.modelStats()
|
||
aggregate.value = data.aggregate
|
||
calls.value = data.recent_calls
|
||
} catch (e) {
|
||
loadError.value = e.message
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<PageHeader icon="🤖" title="云端模型统计" sub="请求时间 · 耗时 · 成功/失败 · 失败原因" />
|
||
|
||
<Spinner v-if="loading" text="加载模型统计…" />
|
||
<template v-else>
|
||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||
|
||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">按模型聚合</div>
|
||
<EmptyState v-if="!aggRows.length" text="暂无模型调用记录(视频处理中或尚未同步)" />
|
||
<div v-else class="mb-8 overflow-x-auto rounded-xl border border-border">
|
||
<table class="w-full whitespace-nowrap text-sm">
|
||
<thead>
|
||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||
<th class="px-4 py-2 font-medium">模型</th>
|
||
<th class="px-4 py-2 font-medium">Key</th>
|
||
<th class="px-4 py-2 font-medium">成功</th>
|
||
<th class="px-4 py-2 font-medium">失败</th>
|
||
<th class="px-4 py-2 font-medium">成功率%</th>
|
||
<th class="px-4 py-2 font-medium">平均耗时s</th>
|
||
<th class="px-4 py-2 font-medium">最后调用</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(r, i) in aggRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||
<td class="px-4 py-2 font-medium text-text">{{ r.provider }} / {{ r.model }}</td>
|
||
<td class="px-4 py-2"><Badge v-if="r.key" tone="violet">{{ r.key }}</Badge><span v-else class="text-text-faint">—</span></td>
|
||
<td class="px-4 py-2 text-ok">{{ r.ok }}</td>
|
||
<td class="px-4 py-2 text-danger">{{ r.fail }}</td>
|
||
<td class="px-4 py-2 font-mono tabular">{{ r.rate }}</td>
|
||
<td class="px-4 py-2 font-mono tabular">{{ r.avgDur }}</td>
|
||
<td class="px-4 py-2 font-mono tabular">{{ r.lastCall }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">最近调用明细</div>
|
||
<EmptyState v-if="!callRows.length" text="暂无调用明细" />
|
||
<div v-else class="mb-4 max-h-[520px] overflow-auto rounded-xl border border-border">
|
||
<table class="w-full whitespace-nowrap text-sm">
|
||
<thead class="sticky top-0">
|
||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||
<th class="px-4 py-2 font-medium">请求时间</th>
|
||
<th class="px-4 py-2 font-medium">模型</th>
|
||
<th class="px-4 py-2 font-medium">Key</th>
|
||
<th class="px-4 py-2 font-medium">耗时s</th>
|
||
<th class="px-4 py-2 font-medium">状态</th>
|
||
<th class="px-4 py-2 font-medium">失败原因</th>
|
||
<th class="px-4 py-2 font-medium">视频</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(c, i) in callRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||
<td class="px-4 py-2 font-mono tabular">{{ c.time }}</td>
|
||
<td class="px-4 py-2">{{ c.provider }} / {{ c.model }}</td>
|
||
<td class="px-4 py-2"><Badge v-if="c.key" tone="violet">{{ c.key }}</Badge><span v-else class="text-text-faint">—</span></td>
|
||
<td class="px-4 py-2 font-mono tabular">{{ c.duration }}</td>
|
||
<td class="px-4 py-2" :class="c.success ? 'text-ok' : 'text-danger'">{{ c.success ? '✅ 成功' : '❌ 失败' }}</td>
|
||
<td class="px-4 py-2 text-text-faint">{{ c.error }}</td>
|
||
<td class="px-4 py-2 text-text-faint">{{ c.filename }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<p class="text-[11px] text-text-mute">
|
||
统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。Key 列只显示配置里第几个
|
||
Gemini Key 被用到(key1=主 key,key2/3/4=备用 key),不会显示密钥原文。
|
||
失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。
|
||
</p>
|
||
</template>
|
||
</template>
|