This commit is contained in:
2026-07-23 12:07:34 +08:00
18 changed files with 777 additions and 1022 deletions
+140 -17
View File
@@ -136,13 +136,37 @@ const AdminDashboard: React.FC = () => {
</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: 12, border: '1px solid #f0f0f5', height: '100%' }}
<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>
@@ -186,19 +210,35 @@ const LineChart: React.FC<{ data: { date: string; module: string; credits: numbe
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 4, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
<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' }}>
<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 style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '3px 3px 0 0', minHeight: 3, transition: 'height 0.3s' }} />
<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: 4, marginTop: 4 }}>
<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>
@@ -243,9 +283,27 @@ const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({
return (
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0 }}>
<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={s.color} stroke="#fff" strokeWidth={2} />
<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>
@@ -253,8 +311,11 @@ const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({
</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' }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0 }} />
<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>
@@ -271,19 +332,26 @@ const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: stri
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' }}>
<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}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#1e293b' }}>{val.toLocaleString()}</span>
<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: 16, background: '#f1f5f9', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${COLORS[i % COLORS.length]}, ${COLORS[(i + 1) % COLORS.length]})`, borderRadius: 4, transition: 'width 0.3s' }} />
<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>
);
@@ -292,7 +360,62 @@ const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: stri
);
};
const EmptyChart = () => (
// ── 视频参数分布(紧凑饼图+列表)──
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>
@@ -5,6 +5,8 @@ import {
import {
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
} from '@ant-design/icons';
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
import { formatDate } from '../utils/formatDate';
@@ -131,6 +133,7 @@ const AdminNotificationManager: React.FC = () => {
},
{
title: '内容', dataIndex: 'content', ellipsis: true,
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
},
{
title: '类型', dataIndex: 'type', width: 80,
@@ -210,8 +213,8 @@ const AdminNotificationManager: React.FC = () => {
<Input placeholder="请输入消息标题" size="large" />
</Form.Item>
<Form.Item name="content" label="消息内容"
rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
<ReactQuill theme="snow" placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
+9
View File
@@ -217,6 +217,12 @@ export interface ModelUsageOut {
count: number;
}
export interface VideoParamOut {
model: string;
label: string;
count: number;
}
export interface AdminStats {
totalUsers: number;
totalProjects: number;
@@ -236,6 +242,9 @@ export interface AdminStats {
periodCreditsByModule: DailyCredit[];
creditsByTeam: TeamCredit[];
modelUsage: ModelUsageOut[];
videoResolutionUsage: VideoParamOut[];
videoRatioUsage: VideoParamOut[];
videoDurationUsage: VideoParamOut[];
}
export interface PaymentStats {