统计面板继续迭代

This commit is contained in:
2025-12-23 00:35:57 +08:00
parent 24984796cf
commit e2a1555b46
15 changed files with 875 additions and 251 deletions

View File

@@ -51,9 +51,11 @@ function App() {
<Routes>
<Route path="dashboard" element={<AdminDashboardPage />} />
<Route path="questions" element={<QuestionManagePage />} />
<Route path="question-bank" element={<QuestionManagePage />} />
<Route path="categories" element={<QuestionCategoryPage />} />
<Route path="subjects" element={<ExamSubjectPage />} />
<Route path="tasks" element={<ExamTaskPage />} />
<Route path="exam-tasks" element={<ExamTaskPage />} />
<Route path="users" element={<UserManagePage />} />
<Route path="config" element={<QuizConfigPage />} />
<Route path="statistics" element={<StatisticsPage />} />
@@ -72,4 +74,4 @@ function App() {
);
}
export default App;
export default App;

View File

@@ -1,20 +1,34 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Row, Col, Statistic, Button, Table, message } from 'antd';
import {
UserOutlined,
QuestionCircleOutlined,
BarChartOutlined,
ReloadOutlined
import {
TeamOutlined,
DatabaseOutlined,
BookOutlined,
CalendarOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { adminAPI, quizAPI } from '../../services/api';
import { formatDateTime } from '../../utils/validation';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend, Label } from 'recharts';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip as RechartsTooltip,
Legend,
Label,
} from 'recharts';
interface Statistics {
interface DashboardOverview {
totalUsers: number;
totalRecords: number;
averageScore: number;
typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>;
activeSubjectCount: number;
questionCategoryStats: Array<{ category: string; count: number }>;
taskStatusDistribution: {
completed: number;
ongoing: number;
notStarted: number;
};
}
interface RecentRecord {
@@ -43,9 +57,21 @@ interface ActiveTaskStat {
}
const AdminDashboardPage = () => {
const [statistics, setStatistics] = useState<Statistics | null>(null);
const navigate = useNavigate();
const [overview, setOverview] = useState<DashboardOverview | null>(null);
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
const [activeTasks, setActiveTasks] = useState<ActiveTaskStat[]>([]);
const [historyTasks, setHistoryTasks] = useState<ActiveTaskStat[]>([]);
const [upcomingTasks, setUpcomingTasks] = useState<ActiveTaskStat[]>([]);
const [historyPagination, setHistoryPagination] = useState({
page: 1,
limit: 5,
total: 0,
});
const [upcomingPagination, setUpcomingPagination] = useState({
page: 1,
limit: 5,
total: 0,
});
const [loading, setLoading] = useState(false);
useEffect(() => {
@@ -55,16 +81,33 @@ const AdminDashboardPage = () => {
const fetchDashboardData = async () => {
try {
setLoading(true);
// 并行获取所有数据,提高性能
const [statsResponse, recordsResponse, activeTasksResponse] = await Promise.all([
adminAPI.getStatistics(),
fetchRecentRecords(),
adminAPI.getActiveTasksStats()
]);
setStatistics(statsResponse.data);
const [overviewResponse, recordsResponse, historyResponse, upcomingResponse] =
await Promise.all([
adminAPI.getDashboardOverview(),
fetchRecentRecords(),
adminAPI.getHistoryTaskStats({ page: 1, limit: 5 }),
adminAPI.getUpcomingTaskStats({ page: 1, limit: 5 }),
]);
setOverview(overviewResponse.data);
setRecentRecords(recordsResponse);
setActiveTasks(activeTasksResponse.data);
setHistoryTasks((historyResponse as any).data || []);
setUpcomingTasks((upcomingResponse as any).data || []);
if ((historyResponse as any).pagination) {
setHistoryPagination({
page: (historyResponse as any).pagination.page,
limit: (historyResponse as any).pagination.limit,
total: (historyResponse as any).pagination.total,
});
}
if ((upcomingResponse as any).pagination) {
setUpcomingPagination({
page: (upcomingResponse as any).pagination.page,
limit: (upcomingResponse as any).pagination.limit,
total: (upcomingResponse as any).pagination.total,
});
}
} catch (error: any) {
message.error(error.message || '获取数据失败');
} finally {
@@ -72,11 +115,63 @@ const AdminDashboardPage = () => {
}
};
const fetchHistoryTasks = async (page: number) => {
try {
setLoading(true);
const response = (await adminAPI.getHistoryTaskStats({ page, limit: 5 })) as any;
setHistoryTasks(response.data || []);
if (response.pagination) {
setHistoryPagination({
page: response.pagination.page,
limit: response.pagination.limit,
total: response.pagination.total,
});
}
} catch (error: any) {
message.error(error.message || '获取历史考试任务统计失败');
} finally {
setLoading(false);
}
};
const fetchUpcomingTasks = async (page: number) => {
try {
setLoading(true);
const response = (await adminAPI.getUpcomingTaskStats({ page, limit: 5 })) as any;
setUpcomingTasks(response.data || []);
if (response.pagination) {
setUpcomingPagination({
page: response.pagination.page,
limit: response.pagination.limit,
total: response.pagination.total,
});
}
} catch (error: any) {
message.error(error.message || '获取未开始考试任务统计失败');
} finally {
setLoading(false);
}
};
const fetchRecentRecords = async () => {
const response = await quizAPI.getAllRecords({ page: 1, limit: 10 }) as any;
return response.data || [];
};
const categoryPieData =
overview?.questionCategoryStats?.map((item) => ({
name: item.category,
value: item.count,
})) || [];
const taskStatusPieData = overview
? [
{ name: '已完成', value: overview.taskStatusDistribution.completed, color: '#008C8C' },
{ name: '进行中', value: overview.taskStatusDistribution.ongoing, color: '#00A3A3' },
{ name: '未开始', value: overview.taskStatusDistribution.notStarted, color: '#f0f0f0' },
].filter((i) => i.value > 0)
: [];
const columns = [
{
title: '姓名',
@@ -124,6 +219,128 @@ const AdminDashboardPage = () => {
},
];
const taskStatsColumns = [
{
title: '任务名称',
dataIndex: 'taskName',
key: 'taskName',
},
{
title: '科目',
dataIndex: 'subjectName',
key: 'subjectName',
},
{
title: '指定考试人数',
dataIndex: 'totalUsers',
key: 'totalUsers',
},
{
title: '考试进度',
key: 'progress',
render: (_: any, record: ActiveTaskStat) => {
const now = new Date();
const start = new Date(record.startAt);
const end = new Date(record.endAt);
const totalDuration = end.getTime() - start.getTime();
const elapsedDuration = now.getTime() - start.getTime();
const progress =
totalDuration > 0
? Math.max(0, Math.min(100, Math.round((elapsedDuration / totalDuration) * 100)))
: 0;
return (
<div className="flex items-center">
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
<div
className="h-full bg-mars-500 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
></div>
</div>
<span className="font-semibold text-mars-600">{progress}%</span>
</div>
);
},
},
{
title: '考试人数统计',
key: 'statistics',
render: (_: any, record: ActiveTaskStat) => {
const total = record.totalUsers;
const completed = record.completedUsers;
const passedTotal = Math.round(completed * (record.passRate / 100));
const excellentTotal = Math.round(completed * (record.excellentRate / 100));
const incomplete = total - completed;
const failed = completed - passedTotal;
const passedOnly = passedTotal - excellentTotal;
const excellent = excellentTotal;
const pieData = [
{ name: '优秀', value: excellent, color: '#008C8C' },
{ name: '合格', value: passedOnly, color: '#00A3A3' },
{ name: '不及格', value: failed, color: '#ff4d4f' },
{ name: '未完成', value: incomplete, color: '#f0f0f0' },
];
const filteredData = pieData.filter((item) => item.value > 0);
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="w-full h-20">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={filteredData}
cx="50%"
cy="50%"
innerRadius={25}
outerRadius={35}
paddingAngle={2}
dataKey="value"
>
{filteredData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
))}
<Label
value={`${completionRate}%`}
position="center"
className="text-sm font-bold fill-gray-700"
/>
</Pie>
<RechartsTooltip
formatter={(value: any) => [`${value}`, '数量']}
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
fontSize: '12px',
padding: '8px',
}}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: '12px' }}
formatter={(value, entry: any) => (
<span className="text-xs text-gray-600 ml-1">
{value} {entry.payload.value}
</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
},
},
];
return (
<div>
<div className="flex justify-between items-center mb-6">
@@ -141,197 +358,144 @@ const AdminDashboardPage = () => {
{/* 统计卡片 */}
<Row gutter={16} className="mb-8">
<Col span={8}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Col span={6}>
<Card
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate('/admin/users')}
>
<Statistic
title="总用户数"
value={statistics?.totalUsers || 0}
prefix={<UserOutlined className="text-mars-400" />}
value={overview?.totalUsers || 0}
prefix={<TeamOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Col span={6}>
<Card
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate('/admin/question-bank')}
styles={{ body: { padding: 16 } }}
>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-600"></div>
<DatabaseOutlined className="text-mars-400" />
</div>
<div className="w-full h-24">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryPieData.filter((i) => i.value > 0)}
cx="50%"
cy="50%"
innerRadius={25}
outerRadius={40}
paddingAngle={2}
dataKey="value"
>
{categoryPieData.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={['#008C8C', '#00A3A3', '#3D9D9D', '#8CCCCC'][index % 4]}
stroke="none"
/>
))}
</Pie>
<RechartsTooltip formatter={(value: any) => [`${value}`, '数量']} />
</PieChart>
</ResponsiveContainer>
</div>
</Card>
</Col>
<Col span={6}>
<Card
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate('/admin/subjects')}
>
<Statistic
title="答题记录"
value={statistics?.totalRecords || 0}
prefix={<BarChartOutlined className="text-mars-400" />}
title="考试科目"
value={overview?.activeSubjectCount || 0}
prefix={<BookOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
suffix="个"
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title="平均得分"
value={statistics?.averageScore || 0}
precision={1}
prefix={<QuestionCircleOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
suffix="分"
/>
<Col span={6}>
<Card
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate('/admin/exam-tasks')}
styles={{ body: { padding: 16 } }}
>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-600"></div>
<CalendarOutlined className="text-mars-400" />
</div>
<div className="w-full h-24">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={taskStatusPieData}
cx="50%"
cy="50%"
innerRadius={25}
outerRadius={40}
paddingAngle={2}
dataKey="value"
>
{taskStatusPieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
))}
<Label
value={`${taskStatusPieData.reduce((sum, i) => sum + i.value, 0)}`}
position="center"
className="text-sm font-bold fill-gray-700"
/>
</Pie>
<RechartsTooltip formatter={(value: any) => [`${value}`, '数量']} />
</PieChart>
</ResponsiveContainer>
</div>
</Card>
</Col>
</Row>
{/* 题型正确率统计 */}
{statistics?.typeStats && statistics.typeStats.length > 0 && (
<Card title="题型正确率统计" className="mb-8 shadow-sm">
<Row gutter={16}>
{statistics.typeStats.map((stat) => (
<Col span={6} key={stat.type}>
<Card size="small" className="text-center hover:shadow-sm transition-shadow">
<div className="text-sm text-gray-600 mb-2">
{stat.type === 'single' && '单选题'}
{stat.type === 'multiple' && '多选题'}
{stat.type === 'judgment' && '判断题'}
{stat.type === 'text' && '文字题'}
</div>
<div className="text-2xl font-bold text-mars-600">
{stat.correctRate}%
</div>
<div className="text-xs text-gray-500">
{stat.correct}/{stat.total}
</div>
</Card>
</Col>
))}
</Row>
</Card>
)}
<Card title="历史考试任务统计" className="mb-8 shadow-sm">
<Table
columns={taskStatsColumns}
dataSource={historyTasks}
rowKey="taskId"
loading={loading}
size="small"
pagination={{
current: historyPagination.page,
pageSize: 5,
total: historyPagination.total,
showSizeChanger: false,
onChange: (page) => fetchHistoryTasks(page),
}}
/>
</Card>
{/* 当前有效考试任务统计 */}
{activeTasks.length > 0 && (
<Card title="当前有效考试任务统计" className="mb-8 shadow-sm">
<Table
columns={[
{
title: '任务名称',
dataIndex: 'taskName',
key: 'taskName',
},
{
title: '科目',
dataIndex: 'subjectName',
key: 'subjectName',
},
{
title: '指定考试人数',
dataIndex: 'totalUsers',
key: 'totalUsers',
},
{
title: '考试进度',
key: 'progress',
render: (_: any, record: ActiveTaskStat) => {
// 计算考试进度百分率
const now = new Date();
const start = new Date(record.startAt);
const end = new Date(record.endAt);
// 计算总时长(毫秒)
const totalDuration = end.getTime() - start.getTime();
// 计算已经过去的时长(毫秒)
const elapsedDuration = now.getTime() - start.getTime();
// 计算进度百分率确保在0-100之间
const progress = Math.max(0, Math.min(100, Math.round((elapsedDuration / totalDuration) * 100)));
return (
<div className="flex items-center">
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
<div
className="h-full bg-mars-500 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
></div>
</div>
<span className="font-semibold text-mars-600">{progress}%</span>
</div>
);
},
},
{
title: '考试人数统计',
key: 'statistics',
render: (_: any, record: ActiveTaskStat) => {
// 计算各类人数
const total = record.totalUsers;
const completed = record.completedUsers;
// 原始计算
const passedTotal = Math.round(completed * (record.passRate / 100));
const excellentTotal = Math.round(completed * (record.excellentRate / 100));
// 互斥分类计算
const incomplete = total - completed;
const failed = completed - passedTotal;
const passedOnly = passedTotal - excellentTotal;
const excellent = excellentTotal;
// 准备环形图数据 (互斥分类)
const pieData = [
{ name: '优秀', value: excellent, color: '#008C8C' }, // Mars Green (Primary)
{ name: '合格', value: passedOnly, color: '#00A3A3' }, // Mars Light
{ name: '不及格', value: failed, color: '#ff4d4f' }, // Red (Error)
{ name: '未完成', value: incomplete, color: '#f0f0f0' } // Gray
];
// 只显示有数据的项
const filteredData = pieData.filter(item => item.value > 0);
// 计算完成率用于中间显示
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="w-full h-20">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={filteredData}
cx="50%"
cy="50%"
innerRadius={25}
outerRadius={35}
paddingAngle={2}
dataKey="value"
>
{filteredData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
))}
<Label
value={`${completionRate}%`}
position="center"
className="text-sm font-bold fill-gray-700"
/>
</Pie>
<RechartsTooltip
formatter={(value: any) => [`${value}`, '数量']}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', fontSize: '12px', padding: '8px' }}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: '12px' }}
formatter={(value, entry: any) => <span className="text-xs text-gray-600 ml-1">{value} {entry.payload.value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
},
},
]}
dataSource={activeTasks}
rowKey="taskId"
loading={loading}
pagination={false}
size="small"
/>
</Card>
)}
<Card title="未开始考试任务统计" className="mb-8 shadow-sm">
<Table
columns={taskStatsColumns}
dataSource={upcomingTasks}
rowKey="taskId"
loading={loading}
size="small"
pagination={{
current: upcomingPagination.page,
pageSize: 5,
total: upcomingPagination.total,
showSizeChanger: false,
onChange: (page) => fetchUpcomingTasks(page),
}}
/>
</Card>
{/* 最近答题记录 */}
<Card title="最近答题记录" className="shadow-sm">

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Table, DatePicker, Button, message, Tabs, Select } from 'antd';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts';
import { Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import api, { adminAPI, quizAPI } from '../../services/api';
import { formatDateTime } from '../../utils/validation';
@@ -180,15 +180,6 @@ const StatisticsPage = () => {
message.success('数据导出成功');
};
// 准备图表数据
const typeChartData = statistics?.typeStats.map(stat => ({
name: stat.type === 'single' ? '单选题' :
stat.type === 'multiple' ? '多选题' :
stat.type === 'judgment' ? '判断题' : '文字题',
正确率: stat.correctRate,
总题数: stat.total,
})) || [];
const scoreDistribution = [
{ range: '0-59分', count: records.filter(r => r.totalScore < 60).length },
{ range: '60-69分', count: records.filter(r => r.totalScore >= 60 && r.totalScore < 70).length },
@@ -292,20 +283,7 @@ const StatisticsPage = () => {
<TabPane tab="总体概览" key="overview">
{/* 图表 */}
<Row gutter={16} className="mb-8">
<Col span={12}>
<Card title="题型正确率对比" className="shadow-sm">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={typeChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value) => [`${value}%`, '正确率']} />
<Bar dataKey="正确率" fill="#1890ff" />
</BarChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Col span={24}>
<Card title="分数分布" className="shadow-sm">
<ResponsiveContainer width="100%" height={300}>
<PieChart>

View File

@@ -110,6 +110,11 @@ export const adminAPI = {
updateQuizConfig: (data: any) => api.put('/admin/config', data),
getStatistics: () => api.get('/admin/statistics'),
getActiveTasksStats: () => api.get('/admin/active-tasks'),
getDashboardOverview: () => api.get('/admin/dashboard/overview'),
getHistoryTaskStats: (params?: { page?: number; limit?: number }) =>
api.get('/admin/tasks/history-stats', { params }),
getUpcomingTaskStats: (params?: { page?: number; limit?: number }) =>
api.get('/admin/tasks/upcoming-stats', { params }),
getUserStats: () => api.get('/admin/statistics/users'),
getSubjectStats: () => api.get('/admin/statistics/subjects'),
getTaskStats: () => api.get('/admin/statistics/tasks'),