Files
nl-tcm-agent-admin/src/views/stats/index.vue
2026-08-15 12:52:39 +08:00

180 lines
8.2 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>
// 统计分析runs 缓冲(最近 200 条)前端聚合出趋势 / 场景分布 / token 用量
// 维度切换 1h / 6h / 24h图表配色跟随主题
import { ref, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { getRuns, getStats } from '@/api/agent'
import { usePolling } from '@/composables/usePolling'
import { sceneLabel, fmtMs, estimateCost, fmtCost } from '@/utils/format'
import PageHeader from '@/components/PageHeader.vue'
import StatCard from '@/components/StatCard.vue'
import EChart from '@/components/EChart.vue'
import EmptyHint from '@/components/EmptyHint.vue'
const app = useAppStore()
const range = ref('6h')
const runs = ref([])
const stats = ref(null)
const loading = ref(true)
async function load() {
// allSettled单接口失败不清空另一半数据loading 一定解除(同仪表盘)
const [r, s] = await Promise.allSettled([
getRuns({ limit: 200 }, { silent: true }),
getStats({ silent: true })
])
if (r.status === 'fulfilled') runs.value = r.value.data || []
if (s.status === 'fulfilled') stats.value = s.value.data
loading.value = false
}
usePolling(load, 30000)
// 主题相关配色(图表不吃 CSS 变量,手动映射)
const chartColors = computed(() => {
const dark = app.theme === 'dark'
return {
text: dark ? '#bfae8d' : '#5a6478',
line: dark ? 'rgba(245,158,11,0.15)' : 'rgba(67,97,238,0.12)',
primary: dark ? '#f59e0b' : '#4361ee',
accent: dark ? '#fb923c' : '#7c3aed',
ok: '#22c55e', err: '#ef4444',
palette: dark
? ['#f59e0b', '#fb923c', '#22c55e', '#3b82f6', '#a855f7', '#ef4444']
: ['#4361ee', '#7c3aed', '#16a34a', '#2563eb', '#d97706', '#dc2626']
}
})
// 时间桶聚合1h→5min 12桶 / 6h→30min 12桶 / 24h→2h 12桶
const buckets = computed(() => {
const now = Math.floor(Date.now() / 1000)
const conf = { '1h': [3600, 300], '6h': [21600, 1800], '24h': [86400, 7200] }[range.value]
const [span, step] = conf
const start = now - span
const n = Math.ceil(span / step)
const out = Array.from({ length: n }, (_, i) => ({
t: start + i * step, total: 0, ok: 0, prompt: 0, completion: 0
}))
for (const r of runs.value) {
if (r.started_at < start) continue
const idx = Math.min(Math.floor((r.started_at - start) / step), n - 1)
out[idx].total++
if (r.status === 1) out[idx].ok++
// 摘要没有分项 token用 total_tokens 估拆7:3 经验比例)
out[idx].prompt += Math.round((r.total_tokens || 0) * 0.7)
out[idx].completion += Math.round((r.total_tokens || 0) * 0.3)
}
return out
})
const bucketLabels = computed(() => buckets.value.map(b => {
const d = new Date(b.t * 1000)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}))
// 图1请求量 × 成功率双轴
const trendOption = computed(() => {
const c = chartColors.value
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { textStyle: { color: c.text }, top: 0 },
grid: { left: 40, right: 44, top: 34, bottom: 24 },
xAxis: { type: 'category', data: bucketLabels.value, axisLabel: { color: c.text }, axisLine: { lineStyle: { color: c.line } } },
yAxis: [
{ type: 'value', name: '请求量', axisLabel: { color: c.text }, splitLine: { lineStyle: { color: c.line } } },
{ type: 'value', name: '成功率%', max: 100, axisLabel: { color: c.text }, splitLine: { show: false } }
],
series: [
{ name: '请求量', type: 'bar', data: buckets.value.map(b => b.total), itemStyle: { color: c.primary, borderRadius: [3, 3, 0, 0] }, barMaxWidth: 18 },
{ name: '成功率', type: 'line', yAxisIndex: 1, smooth: true,
data: buckets.value.map(b => b.total ? Math.round((b.ok / b.total) * 100) : null),
itemStyle: { color: c.ok }, connectNulls: true }
]
}
})
// 图2场景分布 donut用全缓冲统计
const sceneOption = computed(() => {
const c = chartColors.value
const entries = Object.entries(stats.value?.scene_counts || {})
return {
backgroundColor: 'transparent',
color: c.palette,
tooltip: { trigger: 'item' },
legend: { bottom: 0, textStyle: { color: c.text } },
series: [{
type: 'pie', radius: ['42%', '68%'], center: ['50%', '44%'],
label: { color: c.text, formatter: '{b}\n{c} 次' },
data: entries.map(([k, v]) => ({ name: sceneLabel(k), value: v }))
}]
}
})
// 图3token 用量堆叠面积
const tokenOption = computed(() => {
const c = chartColors.value
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { textStyle: { color: c.text }, top: 0 },
grid: { left: 52, right: 20, top: 34, bottom: 24 },
xAxis: { type: 'category', data: bucketLabels.value, axisLabel: { color: c.text }, axisLine: { lineStyle: { color: c.line } } },
yAxis: { type: 'value', axisLabel: { color: c.text }, splitLine: { lineStyle: { color: c.line } } },
series: [
{ name: 'prompt', type: 'line', stack: 'tok', areaStyle: { opacity: .35 }, smooth: true, itemStyle: { color: c.primary }, data: buckets.value.map(b => b.prompt) },
{ name: 'completion', type: 'line', stack: 'tok', areaStyle: { opacity: .35 }, smooth: true, itemStyle: { color: c.accent }, data: buckets.value.map(b => b.completion) }
]
}
})
// 汇总卡
const rangeRuns = computed(() => {
const now = Math.floor(Date.now() / 1000)
const span = { '1h': 3600, '6h': 21600, '24h': 86400 }[range.value]
return runs.value.filter(r => r.started_at >= now - span)
})
const rangeTokens = computed(() => rangeRuns.value.reduce((s, r) => s + (r.total_tokens || 0), 0))
const rangeCost = computed(() => rangeRuns.value.reduce((s, r) => s + estimateCost(r.provider, (r.total_tokens || 0) * 0.7, (r.total_tokens || 0) * 0.3), 0))
const rangeAvgMs = computed(() => {
const ok = rangeRuns.value.filter(r => r.status === 1)
if (!ok.length) return null
return Math.round(ok.reduce((s, r) => s + r.total_ms, 0) / ok.length)
})
</script>
<template>
<div>
<PageHeader title="统计分析" desc="基于内存缓冲(最近 200 次运行)的前端聚合;重启后清零,长期趋势看历史记录页">
<template #actions>
<a-segmented v-model:value="range" :options="[{ label: '1 小时', value: '1h' }, { label: '6 小时', value: '6h' }, { label: '24 小时', value: '24h' }]" />
</template>
</PageHeader>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<StatCard label="区间运行数" :value="rangeRuns.length" :loading="loading" />
<StatCard label="区间成功率" :loading="loading"
:value="rangeRuns.length ? ((rangeRuns.filter(r => r.status === 1).length / rangeRuns.length) * 100).toFixed(1) : '-'" unit="%" />
<StatCard label="区间平均耗时" :value="rangeAvgMs != null ? fmtMs(rangeAvgMs) : '-'" :loading="loading" />
<StatCard label="区间 token / 成本" :value="rangeTokens" :hint="`估算 ${fmtCost(rangeCost)}`" :loading="loading" />
</div>
<EmptyHint v-if="!loading && !runs.length" text="缓冲内没有运行数据,图表无从画起"
action-text="去调试工具发一条测试请求" @action="$router.push('/debug')" />
<template v-else>
<div class="glass p-4 mb-4">
<div class="text-sm font-medium mb-2" style="color: var(--text-1)">请求量 × 成功率</div>
<EChart :option="trendOption" height="280px" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="glass p-4">
<div class="text-sm font-medium mb-2" style="color: var(--text-1)">场景分布全缓冲</div>
<EChart :option="sceneOption" height="300px" />
</div>
<div class="glass p-4">
<div class="text-sm font-medium mb-2" style="color: var(--text-1)">token 用量堆叠 7:3 估拆输入/输出</div>
<EChart :option="tokenOption" height="300px" />
</div>
</div>
</template>
</div>
</template>