video-shuoshan/web/src/pages/TeamDashboardPage.tsx
seaislee1209 add3af7904 feat: v0.7.0 — 确认弹窗 + 秒数显示统一 + 弹窗拖拽修复 + 团队模型完善
- 新增 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>
2026-03-15 20:16:21 +08:00

172 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}