diff --git a/web/src/components/dashboard/BentoTrend.jsx b/web/src/components/dashboard/BentoTrend.jsx
index c93d94e..9f5cde0 100644
--- a/web/src/components/dashboard/BentoTrend.jsx
+++ b/web/src/components/dashboard/BentoTrend.jsx
@@ -17,10 +17,10 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { useMemo, useState, useRef, useEffect } from 'react';
+import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { TrendingUp, RefreshCw, ChevronDown } from 'lucide-react';
-import BarChart from './BarChart';
-import { renderQuota } from '../../helpers';
+import Sparkline from './Sparkline';
+import { renderQuota, renderNumber } from '../../helpers';
const formatMD = (d) => {
const m = d.getMonth() + 1;
@@ -28,12 +28,8 @@ const formatMD = (d) => {
return `${m}/${day}`;
};
-const toDateStr = (d) => {
- const y = d.getFullYear();
- const m = String(d.getMonth() + 1).padStart(2, '0');
- const day = String(d.getDate()).padStart(2, '0');
- return `${y}-${m}-${day}`;
-};
+const ACCENT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b'];
+const DOT_COLOR = '#3b82f6';
const RANGE_OPTIONS = [
{ key: 'today', label: '今天' },
@@ -46,36 +42,6 @@ const RANGE_OPTIONS = [
{ key: 'lastMonth', label: '上月' },
];
-const getRangeDates = (key) => {
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- switch (key) {
- case 'today':
- return { start: today, end: now };
- case 'yesterday': {
- const y = new Date(today.getTime() - 86400000);
- return { start: y, end: new Date(today.getTime() - 1000) };
- }
- case '24h':
- return { start: new Date(now.getTime() - 86400000), end: now };
- case '7d':
- return { start: new Date(today.getTime() - 6 * 86400000), end: now };
- case '14d':
- return { start: new Date(today.getTime() - 13 * 86400000), end: now };
- case '30d':
- return { start: new Date(today.getTime() - 29 * 86400000), end: now };
- case 'thisMonth':
- return { start: new Date(now.getFullYear(), now.getMonth(), 1), end: now };
- case 'lastMonth': {
- const lmStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
- const lmEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
- return { start: lmStart, end: lmEnd };
- }
- default:
- return { start: new Date(today.getTime() - 6 * 86400000), end: now };
- }
-};
-
const BentoTrend = ({
dailyStats = [],
topModels = [],
@@ -87,7 +53,9 @@ const BentoTrend = ({
const [selectedKey, setSelectedKey] = useState('7d');
const [customStart, setCustomStart] = useState('');
const [customEnd, setCustomEnd] = useState('');
+ const [tooltip, setTooltip] = useState(null); // { dayIdx, x, y } or null
const rangeRef = useRef(null);
+ const chartRef = useRef(null);
useEffect(() => {
const handler = (e) => {
@@ -108,6 +76,19 @@ const BentoTrend = ({
setRangeOpen(false);
};
+ const handleHover = useCallback((idx, cx, cy) => {
+ if (idx < 0 || idx >= dailyStats.length) {
+ setTooltip(null);
+ return;
+ }
+ const rect = chartRef.current?.getBoundingClientRect();
+ setTooltip({
+ dayIdx: idx,
+ x: rect ? cx - rect.left : cx,
+ y: rect ? cy - rect.top - 120 : cy - 120,
+ });
+ }, [dailyStats.length]);
+
const counts = useMemo(() => dailyStats.map((d) => d.count), [dailyStats]);
const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]);
@@ -117,13 +98,17 @@ const BentoTrend = ({
const peak = counts.length > 0 ? Math.max(...counts) : 0;
const topMax = topModels[0]?.count || 0;
+ const hoverDay = tooltip ? dailyStats[tooltip.dayIdx] : null;
+ const hoverModels = hoverDay ? Object.entries(hoverDay.models || {}).sort((a, b) => b[1] - a[1]) : [];
+
return (
+ {/* Header */}
-
+
@@ -140,12 +125,13 @@ const BentoTrend = ({
花费
-
+
{totalCost > 0 ? renderQuota(totalCost) : '—'}
+ {/* Controls */}
@@ -171,7 +157,7 @@ const BentoTrend = ({
}}
className={`w-full rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
selectedKey === opt.key
- ? 'bg-slate-100 font-medium text-slate-900 dark:bg-white/10 dark:text-white'
+ ? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
: 'text-slate-600 hover:bg-slate-50 dark:text-white/60 dark:hover:bg-white/5'
}`}
>
@@ -188,7 +174,7 @@ const BentoTrend = ({
type='date'
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
- className='flex-1 rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 outline-none focus:border-slate-400 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:focus:border-white/30'
+ className='flex-1 rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 outline-none focus:border-blue-400 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70'
placeholder='开始日期'
/>
—
@@ -196,14 +182,14 @@ const BentoTrend = ({
type='date'
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
- className='flex-1 rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 outline-none focus:border-slate-400 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:focus:border-white/30'
+ className='flex-1 rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 outline-none focus:border-blue-400 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70'
placeholder='结束日期'
/>
@@ -221,30 +207,83 @@ const BentoTrend = ({
+
+ {/* Peak / total indicators */}
+
+
+
+ 峰值 {peak} 次
+
+
+ 合计 {totalCalls} 次
+
+
-
-
+ {/* Chart area */}
+
+
+
+ {/* Tooltip */}
+ {tooltip && hoverDay && (
+
+
+ {hoverDay.date.getMonth() + 1}月{hoverDay.date.getDate()}日
+
+
+ 调用 {hoverDay.count} 次
+ 花费 {renderQuota(hoverDay.cost)}
+
+ {hoverModels.length > 0 && (
+ <>
+
+
+ {hoverModels.slice(0, 5).map(([name, count], i) => (
+
+
+
+ {name}
+
+
+ {count}
+
+
+ ))}
+
+ >
+ )}
+
+ )}
+ {/* Date labels */}
- {dailyStats.map((d, i) => (
-
- {formatMD(d.date)}
-
- ))}
-
-
- 峰值{' '}
- {peak} 次
+ {dailyStats.map((d, i) => {
+ const isHovered = tooltip?.dayIdx === i;
+ return (
+
+ {formatMD(d.date)}
+
+ );
+ })}
+ {/* Top 5 Models */}
{topModels.length > 0 && (
@@ -254,13 +293,14 @@ const BentoTrend = ({
{topModels.map((m, i) => {
const pct = topMax > 0 ? (m.count / topMax) * 100 : 0;
+ const color = ACCENT_COLORS[i % ACCENT_COLORS.length];
return (
-
-
+
{i + 1}
@@ -268,8 +308,8 @@ const BentoTrend = ({
diff --git a/web/src/components/dashboard/Sparkline.jsx b/web/src/components/dashboard/Sparkline.jsx
index 1b7929a..c01cc52 100644
--- a/web/src/components/dashboard/Sparkline.jsx
+++ b/web/src/components/dashboard/Sparkline.jsx
@@ -1,68 +1,94 @@
-/*
-Copyright (C) 2025 QuantumNous
+import React, { useState, useCallback } from 'react';
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
+const Sparkline = ({ data = [], height = 80, stroke = 'currentColor', onHover }) => {
+ const [hoverIdx, setHoverIdx] = useState(null);
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see .
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React from 'react';
-
-const Sparkline = ({
- data = [],
- width = 200,
- height = 40,
- stroke = 'currentColor',
- className = '',
-}) => {
if (!data || data.length === 0) return null;
- const max = Math.max(...data, 1);
- const step = data.length > 1 ? width / (data.length - 1) : 0;
- const points = data
- .map((v, i) => {
- const x = i * step;
- const y = height - (v / max) * (height - 4) - 2;
- return `${x.toFixed(1)},${y.toFixed(1)}`;
- })
- .join(' ');
- const areaPoints = `0,${height} ${points} ${width},${height}`;
+ const max = Math.max(...data, 1);
+ const n = data.length;
+ const w = 400;
+
+ const xs = data.map((_, i) => (n > 1 ? (i / (n - 1)) * w : w / 2));
+ const ys = data.map((v) => height - (v / max) * (height - 8) - 4);
+
+ const points = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(' ');
+ const areaPoints = `0,${height} ${points} ${w},${height}`;
+
+ const handleMouseMove = useCallback((e) => {
+ const svg = e.currentTarget;
+ const rect = svg.getBoundingClientRect();
+ const mx = ((e.clientX - rect.left) / rect.width) * w;
+ const dxs = xs.map((x) => Math.abs(x - mx));
+ const idx = dxs.indexOf(Math.min(...dxs));
+ setHoverIdx(idx);
+ onHover?.(idx, e.clientX, e.clientY);
+ }, [xs, w, onHover]);
+
+ const handleMouseLeave = useCallback(() => {
+ setHoverIdx(null);
+ onHover?.(-1);
+ }, [onHover]);
return (
);
};
diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js
index a36542e..e8918ab 100644
--- a/web/src/hooks/dashboard/useDashboardData.js
+++ b/web/src/hooks/dashboard/useDashboardData.js
@@ -146,11 +146,17 @@ export const useDashboardData = (userState) => {
const dayLogs = recentLogs.filter(
(l) => (l?.created_at || 0) >= dayStart && (l?.created_at || 0) < dayEnd && isSuccess(l),
);
+ const models = {};
+ dayLogs.forEach((l) => {
+ const name = l?.model_name || '未知模型';
+ models[name] = (models[name] || 0) + 1;
+ });
days.push({
date: d,
count: dayLogs.length,
cost: sum(dayLogs, 'quota'),
tokens: sum(dayLogs, 'token_used'),
+ models,
});
}
return days;