import { useStore } from '@nanostores/react'; import { ArcElement, BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Title, Tooltip, } from 'chart.js'; import classNames from 'classnames'; import { useMemo } from 'react'; import { Doughnut, Line, Pie } from 'react-chartjs-2'; import type { ChatUsageStats } from '~/.client/hooks/useChatUsage'; import { themeStore } from '~/stores/theme'; ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement); type ChatUsageVisualizationProps = { usageStats: ChatUsageStats; }; export function ChatUsageVisualization({ usageStats }: ChatUsageVisualizationProps) { const theme = useStore(themeStore); const isDarkMode = useMemo(() => theme === 'dark', [theme]); const getThemeColor = (varName: string): string => { if (typeof document !== 'undefined') { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); } return isDarkMode ? '#FFFFFF' : '#000000'; }; const chartColors = { grid: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', text: getThemeColor('--upage-elements-textPrimary'), textSecondary: getThemeColor('--upage-elements-textSecondary'), background: getThemeColor('--upage-elements-bg-depth-1'), accent: getThemeColor('--upage-elements-button-primary-text'), border: getThemeColor('--upage-elements-borderColor'), success: isDarkMode ? 'rgba(34, 197, 94, 0.7)' : 'rgba(34, 197, 94, 0.6)', // 绿色 successBorder: isDarkMode ? 'rgba(34, 197, 94, 0.9)' : 'rgba(34, 197, 94, 0.8)', failed: isDarkMode ? 'rgba(239, 68, 68, 0.7)' : 'rgba(239, 68, 68, 0.6)', // 红色 failedBorder: isDarkMode ? 'rgba(239, 68, 68, 0.9)' : 'rgba(239, 68, 68, 0.8)', pending: isDarkMode ? 'rgba(234, 179, 8, 0.7)' : 'rgba(234, 179, 8, 0.6)', // 黄色 pendingBorder: isDarkMode ? 'rgba(234, 179, 8, 0.9)' : 'rgba(234, 179, 8, 0.8)', aborted: isDarkMode ? 'rgba(107, 114, 128, 0.7)' : 'rgba(107, 114, 128, 0.6)', // 灰色 abortedBorder: isDarkMode ? 'rgba(107, 114, 128, 0.9)' : 'rgba(107, 114, 128, 0.8)', }; const getChartColors = (index: number) => { const baseColors = [ { base: getThemeColor('--upage-elements-button-primary-text'), }, { base: isDarkMode ? 'rgb(244, 114, 182)' : 'rgb(236, 72, 153)', }, { base: getThemeColor('--upage-elements-icon-success'), }, { base: isDarkMode ? 'rgb(250, 204, 21)' : 'rgb(234, 179, 8)', }, { base: isDarkMode ? 'rgb(56, 189, 248)' : 'rgb(14, 165, 233)', }, ]; const color = baseColors[index % baseColors.length].base; let r = 0, g = 0, b = 0; const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/); if (rgbMatch) { [, r, g, b] = rgbMatch.map(Number); } else if (rgbaMatch) { [, r, g, b] = rgbaMatch.map(Number); } else if (color.startsWith('#')) { const hex = color.slice(1); const bigint = parseInt(hex, 16); r = (bigint >> 16) & 255; g = (bigint >> 8) & 255; b = bigint & 255; } return { bg: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.7 : 0.5})`, border: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.9 : 0.8})`, }; }; const formatStatus = (status: string) => { switch (status) { case 'SUCCESS': return '成功'; case 'FAILED': return '失败'; case 'PENDING': return '处理中'; case 'ABORTED': return '中止'; default: return status; } }; const getStatusColor = (status: string, isBackground = true) => { switch (status) { case 'SUCCESS': return isBackground ? chartColors.success : chartColors.successBorder; case 'FAILED': return isBackground ? chartColors.failed : chartColors.failedBorder; case 'PENDING': return isBackground ? chartColors.pending : chartColors.pendingBorder; case 'ABORTED': return isBackground ? chartColors.aborted : chartColors.abortedBorder; default: return isBackground ? getChartColors(0).bg : getChartColors(0).border; } }; const statusDistributionData = { labels: usageStats.byStatus.map((status) => formatStatus(status.status)), datasets: [ { label: '请求状态', data: usageStats.byStatus.map((status) => status._count), backgroundColor: usageStats.byStatus.map((status) => getStatusColor(status.status)), borderColor: usageStats.byStatus.map((status) => getStatusColor(status.status, false)), borderWidth: 1, }, ], }; const tokenUsageData = { labels: ['输入 Token', '输出 Token', '缓存 Token'], datasets: [ { label: 'Token 使用量', data: [ usageStats.total._sum.inputTokens || 0, usageStats.total._sum.outputTokens || 0, usageStats.total._sum.cachedTokens || 0, ], backgroundColor: [getChartColors(1).bg, getChartColors(2).bg, getChartColors(4).bg], borderColor: [getChartColors(1).border, getChartColors(2).border, getChartColors(4).border], borderWidth: 1, }, ], }; const dailyRequestsData = { labels: usageStats.byDate.map((day) => day.date), datasets: [ { label: '每日请求数', data: usageStats.byDate.map((day) => day.count), borderColor: getChartColors(4).border, backgroundColor: 'transparent', borderWidth: 2, tension: 0.4, // 添加曲线平滑 pointBackgroundColor: getChartColors(4).border, pointBorderColor: chartColors.background, pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6, fill: false, }, { label: '每日 Token 用量', data: usageStats.byDate.map((day) => day.totalTokens), borderColor: getChartColors(2).border, backgroundColor: 'transparent', borderWidth: 2, tension: 0.4, pointBackgroundColor: getChartColors(2).border, pointBorderColor: chartColors.background, pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6, fill: false, }, ], }; const baseChartOptions = { responsive: true, maintainAspectRatio: false, color: chartColors.text, plugins: { legend: { position: 'top' as const, labels: { color: chartColors.text, font: { weight: 'bold' as const, size: 12, }, padding: 16, usePointStyle: true, }, }, title: { display: true, color: chartColors.text, font: { size: 16, weight: 'bold' as const, }, padding: 16, }, tooltip: { titleColor: chartColors.text, bodyColor: chartColors.text, backgroundColor: isDarkMode ? 'rgba(23, 23, 23, 0.8)' : 'rgba(255, 255, 255, 0.8)', borderColor: chartColors.border, borderWidth: 1, }, }, }; const statusPieOptions = { ...baseChartOptions, plugins: { ...baseChartOptions.plugins, title: { ...baseChartOptions.plugins.title, text: '请求状态分布', }, legend: { ...baseChartOptions.plugins.legend, position: 'right' as const, }, }, }; const doughnutOptions = { ...baseChartOptions, plugins: { ...baseChartOptions.plugins, title: { ...baseChartOptions.plugins.title, text: 'Token 使用分布', }, legend: { ...baseChartOptions.plugins.legend, position: 'right' as const, }, }, }; const lineChartOptions = { ...baseChartOptions, plugins: { ...baseChartOptions.plugins, title: { ...baseChartOptions.plugins.title, text: '每日请求统计', }, legend: { ...baseChartOptions.plugins.legend, onClick: function (_e: any, legendItem: any, legend: any) { const index = legendItem.datasetIndex; const ci = legend.chart; const datasets = ci.data.datasets; const visibleCount = datasets.reduce((count: number, _dataset: any, i: number) => { return count + (ci.getDatasetMeta(i).hidden ? 0 : 1); }, 0); const meta = ci.getDatasetMeta(index); const isCurrentlyVisible = !meta.hidden; if (isCurrentlyVisible && visibleCount === 1) { meta.hidden = true; datasets.forEach((_dataset: any, i: number) => { if (i !== index) { ci.getDatasetMeta(i).hidden = false; } }); } else if (visibleCount === 0) { datasets.forEach((_dataset: any, i: number) => { ci.getDatasetMeta(i).hidden = i !== index; }); } else { meta.hidden = !meta.hidden; } ci.update(); }, }, }, scales: { x: { grid: { color: chartColors.grid, drawBorder: false, }, border: { display: false, }, ticks: { color: chartColors.text, font: { weight: 500, }, maxRotation: 45, minRotation: 45, }, }, y: { grid: { color: chartColors.grid, drawBorder: false, }, border: { display: false, }, ticks: { color: chartColors.text, font: { weight: 500, }, }, beginAtZero: true, }, }, }; const cardClasses = classNames( 'p-6 rounded-lg shadow-sm', 'bg-upage-elements-bg-depth-1', 'border border-upage-elements-borderColor', ); return (