feat: 添加 Dashboard 供应商成本统计功能

- 新增 stats_daily_provider 表存储每日供应商统计数据
- 实现供应商维度的数据聚合服务
- Dashboard API 返回 provider_summary 供应商汇总数据
- 前端新增 DoughnutChart 环形图组件
- Dashboard 新增供应商成本分布可视化卡片
- 移除重复的请求次数/费用趋势折线图

Closes #110

Co-authored-by: RWDai <27391645+RWDai@users.noreply.github.com>
This commit is contained in:
fawney19
2026-01-19 20:23:55 +08:00
parent 6ae862980d
commit c29d57622f
8 changed files with 487 additions and 96 deletions

View File

@@ -206,6 +206,13 @@ export interface ModelSummary {
tokens_per_request: number
}
export interface ProviderSummary {
provider: string
requests: number
tokens: number
cost: number
}
export interface DailyStat {
date: string // ISO date string
requests: number
@@ -220,6 +227,7 @@ export interface DailyStat {
export interface DailyStatsResponse {
daily_stats: DailyStat[]
model_summary: ModelSummary[]
provider_summary: ProviderSummary[]
period: {
start_date: string
end_date: string

View File

@@ -0,0 +1,113 @@
<template>
<div class="w-full h-full">
<canvas ref="chartRef" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Chart as ChartJS,
ArcElement,
DoughnutController,
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions
} from 'chart.js'
ChartJS.register(
ArcElement,
DoughnutController,
Title,
Tooltip,
Legend
)
interface Props {
data: ChartData<'doughnut'>
options?: ChartOptions<'doughnut'>
height?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 300,
options: undefined
})
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'doughnut'> | null = null
const defaultOptions: ChartOptions<'doughnut'> = {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
color: 'rgb(107, 114, 128)',
usePointStyle: true,
padding: 16,
font: { size: 11 }
}
},
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
titleColor: 'rgb(243, 244, 246)',
bodyColor: 'rgb(243, 244, 246)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1,
callbacks: {
label: (context) => {
const value = context.raw as number
const total = (context.dataset.data as number[]).reduce((a, b) => a + b, 0)
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0'
return `${context.label}: $${value.toFixed(4)} (${percentage}%)`
}
}
}
}
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'doughnut',
data: props.data,
options: {
...defaultOptions,
...props.options
}
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.update('none')
}
}
onMounted(async () => {
await nextTick()
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
chart = null
}
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = { ...defaultOptions, ...props.options }
chart.update()
}
}, { deep: true })
</script>

View File

