600 lines
14 KiB
Vue
600 lines
14 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="period !== 'daily' && stats.daily && chartData.length > 0" class="stats-chart">
|
|
<div class="chart-title">每日趋势</div>
|
|
<div class="chart-container">
|
|
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`">
|
|
<defs>
|
|
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" :stop-color="accentColor" stop-opacity="0.25"/>
|
|
<stop offset="100%" :stop-color="accentColor" stop-opacity="0.02"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<!-- 网格线 -->
|
|
<line
|
|
v-for="i in 4"
|
|
:key="'grid-' + i"
|
|
:x1="padding"
|
|
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
|
:x2="chartWidth - padding"
|
|
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
|
stroke="var(--border-light)"
|
|
stroke-dasharray="3,3"
|
|
/>
|
|
<!-- Y轴标签 -->
|
|
<text
|
|
v-for="i in 4"
|
|
:key="'yl-' + i"
|
|
:x="padding - 4"
|
|
:y="padding + (chartHeight - 2 * padding) * (i - 1) / 3 + 3"
|
|
text-anchor="end"
|
|
class="y-label"
|
|
>{{ formatNumber(maxValue - (maxValue * (i - 1)) / 3) }}</text>
|
|
<!-- 填充区域 -->
|
|
<path :d="areaPath" fill="url(#areaGradient)"/>
|
|
<!-- 折线 -->
|
|
<path
|
|
:d="linePath"
|
|
fill="none"
|
|
:stroke="accentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<!-- 数据点 -->
|
|
<circle
|
|
v-for="(point, idx) in chartPoints"
|
|
:key="idx"
|
|
:cx="point.x"
|
|
:cy="point.y"
|
|
r="3"
|
|
:fill="accentColor"
|
|
stroke="var(--bg-primary)"
|
|
stroke-width="2"
|
|
class="data-point"
|
|
@mouseenter="hoveredPoint = idx"
|
|
@mouseleave="hoveredPoint = null"
|
|
/>
|
|
<!-- 竖线指示 -->
|
|
<line
|
|
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
|
:x1="chartPoints[hoveredPoint].x"
|
|
:y1="padding"
|
|
:x2="chartPoints[hoveredPoint].x"
|
|
:y2="chartHeight - padding"
|
|
stroke="var(--border-medium)"
|
|
stroke-dasharray="3,3"
|
|
/>
|
|
</svg>
|
|
|
|
<!-- X轴标签 -->
|
|
<div class="x-labels">
|
|
<span
|
|
v-for="(point, idx) in chartPoints"
|
|
:key="idx"
|
|
class="x-label"
|
|
:class="{ active: hoveredPoint === idx }"
|
|
>
|
|
{{ formatDateLabel(point.date) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 悬浮提示 -->
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
|
class="tooltip"
|
|
:style="{
|
|
left: chartPoints[hoveredPoint].x + 'px',
|
|
top: (chartPoints[hoveredPoint].y - 52) + 'px'
|
|
}"
|
|
>
|
|
<div class="tooltip-date">{{ formatFullDate(chartPoints[hoveredPoint].date) }}</div>
|
|
<div class="tooltip-row">
|
|
<span class="tooltip-dot prompt"></span>
|
|
输入 {{ formatNumber(chartPoints[hoveredPoint].prompt) }}
|
|
</div>
|
|
<div class="tooltip-row">
|
|
<span class="tooltip-dot completion"></span>
|
|
输出 {{ formatNumber(chartPoints[hoveredPoint].completion) }}
|
|
</div>
|
|
<div class="tooltip-total">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
|
|
</div>
|
|
</Transition>
|
|
</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, onMounted } from 'vue'
|
|
import { statsApi } from '../api'
|
|
import { formatNumber } from '../utils/format'
|
|
import { icons } from '../utils/icons'
|
|
|
|
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 hoveredPoint = ref(null)
|
|
|
|
const accentColor = computed(() => {
|
|
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
|
|
})
|
|
|
|
const chartWidth = 320
|
|
const chartHeight = 140
|
|
const padding = 32
|
|
|
|
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(() => {
|
|
const data = sortedDaily.value
|
|
return Object.entries(data).map(([date, val]) => ({
|
|
date,
|
|
value: val.total,
|
|
prompt: val.prompt || 0,
|
|
completion: val.completion || 0,
|
|
}))
|
|
})
|
|
|
|
const maxValue = computed(() => {
|
|
if (chartData.value.length === 0) return 100
|
|
return Math.max(100, ...chartData.value.map(d => d.value))
|
|
})
|
|
|
|
const maxModelTokens = computed(() => {
|
|
if (!stats.value?.by_model) return 1
|
|
return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total))
|
|
})
|
|
|
|
const chartPoints = computed(() => {
|
|
const data = chartData.value
|
|
if (data.length === 0) return []
|
|
|
|
const xRange = chartWidth - 2 * padding
|
|
const yRange = chartHeight - 2 * padding
|
|
|
|
return data.map((d, i) => ({
|
|
x: data.length === 1
|
|
? chartWidth / 2
|
|
: padding + (i / Math.max(1, data.length - 1)) * xRange,
|
|
y: chartHeight - padding - (d.value / maxValue.value) * yRange,
|
|
date: d.date,
|
|
value: d.value,
|
|
prompt: d.prompt,
|
|
completion: d.completion,
|
|
}))
|
|
})
|
|
|
|
const linePath = computed(() => {
|
|
const points = chartPoints.value
|
|
if (points.length === 0) return ''
|
|
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
|
})
|
|
|
|
const areaPath = computed(() => {
|
|
const points = chartPoints.value
|
|
if (points.length === 0) return ''
|
|
|
|
const baseY = chartHeight - padding
|
|
|
|
let path = `M ${points[0].x} ${baseY} `
|
|
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
|
|
path += ` L ${points[points.length - 1].x} ${baseY} Z`
|
|
|
|
return path
|
|
})
|
|
|
|
function formatDateLabel(dateStr) {
|
|
const d = new Date(dateStr)
|
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
|
}
|
|
|
|
function formatFullDate(dateStr) {
|
|
const d = new Date(dateStr)
|
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
|
}
|
|
|
|
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
|
|
hoveredPoint.value = null
|
|
loadStats()
|
|
}
|
|
|
|
onMounted(loadStats)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.stats-panel {
|
|
padding: 0;
|
|
}
|
|
|
|
/* panel-header, panel-title, header-actions now in global.css */
|
|
|
|
.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: 12px 8px 8px 8px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.line-chart {
|
|
width: 100%;
|
|
height: 140px;
|
|
}
|
|
|
|
.y-label {
|
|
fill: var(--text-tertiary);
|
|
font-size: 9px;
|
|
}
|
|
|
|
.data-point {
|
|
cursor: pointer;
|
|
transition: r 0.15s;
|
|
}
|
|
|
|
.data-point:hover {
|
|
r: 5;
|
|
}
|
|
|
|
.x-labels {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-top: 6px;
|
|
padding: 0 28px 0 32px;
|
|
}
|
|
|
|
.x-label {
|
|
font-size: 10px;
|
|
color: var(--text-tertiary);
|
|
transition: color 0.15s;
|
|
}
|
|
|
|
.x-label.active {
|
|
color: var(--text-primary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* 提示框 */
|
|
.tooltip {
|
|
position: absolute;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-medium);
|
|
padding: 8px 10px;
|
|
border-radius: 8px;
|
|
font-size: 11px;
|
|
pointer-events: none;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
transform: translateX(-50%);
|
|
z-index: 10;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.tooltip-date {
|
|
color: var(--text-tertiary);
|
|
font-size: 10px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.tooltip-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.tooltip-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tooltip-dot.prompt {
|
|
background: #3b82f6;
|
|
}
|
|
|
|
.tooltip-dot.completion {
|
|
background: #a855f7;
|
|
}
|
|
|
|
.tooltip-total {
|
|
margin-top: 4px;
|
|
padding-top: 4px;
|
|
border-top: 1px solid var(--border-light);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* 模型分布 */
|
|
.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>
|