时间范围改为下拉菜单:预设选项 + 自定义日期范围 + 应用按钮

This commit is contained in:
wangxiaoji 2026-06-30 11:14:25 +08:00
parent 6b73f5b6b9
commit 1ff561c32c
1 changed files with 138 additions and 29 deletions

View File

@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { TrendingUp, RefreshCw, ChevronDown } from 'lucide-react';
import Sparkline from './Sparkline';
import { renderQuota } from '../../helpers';
@ -28,13 +28,54 @@ const formatMD = (d) => {
return `${m}/${day}`;
};
const GRANULARITY = [
{ value: 'hour', label: '按小时' },
{ value: 'day', label: '按天' },
{ value: 'week', label: '按周' },
{ value: 'month', label: '按月' },
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 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 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 = [],
@ -42,7 +83,31 @@ const BentoTrend = ({
onRefresh,
loading = false,
}) => {
const [granularity, setGranularity] = useState('day');
const [rangeOpen, setRangeOpen] = useState(false);
const [selectedKey, setSelectedKey] = useState('7d');
const [customStart, setCustomStart] = useState('');
const [customEnd, setCustomEnd] = useState('');
const rangeRef = 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 === selectedKey)?.label || '近 7 天';
const handleApply = () => {
if (customStart && customEnd) {
setSelectedKey('custom');
}
setRangeOpen(false);
};
const counts = useMemo(() => dailyStats.map((d) => d.count), [dailyStats]);
const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]);
@ -64,7 +129,7 @@ const BentoTrend = ({
<div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>Token 使用趋势</div>
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
7
{selectedLabel}
<span className='font-semibold text-slate-900 dark:text-white'> {totalCalls}</span>
· 日均{' '}
<span className='font-semibold text-slate-900 dark:text-white'>{avg}</span>
@ -82,10 +147,71 @@ const BentoTrend = ({
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='inline-flex items-center gap-1 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-white/5 dark:text-white/70'>
7
</span>
<div className='flex items-center gap-2' ref={rangeRef}>
<div className='relative'>
<button
onClick={() => setRangeOpen(!rangeOpen)}
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:border-slate-300 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:bg-white/5'
>
{selectedLabel}
<ChevronDown size={12} className='text-slate-400 dark:text-white/40' />
</button>
{rangeOpen && (
<div className='absolute left-0 top-full z-50 mt-1 w-64 rounded-xl border border-slate-200 bg-white p-3 shadow-lg dark:border-white/10 dark:bg-neutral-900'>
<div className='space-y-0.5'>
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => {
setSelectedKey(opt.key);
setCustomStart('');
setCustomEnd('');
setRangeOpen(false);
}}
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'
: 'text-slate-600 hover:bg-slate-50 dark:text-white/60 dark:hover:bg-white/5'
}`}
>
{opt.label}
</button>
))}
</div>
<div className='my-2 border-t border-slate-100 dark:border-white/5' />
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<input
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'
placeholder='开始日期'
/>
<span className='text-xs text-slate-400'></span>
<input
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'
placeholder='结束日期'
/>
</div>
<button
onClick={handleApply}
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'
>
应用
</button>
</div>
</div>
)}
</div>
<button
onClick={onRefresh}
disabled={loading}
@ -95,23 +221,6 @@ const BentoTrend = ({
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className='flex items-center gap-1.5'>
<span className='text-[10px] text-slate-400 dark:text-white/40'>粒度</span>
<div className='relative'>
<select
value={granularity}
onChange={(e) => setGranularity(e.target.value)}
className='appearance-none rounded-lg border border-slate-200 bg-white py-1 pl-2.5 pr-7 text-xs text-slate-700 outline-none transition-colors hover:border-slate-300 focus:border-slate-400 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:bg-white/5 dark:focus:border-white/30'
>
{GRANULARITY.map((g) => (
<option key={g.value} value={g.value} className='bg-white text-slate-900 dark:bg-neutral-900 dark:text-white'>
{g.label}
</option>
))}
</select>
<ChevronDown size={12} className='pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-slate-400 dark:text-white/40' />
</div>
</div>
</div>
<div className='flex min-h-[80px] flex-1 items-end text-blue-500'>