Files
sentinel-home-ai/fam-ui/src/views/ModelStats.vue
ericwyuan 22df28f2d3 feat(fam-ui): 各页面加载状态 - 数据到达前不再是空白/半截页面
新增 Spinner 组件,接入时间轴/AI对话历史/人物管理/统计图表/模型统计/
服务状态 6 个页面。People.vue 特殊处理:命名或合并后触发的刷新不重新
显示整页 Spinner(此时列表已有数据,会闪一下丢失滚动位置),只有首次
加载(列表为空)才转圈。ServiceStatus.vue 同理,手动点刷新只变按钮文
字,不重新蒙一层 Spinner。
2026-08-22 07:19:32 +08:00

135 lines
5.7 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>
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= keykey2/3/4=备用 key不会显示密钥原文
失败原因取值429_quota配额耗尽/ timeout / 503_overload过载重试/ http_xxx / json_parse_failed
</p>
</template>
</template>