feat: /admin/usage页面增加模型分布情况显示

This commit is contained in:
shaw
2025-12-20 10:06:55 +08:00
parent ef81aeb463
commit 0484c59ead
8 changed files with 518 additions and 213 deletions

View File

@@ -0,0 +1,125 @@
<template>
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
<div v-if="loading" class="flex items-center justify-center h-48">
<LoadingSpinner />
</div>
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="w-48 h-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="flex-1 max-h-48 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend
} from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { ModelStat } from '@/types'
ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
const props = defineProps<{
modelStats: ModelStat[]
loading?: boolean
}>()
const chartColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
]
const chartData = computed(() => {
if (!props.modelStats?.length) return null
return {
labels: props.modelStats.map(m => m.model),
datasets: [{
data: props.modelStats.map(m => m.total_tokens),
backgroundColor: chartColors.slice(0, props.modelStats.length),
borderWidth: 0,
}],
}
})
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
},
},
},
},
}))
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
</script>

View File

@@ -0,0 +1,182 @@
<template>
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
<div v-if="loading" class="flex items-center justify-center h-48">
<LoadingSpinner />
</div>
<div v-else-if="trendData.length > 0 && chartData" class="h-48">
<Line :data="chartData" :options="lineOptions" />
</div>
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { TrendDataPoint } from '@/types'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const { t } = useI18n()
const props = defineProps<{
trendData: TrendDataPoint[]
loading?: boolean
}>()
const isDarkMode = computed(() => {
return document.documentElement.classList.contains('dark')
})
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
input: '#3b82f6',
output: '#10b981',
cache: '#f59e0b',
}))
const chartData = computed(() => {
if (!props.trendData?.length) return null
return {
labels: props.trendData.map(d => d.date),
datasets: [
{
label: 'Input',
data: props.trendData.map(d => d.input_tokens),
borderColor: chartColors.value.input,
backgroundColor: `${chartColors.value.input}20`,
fill: true,
tension: 0.3,
},
{
label: 'Output',
data: props.trendData.map(d => d.output_tokens),
borderColor: chartColors.value.output,
backgroundColor: `${chartColors.value.output}20`,
fill: true,
tension: 0.3,
},
{
label: 'Cache',
data: props.trendData.map(d => d.cache_tokens),
borderColor: chartColors.value.cache,
backgroundColor: `${chartColors.value.cache}20`,
fill: true,
tension: 0.3,
},
],
}
})
const lineOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
},
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.value.text,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 11,
},
},
},
tooltip: {
callbacks: {
label: (context: any) => {
return `${context.dataset.label}: ${formatTokens(context.raw)}`
},
footer: (tooltipItems: any) => {
const dataIndex = tooltipItems[0]?.dataIndex
if (dataIndex !== undefined && props.trendData[dataIndex]) {
const data = props.trendData[dataIndex]
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
}
return ''
},
},
},
},
scales: {
x: {
grid: {
color: chartColors.value.grid,
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
},
},
y: {
grid: {
color: chartColors.value.grid,
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
callback: (value: string | number) => formatTokens(Number(value)),
},
},
},
}))
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
</script>