nanoClaw/frontend/src/components/StatsPanel.vue

507 lines
12 KiB
Vue

<template>
<div class="stats-panel">
<div class="panel-header">
<div class="panel-title">
<span v-html="icons.stats" />
<h4>使用统计</h4>
</div>
<div class="header-actions">
<div class="period-tabs">
<button
v-for="p in periods"
:key="p.value"
:class="['tab', { active: period === p.value }]"
@click="changePeriod(p.value)"
>
{{ p.label }}
</button>
</div>
<button class="btn-close" @click="$emit('close')">
<span v-html="icons.closeMd" />
</button>
</div>
</div>
<div v-if="loading" class="stats-loading">
<span class="spinner" v-html="icons.spinnerMd" />
加载中...
</div>
<template v-else-if="stats">
<!-- 统计卡片 -->
<div class="stats-summary">
<div class="stat-card">
<div class="stat-icon input-icon">
<span v-html="icons.edit" />
</div>
<div class="stat-info">
<span class="stat-label">输入</span>
<span class="stat-value">{{ formatNumber(stats.prompt_tokens) }}</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon output-icon">
<span v-html="icons.file" />
</div>
<div class="stat-info">
<span class="stat-label">输出</span>
<span class="stat-value">{{ formatNumber(stats.completion_tokens) }}</span>
</div>
</div>
<div class="stat-card total">
<div class="stat-icon total-icon">
<span v-html="icons.trendUp" />
</div>
<div class="stat-info">
<span class="stat-label">总计</span>
<span class="stat-value">{{ formatNumber(stats.total_tokens) }}</span>
</div>
</div>
</div>
<!-- 趋势图 -->
<div v-if="chartData.length > 0" class="stats-chart">
<div class="chart-title">{{ period === 'daily' ? '今日趋势' : '每日趋势' }}</div>
<div class="chart-container">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
<!-- 按模型分布 -->
<div v-if="stats.by_model" class="stats-by-model">
<div class="model-title">模型分布</div>
<div class="model-list">
<div
v-for="(data, model) in stats.by_model"
:key="model"
class="model-row"
>
<div class="model-info">
<span class="model-name">{{ model }}</span>
<span class="model-value">{{ formatNumber(data.total) }} <span class="model-unit">tokens</span></span>
</div>
<div class="model-bar-bg">
<div
class="model-bar-fill"
:style="{ width: (data.total / maxModelTokens * 100) + '%' }"
></div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!stats.total_tokens" class="stats-empty">
<span v-html="icons.stats" style="opacity: 0.5;" />
<span>暂无使用数据</span>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
import { statsApi } from '../api'
import { formatNumber } from '../utils/format'
import { icons } from '../utils/icons'
Chart.register(...registerables)
defineEmits(['close'])
const periods = [
{ value: 'daily', label: '今日' },
{ value: 'weekly', label: '本周' },
{ value: 'monthly', label: '本月' },
]
const period = ref('daily')
const stats = ref(null)
const loading = ref(false)
const chartCanvas = ref(null)
let chartInstance = null
const sortedDaily = computed(() => {
if (!stats.value?.daily) return {}
const entries = Object.entries(stats.value.daily)
entries.sort((a, b) => a[0].localeCompare(b[0]))
return Object.fromEntries(entries)
})
const chartData = computed(() => {
if (period.value === 'daily' && stats.value?.hourly) {
const hourly = stats.value.hourly
let minH = 24, maxH = -1
for (const h of Object.keys(hourly)) {
const hour = parseInt(h)
if (hour < minH) minH = hour
if (hour > maxH) maxH = hour
}
if (minH > maxH) return []
const start = Math.max(0, minH)
const end = Math.min(23, maxH)
return Array.from({ length: end - start + 1 }, (_, i) => {
const h = start + i
return {
label: `${h}:00`,
value: hourly[String(h)]?.total || 0,
}
})
}
const data = sortedDaily.value
return Object.entries(data).map(([date, val]) => {
const d = new Date(date)
return {
label: `${d.getMonth() + 1}/${d.getDate()}`,
value: val.total,
prompt: val.prompt || 0,
completion: val.completion || 0,
}
})
})
const maxModelTokens = computed(() => {
if (!stats.value?.by_model) return 1
return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total))
})
function getAccentColor() {
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
}
function getTextColor(alpha = 1) {
const c = getComputedStyle(document.documentElement).getPropertyValue('--text-tertiary').trim() || '#888'
if (alpha === 1) return c
// Convert hex to rgba
if (c.startsWith('#')) {
const r = parseInt(c.slice(1, 3), 16)
const g = parseInt(c.slice(3, 5), 16)
const b = parseInt(c.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
return c
}
function destroyChart() {
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
}
function buildChart() {
if (!chartCanvas.value || chartData.value.length === 0) return
destroyChart()
const accent = getAccentColor()
const ctx = chartCanvas.value.getContext('2d')
// Gradient fill
const gradient = ctx.createLinearGradient(0, 0, 0, 200)
gradient.addColorStop(0, accent + '40')
gradient.addColorStop(1, accent + '05')
const labels = chartData.value.map(d => d.label)
const values = chartData.value.map(d => d.value)
// Determine max ticks for x-axis
const maxTicks = chartData.value.length <= 8 ? chartData.value.length : 6
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data: values,
borderColor: accent,
backgroundColor: gradient,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: accent,
pointHoverBorderColor: '#fff',
pointHoverBorderWidth: 2,
fill: true,
tension: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
layout: {
padding: { top: 4, right: 4, bottom: 0, left: 0 },
},
scales: {
x: {
grid: { display: false },
border: { display: false },
ticks: {
color: getTextColor(),
font: { size: 10 },
maxTicksLimit: maxTicks,
maxRotation: 0,
},
},
y: {
beginAtZero: true,
grid: {
color: getTextColor(0.15),
drawBorder: false,
},
border: { display: false },
ticks: {
color: getTextColor(),
font: { size: 9 },
maxTicksLimit: 4,
callback: (v) => formatNumber(v),
},
},
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleColor: '#fff',
bodyColor: '#ccc',
titleFont: { size: 11, weight: '500' },
bodyFont: { size: 11 },
padding: 8,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (items) => {
const idx = items[0].dataIndex
const d = chartData.value[idx]
if (period.value === 'daily') {
return `${d.label} - ${parseInt(d.label) + 1}:00`
}
return d.label
},
label: (item) => `${formatNumber(item.raw)} tokens`,
},
},
},
interaction: {
mode: 'index',
intersect: false,
},
},
})
}
watch(chartData, () => {
nextTick(buildChart)
})
async function loadStats() {
loading.value = true
try {
const res = await statsApi.getTokens(period.value)
stats.value = res.data
} catch (e) {
console.error('Failed to load stats:', e)
} finally {
loading.value = false
}
}
function changePeriod(p) {
period.value = p
loadStats()
}
onMounted(loadStats)
onBeforeUnmount(destroyChart)
</script>
<style scoped>
.stats-panel {
padding: 0;
}
.stats-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-tertiary);
font-size: 13px;
}
/* 统计卡片 */
.stats-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 12px;
transition: border-color 0.2s;
}
.stat-card:hover {
border-color: var(--border-medium);
}
.stat-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.input-icon {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.output-icon {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.total-icon {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.stat-card.total {
background: var(--accent-primary-light);
border-color: rgba(37, 99, 235, 0.15);
}
.stat-card.total .total-icon {
background: rgba(37, 99, 235, 0.15);
color: var(--accent-primary);
}
.stat-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-label {
font-size: 11px;
color: var(--text-tertiary);
font-weight: 500;
}
.stat-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
/* 趋势图 */
.stats-chart {
margin-bottom: 16px;
}
.chart-title,
.model-title {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 10px;
}
.chart-container {
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 10px;
position: relative;
height: 180px;
}
/* 模型分布 */
.stats-by-model {
margin-bottom: 4px;
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.model-info {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.model-name {
font-size: 12px;
color: var(--text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.model-value {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.model-unit {
font-weight: 400;
color: var(--text-tertiary);
}
.model-bar-bg {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.model-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), #a855f7);
border-radius: 3px;
transition: width 0.5s ease;
min-width: 4px;
}
/* 空状态 */
.stats-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
color: var(--text-tertiary);
font-size: 13px;
}
</style>