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

@@ -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>