Compare commits
2 Commits
316fec8ac8
...
65c28251f9
| Author | SHA1 | Date |
|---|---|---|
|
|
65c28251f9 | |
|
|
d2913ff260 |
|
|
@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
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 { TrendingUp, RefreshCw, ChevronDown } from 'lucide-react';
|
||||||
import BarChart from './BarChart';
|
import Sparkline from './Sparkline';
|
||||||
import { renderQuota } from '../../helpers';
|
import { renderQuota, renderNumber } from '../../helpers';
|
||||||
|
|
||||||
const formatMD = (d) => {
|
const formatMD = (d) => {
|
||||||
const m = d.getMonth() + 1;
|
const m = d.getMonth() + 1;
|
||||||
|
|
@ -28,12 +28,8 @@ const formatMD = (d) => {
|
||||||
return `${m}/${day}`;
|
return `${m}/${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toDateStr = (d) => {
|
const ACCENT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b'];
|
||||||
const y = d.getFullYear();
|
const DOT_COLOR = '#3b82f6';
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RANGE_OPTIONS = [
|
const RANGE_OPTIONS = [
|
||||||
{ key: 'today', label: '今天' },
|
{ key: 'today', label: '今天' },
|
||||||
|
|
@ -46,48 +42,21 @@ const RANGE_OPTIONS = [
|
||||||
{ key: 'lastMonth', label: '上月' },
|
{ 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 = ({
|
const BentoTrend = ({
|
||||||
dailyStats = [],
|
dailyStats = [],
|
||||||
topModels = [],
|
topModels = [],
|
||||||
className = '',
|
className = '',
|
||||||
onRefresh,
|
onRefresh,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
rangeKey = '7d',
|
||||||
|
onRangeChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [rangeOpen, setRangeOpen] = useState(false);
|
const [rangeOpen, setRangeOpen] = useState(false);
|
||||||
const [selectedKey, setSelectedKey] = useState('7d');
|
|
||||||
const [customStart, setCustomStart] = useState('');
|
const [customStart, setCustomStart] = useState('');
|
||||||
const [customEnd, setCustomEnd] = useState('');
|
const [customEnd, setCustomEnd] = useState('');
|
||||||
|
const [tooltip, setTooltip] = useState(null); // { dayIdx, x, y } or null
|
||||||
const rangeRef = useRef(null);
|
const rangeRef = useRef(null);
|
||||||
|
const chartRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
|
|
@ -99,15 +68,28 @@ const BentoTrend = ({
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedLabel = RANGE_OPTIONS.find((r) => r.key === selectedKey)?.label || '近 7 天';
|
const selectedLabel = RANGE_OPTIONS.find((r) => r.key === rangeKey)?.label || '近 7 天';
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
if (customStart && customEnd) {
|
if (customStart && customEnd) {
|
||||||
setSelectedKey('custom');
|
onRangeChange('custom');
|
||||||
}
|
}
|
||||||
setRangeOpen(false);
|
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 counts = useMemo(() => dailyStats.map((d) => d.count), [dailyStats]);
|
||||||
const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]);
|
const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]);
|
||||||
|
|
||||||
|
|
@ -117,13 +99,17 @@ const BentoTrend = ({
|
||||||
const peak = counts.length > 0 ? Math.max(...counts) : 0;
|
const peak = counts.length > 0 ? Math.max(...counts) : 0;
|
||||||
const topMax = topModels[0]?.count || 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
className={`relative flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||||
>
|
>
|
||||||
|
{/* Header */}
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||||
<TrendingUp size={18} />
|
<TrendingUp size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -140,12 +126,13 @@ const BentoTrend = ({
|
||||||
<div className='text-[10px] uppercase tracking-wider text-slate-400 font-mono dark:text-white/30'>
|
<div className='text-[10px] uppercase tracking-wider text-slate-400 font-mono dark:text-white/30'>
|
||||||
花费
|
花费
|
||||||
</div>
|
</div>
|
||||||
<div className='font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-white'>
|
<div className='font-mono text-sm font-semibold tabular-nums text-emerald-600 dark:text-emerald-400'>
|
||||||
{totalCost > 0 ? renderQuota(totalCost) : '—'}
|
{totalCost > 0 ? renderQuota(totalCost) : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex items-center gap-2' ref={rangeRef}>
|
<div className='flex items-center gap-2' ref={rangeRef}>
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
|
|
@ -164,14 +151,14 @@ const BentoTrend = ({
|
||||||
<button
|
<button
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedKey(opt.key);
|
onRangeChange(opt.key);
|
||||||
setCustomStart('');
|
setCustomStart('');
|
||||||
setCustomEnd('');
|
setCustomEnd('');
|
||||||
setRangeOpen(false);
|
setRangeOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
|
className={`w-full rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
|
||||||
selectedKey === opt.key
|
rangeKey === 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'
|
: 'text-slate-600 hover:bg-slate-50 dark:text-white/60 dark:hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -188,7 +175,7 @@ const BentoTrend = ({
|
||||||
type='date'
|
type='date'
|
||||||
value={customStart}
|
value={customStart}
|
||||||
onChange={(e) => setCustomStart(e.target.value)}
|
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='开始日期'
|
placeholder='开始日期'
|
||||||
/>
|
/>
|
||||||
<span className='text-xs text-slate-400'>—</span>
|
<span className='text-xs text-slate-400'>—</span>
|
||||||
|
|
@ -196,14 +183,14 @@ const BentoTrend = ({
|
||||||
type='date'
|
type='date'
|
||||||
value={customEnd}
|
value={customEnd}
|
||||||
onChange={(e) => setCustomEnd(e.target.value)}
|
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='结束日期'
|
placeholder='结束日期'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
disabled={!customStart || !customEnd}
|
disabled={!customStart || !customEnd}
|
||||||
className='w-full rounded-lg bg-slate-900 py-1.5 text-xs font-medium text-white transition-colors hover:bg-slate-800 disabled:opacity-40 dark:bg-white dark:text-neutral-900 dark:hover:bg-white/90'
|
className='w-full rounded-lg bg-blue-600 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||||
>
|
>
|
||||||
应用
|
应用
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -221,30 +208,83 @@ const BentoTrend = ({
|
||||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Peak / total indicators */}
|
||||||
|
<div className='hidden items-center gap-3 sm:flex'>
|
||||||
|
<div className='flex items-center gap-1.5 text-[11px] text-slate-400 dark:text-white/40'>
|
||||||
|
<span className='inline-block h-1.5 w-1.5 rounded-full bg-blue-500' />
|
||||||
|
峰值 <span className='font-semibold text-slate-700 dark:text-white/80'>{peak}</span> 次
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-1.5 text-[11px] text-slate-400 dark:text-white/40'>
|
||||||
|
合计 <span className='font-semibold text-slate-700 dark:text-white/80'>{totalCalls}</span> 次
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex min-h-[80px] flex-1 items-end text-blue-500'>
|
{/* Chart area */}
|
||||||
<BarChart data={counts} height={80} barW={26} />
|
<div ref={chartRef} className='relative flex min-h-[80px] flex-1 items-end text-blue-500'>
|
||||||
|
<Sparkline data={counts} height={80} stroke={DOT_COLOR} onHover={handleHover} />
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{tooltip && hoverDay && (
|
||||||
|
<div
|
||||||
|
className='pointer-events-none absolute z-50 min-w-[180px] rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-lg dark:border-white/10 dark:bg-neutral-900'
|
||||||
|
style={{ left: tooltip.x, top: tooltip.y, transform: 'translate(-50%, -100%)' }}
|
||||||
|
>
|
||||||
|
<div className='mb-1 text-xs font-semibold text-slate-900 dark:text-white'>
|
||||||
|
{hoverDay.date.getMonth() + 1}月{hoverDay.date.getDate()}日
|
||||||
|
</div>
|
||||||
|
<div className='mb-2 flex items-center gap-3 text-[11px] text-slate-500 dark:text-white/50'>
|
||||||
|
<span>调用 <b className='text-slate-900 dark:text-white'>{hoverDay.count}</b> 次</span>
|
||||||
|
<span>花费 <b className='text-slate-900 dark:text-white'>{renderQuota(hoverDay.cost)}</b></span>
|
||||||
|
</div>
|
||||||
|
{hoverModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className='mb-1.5 h-px bg-slate-100 dark:bg-white/5' />
|
||||||
|
<div className='space-y-1'>
|
||||||
|
{hoverModels.slice(0, 5).map(([name, count], i) => (
|
||||||
|
<div key={name} className='flex items-center gap-2 text-[11px]'>
|
||||||
|
<span
|
||||||
|
className='inline-block h-1.5 w-1.5 rounded-full shrink-0'
|
||||||
|
style={{ backgroundColor: ACCENT_COLORS[i % ACCENT_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className='min-w-0 flex-1 truncate text-slate-600 dark:text-white/70'>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className='shrink-0 font-mono tabular-nums text-slate-400 dark:text-white/40'>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date labels */}
|
||||||
<div className='flex items-center justify-between text-[11px]'>
|
<div className='flex items-center justify-between text-[11px]'>
|
||||||
<div className='flex w-full items-center justify-between tabular-nums'>
|
<div className='flex w-full items-center justify-between tabular-nums'>
|
||||||
{dailyStats.map((d, i) => (
|
{dailyStats.map((d, i) => {
|
||||||
<span
|
const isHovered = tooltip?.dayIdx === i;
|
||||||
key={i}
|
return (
|
||||||
className='text-center text-xs text-slate-500 dark:text-white/50'
|
<span
|
||||||
title={`${d.date.getMonth() + 1}月${d.date.getDate()}日 ${d.count} 次`}
|
key={i}
|
||||||
>
|
className={`text-center text-xs transition-colors ${
|
||||||
{formatMD(d.date)}
|
isHovered
|
||||||
</span>
|
? 'font-semibold text-blue-600 dark:text-blue-400'
|
||||||
))}
|
: 'text-slate-500 dark:text-white/50'
|
||||||
</div>
|
}`}
|
||||||
<div className='ml-4 shrink-0 text-slate-500 tabular-nums dark:text-white/50'>
|
>
|
||||||
峰值{' '}
|
{formatMD(d.date)}
|
||||||
<span className='font-semibold text-slate-900 dark:text-white'>{peak}</span> 次
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top 5 Models */}
|
||||||
{topModels.length > 0 && (
|
{topModels.length > 0 && (
|
||||||
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
|
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||||
<div className='mb-2 flex items-center justify-between text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
<div className='mb-2 flex items-center justify-between text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||||
|
|
@ -254,13 +294,14 @@ const BentoTrend = ({
|
||||||
<ul className='space-y-1.5'>
|
<ul className='space-y-1.5'>
|
||||||
{topModels.map((m, i) => {
|
{topModels.map((m, i) => {
|
||||||
const pct = topMax > 0 ? (m.count / topMax) * 100 : 0;
|
const pct = topMax > 0 ? (m.count / topMax) * 100 : 0;
|
||||||
|
const color = ACCENT_COLORS[i % ACCENT_COLORS.length];
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={m.name + i}
|
key={m.name + i}
|
||||||
className='flex items-center gap-3 text-xs'
|
className='flex items-center gap-3 text-xs'
|
||||||
title={m.name}
|
title={m.name}
|
||||||
>
|
>
|
||||||
<span className='w-3 shrink-0 text-right font-mono text-[10px] text-slate-300 tabular-nums dark:text-white/30'>
|
<span className='w-3 shrink-0 text-right font-mono text-[10px] tabular-nums' style={{ color }}>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className='min-w-0 flex-1 truncate text-slate-700 dark:text-white/80'>
|
<span className='min-w-0 flex-1 truncate text-slate-700 dark:text-white/80'>
|
||||||
|
|
@ -268,8 +309,8 @@ const BentoTrend = ({
|
||||||
</span>
|
</span>
|
||||||
<div className='hidden h-1 w-24 overflow-hidden rounded-full bg-slate-100 sm:block dark:bg-white/5'>
|
<div className='hidden h-1 w-24 overflow-hidden rounded-full bg-slate-100 sm:block dark:bg-white/5'>
|
||||||
<div
|
<div
|
||||||
className='h-full rounded-full bg-slate-300 dark:bg-white/30'
|
className='h-full rounded-full'
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className='w-12 shrink-0 text-right font-mono tabular-nums text-slate-500 dark:text-white/60'>
|
<span className='w-12 shrink-0 text-right font-mono tabular-nums text-slate-500 dark:text-white/60'>
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,94 @@
|
||||||
/*
|
import React, { useState, useCallback } from 'react';
|
||||||
Copyright (C) 2025 QuantumNous
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
const Sparkline = ({ data = [], height = 80, stroke = 'currentColor', onHover }) => {
|
||||||
it under the terms of the GNU Affero General Public License as
|
const [hoverIdx, setHoverIdx] = useState(null);
|
||||||
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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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;
|
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 (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width='100%'
|
width='100%'
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
viewBox={`0 0 ${w} ${height}`}
|
||||||
preserveAspectRatio='none'
|
preserveAspectRatio='none'
|
||||||
className={className}
|
className='cursor-crosshair'
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id='sparkline-gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
|
<linearGradient id='sg' x1='0%' y1='0%' x2='0%' y2='100%'>
|
||||||
<stop offset='0%' stopColor={stroke} stopOpacity='0.18' />
|
<stop offset='0%' stopColor={stroke} stopOpacity='0.12' />
|
||||||
<stop offset='100%' stopColor={stroke} stopOpacity='0' />
|
<stop offset='100%' stopColor={stroke} stopOpacity='0' />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<polygon points={areaPoints} fill='url(#sparkline-gradient)' />
|
|
||||||
|
{/* hover vertical line */}
|
||||||
|
{hoverIdx != null && (
|
||||||
|
<line
|
||||||
|
x1={xs[hoverIdx].toFixed(1)}
|
||||||
|
y1={0}
|
||||||
|
x2={xs[hoverIdx].toFixed(1)}
|
||||||
|
y2={height}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth='1'
|
||||||
|
strokeDasharray='3 3'
|
||||||
|
opacity='0.3'
|
||||||
|
vectorEffect='non-scaling-stroke'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<polygon points={areaPoints} fill='url(#sg)' />
|
||||||
<polyline
|
<polyline
|
||||||
points={points}
|
points={points}
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke={stroke}
|
stroke={stroke}
|
||||||
strokeWidth='2'
|
strokeWidth='2.5'
|
||||||
strokeLinecap='round'
|
strokeLinecap='round'
|
||||||
strokeLinejoin='round'
|
strokeLinejoin='round'
|
||||||
vectorEffect='non-scaling-stroke'
|
vectorEffect='non-scaling-stroke'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{data.map((v, i) => {
|
||||||
|
const active = hoverIdx === i;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={xs[i].toFixed(1)}
|
||||||
|
cy={ys[i].toFixed(1)}
|
||||||
|
r={active ? 5 : 3}
|
||||||
|
fill={active ? stroke : 'white'}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={active ? 3 : 2}
|
||||||
|
vectorEffect='non-scaling-stroke'
|
||||||
|
style={{ transition: 'r 0.15s, fill 0.15s' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { UserContext } from '../../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { useUserMessageUnreadCount } from '../../hooks/common/useUserMessageUnreadCount';
|
import { useUserMessageUnreadCount } from '../../hooks/common/useUserMessageUnreadCount';
|
||||||
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
||||||
|
|
@ -30,11 +30,23 @@ import BentoTrend from './BentoTrend';
|
||||||
import MonthlyForecast from './MonthlyForecast';
|
import MonthlyForecast from './MonthlyForecast';
|
||||||
import RecentActivity from './RecentActivity';
|
import RecentActivity from './RecentActivity';
|
||||||
|
|
||||||
|
const RANGE_DAYS = {
|
||||||
|
today: 1,
|
||||||
|
yesterday: 1,
|
||||||
|
'24h': 1,
|
||||||
|
'7d': 7,
|
||||||
|
'14d': 14,
|
||||||
|
'30d': 30,
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [userState] = useContext(UserContext);
|
const [userState] = useContext(UserContext);
|
||||||
const user = userState?.user;
|
const user = userState?.user;
|
||||||
|
const [rangeKey, setRangeKey] = useState('7d');
|
||||||
|
|
||||||
const dashboard = useDashboardData(userState);
|
const rangeDays = RANGE_DAYS[rangeKey] || 7;
|
||||||
|
|
||||||
|
const dashboard = useDashboardData(userState, rangeDays);
|
||||||
const { unreadCount } = useUserMessageUnreadCount(user);
|
const { unreadCount } = useUserMessageUnreadCount(user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -66,6 +78,8 @@ const Dashboard = () => {
|
||||||
topModels={dashboard.topModels}
|
topModels={dashboard.topModels}
|
||||||
onRefresh={dashboard.refresh}
|
onRefresh={dashboard.refresh}
|
||||||
loading={dashboard.loading}
|
loading={dashboard.loading}
|
||||||
|
rangeKey={rangeKey}
|
||||||
|
onRangeChange={setRangeKey}
|
||||||
className='md:col-span-2'
|
className='md:col-span-2'
|
||||||
/>
|
/>
|
||||||
<MonthlyForecast
|
<MonthlyForecast
|
||||||
|
|
@ -88,4 +102,4 @@ const Dashboard = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const isSuccess = (l) =>
|
||||||
const sum = (arr, key) =>
|
const sum = (arr, key) =>
|
||||||
arr.reduce((s, l) => s + (Number(l[key]) || 0), 0);
|
arr.reduce((s, l) => s + (Number(l[key]) || 0), 0);
|
||||||
|
|
||||||
export const useDashboardData = (userState) => {
|
export const useDashboardData = (userState, rangeDays = 7) => {
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [recentLogs, setRecentLogs] = useState([]);
|
const [recentLogs, setRecentLogs] = useState([]);
|
||||||
|
|
@ -138,7 +138,7 @@ export const useDashboardData = (userState) => {
|
||||||
const dailyStats = useMemo(() => {
|
const dailyStats = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const days = [];
|
const days = [];
|
||||||
const n = 7;
|
const n = rangeDays;
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||||
const dayStart = Math.floor(d.getTime() / 1000);
|
const dayStart = Math.floor(d.getTime() / 1000);
|
||||||
|
|
@ -146,15 +146,21 @@ export const useDashboardData = (userState) => {
|
||||||
const dayLogs = recentLogs.filter(
|
const dayLogs = recentLogs.filter(
|
||||||
(l) => (l?.created_at || 0) >= dayStart && (l?.created_at || 0) < dayEnd && isSuccess(l),
|
(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({
|
days.push({
|
||||||
date: d,
|
date: d,
|
||||||
count: dayLogs.length,
|
count: dayLogs.length,
|
||||||
cost: sum(dayLogs, 'quota'),
|
cost: sum(dayLogs, 'quota'),
|
||||||
tokens: sum(dayLogs, 'token_used'),
|
tokens: sum(dayLogs, 'token_used'),
|
||||||
|
models,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return days;
|
return days;
|
||||||
}, [recentLogs]);
|
}, [recentLogs, rangeDays]);
|
||||||
|
|
||||||
// ========== MoM delta (this month cost vs last month) ==========
|
// ========== MoM delta (this month cost vs last month) ==========
|
||||||
const monthDelta = useMemo(() => {
|
const monthDelta = useMemo(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue