第一版提交,答题功能OK,题库管理待完善

This commit is contained in:
2025-12-18 19:07:21 +08:00
parent e5600535be
commit ba252b2f56
93 changed files with 20431 additions and 1 deletions

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import { Card, Row, Col, Statistic, Button, Table, message } from 'antd';
import {
UserOutlined,
QuestionCircleOutlined,
BarChartOutlined,
ReloadOutlined
} from '@ant-design/icons';
import { adminAPI } from '../../services/api';
import { formatDateTime } from '../../utils/validation';
interface Statistics {
totalUsers: number;
totalRecords: number;
averageScore: number;
typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>;
}
interface RecentRecord {
id: string;
userName: string;
userPhone: string;
totalScore: number;
correctCount: number;
totalCount: number;
createdAt: string;
}
const AdminDashboardPage = () => {
const [statistics, setStatistics] = useState<Statistics | null>(null);
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
try {
setLoading(true);
const statsResponse = await adminAPI.getStatistics();
setStatistics(statsResponse.data);
// 获取最近10条答题记录
const recordsResponse = await fetchRecentRecords();
setRecentRecords(recordsResponse);
} catch (error: any) {
message.error(error.message || '获取数据失败');
} finally {
setLoading(false);
}
};
const fetchRecentRecords = async () => {
// 这里简化处理实际应该调用专门的API
const response = await fetch('/api/quiz/records?page=1&limit=10', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}`
}
});
const data = await response.json();
return data.success ? data.data : [];
};
const columns = [
{
title: '姓名',
dataIndex: 'userName',
key: 'userName',
},
{
title: '手机号',
dataIndex: 'userPhone',
key: 'userPhone',
},
{
title: '得分',
dataIndex: 'totalScore',
key: 'totalScore',
render: (score: number) => <span className="font-semibold text-blue-600">{score} </span>,
},
{
title: '正确率',
key: 'correctRate',
render: (_: any, record: RecentRecord) => {
const rate = record.totalCount > 0
? ((record.correctCount / record.totalCount) * 100).toFixed(1)
: '0.0';
return <span>{rate}%</span>;
},
},
{
title: '答题时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => formatDateTime(date),
},
];
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={fetchDashboardData}
loading={loading}
>
</Button>
</div>
{/* 统计卡片 */}
<Row gutter={16} className="mb-8">
<Col span={8}>
<Card className="shadow-sm">
<Statistic
title="总用户数"
value={statistics?.totalUsers || 0}
prefix={<UserOutlined className="text-blue-500" />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm">
<Statistic
title="答题记录"
value={statistics?.totalRecords || 0}
prefix={<BarChartOutlined className="text-green-500" />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm">
<Statistic
title="平均得分"
value={statistics?.averageScore || 0}
precision={1}
prefix={<QuestionCircleOutlined className="text-orange-500" />}
valueStyle={{ color: '#fa8c16' }}
suffix="分"
/>
</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">
<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-blue-600">
{stat.correctRate}%
</div>
<div className="text-xs text-gray-500">
{stat.correct}/{stat.total}
</div>
</Card>
</Col>
))}
</Row>
</Card>
)}
{/* 最近答题记录 */}
<Card title="最近答题记录" className="shadow-sm">
<Table
columns={columns}
dataSource={recentRecords}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
</Card>
</div>
);
};
export default AdminDashboardPage;

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import { Card, Form, Input, Button, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts';
import { adminAPI } from '../../services/api';
const AdminLoginPage = () => {
const navigate = useNavigate();
const { setAdmin } = useAdmin();
const [loading, setLoading] = useState(false);
const handleSubmit = async (values: { username: string; password: string }) => {
try {
setLoading(true);
const response = await adminAPI.login(values) as any;
if (response.success) {
setAdmin({
username: values.username,
token: response.data.token
});
message.success('登录成功');
navigate('/admin/dashboard');
}
} catch (error: any) {
message.error(error.message || '登录失败');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<Card className="shadow-xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-blue-600 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
<Form
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
>
<Input
placeholder="请输入用户名"
size="large"
className="rounded-lg"
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' }
]}
>
<Input.Password
placeholder="请输入密码"
size="large"
className="rounded-lg"
/>
</Form.Item>
<Form.Item className="mb-0">
<Button
type="primary"
htmlType="submit"
loading={loading}
size="large"
className="w-full rounded-lg bg-blue-600 hover:bg-blue-700 border-none"
>
</Button>
</Form.Item>
</Form>
<div className="mt-6 text-center">
<a
href="/"
className="text-blue-600 hover:text-blue-800 text-sm"
>
</a>
</div>
</Card>
</div>
</div>
);
};
export default AdminLoginPage;

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import { Card, Table, Button, message, Upload, Modal } from 'antd';
import { UploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import * as XLSX from 'xlsx';
const BackupRestorePage = () => {
const [loading, setLoading] = useState(false);
// 数据备份
const handleBackup = async () => {
try {
setLoading(true);
// 获取所有数据
const [users, questions, records, answers] = await Promise.all([
fetch('/api/admin/export/users').then(res => res.json()),
fetch('/api/admin/export/questions').then(res => res.json()),
fetch('/api/admin/export/records').then(res => res.json()),
fetch('/api/admin/export/answers').then(res => res.json())
]);
// 创建工作簿
const workbook = XLSX.utils.book_new();
// 添加工作表
if (users.success && users.data) {
const usersWS = XLSX.utils.json_to_sheet(users.data);
XLSX.utils.book_append_sheet(workbook, usersWS, '用户数据');
}
if (questions.success && questions.data) {
const questionsWS = XLSX.utils.json_to_sheet(questions.data);
XLSX.utils.book_append_sheet(workbook, questionsWS, '题库数据');
}
if (records.success && records.data) {
const recordsWS = XLSX.utils.json_to_sheet(records.data);
XLSX.utils.book_append_sheet(workbook, recordsWS, '答题记录');
}
if (answers.success && answers.data) {
const answersWS = XLSX.utils.json_to_sheet(answers.data);
XLSX.utils.book_append_sheet(workbook, answersWS, '答题答案');
}
// 下载文件
const fileName = `问卷系统备份_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, fileName);
message.success('数据备份成功');
} catch (error) {
console.error('备份失败:', error);
message.error('数据备份失败');
} finally {
setLoading(false);
}
};
// 数据恢复
const handleRestore = async (file: File) => {
try {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
// 解析各个工作表
const sheetNames = workbook.SheetNames;
const restoreData: any = {};
sheetNames.forEach(sheetName => {
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
if (sheetName.includes('用户')) {
restoreData.users = jsonData;
} else if (sheetName.includes('题库')) {
restoreData.questions = jsonData;
} else if (sheetName.includes('记录')) {
restoreData.records = jsonData;
} else if (sheetName.includes('答案')) {
restoreData.answers = jsonData;
}
});
// 显示恢复确认对话框
Modal.confirm({
title: '确认数据恢复',
content: (
<div>
<p></p>
<ul>
{restoreData.users && <li>{restoreData.users.length} </li>}
{restoreData.questions && <li>{restoreData.questions.length} </li>}
{restoreData.records && <li>{restoreData.records.length} </li>}
{restoreData.answers && <li>{restoreData.answers.length} </li>}
</ul>
<p style={{ color: 'red', marginTop: 16 }}>
</p>
</div>
),
onOk: async () => {
await performRestore(restoreData);
},
width: 500,
});
} catch (error) {
console.error('解析文件失败:', error);
message.error('文件解析失败,请检查文件格式');
}
};
reader.readAsArrayBuffer(file);
} catch (error) {
console.error('恢复失败:', error);
message.error('数据恢复失败');
}
return false; // 阻止上传
};
// 执行数据恢复
const performRestore = async (data: any) => {
try {
setLoading(true);
// 调用恢复API
const response = await fetch('/api/admin/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}`
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
message.success('数据恢复成功');
} else {
message.error(result.message || '数据恢复失败');
}
} catch (error) {
console.error('恢复失败:', error);
message.error('数据恢复失败');
} finally {
setLoading(false);
}
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 数据备份 */}
<Card title="数据备份" className="shadow-sm">
<div className="space-y-4">
<p className="text-gray-600">
Excel文件
</p>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleBackup}
loading={loading}
size="large"
className="w-full"
>
</Button>
</div>
</Card>
{/* 数据恢复 */}
<Card title="数据恢复" className="shadow-sm">
<div className="space-y-4">
<p className="text-gray-600">
Excel文件恢复数据
</p>
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleRestore}
>
<Button
icon={<UploadOutlined />}
loading={loading}
size="large"
className="w-full"
danger
>
</Button>
</Upload>
</div>
</Card>
</div>
{/* 注意事项 */}
<Card title="注意事项" className="mt-6 shadow-sm">
<div className="space-y-2 text-gray-600">
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
</div>
</Card>
</div>
);
};
export default BackupRestorePage;

