180 lines
8.2 KiB
Vue
180 lines
8.2 KiB
Vue
<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 }))
|
||
}]
|
||
}
|
||
})
|
||
|
||
// 图3:token 用量堆叠面积
|
||
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>
|