/*
Copyright (C) 2025 QuantumNous
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.
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, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { TrendingUp, RefreshCw, ChevronDown } from 'lucide-react';
import Sparkline from './Sparkline';
import { renderQuota, renderNumber } from '../../helpers';
const formatMD = (d) => {
const m = d.getMonth() + 1;
const day = d.getDate();
return `${m}/${day}`;
};
const ACCENT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b'];
const DOT_COLOR = '#3b82f6';
const RANGE_OPTIONS = [
{ key: 'today', label: '今天' },
{ key: 'yesterday', label: '昨天' },
{ key: '24h', label: '近 24 小时' },
{ key: '7d', label: '近 7 天' },
{ key: '14d', label: '近 14 天' },
{ key: '30d', label: '近 30 天' },
{ key: 'thisMonth', label: '本月' },
{ key: 'lastMonth', label: '上月' },
];
const BentoTrend = ({
dailyStats = [],
topModels = [],
className = '',
onRefresh,
loading = false,
rangeKey = '7d',
onRangeChange,
}) => {
const [rangeOpen, setRangeOpen] = useState(false);
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) => {
if (rangeRef.current && !rangeRef.current.contains(e.target)) {
setRangeOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selectedLabel = RANGE_OPTIONS.find((r) => r.key === rangeKey)?.label || '近 7 天';
const handleApply = () => {
if (customStart && customEnd) {
onRangeChange('custom');
}
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]);
const totalCalls = counts.reduce((s, v) => s + v, 0);
const totalCost = costs.reduce((s, v) => s + v, 0);
const avg = totalCalls > 0 ? (totalCalls / Math.max(counts.length, 1)).toFixed(1) : '0.0';
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 */}
Token 使用趋势
{selectedLabel}
{totalCalls} 次
· 日均{' '}
{avg}
花费
{totalCost > 0 ? renderQuota(totalCost) : '—'}
{/* Controls */}
{rangeOpen && (
{RANGE_OPTIONS.map((opt) => (
))}
)}
{/* 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) => {
const isHovered = tooltip?.dayIdx === i;
return (
{formatMD(d.date)}
);
})}
{/* Top 5 Models */}
{topModels.length > 0 && (
Top 5 模型
近 30 日
{topModels.map((m, i) => {
const pct = topMax > 0 ? (m.count / topMax) * 100 : 0;
const color = ACCENT_COLORS[i % ACCENT_COLORS.length];
return (
-
{i + 1}
{m.name}
{m.count}
);
})}
)}
);
};
export default BentoTrend;