From d2913ff2607482ee29430477c5de412b0a8ca253 Mon Sep 17 00:00:00 2001 From: wxj Date: Tue, 30 Jun 2026 14:24:25 +0800 Subject: [PATCH] =?UTF-8?q?Token=20=E4=BD=BF=E7=94=A8=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E5=8D=87=E7=BA=A7=EF=BC=9A=E6=82=AC=E5=81=9C?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=20+=20=E6=A8=A1=E5=9E=8B=E6=98=8E=E7=BB=86?= =?UTF-8?q?=20+=20=E8=A7=86=E8=A7=89=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/dashboard/BentoTrend.jsx | 166 ++++++++++++-------- web/src/components/dashboard/Sparkline.jsx | 110 ++++++++----- web/src/hooks/dashboard/useDashboardData.js | 6 + 3 files changed, 177 insertions(+), 105 deletions(-) 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 ( - - + + - + + {/* hover vertical line */} + {hoverIdx != null && ( + + )} + + + + {data.map((v, i) => { + const active = hoverIdx === i; + 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;