mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user