Compare commits
7 Commits
4635f1c85b
...
316fec8ac8
| Author | SHA1 | Date |
|---|---|---|
|
|
316fec8ac8 | |
|
|
8a9e0226d0 | |
|
|
31f4d247fe | |
|
|
66e61ef51b | |
|
|
1ff561c32c | |
|
|
6b73f5b6b9 | |
|
|
dd5474f500 |
|
|
@ -6,6 +6,7 @@
|
||||||
"name": "react-template",
|
"name": "react-template",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.63.1",
|
"@douyinfe/semi-icons": "^2.63.1",
|
||||||
|
"@douyinfe/semi-illustrations": "^2.100.0",
|
||||||
"@douyinfe/semi-ui": "^2.69.1",
|
"@douyinfe/semi-ui": "^2.69.1",
|
||||||
"@lobehub/icons": "^2.0.0",
|
"@lobehub/icons": "^2.0.0",
|
||||||
"@visactor/react-vchart": "~1.8.8",
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
|
|
@ -171,7 +172,7 @@
|
||||||
|
|
||||||
"@douyinfe/semi-icons": ["@douyinfe/semi-icons@2.94.0", "https://registry.npmmirror.com/@douyinfe/semi-icons/-/semi-icons-2.94.0.tgz", { "dependencies": { "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-Hn6Bm8umITAxw5xZlslrFXf+UBUWU5OD9YSNDN46H+OKnZFwbnYV628DBWSewBrvCItxUGMXHnLal4h1ZIb6qQ=="],
|
"@douyinfe/semi-icons": ["@douyinfe/semi-icons@2.94.0", "https://registry.npmmirror.com/@douyinfe/semi-icons/-/semi-icons-2.94.0.tgz", { "dependencies": { "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-Hn6Bm8umITAxw5xZlslrFXf+UBUWU5OD9YSNDN46H+OKnZFwbnYV628DBWSewBrvCItxUGMXHnLal4h1ZIb6qQ=="],
|
||||||
|
|
||||||
"@douyinfe/semi-illustrations": ["@douyinfe/semi-illustrations@2.94.0", "https://registry.npmmirror.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.94.0.tgz", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-nXi4bysh4GsO/DxoNUmCd5NlRYeYZb7pqXcGZFj+PpxbPoG7Qn4jqhrUUFrJNppvven9nS0SgabDAHnT3o+jaQ=="],
|
"@douyinfe/semi-illustrations": ["@douyinfe/semi-illustrations@2.100.0", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-SN7plpE328WGBohLHOVpYe6FwWSO6RLS7Xf6LhqEdtarwK52ircr4C/b+OyRqIwcLOzRYMgIoqcWnAQGmowcUw=="],
|
||||||
|
|
||||||
"@douyinfe/semi-json-viewer-core": ["@douyinfe/semi-json-viewer-core@2.94.0", "https://registry.npmmirror.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.94.0.tgz", { "dependencies": { "jsonc-parser": "^3.3.1" } }, "sha512-pi4+Oi2zvsc0S3smWz58RNbfB73NgfO68fntKsVbLV1lTwNFuDxSswT8n8SXDZhtKoYgPVMmE3eMTc2nkXnvng=="],
|
"@douyinfe/semi-json-viewer-core": ["@douyinfe/semi-json-viewer-core@2.94.0", "https://registry.npmmirror.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.94.0.tgz", { "dependencies": { "jsonc-parser": "^3.3.1" } }, "sha512-pi4+Oi2zvsc0S3smWz58RNbfB73NgfO68fntKsVbLV1lTwNFuDxSswT8n8SXDZhtKoYgPVMmE3eMTc2nkXnvng=="],
|
||||||
|
|
||||||
|
|
@ -2261,6 +2262,8 @@
|
||||||
|
|
||||||
"@ant-design/cssinjs-utils/@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
|
"@ant-design/cssinjs-utils/@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
|
||||||
|
|
||||||
|
"@douyinfe/semi-ui/@douyinfe/semi-illustrations": ["@douyinfe/semi-illustrations@2.94.0", "https://registry.npmmirror.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.94.0.tgz", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-nXi4bysh4GsO/DxoNUmCd5NlRYeYZb7pqXcGZFj+PpxbPoG7Qn4jqhrUUFrJNppvven9nS0SgabDAHnT3o+jaQ=="],
|
||||||
|
|
||||||
"@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
|
"@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
|
||||||
|
|
||||||
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const BarChart = ({ data = [], height = 80, barW = 24 }) => {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full items-end justify-between' style={{ height }}>
|
||||||
|
{data.map((v, i) => {
|
||||||
|
const pct = Math.max(2, Math.round((v / max) * 100));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className='flex flex-1 justify-center'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='rounded-sm bg-blue-500'
|
||||||
|
style={{ height: `${pct}%`, width: barW }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BarChart;
|
||||||
|
|
@ -17,8 +17,8 @@ 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 } from 'react';
|
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||||
import { TrendingUp } from 'lucide-react';
|
import { TrendingUp, RefreshCw, ChevronDown } from 'lucide-react';
|
||||||
import Sparkline from './Sparkline';
|
import Sparkline from './Sparkline';
|
||||||
import { renderQuota } from '../../helpers';
|
import { renderQuota } from '../../helpers';
|
||||||
|
|
||||||
|
|
@ -28,11 +28,86 @@ const formatMD = (d) => {
|
||||||
return `${m}/${day}`;
|
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 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 = ({
|
const BentoTrend = ({
|
||||||
dailyStats = [],
|
dailyStats = [],
|
||||||
topModels = [],
|
topModels = [],
|
||||||
className = '',
|
className = '',
|
||||||
|
onRefresh,
|
||||||
|
loading = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
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 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]);
|
||||||
|
|
||||||
|
|
@ -54,7 +129,7 @@ const BentoTrend = ({
|
||||||
<div>
|
<div>
|
||||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>Token 使用趋势</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'>
|
<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'> {totalCalls}</span> 次
|
||||||
· 日均{' '}
|
· 日均{' '}
|
||||||
<span className='font-semibold text-slate-900 dark:text-white'>{avg}</span>
|
<span className='font-semibold text-slate-900 dark:text-white'>{avg}</span>
|
||||||
|
|
@ -71,31 +146,100 @@ const BentoTrend = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<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}
|
||||||
|
className='flex h-7 w-7 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700 disabled:opacity-50 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white'
|
||||||
|
title='刷新'
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='flex min-h-[80px] flex-1 items-end text-blue-500'>
|
<div className='flex min-h-[80px] flex-1 items-end text-blue-500'>
|
||||||
<Sparkline data={counts} width={400} height={80} />
|
<Sparkline data={counts} height={80} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-between text-[11px]'>
|
<div className='flex items-center justify-between text-[11px]'>
|
||||||
<div className='flex items-center gap-1 tabular-nums'>
|
<div className='flex w-full items-center justify-between tabular-nums'>
|
||||||
{dailyStats.map((d, i) => {
|
{dailyStats.map((d, i) => (
|
||||||
const isPeak = d.count === peak && peak > 0;
|
|
||||||
return (
|
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={`w-8 text-center text-xs ${
|
className='text-center text-xs text-slate-500 dark:text-white/50'
|
||||||
isPeak
|
|
||||||
? 'font-semibold text-slate-900 dark:text-white'
|
|
||||||
: 'text-slate-500 dark:text-white/50'
|
|
||||||
}`}
|
|
||||||
title={`${d.date.getMonth() + 1}月${d.date.getDate()}日 ${d.count} 次`}
|
title={`${d.date.getMonth() + 1}月${d.date.getDate()}日 ${d.count} 次`}
|
||||||
>
|
>
|
||||||
{formatMD(d.date)}
|
{formatMD(d.date)}
|
||||||
</span>
|
</span>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className='ml-4 shrink-0 text-slate-500 tabular-nums dark:text-white/50'>
|
||||||
<div className='text-slate-500 tabular-nums dark:text-white/50'>
|
|
||||||
峰值{' '}
|
峰值{' '}
|
||||||
<span className='font-semibold text-slate-900 dark:text-white'>{peak}</span> 次
|
<span className='font-semibold text-slate-900 dark:text-white'>{peak}</span> 次
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,61 @@
|
||||||
/*
|
|
||||||
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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const Sparkline = ({
|
const Sparkline = ({ data = [], height = 80, stroke = 'currentColor' }) => {
|
||||||
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 max = Math.max(...data, 1);
|
||||||
const step = data.length > 1 ? width / (data.length - 1) : 0;
|
const n = data.length;
|
||||||
|
const w = 400;
|
||||||
|
|
||||||
const points = data
|
const points = data
|
||||||
.map((v, i) => {
|
.map((v, i) => {
|
||||||
const x = i * step;
|
const x = n > 1 ? (i / (n - 1)) * w : w / 2;
|
||||||
const y = height - (v / max) * (height - 4) - 2;
|
const y = height - (v / max) * (height - 8) - 4;
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
})
|
})
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
const areaPoints = `0,${height} ${points} ${width},${height}`;
|
const areaPoints = `0,${height} ${points} ${w},${height}`;
|
||||||
|
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<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.15' />
|
||||||
<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)' />
|
<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 x = n > 1 ? (i / (n - 1)) * w : w / 2;
|
||||||
|
const y = height - (v / max) * (height - 8) - 4;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={x.toFixed(1)}
|
||||||
|
cy={y.toFixed(1)}
|
||||||
|
r='3'
|
||||||
|
fill='white'
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth='2'
|
||||||
|
vectorEffect='non-scaling-stroke'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ const Dashboard = () => {
|
||||||
<BentoTrend
|
<BentoTrend
|
||||||
dailyStats={dashboard.dailyStats}
|
dailyStats={dashboard.dailyStats}
|
||||||
topModels={dashboard.topModels}
|
topModels={dashboard.topModels}
|
||||||
|
onRefresh={dashboard.refresh}
|
||||||
|
loading={dashboard.loading}
|
||||||
className='md:col-span-2'
|
className='md:col-span-2'
|
||||||
/>
|
/>
|
||||||
<MonthlyForecast
|
<MonthlyForecast
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue