统计面板继续迭代
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user