- 新增 ConfirmModal 组件,为6处危险操作添加二次确认弹窗 (禁用团队/用户/成员、删除视频×3处) - 所有秒数显示统一为千位分隔符+s后缀(如 36,000s) - 修复 modal/drawer 在 input 中拖拽导致误关闭的 bug (onClick → onMouseDown + e.target === e.currentTarget) - 团队模型完善:三种角色(超管/团管/成员)、四层额度检查、 团管成员管理页、超管团队管理页 - 关闭公开注册,所有账号由管理员创建 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||
import * as echarts from 'echarts/core';
|
||
import { LineChart, BarChart } from 'echarts/charts';
|
||
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components';
|
||
import { CanvasRenderer } from 'echarts/renderers';
|
||
import { teamApi } from '../lib/api';
|
||
import type { TeamInfo, TeamStats } from '../types';
|
||
import { showToast } from '../components/Toast';
|
||
import styles from './DashboardPage.module.css';
|
||
|
||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
|
||
|
||
export function TeamDashboardPage() {
|
||
const [info, setInfo] = useState<(TeamInfo & { daily_member_limit_default: number; member_count: number }) | null>(null);
|
||
const [stats, setStats] = useState<TeamStats | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
const [infoRes, statsRes] = await Promise.all([
|
||
teamApi.getInfo(),
|
||
teamApi.getStats(),
|
||
]);
|
||
setInfo(infoRes.data);
|
||
setStats(statsRes.data);
|
||
} catch {
|
||
showToast('加载团队数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { fetchData(); }, [fetchData]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={styles.page}>
|
||
<div className={styles.skeleton}>
|
||
<div className={styles.skeletonCards}>
|
||
{[1, 2, 3, 4, 5].map((i) => <div key={i} className={styles.skeletonCard} />)}
|
||
</div>
|
||
<div className={styles.skeletonChart} />
|
||
<div className={styles.skeletonChart} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!info || !stats) return null;
|
||
|
||
const formatLimit = (v: number) => v === -1 ? '不限' : v.toLocaleString() + 's';
|
||
|
||
const statCards = [
|
||
{ label: '总秒数池', value: formatLimit(info.total_seconds_pool) },
|
||
{ label: '已使用', value: info.total_seconds_used.toLocaleString() + 's' },
|
||
{ label: '剩余', value: info.remaining_seconds.toLocaleString() + 's' },
|
||
{ label: '月限额', value: formatLimit(info.monthly_seconds_limit) },
|
||
{ label: '本月已用', value: info.monthly_seconds_used.toLocaleString() + 's' },
|
||
];
|
||
|
||
const trendOption: echarts.EChartsCoreOption = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
backgroundColor: 'rgba(13, 13, 26, 0.95)',
|
||
borderColor: 'rgba(255, 255, 255, 0.10)',
|
||
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
||
formatter: (params: unknown) => {
|
||
const p = (params as { name: string; value: number }[])[0];
|
||
return `${p.name}<br/>消费: ${p.value}s`;
|
||
},
|
||
},
|
||
grid: { left: 50, right: 20, top: 20, bottom: 60 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: stats.daily_trend.map((d) => d.date.slice(5)),
|
||
axisLabel: { color: '#8b8ea8', fontSize: 11 },
|
||
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
axisLabel: { color: '#8b8ea8', fontSize: 11 },
|
||
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
|
||
},
|
||
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
|
||
series: [{
|
||
type: 'line',
|
||
data: stats.daily_trend.map((d) => d.seconds),
|
||
smooth: true,
|
||
lineStyle: { color: '#6c63ff', width: 2 },
|
||
areaStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
|
||
{ offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
|
||
]),
|
||
},
|
||
itemStyle: { color: '#6c63ff' },
|
||
}],
|
||
};
|
||
|
||
const sortedMembers = [...stats.member_consumption].sort((a, b) => a.seconds_consumed - b.seconds_consumed);
|
||
const barOption: echarts.EChartsCoreOption = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
backgroundColor: 'rgba(13, 13, 26, 0.95)',
|
||
borderColor: 'rgba(255, 255, 255, 0.10)',
|
||
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
||
},
|
||
grid: { left: 80, right: 40, top: 10, bottom: 20 },
|
||
xAxis: {
|
||
type: 'value',
|
||
axisLabel: { color: '#8b8ea8', fontSize: 11 },
|
||
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: sortedMembers.map((m) => m.username),
|
||
axisLabel: { color: '#8b8ea8', fontSize: 12 },
|
||
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
|
||
},
|
||
series: [{
|
||
type: 'bar',
|
||
data: sortedMembers.map((m) => m.seconds_consumed),
|
||
barWidth: 16,
|
||
itemStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||
{ offset: 0, color: '#6c63ff' },
|
||
{ offset: 1, color: '#8b5cf6' },
|
||
]),
|
||
borderRadius: [0, 4, 4, 0],
|
||
},
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
color: '#8b8ea8',
|
||
fontSize: 11,
|
||
formatter: '{c}s',
|
||
},
|
||
}],
|
||
};
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<h1 className={styles.title}>团队概览</h1>
|
||
|
||
<div className={styles.statsGrid} style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||
{statCards.map((card) => (
|
||
<div key={card.label} className={styles.statCard}>
|
||
<div className={styles.statLabel}>{card.label}</div>
|
||
<div className={styles.statValue}>{card.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className={styles.chartSection}>
|
||
<h2 className={styles.sectionTitle}>团队消费趋势(近30天)</h2>
|
||
<div className={styles.chartWrapper}>
|
||
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.chartSection}>
|
||
<h2 className={styles.sectionTitle}>成员消费排行</h2>
|
||
<div className={styles.chartWrapper}>
|
||
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedMembers.length * 36) }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|