@@ -395,35 +395,6 @@
<!-- 趋势图表区域 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 请求次数和费用趋势 -->
<Card class="p-5">
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">
请求次数 / 费用趋势
</h4>
<div
v-if="loadingDaily"
class="flex items-center justify-center h-[280px]"
>
<Skeleton class="h-full w-full" />
</div>
<div
v-else
style="height: 280px;"
>
<LineChart
v-if="chartData.requests"
:data="chartData.requests"
:options="chartOptions.requests"
/>
<div
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
</div>
</div>
</Card>
<!-- 每日模型成本堆叠柱状图 -->
<Card class="p-5">
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">
@@ -452,6 +423,35 @@
</div>
</div>
</Card>
<!-- 提供商成本分布环形图 -->
<Card class="p-5">
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">
提供商成本分布
</h4>
<div
v-if="loadingDaily"
class="flex items-center justify-center h-[280px]"
>
<Skeleton class="h-full w-full" />
</div>
<div
v-else
style="height: 280px;"
>
<DoughnutChart
v-if="providerCostChartData.labels && providerCostChartData.labels.length > 0"
:data="providerCostChartData"
:options="providerCostChartOptions"
/>
<div
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
</div>
</div>
</Card>
</div>
<!-- 每日统计 -->
@@ -707,7 +707,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi, type DashboardStat, type DailyStat } from '@/api/dashboard'
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
import { announcementApi, type Announcement } from '@/api/announcements'
import {
Card,
@@ -722,8 +722,8 @@ import {
TableHead,
TableCell,
} from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import BarChart from '@/components/charts/BarChart.vue'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import {
Users,
Activity,
@@ -894,6 +894,7 @@ const tokenBreakdown = ref<{
const activeUsers = ref(0)
const dailyStats = ref<DailyStat[]>([])
const providerSummary = ref<ProviderSummary[]>([])
const selectedDays = ref(7)
const loadingDaily = ref(false)
const loading = ref(false)
@@ -946,41 +947,6 @@ const totalStats = computed(() => {
}
})
// 图表数据
const chartData = computed(() => {
if (dailyStats.value.length === 0) {
return { requests: null }
}
const labels = dailyStats.value.map(stat => formatDateForChart(stat.date))
const requests = dailyStats.value.map(stat => stat.requests)
const costs = dailyStats.value.map(stat => stat.cost)
return {
requests: {
labels,
datasets: [
{
label: '请求次数',
data: requests,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
yAxisID: 'y'
},
{
label: '费用 ($)',
data: costs,
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.4,
yAxisID: 'y1'
}
]
} as ChartData<'line'>
}
})
// 每日模型成本(堆叠柱状图)
const MODEL_COLORS = [
'rgba(59, 130, 246, 0.8)', // blue
@@ -1077,37 +1043,58 @@ const dailyModelCostChartOptions = computed<ChartOptions<'bar'>>(() => ({
}
}))
const chartOptions = computed(() => ({
requests: {
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: '请求次数', color: 'rgb(107, 114, 128)', font: { size: 10 } }
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: { display: true, text: '费用 ($)', color: 'rgb(107, 114, 128)', font: { size: 10 } },
grid: { drawOnChartArea: false }
// 提供商成本分布(环形图)
const PROVIDER_COLORS = [
'rgba(59, 130, 246, 0.8)', // blue
'rgba(239, 68, 68, 0.8)', // red
'rgba(16, 185, 129, 0.8)', // green
'rgba(245, 158, 11, 0.8)', // amber
'rgba(139, 92, 246, 0.8)', // purple
'rgba(6, 182, 212, 0.8)', // cyan
'rgba(132, 204, 22, 0.8)', // lime
'rgba(249, 115, 22, 0.8)' // orange
]
const providerCostChartData = computed<ChartData<'doughnut'>>(() => {
if (providerSummary.value.length === 0) {
return { labels: [], datasets: [] }
}
return {
labels: providerSummary.value.map(p => p.provider),
datasets: [{
data: providerSummary.value.map(p => p.cost),
backgroundColor: providerSummary.value.map((_, i) => PROVIDER_COLORS[i % PROVIDER_COLORS.length]),
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)'
}]
}
})
const providerCostChartOptions = computed<ChartOptions<'doughnut'>>(() => ({
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
font: { size: 10 },
boxWidth: 12,
padding: 8
}
},
plugins: {
legend: { labels: { font: { size: 11 } } },
tooltip: {
callbacks: {
label: (context: any) => {
const label = context.dataset.label || ''
const value = context.parsed.y
if (label.includes('费用')) return `${label}: $${value.toFixed(4)}`
return `${label}: ${value.toLocaleString()}`
}
tooltip: {
callbacks: {
label: (context) => {
const value = context.raw as number
const total = (context.dataset.data as number[]).reduce((a, b) => a + b, 0)
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0'
return `${context.label}: $${value.toFixed(4)} (${percentage}%)`
}
}
}
} as ChartOptions<'line'>
}
}))
onMounted(async () => {
@@ -1170,8 +1157,10 @@ async function loadDailyStats() {
try {
const response = await dashboardApi.getDailyStats(selectedDays.value)
dailyStats.value = response.daily_stats
providerSummary.value = response.provider_summary || []
} catch {
dailyStats.value = []
providerSummary.value = []
} finally {
loadingDaily.value = false
}