425 lines
21 KiB
TypeScript
425 lines
21 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Card, Col, Row, Typography, DatePicker, Button, Space, Spin } from 'antd';
|
|
import {
|
|
UserOutlined, ProjectOutlined, PlayCircleOutlined, FileTextOutlined,
|
|
DollarOutlined, WalletOutlined, ArrowUpOutlined, CalendarOutlined,
|
|
} from '@ant-design/icons';
|
|
import { getAdminStats, getSystemConfigs } from '../api';
|
|
import type { AdminStats, SystemConfig } from '../types';
|
|
import dayjs from 'dayjs';
|
|
import 'dayjs/locale/zh-cn';
|
|
|
|
dayjs.locale('zh-cn');
|
|
|
|
const MODULE_LABELS: Record<string, string> = {
|
|
'ai_creation': 'AI创作',
|
|
'generation_record': '项目生成',
|
|
'hot_opening_replicate': '爆款开头复刻',
|
|
'shot_replicate': '拆镜复刻',
|
|
'payment': '支付充值',
|
|
'admin': '后台管理',
|
|
'team': '团队管理',
|
|
'unknown': '历史未知',
|
|
'other': '其他',
|
|
};
|
|
|
|
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#ec4899', '#14b8a6', '#f97316'];
|
|
|
|
const AdminDashboard: React.FC = () => {
|
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
|
|
const [activeRange, setActiveRange] = useState<string>('today');
|
|
const [siteName, setSiteName] = useState<string>('数据概览');
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const start = startDate[0]?.format('YYYY-MM-DD') || undefined;
|
|
const end = startDate[1]?.format('YYYY-MM-DD') || undefined;
|
|
const data = await getAdminStats(start, end);
|
|
setStats(data);
|
|
} catch { /* auth error handled by client */ }
|
|
setLoading(false);
|
|
};
|
|
|
|
const loadSiteName = async () => {
|
|
try {
|
|
const configs = await getSystemConfigs();
|
|
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
|
|
if (siteConfig) {
|
|
setSiteName(`${siteConfig.value} 管理后台`);
|
|
document.title = `${siteConfig.value} 管理后台`;
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
useEffect(() => { load(); loadSiteName(); }, []);
|
|
|
|
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs], range?: string) => {
|
|
setStartDate(dates);
|
|
if (range) setActiveRange(range);
|
|
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
|
|
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
|
|
setLoading(true);
|
|
getAdminStats(start, end).then(data => { setStats(data); setLoading(false); }).catch(() => setLoading(false));
|
|
};
|
|
|
|
const handleToday = () => loadWithDates([dayjs().startOf('day'), dayjs()], 'today');
|
|
const handleYesterday = () => { const y = dayjs().subtract(1, 'day'); loadWithDates([y.startOf('day'), y.endOf('day')], 'yesterday'); };
|
|
const handleWeek = () => loadWithDates([dayjs().startOf('week'), dayjs()], 'week');
|
|
const handleMonth = () => loadWithDates([dayjs().startOf('month'), dayjs()], 'month');
|
|
|
|
const handleDateChange = (dates: any) => {
|
|
if (dates) { setActiveRange(''); loadWithDates([dates[0], dates[1]]); }
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* 日期筛选 */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
|
|
<Space>
|
|
<Button type={activeRange === 'today' ? 'primary' : 'default'} size="small" onClick={handleToday}>今日</Button>
|
|
<Button type={activeRange === 'yesterday' ? 'primary' : 'default'} size="small" onClick={handleYesterday}>昨日</Button>
|
|
<Button type={activeRange === 'week' ? 'primary' : 'default'} size="small" onClick={handleWeek}>本周</Button>
|
|
<Button type={activeRange === 'month' ? 'primary' : 'default'} size="small" onClick={handleMonth}>本月</Button>
|
|
</Space>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '4px 12px', borderRadius: 8 }}>
|
|
<CalendarOutlined style={{ color: '#64748b' }} />
|
|
<DatePicker.RangePicker value={startDate} onChange={handleDateChange} size="small" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 核心数据 */}
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<Typography.Text strong style={{ fontSize: 15 }}>核心数据</Typography.Text>
|
|
</div>
|
|
<Row gutter={[12, 12]}>
|
|
{[
|
|
{ title: '新增用户数量', value: stats?.totalUsers, icon: <UserOutlined />, color: '#6366f1' },
|
|
{ title: '总收入', value: stats?.totalRevenue, icon: <WalletOutlined />, color: '#ec4899', prefix: '¥' },
|
|
{ title: '消耗积分', value: stats?.creditsConsumedToday, icon: <DollarOutlined />, color: '#ef4444' },
|
|
].map(s => (
|
|
<Col xs={12} sm={8} md={4} key={s.title}>
|
|
<CompactStatCard {...s} loading={loading} />
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
</div>
|
|
|
|
{/* 图表区域 */}
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<Typography.Text strong style={{ fontSize: 15 }}>数据统计</Typography.Text>
|
|
</div>
|
|
<Row gutter={[16, 16]}>
|
|
<Col xs={24} lg={12}>
|
|
<ChartCard title="每日积分消耗趋势" loading={loading}>
|
|
<LineChart data={stats?.dailyCreditsByModule || []} />
|
|
</ChartCard>
|
|
</Col>
|
|
<Col xs={24} lg={12}>
|
|
<ChartCard title="各模块积分占比" loading={loading}>
|
|
<ModulePie data={stats?.periodCreditsByModule || []} />
|
|
</ChartCard>
|
|
</Col>
|
|
<Col xs={24} lg={12}>
|
|
<ChartCard title="团队积分消耗排行" loading={loading}>
|
|
<HorizontalBarChart data={(stats?.creditsByTeam || []).slice(0, 8)} />
|
|
</ChartCard>
|
|
</Col>
|
|
<Col xs={24} lg={12}>
|
|
<ChartCard title="模型使用次数" loading={loading}>
|
|
<HorizontalBarChart data={(stats?.modelUsage || []).slice(0, 8)} valueKey="count" />
|
|
</ChartCard>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
|
|
{/* 视频参数分布 */}
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<Typography.Text strong style={{ fontSize: 15 }}>视频参数分布</Typography.Text>
|
|
</div>
|
|
<Row gutter={[16, 16]}>
|
|
<Col xs={24} lg={8}>
|
|
<ChartCard title="分辨率分布" loading={loading}>
|
|
<PieBarChart data={stats?.videoResolutionUsage || []} />
|
|
</ChartCard>
|
|
</Col>
|
|
<Col xs={24} lg={8}>
|
|
<ChartCard title="画面比例分布" loading={loading}>
|
|
<PieBarChart data={stats?.videoRatioUsage || []} />
|
|
</ChartCard>
|
|
</Col>
|
|
<Col xs={24} lg={8}>
|
|
<ChartCard title="时长分布" loading={loading}>
|
|
<PieBarChart data={stats?.videoDurationUsage || []} />
|
|
</ChartCard>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 图表卡片 ──
|
|
const ChartCard: React.FC<{ title: string; loading: boolean; children: React.ReactNode }> = ({ title, loading, children }) => (
|
|
<Card bordered={false} style={{ borderRadius: 16, border: '1px solid #f0f0f5', height: '100%', boxShadow: '0 4px 20px rgba(0,0,0,0.04)', transition: 'box-shadow 0.3s' }}
|
|
styles={{ body: { padding: '16px' } }}>
|
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
|
|
</div>
|
|
<Spin spinning={loading}>{children}</Spin>
|
|
</Card>
|
|
);
|
|
|
|
// ── 核心数据小卡片 ──
|
|
const CompactStatCard: React.FC<{ title: string; value?: number; icon: React.ReactNode; color: string; prefix?: string; loading: boolean }> = ({ title, value, icon, color, prefix = '', loading }) => (
|
|
<Card bordered={false} loading={loading} style={{ borderRadius: 10, border: '1px solid #f0f0f5' }}
|
|
styles={{ body: { padding: '12px 14px' } }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<div style={{ width: 34, height: 34, borderRadius: 8, background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 16 }}>
|
|
{icon}
|
|
</div>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{title}</Typography.Text>
|
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b', lineHeight: 1.3 }}>
|
|
{prefix}{typeof value === 'number' ? value.toLocaleString() : '-'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
|
|
// ── 折线图(按日期+模块,固定展示选中日期往前7天)──
|
|
const LineChart: React.FC<{ data: { date: string; module: string; credits: number }[] }> = ({ data }) => {
|
|
// 以数据中最新日期为基准,往前推 7 天;不足 7 天按实际天数
|
|
if (!data.length) return <EmptyChart />;
|
|
const sortedDates = Array.from(new Set(data.map(d => d.date))).sort();
|
|
const maxDate = sortedDates[sortedDates.length - 1];
|
|
// 生成 [maxDate-6, maxDate] 共 7 天
|
|
const baseDayjs = dayjs(maxDate);
|
|
const sevenDays: string[] = [];
|
|
for (let i = 6; i >= 0; i--) sevenDays.push(baseDayjs.subtract(i, 'day').format('YYYY-MM-DD'));
|
|
|
|
const dateMap = new Map<string, number>();
|
|
data.forEach(d => { dateMap.set(d.date, (dateMap.get(d.date) || 0) + d.credits); });
|
|
const maxVal = Math.max(...sevenDays.map(d => dateMap.get(d) || 0), 1);
|
|
|
|
return (
|
|
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 6, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
|
|
{sevenDays.map(d => {
|
|
const val = dateMap.get(d) || 0;
|
|
const pct = (val / maxVal) * 100;
|
|
return (
|
|
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end', position: 'relative' }}
|
|
onMouseEnter={e => {
|
|
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
|
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
|
if (bar) { bar.style.transform = 'scaleY(1.08)'; bar.style.filter = 'brightness(1.15) drop-shadow(0 4px 12px rgba(99,102,241,0.4))'; }
|
|
if (tip) { tip.style.opacity = '1'; }
|
|
}}
|
|
onMouseLeave={e => {
|
|
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
|
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
|
if (bar) { bar.style.transform = 'scaleY(1)'; bar.style.filter = 'none'; }
|
|
if (tip) { tip.style.opacity = '0'; }
|
|
}}
|
|
>
|
|
<div className="tip" style={{ position: 'absolute', bottom: '100%', marginBottom: 6, background: '#1e293b', color: '#fff', fontSize: 11, padding: '4px 10px', borderRadius: 6, whiteSpace: 'nowrap', opacity: 0, transition: 'opacity 0.2s', pointerEvents: 'none', zIndex: 10 }}>
|
|
{val > 0 ? `${val.toFixed(0)} 积分` : '无数据'}
|
|
</div>
|
|
<span style={{ fontSize: 9, color: '#6366f1', fontWeight: 600, marginBottom: 2 }}>{val > 0 ? val.toFixed(0) : ''}</span>
|
|
<div className="bar" style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #818cf8 0%, #6366f1 40%, #4f46e5 100%)', borderRadius: '4px 4px 0 0', minHeight: 4, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(99,102,241,0.25)' }} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
|
{sevenDays.map(d => (
|
|
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
|
|
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 模块积分占比(饼图)──
|
|
const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({ data }) => {
|
|
if (!data.length) return <EmptyChart />;
|
|
const moduleMap = new Map<string, number>();
|
|
data.forEach(d => { moduleMap.set(d.module, (moduleMap.get(d.module) || 0) + d.credits); });
|
|
const modules = Array.from(moduleMap.entries()).sort((a, b) => b[1] - a[1]);
|
|
const total = modules.reduce((s, [, v]) => s + v, 0) || 1;
|
|
|
|
// 计算饼图扇形路径
|
|
const size = 160;
|
|
const cx = size / 2;
|
|
const cy = size / 2;
|
|
const r = 68;
|
|
let cumAngle = -90; // 从顶部开始
|
|
|
|
const slices = modules.map(([mod, val], i) => {
|
|
const pct = val / total;
|
|
const angle = pct * 360;
|
|
const startAngle = cumAngle;
|
|
cumAngle += angle;
|
|
const endAngle = cumAngle;
|
|
const startRad = (startAngle * Math.PI) / 180;
|
|
const endRad = (endAngle * Math.PI) / 180;
|
|
const largeArc = angle > 180 ? 1 : 0;
|
|
const x1 = cx + r * Math.cos(startRad);
|
|
const y1 = cy + r * Math.sin(startRad);
|
|
const x2 = cx + r * Math.cos(endRad);
|
|
const y2 = cy + r * Math.sin(endRad);
|
|
const d = `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z`;
|
|
return { d, color: COLORS[i % COLORS.length], label: MODULE_LABELS[mod] || mod, val, pct };
|
|
});
|
|
|
|
return (
|
|
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0, filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.08))' }}>
|
|
<defs>
|
|
{slices.map((s, i) => (
|
|
<linearGradient key={i} id={`pie-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stopColor={s.color} stopOpacity={1} />
|
|
<stop offset="100%" stopColor={s.color} stopOpacity={0.7} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
{slices.map((s, i) => (
|
|
<path key={i} d={s.d} fill={`url(#pie-grad-${i})`} stroke="#fff" strokeWidth={2}
|
|
onMouseEnter={e => {
|
|
(e.target as SVGPathElement).style.transform = 'scale(1.06)';
|
|
(e.target as SVGPathElement).style.filter = 'brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2))';
|
|
}}
|
|
onMouseLeave={e => {
|
|
(e.target as SVGPathElement).style.transform = 'scale(1)';
|
|
(e.target as SVGPathElement).style.filter = 'none';
|
|
}}
|
|
style={{ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
|
|
/>
|
|
))}
|
|
<circle cx={cx} cy={cy} r={36} fill="#fff" />
|
|
<text x={cx} y={cy - 4} textAnchor="middle" fontSize={11} fill="#64748b">总计</text>
|
|
<text x={cx} y={cy + 12} textAnchor="middle" fontSize={13} fontWeight={700} fill="#1e293b">{total.toFixed(0)}</text>
|
|
</svg>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{slices.map((s, i) => (
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s', cursor: 'default' }}
|
|
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#f8fafc'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
>
|
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0, boxShadow: `0 2px 4px ${s.color}40` }} />
|
|
<span>{s.label}</span>
|
|
<span style={{ fontWeight: 600, color: '#1e293b' }}>{s.val.toFixed(0)}</span>
|
|
<span style={{ fontSize: 10 }}>{(s.pct * 100).toFixed(1)}%</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 横向柱状图(团队/模型)──
|
|
const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: string; credits?: number; count?: number }[]; valueKey?: string }> = ({ data, valueKey = 'credits' }) => {
|
|
if (!data.length) return <EmptyChart />;
|
|
const maxVal = Math.max(...data.map(d => (d as any)[valueKey] || 0), 1);
|
|
|
|
return (
|
|
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', paddingRight: 4 }}>
|
|
{data.map((d, i) => {
|
|
const label = d.teamName || d.modelName || '-';
|
|
const val = (d as any)[valueKey] || 0;
|
|
const pct = (val / maxVal) * 100;
|
|
const c1 = COLORS[i % COLORS.length];
|
|
const c2 = COLORS[(i + 1) % COLORS.length];
|
|
return (
|
|
<div key={i} style={{ padding: '4px 8px', borderRadius: 8, transition: 'background-color 0.2s, box-shadow 0.2s' }}
|
|
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(99,102,241,0.08)'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.boxShadow = 'none'; }}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%', fontWeight: 500 }}>{label}</span>
|
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#1e293b' }}>{val.toLocaleString()}</span>
|
|
</div>
|
|
<div style={{ height: 18, background: '#f1f5f9', borderRadius: 6, overflow: 'hidden', boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)' }}>
|
|
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${c1}, ${c2})`, borderRadius: 6, transition: 'width 0.35s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: `0 1px 3px ${c1}40`, position: 'relative' }}>
|
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)', borderRadius: '6px 6px 0 0' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 视频参数分布(紧凑饼图+列表)──
|
|
const PieBarChart: React.FC<{ data: { model: string; label: string; count: number }[] }> = ({ data }) => {
|
|
if (!data.length) return <EmptyChart />;
|
|
// 按模型分组
|
|
const modelMap = new Map<string, { label: string; count: number }[]>();
|
|
data.forEach(d => {
|
|
if (!modelMap.has(d.model)) modelMap.set(d.model, []);
|
|
modelMap.get(d.model)!.push({ label: d.label, count: d.count });
|
|
});
|
|
const models = Array.from(modelMap.entries());
|
|
const total = data.reduce((s, d) => s + d.count, 0) || 1;
|
|
|
|
return (
|
|
<div style={{ height: 220, overflowY: 'auto' }}>
|
|
{models.map(([model, items], mi) => {
|
|
const modelTotal = items.reduce((s, it) => s + it.count, 0);
|
|
return (
|
|
<div key={model} style={{ marginBottom: 12 }}>
|
|
{/* 模型名称 + 总数 */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
|
|
<span style={{ fontSize: 10, color: '#94a2b3' }}>共 {modelTotal} 条 ({((modelTotal / total) * 100).toFixed(1)}%)</span>
|
|
</div>
|
|
{/* 各参数 */}
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
{items.map((item, i) => (
|
|
<div key={i} style={{
|
|
flex: '0 0 calc(50% - 2px)',
|
|
padding: '4px 8px',
|
|
background: '#f8fafc',
|
|
borderRadius: 6,
|
|
border: '1px solid #f1f5f9',
|
|
transition: 'all 0.2s',
|
|
cursor: 'default',
|
|
}}
|
|
onMouseEnter={e => { Object.assign(e.currentTarget.style, { background: '#fafbff', boxShadow: '0 2px 8px rgba(99,102,241,0.1)', transform: 'translateY(-1px)' }); }}
|
|
onMouseLeave={e => { Object.assign(e.currentTarget.style, { background: '#f8fafc', boxShadow: 'none', transform: 'translateY(0)' }); }}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{item.label}</span>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{item.count}</span>
|
|
</div>
|
|
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2, marginTop: 3, overflow: 'hidden' }}>
|
|
<div style={{ height: '100%', width: `${(item.count / modelTotal) * 100}%`, background: COLORS[mi % COLORS.length], borderRadius: 2, transition: 'width 0.3s' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const EmptyChart: React.FC = () => (
|
|
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
|
|
暂无数据
|
|
</div>
|
|
);
|
|
|
|
export default AdminDashboard;
|