View File

@@ -0,0 +1,356 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Checkbox, Progress, Row, Col } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../../services/api';
interface ExamSubject {
id: string;
name: string;
totalScore: number;
timeLimitMinutes: number;
typeRatios: Record<string, number>;
categoryRatios: Record<string, number>;
createdAt: string;
}
interface QuestionCategory {
id: string;
name: string;
}
const ExamSubjectPage = () => {
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
const [categories, setCategories] = useState<QuestionCategory[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingSubject, setEditingSubject] = useState<ExamSubject | null>(null);
const [form] = Form.useForm();
// 题型配置
const questionTypes = [
{ key: 'single', label: '单选题', color: '#52c41a' },
{ key: 'multiple', label: '多选题', color: '#faad14' },
{ key: 'judgment', label: '判断题', color: '#ff4d4f' },
{ key: 'text', label: '文字题', color: '#1890ff' },
];
const fetchSubjects = async () => {
setLoading(true);
try {
const [subjectsRes, categoriesRes] = await Promise.all([
api.get('/admin/subjects'),
api.get('/question-categories')
]);
setSubjects(subjectsRes.data);
setCategories(categoriesRes.data);
} catch (error) {
message.error('获取数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSubjects();
}, []);
const handleCreate = () => {
setEditingSubject(null);
form.resetFields();
// 设置默认值
form.setFieldsValue({
typeRatios: { single: 40, multiple: 30, judgment: 20, text: 10 },
categoryRatios: { 通用: 100 },
totalScore: 100,
timeLimitMinutes: 60,
});
setModalVisible(true);
};
const handleEdit = (subject: ExamSubject) => {
setEditingSubject(subject);
form.setFieldsValue({
name: subject.name,
totalScore: subject.totalScore,
timeLimitMinutes: subject.timeLimitMinutes,
typeRatios: subject.typeRatios,
categoryRatios: subject.categoryRatios,
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/subjects/${id}`);
message.success('删除成功');
fetchSubjects();
} catch (error) {
message.error('删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
// 验证题型比重总和
const typeTotal = Object.values(values.typeRatios).reduce((sum: number, val) => sum + val, 0);
if (typeTotal !== 100) {
message.error('题型比重总和必须为100%');
return;
}
// 验证类别比重总和
const categoryTotal = Object.values(values.categoryRatios).reduce((sum: number, val) => sum + val, 0);
if (categoryTotal !== 100) {
message.error('题目类别比重总和必须为100%');
return;
}
if (editingSubject) {
await api.put(`/admin/subjects/${editingSubject.id}`, values);
message.success('更新成功');
} else {
await api.post('/admin/subjects', values);
message.success('创建成功');
}
setModalVisible(false);
fetchSubjects();
} catch (error) {
message.error('操作失败');
}
};
const handleTypeRatioChange = (type: string, value: number) => {
const currentRatios = form.getFieldValue('typeRatios') || {};
const newRatios = { ...currentRatios, [type]: value };
form.setFieldsValue({ typeRatios: newRatios });
};
const handleCategoryRatioChange = (category: string, value: number) => {
const currentRatios = form.getFieldValue('categoryRatios') || {};
const newRatios = { ...currentRatios, [category]: value };
form.setFieldsValue({ categoryRatios: newRatios });
};
const columns = [
{
title: '科目名称',
dataIndex: 'name',
key: 'name',
},
{
title: '总分',
dataIndex: 'totalScore',
key: 'totalScore',
render: (score: number) => `${score}`,
},
{
title: '答题时间',
dataIndex: 'timeLimitMinutes',
key: 'timeLimitMinutes',
render: (minutes: number) => `${minutes} 分钟`,
},
{
title: '题型分布',
dataIndex: 'typeRatios',
key: 'typeRatios',
render: (ratios: Record<string, number>) => (
<div className="space-y-1">
{ratios && Object.entries(ratios).map(([type, ratio]) => {
const typeConfig = questionTypes.find(t => t.key === type);
return (
<div key={type} className="flex items-center justify-between text-sm">
<span>{typeConfig?.label || type}</span>
<span className="font-medium">{ratio}%</span>
</div>
);
})}
</div>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => new Date(text).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_: any, record: ExamSubject) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确定删除该科目吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={subjects}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
/>
<Modal
title={editingSubject ? '编辑科目' : '新增科目'}
open={modalVisible}
onOk={handleModalOk}
onCancel={() => setModalVisible(false)}
width={800}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="科目名称"
rules={[{ required: true, message: '请输入科目名称' }]}
>
<Input placeholder="请输入科目名称" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="totalScore"
label="试卷总分"
rules={[{ required: true, message: '请输入试卷总分' }]}
>
<InputNumber
min={1}
max={200}
placeholder="请输入总分"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="timeLimitMinutes"
label="答题时间(分钟)"
rules={[{ required: true, message: '请输入答题时间' }]}
>
<InputNumber
min={1}
max={180}
placeholder="请输入时间"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
</Row>
<Card size="small" title="题型比重配置" className="mb-4">
<Form.Item name="typeRatios" noStyle>
<div className="space-y-4">
{questionTypes.map((type) => {
const currentRatios = form.getFieldValue('typeRatios') || {};
const ratio = currentRatios[type.key] || 0;
return (
<div key={type.key}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{type.label}</span>
<span className="text-blue-600 font-bold">{ratio}%</span>
</div>
<div className="flex items-center space-x-4">
<InputNumber
min={0}
max={100}
value={ratio}
onChange={(value) => handleTypeRatioChange(type.key, value || 0)}
style={{ width: 100 }}
/>
<div style={{ flex: 1 }}>
<Progress
percent={ratio}
strokeColor={type.color}
showInfo={false}
size="small"
/>
</div>
</div>
</div>
);
})}
<div className="text-right text-sm text-gray-600">
{Object.values(form.getFieldValue('typeRatios') || {}).reduce((sum: number, val) => sum + val, 0)}%
</div>
</div>
</Form.Item>
</Card>
<Card size="small" title="题目类别比重配置">
<Form.Item name="categoryRatios" noStyle>
<div className="space-y-4">
{categories.map((category) => {
const currentRatios = form.getFieldValue('categoryRatios') || {};
const ratio = currentRatios[category.name] || 0;
return (
<div key={category.id}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{category.name}</span>
<span className="text-blue-600 font-bold">{ratio}%</span>
</div>
<div className="flex items-center space-x-4">
<InputNumber
min={0}
max={100}
value={ratio}
onChange={(value) => handleCategoryRatioChange(category.name, value || 0)}
style={{ width: 100 }}
/>
<div style={{ flex: 1 }}>
<Progress
percent={ratio}
strokeColor="#1890ff"
showInfo={false}
size="small"
/>
</div>
</div>
</div>
);
})}
<div className="text-right text-sm text-gray-600">
{Object.values(form.getFieldValue('categoryRatios') || {}).reduce((sum: number, val) => sum + val, 0)}%
</div>
</div>
</Form.Item>
</Card>
</Form>
</Modal>
</div>
);
};
export default ExamSubjectPage;

View File

@@ -0,0 +1,334 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import api from '../../services/api';
import dayjs from 'dayjs';
interface ExamTask {
id: string;
name: string;
subjectId: string;
subjectName: string;
startAt: string;
endAt: string;
userCount: number;
createdAt: string;
}
interface ExamSubject {
id: string;
name: string;
}
interface User {
id: string;
name: string;
phone: string;
}
const ExamTaskPage = () => {
const [tasks, setTasks] = useState<ExamTask[]>([]);
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [reportModalVisible, setReportModalVisible] = useState(false);
const [reportData, setReportData] = useState<any>(null);
const [editingTask, setEditingTask] = useState<ExamTask | null>(null);
const [form] = Form.useForm();
const fetchData = async () => {
setLoading(true);
try {
const [tasksRes, subjectsRes, usersRes] = await Promise.all([
api.get('/admin/tasks'),
api.get('/admin/subjects'),
api.get('/admin/users'),
]);
setTasks(tasksRes.data);
setSubjects(subjectsRes.data);
setUsers(usersRes.data);
} catch (error) {
message.error('获取数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleCreate = () => {
setEditingTask(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (task: ExamTask) => {
setEditingTask(task);
form.setFieldsValue({
name: task.name,
subjectId: task.subjectId,
startAt: dayjs(task.startAt),
endAt: dayjs(task.endAt),
userIds: [],
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/tasks/${id}`);
message.success('删除成功');
fetchData();
} catch (error) {
message.error('删除失败');
}
};
const handleReport = async (taskId: string) => {
try {
const res = await api.get(`/admin/tasks/${taskId}/report`);
setReportData(res.data);
setReportModalVisible(true);
} catch (error) {
message.error('获取报表失败');
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
const payload = {
...values,
startAt: values.startAt.toISOString(),
endAt: values.endAt.toISOString(),
};
if (editingTask) {
await api.put(`/admin/tasks/${editingTask.id}`, payload);
message.success('更新成功');
} else {
await api.post('/admin/tasks', payload);
message.success('创建成功');
}
setModalVisible(false);
fetchData();
} catch (error) {
message.error('操作失败');
}
};
const columns = [
{
title: '任务名称',
dataIndex: 'name',
key: 'name',
},
{
title: '考试科目',
dataIndex: 'subjectName',
key: 'subjectName',
},
{
title: '开始时间',
dataIndex: 'startAt',
key: 'startAt',
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '结束时间',
dataIndex: 'endAt',
key: 'endAt',
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '参与人数',
dataIndex: 'userCount',
key: 'userCount',
render: (count: number) => `${count}`,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
render: (_: any, record: ExamTask) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Button
type="text"
icon={<FileTextOutlined />}
onClick={() => handleReport(record.id)}
>
</Button>
<Popconfirm
title="确定删除该任务吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
/>
<Modal
title={editingTask ? '编辑任务' : '新增任务'}
open={modalVisible}
onOk={handleModalOk}
onCancel={() => setModalVisible(false)}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="任务名称"
rules={[{ required: true, message: '请输入任务名称' }]}
>
<Input placeholder="请输入任务名称" />
</Form.Item>
<Form.Item
name="subjectId"
label="考试科目"
rules={[{ required: true, message: '请选择考试科目' }]}
>
<Select placeholder="请选择考试科目">
{subjects.map((subject) => (
<Select.Option key={subject.id} value={subject.id}>
{subject.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="startAt"
label="开始时间"
rules={[{ required: true, message: '请选择开始时间' }]}
>
<DatePicker
showTime
placeholder="请选择开始时间"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="endAt"
label="结束时间"
rules={[{ required: true, message: '请选择结束时间' }]}
>
<DatePicker
showTime
placeholder="请选择结束时间"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="userIds"
label="参与用户"
rules={[{ required: true, message: '请选择参与用户' }]}
>
<Select
mode="multiple"
placeholder="请选择参与用户"
style={{ width: '100%' }}
>
{users.map((user) => (
<Select.Option key={user.id} value={user.id}>
{user.name} ({user.phone})
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
<Modal
title="任务报表"
open={reportModalVisible}
onCancel={() => setReportModalVisible(false)}
footer={null}
width={800}
>
{reportData && (
<div>
<div className="mb-4">
<h3 className="text-lg font-bold">{reportData.taskName}</h3>
<p>{reportData.subjectName}</p>
<p>{reportData.totalUsers} </p>
<p>{reportData.completedUsers} </p>
<p>{reportData.averageScore.toFixed(2)} </p>
<p>{reportData.topScore} </p>
<p>{reportData.lowestScore} </p>
</div>
<Table
columns={[
{ title: '姓名', dataIndex: 'userName', key: 'userName' },
{ title: '手机号', dataIndex: 'userPhone', key: 'userPhone' },
{
title: '得分',
dataIndex: 'score',
key: 'score',
render: (score: number | null) => score !== null ? `${score}` : '未答题',
},
{
title: '完成时间',
dataIndex: 'completedAt',
key: 'completedAt',
render: (date: string | null) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '未答题',
},
]}
dataSource={reportData.details}
rowKey="userId"
pagination={false}
/>
</div>
)}
</Modal>
</div>
);
};
export default ExamTaskPage;

View File

@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../../services/api';
interface QuestionCategory {
id: string;
name: string;
createdAt: string;
}
const QuestionCategoryPage = () => {
const [categories, setCategories] = useState<QuestionCategory[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingCategory, setEditingCategory] = useState<QuestionCategory | null>(null);
const [form] = Form.useForm();
const fetchCategories = async () => {
setLoading(true);
try {
const res = await api.get('/question-categories');
setCategories(res.data);
} catch (error) {
message.error('获取题目类别失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCategories();
}, []);
const handleCreate = () => {
setEditingCategory(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (category: QuestionCategory) => {
setEditingCategory(category);
form.setFieldsValue({ name: category.name });
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/question-categories/${id}`);
message.success('删除成功');
fetchCategories();
} catch (error) {
message.error('删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingCategory) {
await api.put(`/admin/question-categories/${editingCategory.id}`, values);
message.success('更新成功');
} else {
await api.post('/admin/question-categories', values);
message.success('创建成功');
}
setModalVisible(false);
fetchCategories();
} catch (error) {
message.error('操作失败');
}
};
const columns = [
{
title: '类别名称',
dataIndex: 'name',
key: 'name',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => new Date(text).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_: any, record: QuestionCategory) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确定删除该类别吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={categories}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
/>
<Modal
title={editingCategory ? '编辑类别' : '新增类别'}
open={modalVisible}
onOk={handleModalOk}
onCancel={() => setModalVisible(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="类别名称"
rules={[{ required: true, message: '请输入类别名称' }]}
>
<Input placeholder="请输入类别名称" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default QuestionCategoryPage;

View File

@@ -0,0 +1,666 @@
import { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Upload,
message,
Tag,
Popconfirm,
Row,
Col,
InputNumber,
Radio,
Checkbox,
DatePicker
} from 'antd';
import dayjs from 'dayjs';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
UploadOutlined,
DownloadOutlined,
SearchOutlined,
ReloadOutlined
} from '@ant-design/icons';
import * as XLSX from 'xlsx';
import { questionAPI } from '../../services/api';
import { questionTypeMap, questionTypeColors } from '../../utils/validation';
const { Option } = Select;
const { TextArea } = Input;
interface Question {
id: string;
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
options?: string[];
answer: string | string[];
score: number;
createdAt: string;
}
const QuestionManagePage = () => {
const [questions, setQuestions] = useState<Question[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
const [form] = Form.useForm();
// 筛选条件
const [searchType, setSearchType] = useState<string>('');
const [searchCategory, setSearchCategory] = useState<string>('');
const [searchKeyword, setSearchKeyword] = useState<string>('');
const [searchDateRange, setSearchDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0
});
// 动态选项
const [availableTypes, setAvailableTypes] = useState<string[]>([]);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
useEffect(() => {
fetchQuestions();
}, [pagination.current, pagination.pageSize, searchType, searchCategory, searchKeyword, searchDateRange]);
const fetchQuestions = async () => {
try {
setLoading(true);
const response = await questionAPI.getQuestions({
type: searchType,
category: searchCategory,
keyword: searchKeyword,
startDate: searchDateRange?.[0]?.format('YYYY-MM-DD'),
endDate: searchDateRange?.[1]?.format('YYYY-MM-DD'),
page: pagination.current,
limit: pagination.pageSize
});
setQuestions(response.data);
setPagination(prev => ({
...prev,
total: (response as any).pagination.total
}));
// 提取并更新可用的题型和类别列表
const allQuestions = await questionAPI.getQuestions({ limit: 10000 });
const types = [...new Set(allQuestions.data.map((q: any) => q.type))];
const categories = [...new Set(allQuestions.data.map((q: any) => q.category || '通用'))];
setAvailableTypes(types);
setAvailableCategories(categories);
} catch (error: any) {
message.error(error.message || '获取题目列表失败');
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setEditingQuestion(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (question: Question) => {
setEditingQuestion(question);
form.setFieldsValue({
...question,
options: question.options?.join('\n')
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await questionAPI.deleteQuestion(id);
message.success('删除成功');
fetchQuestions();
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
const handleSubmit = async (values: any) => {
try {
const formData = {
...values,
options: values.options ? values.options.split('\n').filter((opt: string) => opt.trim()) : undefined
};
if (editingQuestion) {
await questionAPI.updateQuestion(editingQuestion.id, formData);
message.success('更新成功');
} else {
await questionAPI.createQuestion(formData);
message.success('创建成功');
}
setModalVisible(false);
fetchQuestions();
} catch (error: any) {
message.error(error.message || '操作失败');
}
};
// 导入题目
const handleImport = async (file: File) => {
try {
// 创建一个 FileReader 来读取 Excel 文件
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
// 使用顶部导入的 XLSX 对象,避免动态导入的问题
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// 转换为 JSON 数据
const rawData = XLSX.utils.sheet_to_json(worksheet);
// 解析数据
const questions = rawData.map((row: any) => ({
content: row['题目内容'] || row['content'],
type: row['题型'] || row['type'],
category: row['题目类别'] || row['category'] || '通用',
answer: row['标准答案'] || row['answer'],
score: parseInt(row['分值'] || row['score']) || 0,
options: row['选项'] || row['options']
}));
// 检查重复题目
const existingQuestions = await Promise.all(
questions.map(async (q: any) => {
try {
// 获取所有题目,然后在前端检查重复
const response = await questionAPI.getQuestions({ limit: 10000 });
const found = response.data.find((existing: any) => existing.content === q.content);
return { ...q, existing: found };
} catch (error) {
return { ...q, existing: null };
}
})
);
// 分离已存在和不存在的题目
const existing = existingQuestions.filter(q => q.existing);
const newQuestions = existingQuestions.filter(q => !q.existing);
// 如果没有重复题目,直接导入
if (existing.length === 0) {
await importQuestions(existingQuestions);
return;
}
// 如果有重复题目,弹出确认框
const modal = Modal.confirm({
title: '导入确认',
content: (
<div>
<p> {existing.length} {newQuestions.length} </p>
<p></p>
<Radio.Group defaultValue="skip">
<Radio value="skip"></Radio>
<Radio value="overwrite"></Radio>
<Radio value="cancel"></Radio>
</Radio.Group>
<Checkbox defaultChecked={false} id="applyAll">
</Checkbox>
</div>
),
onOk: async () => {
try {
const checkbox = document.getElementById('applyAll') as HTMLInputElement;
const applyAll = checkbox?.checked || false;
const radios = document.querySelectorAll('input[type="radio"]');
let option = 'skip';
radios.forEach(radio => {
if ((radio as HTMLInputElement).checked) {
option = (radio as HTMLInputElement).value;
}
});
if (option === 'cancel') {
message.info('已取消导入');
return;
}
let questionsToImport = existingQuestions;
if (option === 'skip') {
questionsToImport = newQuestions;
}
await importQuestions(questionsToImport);
modal.destroy(); // 关闭弹窗
} catch (error) {
modal.destroy(); // 关闭弹窗
}
},
onCancel: () => {
message.info('已取消导入');
}
});
} catch (error: any) {
console.error('导入失败:', error);
message.error(error.message || '导入失败');
}
};
reader.readAsArrayBuffer(file);
} catch (error: any) {
message.error(error.message || '导入失败');
}
return false; // 阻止上传
};
// 实际导入题目
const importQuestions = async (questions: any[]) => {
try {
// 将题目数据转换为 FormData
const formData = new FormData();
// 创建一个新的 Excel 文件
// 使用顶部导入的 XLSX 对象,避免动态导入的问题
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(questions);
XLSX.utils.book_append_sheet(workbook, worksheet, '题目列表');
// 将 workbook 转换为 blob
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const file = new File([blob], 'temp_import.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
formData.append('file', file);
// 调用导入 API
const response = await questionAPI.importQuestions(file);
message.success(`成功导入 ${response.data.imported} 道题`);
if (response.data.errors.length > 0) {
message.warning(`${response.data.errors.length} 道题导入失败`);
}
fetchQuestions();
} catch (error: any) {
message.error(error.message || '导入失败');
console.error('导入失败:', error);
}
};
// 导出题目为Excel
const handleExport = async () => {
try {
setLoading(true);
// 使用现有的API获取题目数据
let exportUrl = '/api/admin/export/questions';
if (searchType) {
exportUrl += `?type=${encodeURIComponent(searchType)}`;
}
// 获取题目数据
const response = await fetch(exportUrl, {
method: 'GET',
headers: {
'Authorization': localStorage.getItem('survey_admin') ? `Bearer ${JSON.parse(localStorage.getItem('survey_admin') || '{}').token}` : '',
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('导出失败');
}
// 获取JSON数据
const result = await response.json();
// 检查结果格式
if (!result || !result.success || !result.data) {
throw new Error('导出失败:数据格式错误');
}
const questionsData = result.data;
// 创建工作簿和工作表
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(questionsData);
// 设置列宽
const columnWidths = [
{ wch: 10 }, // ID
{ wch: 60 }, // 题目内容
{ wch: 10 }, // 题型
{ wch: 15 }, // 题目类别
{ wch: 80 }, // 选项
{ wch: 20 }, // 标准答案
{ wch: 8 }, // 分值
{ wch: 20 } // 创建时间
];
worksheet['!cols'] = columnWidths;
// 将工作表添加到工作簿
XLSX.utils.book_append_sheet(workbook, worksheet, '题目列表');
// 生成Excel文件
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
// 创建下载链接
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `题库导出_${new Date().getTime()}.xlsx`);
document.body.appendChild(link);
link.click();
// 清理
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
message.success('导出成功');
} catch (error: any) {
console.error('导出失败:', error);
message.error(error.message || '导出失败');
} finally {
setLoading(false);
}
};
const columns = [
{
title: '序号',
key: 'index',
width: 60,
render: (_: any, __: any, index: number) => index + 1,
},
{
title: '题目内容',
dataIndex: 'content',
key: 'content',
width: '30%',
ellipsis: true,
},
{
title: '题型',
dataIndex: 'type',
key: 'type',
width: 100,
render: (type: string) => (
<Tag color={questionTypeColors[type as keyof typeof questionTypeColors]}>
{questionTypeMap[type as keyof typeof questionTypeMap]}
</Tag>
),
},
{
title: '题目类别',
dataIndex: 'category',
key: 'category',
width: 120,
render: (category: string) => <span>{category || '通用'}</span>,
},
{
title: '分值',
dataIndex: 'score',
key: 'score',
width: 80,
render: (score: number) => <span className="font-semibold">{score} </span>,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (date: string) => new Date(date).toLocaleDateString(),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: Question) => (
<Space size="small">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
size="small"
/>
<Popconfirm
title="确定删除这道题吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
/>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-800 mb-4"></h1>
<div className="flex flex-wrap gap-4 items-center">
{/* 题型筛选 - 动态生成选项 */}
<Select
placeholder="筛选题型"
allowClear
style={{ width: 120 }}
value={searchType}
onChange={setSearchType}
>
<Option value=""></Option>
{availableTypes.map(type => (
<Option key={type} value={type}>
{questionTypeMap[type as keyof typeof questionTypeMap] || type}
</Option>
))}
</Select>
{/* 题目类别筛选 - 动态生成选项 */}
<Select
placeholder="筛选类别"
allowClear
style={{ width: 120 }}
value={searchCategory}
onChange={setSearchCategory}
>
<Option value=""></Option>
{availableCategories.map(category => (
<Option key={category} value={category}>
{category}
</Option>
))}
</Select>
{/* 创建时间筛选 */}
<DatePicker.RangePicker
placeholder={['开始时间', '结束时间']}
value={searchDateRange}
onChange={setSearchDateRange}
format="YYYY-MM-DD"
/>
{/* 关键字搜索 */}
<Input
placeholder="关键字搜索"
style={{ width: 200 }}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={fetchQuestions}
/>
{/* 刷新按钮 */}
<Button icon={<ReloadOutlined />} onClick={fetchQuestions}>
</Button>
{/* 导入导出按钮 */}
<Space>
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleImport}
>
<Button icon={<DownloadOutlined />}>Excel导入</Button>
</Upload>
<Button icon={<UploadOutlined />} onClick={handleExport}>
Excel导出
</Button>
</Space>
{/* 新增题目按钮 */}
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
</div>
<Card className="shadow-sm">
<Table
columns={columns}
dataSource={questions}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
onChange={(newPagination) => setPagination(newPagination as any)}
/>
</Card>
{/* 编辑/新增模态框 */}
<Modal
title={editingQuestion ? '编辑题目' : '新增题目'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={800}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ score: 10 }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="type"
label="题型"
rules={[{ required: true, message: '请选择题型' }]}
>
<Select placeholder="选择题型">
<Option value="single"></Option>
<Option value="multiple"></Option>
<Option value="judgment"></Option>
<Option value="text"></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="score"
label="分值"
rules={[{ required: true, message: '请输入分值' }]}
>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="content"
label="题目内容"
rules={[{ required: true, message: '请输入题目内容' }]}
>
<TextArea rows={3} placeholder="请输入题目内容" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
>
{({ getFieldValue }) => {
const type = getFieldValue('type');
if (type === 'single' || type === 'multiple') {
return (
<Form.Item
name="options"
label="选项(每行一个)"
rules={[{ required: true, message: '请输入选项' }]}
>
<TextArea
rows={6}
placeholder="请输入选项,每行一个,例如:\n选项A\n选项B\n选项C\n选项D"
/>
</Form.Item>
);
}
return null;
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
>
{({ getFieldValue }) => {
const type = getFieldValue('type');
if (type === 'judgment') {
return (
<Form.Item
name="answer"
label="正确答案"
rules={[{ required: true, message: '请选择正确答案' }]}
>
<Select placeholder="选择正确答案">
<Option value="正确"></Option>
<Option value="错误"></Option>
</Select>
</Form.Item>
);
}
return (
<Form.Item
name="answer"
label="正确答案"
rules={[{ required: true, message: '请输入正确答案' }]}
>
<Input placeholder={type === 'multiple' ? '多个答案用逗号分隔' : '请输入正确答案'} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item className="mb-0">
<Space className="flex justify-end">
<Button onClick={() => setModalVisible(false)}></Button>
<Button type="primary" htmlType="submit">
{editingQuestion ? '更新' : '创建'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default QuestionManagePage;

View File

@@ -0,0 +1,256 @@
import { useState, useEffect } from 'react';
import { Card, Form, InputNumber, Button, Row, Col, Progress, message } from 'antd';
import { adminAPI } from '../../services/api';
interface QuizConfig {
singleRatio: number;
multipleRatio: number;
judgmentRatio: number;
textRatio: number;
totalScore: number;
}
const QuizConfigPage = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchConfig();
}, []);
const fetchConfig = async () => {
try {
setLoading(true);
const response = await adminAPI.getQuizConfig();
form.setFieldsValue(response.data);
} catch (error: any) {
message.error(error.message || '获取配置失败');
} finally {
setLoading(false);
}
};
const handleSubmit = async (values: QuizConfig) => {
try {
setSaving(true);
// 验证比例总和
const totalRatio = values.singleRatio + values.multipleRatio + values.judgmentRatio + values.textRatio;
if (totalRatio !== 100) {
message.error('题型比例总和必须为100%');
return;
}
// 验证总分
if (values.totalScore <= 0) {
message.error('总分必须大于0');
return;
}
await adminAPI.updateQuizConfig(values);
message.success('配置更新成功');
} catch (error: any) {
message.error(error.message || '更新配置失败');
} finally {
setSaving(false);
}
};
const onValuesChange = (changedValues: any, allValues: QuizConfig) => {
// 实时更新进度条
form.setFieldsValue(allValues);
};
const getProgressColor = (ratio: number) => {
if (ratio >= 40) return '#52c41a';
if (ratio >= 20) return '#faad14';
return '#ff4d4f';
};
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-gray-600 mt-2"></p>
</div>
<Card className="shadow-sm" loading={loading}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={onValuesChange}
initialValues={{
singleRatio: 40,
multipleRatio: 30,
judgmentRatio: 20,
textRatio: 10,
totalScore: 100
}}
>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label="单选题比例 (%)"
name="singleRatio"
rules={[{ required: true, message: '请输入单选题比例' }]}
>
<InputNumber
min={0}
max={100}
style={{ width: '100%' }}
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '') as any}
/>
</Form.Item>
</Col>
<Col span={12}>
<Progress
percent={form.getFieldValue('singleRatio') || 0}
strokeColor={getProgressColor(form.getFieldValue('singleRatio') || 0)}
showInfo={false}
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label="多选题比例 (%)"
name="multipleRatio"
rules={[{ required: true, message: '请输入多选题比例' }]}
>
<InputNumber
min={0}
max={100}
style={{ width: '100%' }}
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '') as any}
/>
</Form.Item>
</Col>
<Col span={12}>
<Progress
percent={form.getFieldValue('multipleRatio') || 0}
strokeColor={getProgressColor(form.getFieldValue('multipleRatio') || 0)}
showInfo={false}
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label="判断题比例 (%)"
name="judgmentRatio"
rules={[{ required: true, message: '请输入判断题比例' }]}
>
<InputNumber
min={0}
max={100}
style={{ width: '100%' }}
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '') as any}
/>
</Form.Item>
</Col>
<Col span={12}>
<Progress
percent={form.getFieldValue('judgmentRatio') || 0}
strokeColor={getProgressColor(form.getFieldValue('judgmentRatio') || 0)}
showInfo={false}
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label="文字题比例 (%)"
name="textRatio"
rules={[{ required: true, message: '请输入文字题比例' }]}
>
<InputNumber
min={0}
max={100}
style={{ width: '100%' }}
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '') as any}
/>
</Form.Item>
</Col>
<Col span={12}>
<Progress
percent={form.getFieldValue('textRatio') || 0}
strokeColor={getProgressColor(form.getFieldValue('textRatio') || 0)}
showInfo={false}
/>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item
label="试卷总分"
name="totalScore"
rules={[{ required: true, message: '请输入试卷总分' }]}
>
<InputNumber
min={1}
max={200}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value!.replace('分', '') as any}
/>
</Form.Item>
</Col>
<Col span={12}>
<div className="h-8 flex items-center text-gray-600">
100-150
</div>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<div className="text-sm text-gray-600 mb-4">
<span className="font-semibold text-blue-600">
{(form.getFieldValue('singleRatio') || 0) +
(form.getFieldValue('multipleRatio') || 0) +
(form.getFieldValue('judgmentRatio') || 0) +
(form.getFieldValue('textRatio') || 0)}%
</span>
</div>
</Col>
</Row>
<Form.Item className="mb-0">
<Button
type="primary"
htmlType="submit"
loading={saving}
className="rounded-lg"
>
</Button>
</Form.Item>
</Form>
</Card>
{/* 配置说明 */}
<Card title="配置说明" className="mt-6 shadow-sm">
<div className="space-y-3 text-gray-600">
<p> 100%</p>
<p> </p>
<p> 30%</p>
<p> 20%</p>
<p> 100便</p>
</div>
</Card>
</div>
);
};
export default QuizConfigPage;

View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect } from 'react';
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import api from '../../services/api';
import dayjs from 'dayjs';
interface Answer {
id: string;
questionId: string;
questionContent: string;
questionType: string;
userAnswer: string | string[];
correctAnswer: string | string[];
score: number;
questionScore: number;
isCorrect: boolean;
createdAt: string;
}
interface Record {
id: string;
userId: string;
totalScore: number;
correctCount: number;
totalCount: number;
createdAt: string;
answers: Answer[];
}
const RecordDetailPage = ({ recordId }: { recordId: string }) => {
const [record, setRecord] = useState<Record | null>(null);
const [loading, setLoading] = useState(false);
const fetchRecordDetail = async () => {
setLoading(true);
try {
const res = await api.get(`/admin/quiz/records/detail/${recordId}`);
setRecord(res.data);
} catch (error) {
message.error('获取答题记录详情失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRecordDetail();
}, [recordId]);
if (!record) return null;
const typeStats = record.answers.reduce((acc: any, answer) => {
const type = answer.questionType;
if (!acc[type]) {
acc[type] = { type, total: 0, correct: 0 };
}
acc[type].total += 1;
if (answer.isCorrect) {
acc[type].correct += 1;
}
return acc;
}, {});
const typeChartData = Object.values(typeStats).map((item: any) => ({
type: item.type,
正确率: item.total > 0 ? ((item.correct / item.total) * 100).toFixed(1) : '0.0',
}));
const pieData = [
{ name: '正确', value: record.correctCount, color: '#10b981' },
{ name: '错误', value: record.totalCount - record.correctCount, color: '#ef4444' },
];
const columns = [
{
title: '题目内容',
dataIndex: 'questionContent',
key: 'questionContent',
width: '40%',
},
{
title: '题型',
dataIndex: 'questionType',
key: 'questionType',
width: '10%',
},
{
title: '用户答案',
dataIndex: 'userAnswer',
key: 'userAnswer',
width: '20%',
render: (answer: string | string[]) => {
if (Array.isArray(answer)) {
return answer.join(', ');
}
return answer;
},
},
{
title: '正确答案',
dataIndex: 'correctAnswer',
key: 'correctAnswer',
width: '20%',
render: (answer: string | string[]) => {
if (Array.isArray(answer)) {
return answer.join(', ');
}
return answer;
},
},
{
title: '得分',
dataIndex: 'score',
key: 'score',
width: '5%',
render: (score: number, record: Answer) => (
<span className={record.isCorrect ? 'text-green-600' : 'text-red-600'}>
{score} / {record.questionScore}
</span>
),
},
{
title: '结果',
dataIndex: 'isCorrect',
key: 'isCorrect',
width: '5%',
render: (isCorrect: boolean) => (
<span className={isCorrect ? 'text-green-600' : 'text-red-600'}>
{isCorrect ? '✓' : '✗'}
</span>
),
},
];
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<p className="text-gray-600 mt-2">
{dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<Row gutter={16} className="mb-6">
<Col span={6}>
<Card>
<Statistic title="总得分" value={record.totalScore} suffix="分" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="正确题数" value={record.correctCount} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="总题数" value={record.totalCount} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="正确率"
value={record.totalCount > 0 ? ((record.correctCount / record.totalCount) * 100).toFixed(1) : 0}
suffix="%"
/>
</Card>
</Col>
</Row>
<Row gutter={16} className="mb-6">
<Col span={12}>
<Card title="题型正确率">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={typeChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="type" />
<YAxis />
<Tooltip />
<Bar dataKey="正确率" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Card title="答题结果分布">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={120}
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex justify-center mt-4">
{pieData.map((item) => (
<div key={item.name} className="flex items-center mx-4">
<div
className="w-4 h-4 rounded mr-2"
style={{ backgroundColor: item.color }}
/>
<span>{item.name}: {item.value}</span>
</div>
))}
</div>
</Card>
</Col>
</Row>
<Card title="答题详情">
<Table
columns={columns}
dataSource={record.answers}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
/>
</Card>
</div>
);
};
export default RecordDetailPage;

View File

@@ -0,0 +1,436 @@
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 { adminAPI } from '../../services/api';
import { formatDateTime } from '../../utils/validation';
const { RangePicker } = DatePicker;
const { TabPane } = Tabs;
const { Option } = Select;
interface Statistics {
totalUsers: number;
totalRecords: number;
averageScore: number;
typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>;
}
interface QuizRecord {
id: string;
userName: string;
userPhone: string;
subjectName?: string;
taskName?: string;
totalScore: number;
correctCount: number;
totalCount: number;
createdAt: string;
}
interface UserStats {
userId: string;
userName: string;
totalRecords: number;
averageScore: number;
highestScore: number;
lowestScore: number;
}
interface SubjectStats {
subjectId: string;
subjectName: string;
totalRecords: number;
averageScore: number;
averageCorrectRate: number;
}
interface TaskStats {
taskId: string;
taskName: string;
totalRecords: number;
averageScore: number;
completionRate: number;
}
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
const StatisticsPage = () => {
const [statistics, setStatistics] = useState<Statistics | null>(null);
const [records, setRecords] = useState<QuizRecord[]>([]);
const [userStats, setUserStats] = useState<UserStats[]>([]);
const [subjectStats, setSubjectStats] = useState<SubjectStats[]>([]);
const [taskStats, setTaskStats] = useState<TaskStats[]>([]);
const [loading, setLoading] = useState(false);
const [dateRange, setDateRange] = useState<any>(null);
const [activeTab, setActiveTab] = useState('overview');
const [selectedSubject, setSelectedSubject] = useState<string>('');
const [selectedTask, setSelectedTask] = useState<string>('');
const [subjects, setSubjects] = useState<any[]>([]);
const [tasks, setTasks] = useState<any[]>([]);
useEffect(() => {
fetchInitialData();
}, []);
const fetchInitialData = async () => {
await Promise.all([
fetchStatistics(),
fetchRecords(),
fetchUserStats(),
fetchSubjectStats(),
fetchTaskStats(),
fetchSubjects(),
fetchTasks(),
]);
};
const fetchStatistics = async () => {
try {
setLoading(true);
const response = await adminAPI.getStatistics();
setStatistics(response.data);
} catch (error: any) {
message.error(error.message || '获取统计数据失败');
} finally {
setLoading(false);
}
};
const fetchRecords = async () => {
try {
const response = await fetch('/api/quiz/records?page=1&limit=100', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}`
}
});
const data = await response.json();
if (data.success) {
setRecords(data.data);
}
} catch (error) {
console.error('获取答题记录失败:', error);
}
};
const fetchUserStats = async () => {
try {
const response = await fetch('/api/admin/statistics/users', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}`
}
});
const data = await response.json();
if (data.success) {
setUserStats(data.data);
}
} catch (error) {
console.error('获取用户统计失败:', error);
}
};
const fetchSubjectStats = async () => {
try {
const response = await fetch('/api/admin/statistics/subjects', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}`
}
});
const data = await response.json();
if (data.success) {
setSubjectStats(data.data);
}
} catch (error) {
console.error('获取科目统计失败:', error);
}
};
const fetchTaskStats = async () => {
try {
const response = await fetch('/api/admin/statistics/tasks', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('survey_admin') ? JSON.parse(localStorage.getItem('survey_admin')!).token : ''}`
}
});
const data = await response.json();
if (data.success) {
setTaskStats(data.data);
}
} catch (error) {
console.error('获取任务统计失败:', error);
}
};
const fetchSubjects = async () => {
try {
const response = await fetch('/api/exam-subjects');
const data = await response.json();
if (data.success) {
setSubjects(data.data);
}
} catch (error) {
console.error('获取科目列表失败:', error);
}
};
const fetchTasks = async () => {
try {
const response = await fetch('/api/exam-tasks');
const data = await response.json();
if (data.success) {
setTasks(data.data);
}
} catch (error) {
console.error('获取任务列表失败:', error);
}
};
const handleDateRangeChange = (dates: any) => {
setDateRange(dates);
// 这里可以添加根据日期范围筛选数据的逻辑
};
const exportData = () => {
const csvContent = [
['姓名', '手机号', '科目', '任务', '得分', '正确数', '总题数', '答题时间'],
...records.map(record => [
record.userName,
record.userPhone,
record.subjectName || '',
record.taskName || '',
record.totalScore,
record.correctCount,
record.totalCount,
formatDateTime(record.createdAt)
])
].map(row => row.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `答题记录_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
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 },
{ range: '70-79分', count: records.filter(r => r.totalScore >= 70 && r.totalScore < 80).length },
{ range: '80-89分', count: records.filter(r => r.totalScore >= 80 && r.totalScore < 90).length },
{ range: '90-100分', count: records.filter(r => r.totalScore >= 90).length },
].filter(item => item.count > 0);
const overviewColumns = [
{ title: '姓名', dataIndex: 'userName', key: 'userName' },
{ title: '手机号', dataIndex: 'userPhone', key: 'userPhone' },
{ title: '科目', dataIndex: 'subjectName', key: 'subjectName' },
{ title: '任务', dataIndex: 'taskName', key: 'taskName' },
{ title: '得分', dataIndex: 'totalScore', key: 'totalScore', render: (score: number) => <span className="font-semibold text-blue-600">{score} </span> },
{ title: '正确率', key: 'correctRate', render: (record: QuizRecord) => {
const rate = ((record.correctCount / record.totalCount) * 100).toFixed(1);
return <span>{rate}%</span>;
}},
{ title: '答题时间', dataIndex: 'createdAt', key: 'createdAt', render: (date: string) => formatDateTime(date) },
];
const userStatsColumns = [
{ title: '用户姓名', dataIndex: 'userName', key: 'userName' },
{ title: '答题次数', dataIndex: 'totalRecords', key: 'totalRecords' },
{ title: '平均分', dataIndex: 'averageScore', key: 'averageScore', render: (score: number) => `${score.toFixed(1)}` },
{ title: '最高分', dataIndex: 'highestScore', key: 'highestScore', render: (score: number) => `${score}` },
{ title: '最低分', dataIndex: 'lowestScore', key: 'lowestScore', render: (score: number) => `${score}` },
];
const subjectStatsColumns = [
{ title: '科目名称', dataIndex: 'subjectName', key: 'subjectName' },
{ title: '答题次数', dataIndex: 'totalRecords', key: 'totalRecords' },
{ title: '平均分', dataIndex: 'averageScore', key: 'averageScore', render: (score: number) => `${score.toFixed(1)}` },
{ title: '平均正确率', dataIndex: 'averageCorrectRate', key: 'averageCorrectRate', render: (rate: number) => `${rate.toFixed(1)}%` },
];
const taskStatsColumns = [
{ title: '任务名称', dataIndex: 'taskName', key: 'taskName' },
{ title: '答题次数', dataIndex: 'totalRecords', key: 'totalRecords' },
{ title: '平均分', dataIndex: 'averageScore', key: 'averageScore', render: (score: number) => `${score.toFixed(1)}` },
{ title: '完成率', dataIndex: 'completionRate', key: 'completionRate', render: (rate: number) => `${rate.toFixed(1)}%` },
];
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<div className="space-x-4">
<RangePicker onChange={handleDateRangeChange} />
<Button type="primary" onClick={exportData}>
</Button>
</div>
</div>
{/* 概览统计 */}
<Row gutter={16} className="mb-8">
<Col span={6}>
<Card>
<Statistic
title="总用户数"
value={statistics?.totalUsers || 0}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="总答题数"
value={statistics?.totalRecords || 0}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="平均分"
value={statistics?.averageScore || 0}
precision={1}
valueStyle={{ color: '#faad14' }}
suffix="分"
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="活跃率"
value={statistics?.totalUsers ? ((statistics.totalRecords / statistics.totalUsers) * 100).toFixed(1) : 0}
precision={1}
valueStyle={{ color: '#f5222d' }}
suffix="%"
/>
</Card>
</Col>
</Row>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<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}>
<Card title="分数分布" className="shadow-sm">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={scoreDistribution}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }: any) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="count"
>
{scoreDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
{/* 详细记录 */}
<Card title="答题记录明细" className="shadow-sm">
<Table
columns={overviewColumns}
dataSource={records}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
/>
</Card>
</TabPane>
<TabPane tab="用户统计" key="users">
<Card title="用户答题统计" className="shadow-sm">
<Table
columns={userStatsColumns}
dataSource={userStats}
rowKey="userId"
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
/>
</Card>
</TabPane>
<TabPane tab="科目统计" key="subjects">
<Card title="科目答题统计" className="shadow-sm">
<Table
columns={subjectStatsColumns}
dataSource={subjectStats}
rowKey="subjectId"
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
/>
</Card>
</TabPane>
<TabPane tab="任务统计" key="tasks">
<Card title="考试任务统计" className="shadow-sm">
<Table
columns={taskStatsColumns}
dataSource={taskStats}
rowKey="taskId"
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
/>
</Card>
</TabPane>
</Tabs>
</div>
);
};
export default StatisticsPage;

View File

@@ -0,0 +1,298 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import api from '../../services/api';
import type { UploadProps } from 'antd';
interface User {
id: string;
name: string;
phone: string;
password: string;
createdAt: string;
}
const UserManagePage = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
const [form] = Form.useForm();
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const fetchUsers = async (page = 1, pageSize = 10) => {
setLoading(true);
try {
const res = await api.get(`/admin/users?page=${page}&limit=${pageSize}`);
setUsers(res.data);
setPagination({
current: page,
pageSize,
total: res.pagination?.total || res.data.length,
});
} catch (error) {
message.error('获取用户列表失败');
console.error('获取用户列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleTableChange = (newPagination: any) => {
fetchUsers(newPagination.current, newPagination.pageSize);
};
const handleCreate = () => {
setEditingUser(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (user: User) => {
setEditingUser(user);
form.setFieldsValue({
name: user.name,
phone: user.phone,
password: user.password,
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await api.delete('/admin/users', { data: { userId: id } });
message.success('删除成功');
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
message.error('删除失败');
console.error('删除用户失败:', error);
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingUser) {
// 编辑用户
await api.put(`/admin/users/${editingUser.id}`, values);
message.success('更新成功');
} else {
// 新增用户
await api.post('/admin/users', values);
message.success('创建成功');
}
setModalVisible(false);
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
message.error(editingUser ? '更新失败' : '创建失败');
console.error('保存用户失败:', error);
}
};
const handleExport = async () => {
try {
const res = await api.get('/admin/users/export');
const data = res.data;
const XLSX = await import('xlsx');
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '用户列表');
XLSX.writeFile(wb, '用户列表.xlsx');
message.success('导出成功');
} catch (error) {
message.error('导出失败');
console.error('导出用户失败:', error);
}
};
const handleImport = async (file: File) => {
try {
const XLSX = await import('xlsx');
const data = await file.arrayBuffer();
const workbook = XLSX.read(data);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
const formData = new FormData();
const blob = new Blob([JSON.stringify(jsonData)], { type: 'application/json' });
formData.append('file', blob, 'users.json');
const res = await api.post('/admin/users/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success(`导入成功,共导入 ${res.data.imported} 条数据`);
fetchUsers();
} catch (error) {
message.error('导入失败');
console.error('导入用户失败:', error);
}
return false;
};
const togglePasswordVisibility = (userId: string) => {
const newVisible = new Set(visiblePasswords);
if (newVisible.has(userId)) {
newVisible.delete(userId);
} else {
newVisible.add(userId);
}
setVisiblePasswords(newVisible);
};
const handleViewRecords = (userId: string) => {
// 打开新窗口查看用户答题记录
window.open(`/admin/users/${userId}/records`, '_blank');
};
const columns = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
},
{
title: '密码',
dataIndex: 'password',
key: 'password',
render: (password: string, record: User) => (
<Space>
<span>
{visiblePasswords.has(record.id) ? password : '••••••'}
</span>
<Button
type="text"
icon={visiblePasswords.has(record.id) ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => togglePasswordVisibility(record.id)}
size="small"
/>
</Space>
),
},
{
title: '注册时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => new Date(text).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_: any, record: User) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Button
type="text"
onClick={() => handleViewRecords(record.id)}
>
</Button>
<Popconfirm
title="确定删除该用户吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
const uploadProps: UploadProps = {
accept: '.xlsx,.xls',
showUploadList: false,
beforeUpload: handleImport,
};
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
<Space>
<Button icon={<ExportOutlined />} onClick={handleExport}>
</Button>
<Upload {...uploadProps}>
<Button icon={<ImportOutlined />}>
</Button>
</Upload>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={pagination}
onChange={handleTableChange}
/>
<Modal
title={editingUser ? '编辑用户' : '新增用户'}
open={modalVisible}
onOk={handleModalOk}
onCancel={() => setModalVisible(false)}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[{ required: true, message: '请输入手机号' }]}
>
<Input placeholder="请输入手机号" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password placeholder="请输入密码" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserManagePage;

View File

@@ -0,0 +1,168 @@
import React, { useState, useEffect } from 'react';
import { Table, Card, Row, Col, Statistic, Button, message } from 'antd';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import api from '../../services/api';
import dayjs from 'dayjs';
interface Record {
id: string;
userId: string;
userName: string;
userPhone: string;
totalScore: number;
correctCount: number;
totalCount: number;
createdAt: string;
}
const UserRecordsPage = ({ userId }: { userId: string }) => {
const [records, setRecords] = useState<Record[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const fetchRecords = async (page = 1, pageSize = 10) => {
setLoading(true);
try {
const res = await api.get(`/admin/users/${userId}/records?page=${page}&limit=${pageSize}`);
setRecords(res.data.data);
setPagination({
current: page,
pageSize,
total: res.data.pagination.total,
});
} catch (error) {
message.error('获取答题记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRecords();
}, [userId]);
const handleTableChange = (newPagination: any) => {
fetchRecords(newPagination.current, newPagination.pageSize);
};
const handleViewDetail = (recordId: string) => {
window.open(`/admin/quiz/records/detail/${recordId}`, '_blank');
};
const columns = [
{
title: '答题时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '总得分',
dataIndex: 'totalScore',
key: 'totalScore',
render: (score: number) => <span className="font-semibold text-blue-600">{score} </span>,
},
{
title: '正确题数',
dataIndex: 'correctCount',
key: 'correctCount',
render: (count: number, record: Record) => `${count} / ${record.totalCount}`,
},
{
title: '正确率',
key: 'correctRate',
render: (_: any, record: Record) => {
const rate = record.totalCount > 0 ? ((record.correctCount / record.totalCount) * 100).toFixed(1) : '0.0';
return <span>{rate}%</span>;
},
},
{
title: '操作',
key: 'action',
render: (_: any, record: Record) => (
<Button type="link" onClick={() => handleViewDetail(record.id)}>
</Button>
),
},
];
const scoreDistribution = records.reduce((acc: any, record) => {
const range = Math.floor(record.totalScore / 10) * 10;
const key = `${range}-${range + 9}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const chartData = Object.entries(scoreDistribution).map(([range, count]) => ({
range,
count,
}));
const totalRecords = records.length;
const averageScore = totalRecords > 0 ? records.reduce((sum, r) => sum + r.totalScore, 0) / totalRecords : 0;
const highestScore = totalRecords > 0 ? Math.max(...records.map(r => r.totalScore)) : 0;
const lowestScore = totalRecords > 0 ? Math.min(...records.map(r => r.totalScore)) : 0;
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-800"></h1>
</div>
<Row gutter={16} className="mb-6">
<Col span={6}>
<Card>
<Statistic title="总答题次数" value={totalRecords} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="平均得分" value={averageScore.toFixed(1)} suffix="分" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="最高得分" value={highestScore} suffix="分" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="最低得分" value={lowestScore} suffix="分" />
</Card>
</Col>
</Row>
{chartData.length > 0 && (
<Card title="得分分布" className="mb-6">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="range" />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</Card>
)}
<Card title="答题记录">
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={loading}
pagination={pagination}
onChange={handleTableChange}
/>
</Card>
</div>
);
};
export default UserRecordsPage;