新增文本题库导入功能,题目新增“解析”字段

This commit is contained in:
2025-12-25 00:15:14 +08:00
parent e2a1555b46
commit dc9fc169ec
30 changed files with 1386 additions and 165 deletions

View File

@@ -13,6 +13,7 @@ import { UserTaskPage } from './pages/UserTaskPage';
import AdminLoginPage from './pages/admin/AdminLoginPage';
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
import QuestionManagePage from './pages/admin/QuestionManagePage';
import QuestionTextImportPage from './pages/admin/QuestionTextImportPage';
import QuizConfigPage from './pages/admin/QuizConfigPage';
import StatisticsPage from './pages/admin/StatisticsPage';
import BackupRestorePage from './pages/admin/BackupRestorePage';
@@ -52,6 +53,7 @@ function App() {
<Route path="dashboard" element={<AdminDashboardPage />} />
<Route path="questions" element={<QuestionManagePage />} />
<Route path="question-bank" element={<QuestionManagePage />} />
<Route path="questions/text-import" element={<QuestionTextImportPage />} />
<Route path="categories" element={<QuestionCategoryPage />} />
<Route path="subjects" element={<ExamSubjectPage />} />
<Route path="tasks" element={<ExamTaskPage />} />

View File

@@ -6,6 +6,7 @@ interface Question {
type: 'single' | 'multiple' | 'judgment' | 'text';
options?: string[];
answer: string | string[];
analysis?: string;
score: number;
createdAt: string;
category?: string;
@@ -62,4 +63,4 @@ export const useQuiz = () => {
throw new Error('useQuiz必须在QuizProvider内使用');
}
return context;
};
};

View File

@@ -14,6 +14,7 @@ interface Question {
type: 'single' | 'multiple' | 'judgment' | 'text';
options?: string[];
answer: string | string[];
analysis?: string;
score: number;
createdAt: string;
category?: string;
@@ -38,6 +39,7 @@ const QuizPage = () => {
const [timeLimit, setTimeLimit] = useState<number | null>(null);
const [subjectId, setSubjectId] = useState<string>('');
const [taskId, setTaskId] = useState<string>('');
const [showAnalysis, setShowAnalysis] = useState(false);
useEffect(() => {
if (!user) {
@@ -83,6 +85,10 @@ const QuizPage = () => {
return () => clearInterval(timer);
}, [timeLeft]);
useEffect(() => {
setShowAnalysis(false);
}, [currentQuestionIndex]);
const generateQuiz = async () => {
try {
setLoading(true);
@@ -335,6 +341,23 @@ const QuizPage = () => {
{renderQuestion(currentQuestion)}
</div>
{String(currentQuestion.analysis ?? '').trim() ? (
<div className="mb-6">
<Button
type="link"
onClick={() => setShowAnalysis((v) => !v)}
className="p-0 h-auto text-mars-600 hover:text-mars-700"
>
{showAnalysis ? '收起解析' : '查看解析'}
</Button>
{showAnalysis ? (
<div className="mt-2 p-4 rounded-lg border border-gray-100 bg-gray-50 text-gray-700 whitespace-pre-wrap">
{currentQuestion.analysis}
</div>
) : null}
</div>
) : null}
{/* 操作按钮 */}
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
<Button

View File

@@ -27,6 +27,7 @@ interface QuizAnswer {
isCorrect: boolean;
correctAnswer?: string | string[];
questionScore?: number;
questionAnalysis?: string;
}
const ResultPage = () => {
@@ -260,6 +261,12 @@ const ResultPage = () => {
{renderCorrectAnswer(answer)}
</div>
)}
{String(answer.questionAnalysis ?? '').trim() ? (
<div className="mb-2">
<span className="text-gray-600"></span>
<span className="text-gray-800 whitespace-pre-wrap">{answer.questionAnalysis}</span>
</div>
) : null}
<div className="mb-2 pt-2 border-t border-gray-100 mt-2">
<span className="text-gray-500 text-sm">
{answer.questionScore || 0} <span className="font-medium text-gray-800">{answer.score}</span>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Row, Col, Statistic, Button, Table, message } from 'antd';
import { Card, Row, Col, Statistic, Button, Table, message, DatePicker, Select, Space } from 'antd';
import {
TeamOutlined,
DatabaseOutlined,
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons';
import { adminAPI, quizAPI } from '../../services/api';
import { formatDateTime } from '../../utils/validation';
import type { Dayjs } from 'dayjs';
import {
PieChart,
Pie,
@@ -56,56 +57,63 @@ interface ActiveTaskStat {
endAt: string;
}
interface TaskStatRow extends ActiveTaskStat {
status: '已完成' | '进行中' | '未开始';
}
const AdminDashboardPage = () => {
const navigate = useNavigate();
const [overview, setOverview] = useState<DashboardOverview | null>(null);
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
const [historyTasks, setHistoryTasks] = useState<ActiveTaskStat[]>([]);
const [upcomingTasks, setUpcomingTasks] = useState<ActiveTaskStat[]>([]);
const [historyPagination, setHistoryPagination] = useState({
page: 1,
limit: 5,
total: 0,
});
const [upcomingPagination, setUpcomingPagination] = useState({
const [taskStats, setTaskStats] = useState<TaskStatRow[]>([]);
const [taskStatsPagination, setTaskStatsPagination] = useState({
page: 1,
limit: 5,
total: 0,
});
const [taskStatusFilter, setTaskStatusFilter] = useState<
'' | 'completed' | 'ongoing' | 'notStarted'
>('');
const [endAtRange, setEndAtRange] = useState<[Dayjs | null, Dayjs | null] | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchDashboardData();
}, []);
const buildTaskStatsParams = (page: number, status?: string, range?: [Dayjs | null, Dayjs | null] | null) => {
const params: any = { page, limit: 5 };
if (status === 'completed' || status === 'ongoing' || status === 'notStarted') {
params.status = status;
}
const start = range?.[0] ?? null;
const end = range?.[1] ?? null;
if (start) params.endAtStart = start.startOf('day').toISOString();
if (end) params.endAtEnd = end.endOf('day').toISOString();
return params;
};
const fetchDashboardData = async () => {
try {
setLoading(true);
const [overviewResponse, recordsResponse, historyResponse, upcomingResponse] =
const [overviewResponse, recordsResponse, taskStatsResponse] =
await Promise.all([
adminAPI.getDashboardOverview(),
fetchRecentRecords(),
adminAPI.getHistoryTaskStats({ page: 1, limit: 5 }),
adminAPI.getUpcomingTaskStats({ page: 1, limit: 5 }),
adminAPI.getAllTaskStats(buildTaskStatsParams(1, taskStatusFilter, endAtRange)),
]);
setOverview(overviewResponse.data);
setRecentRecords(recordsResponse);
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,
setTaskStats((taskStatsResponse as any).data || []);
if ((taskStatsResponse as any).pagination) {
setTaskStatsPagination({
page: (taskStatsResponse as any).pagination.page,
limit: (taskStatsResponse as any).pagination.limit,
total: (taskStatsResponse as any).pagination.total,
});
}
} catch (error: any) {
@@ -115,39 +123,25 @@ const AdminDashboardPage = () => {
}
};
const fetchHistoryTasks = async (page: number) => {
const fetchTaskStats = async (
page: number,
next: { status?: '' | 'completed' | 'ongoing' | 'notStarted'; range?: [Dayjs | null, Dayjs | null] | null } = {},
) => {
try {
setLoading(true);
const response = (await adminAPI.getHistoryTaskStats({ page, limit: 5 })) as any;
setHistoryTasks(response.data || []);
const status = next.status ?? taskStatusFilter;
const range = next.range ?? endAtRange;
const response = (await adminAPI.getAllTaskStats(buildTaskStatsParams(page, status, range))) as any;
setTaskStats(response.data || []);
if (response.pagination) {
setHistoryPagination({
setTaskStatsPagination({
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 || '获取未开始考试任务统计失败');
message.error(error.message || '获取考试任务统计失败');
} finally {
setLoading(false);
}
@@ -158,19 +152,14 @@ const AdminDashboardPage = () => {
return response.data || [];
};
const categoryPieData =
overview?.questionCategoryStats?.map((item) => ({
name: item.category,
value: item.count,
})) || [];
const totalQuestions =
overview?.questionCategoryStats?.reduce((sum, item) => sum + (Number(item.count) || 0), 0) || 0;
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 totalTasks = overview
? Number(overview.taskStatusDistribution.completed || 0) +
Number(overview.taskStatusDistribution.ongoing || 0) +
Number(overview.taskStatusDistribution.notStarted || 0)
: 0;
const columns = [
{
@@ -220,6 +209,11 @@ const AdminDashboardPage = () => {
];
const taskStatsColumns = [
{
title: '状态',
dataIndex: 'status',
key: 'status',
},
{
title: '任务名称',
dataIndex: 'taskName',
@@ -327,7 +321,7 @@ const AdminDashboardPage = () => {
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: '12px' }}
formatter={(value, entry: any) => (
formatter={(value: string, entry: any) => (
<span className="text-xs text-gray-600 ml-1">
{value} {entry.payload.value}
</span>
@@ -362,6 +356,7 @@ const AdminDashboardPage = () => {
<Card
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate('/admin/users')}
styles={{ body: { padding: 16 } }}
>
<Statistic
title="总用户数"
@@ -378,34 +373,13 @@ const AdminDashboardPage = () => {
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>
<Statistic
title="题库统计"
value={totalQuestions}
prefix={<DatabaseOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
suffix="题"
/>
</Card>
</Col>
@@ -413,6 +387,7 @@ const AdminDashboardPage = () => {
<Card
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate('/admin/subjects')}
styles={{ body: { padding: 16 } }}
>
<Statistic
title="考试科目"
@@ -430,69 +405,61 @@ const AdminDashboardPage = () => {
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>
<Statistic
title="考试任务"
value={totalTasks}
prefix={<CalendarOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
suffix="个"
/>
</Card>
</Col>
</Row>
<Card title="历史考试任务统计" className="mb-8 shadow-sm">
<Card
title="考试任务统计"
className="mb-8 shadow-sm"
extra={
<Space>
<Select
style={{ width: 140 }}
value={taskStatusFilter}
onChange={(value) => {
setTaskStatusFilter(value);
fetchTaskStats(1, { status: value });
}}
options={[
{ value: '', label: '全部状态' },
{ value: 'completed', label: '已完成' },
{ value: 'ongoing', label: '进行中' },
{ value: 'notStarted', label: '未开始' },
]}
/>
<DatePicker.RangePicker
value={endAtRange}
placeholder={['结束时间开始', '结束时间结束']}
format="YYYY-MM-DD"
onChange={(value) => {
setEndAtRange(value);
fetchTaskStats(1, { range: value });
}}
allowClear
/>
</Space>
}
>
<Table
columns={taskStatsColumns}
dataSource={historyTasks}
dataSource={taskStats}
rowKey="taskId"
loading={loading}
size="small"
pagination={{
current: historyPagination.page,
current: taskStatsPagination.page,
pageSize: 5,
total: historyPagination.total,
total: taskStatsPagination.total,
showSizeChanger: false,
onChange: (page) => fetchHistoryTasks(page),
}}
/>
</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),
onChange: (page) => fetchTaskStats(page),
}}
/>
</Card>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Checkbox, Progress, Row, Col } from 'antd';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Checkbox, Progress, Row, Col, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import api from '../../services/api';
import { getCategoryColorHex } from '../../lib/categoryColors';
@@ -10,6 +10,7 @@ interface Question {
type: string;
options?: string[];
answer: string | string[];
analysis?: string;
score: number;
category: string;
}
@@ -603,6 +604,11 @@ const ExamSubjectPage = () => {
question.answer}
</span>
</div>
<div className="mt-3">
<Tag color="blue"></Tag>
<span className="text-gray-700 whitespace-pre-wrap">{question.analysis || ''}</span>
</div>
</Card>
))}
</div>

View File

@@ -27,9 +27,11 @@ import {
UploadOutlined,
DownloadOutlined,
SearchOutlined,
ReloadOutlined
ReloadOutlined,
FileTextOutlined
} from '@ant-design/icons';
import * as XLSX from 'xlsx';
import { useNavigate } from 'react-router-dom';
import { questionAPI } from '../../services/api';
import { questionTypeMap, questionTypeColors } from '../../utils/validation';
import { getCategoryColorHex } from '../../lib/categoryColors';
@@ -43,11 +45,13 @@ interface Question {
type: 'single' | 'multiple' | 'judgment' | 'text';
options?: string[];
answer: string | string[];
analysis?: string;
score: number;
createdAt: string;
}
const QuestionManagePage = () => {
const navigate = useNavigate();
const [questions, setQuestions] = useState<Question[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
@@ -114,7 +118,8 @@ const QuestionManagePage = () => {
setEditingQuestion(question);
form.setFieldsValue({
...question,
options: question.options?.join('\n')
options: question.options?.join('\n'),
analysis: question.analysis || ''
});
setModalVisible(true);
};
@@ -523,6 +528,9 @@ const QuestionManagePage = () => {
>
<Button icon={<DownloadOutlined />}>Excel导入</Button>
</Upload>
<Button icon={<FileTextOutlined />} onClick={() => navigate('/admin/questions/text-import')}>
</Button>
<Button icon={<UploadOutlined />} onClick={handleExport}>
Excel导出
</Button>
@@ -655,6 +663,10 @@ const QuestionManagePage = () => {
}}
</Form.Item>
<Form.Item name="analysis" label="解析">
<TextArea rows={3} maxLength={255} showCount placeholder="请输入解析(可为空)" />
</Form.Item>
<Form.Item className="mb-0">
<Space className="flex justify-end">
<Button onClick={() => setModalVisible(false)}></Button>

View File

@@ -0,0 +1,214 @@
import { useState } from 'react';
import { Alert, Button, Card, Input, Modal, Radio, Space, Table, Tag, Typography, message } from 'antd';
import { ArrowLeftOutlined, DeleteOutlined, FileTextOutlined, ImportOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { questionAPI } from '../../services/api';
import { questionTypeMap } from '../../utils/validation';
import { getCategoryColorHex } from '../../lib/categoryColors';
import { type ImportMode, type ImportQuestion, parseTextQuestions } from '../../utils/questionTextImport';
const { TextArea } = Input;
const QuestionTextImportPage = () => {
const navigate = useNavigate();
const [rawText, setRawText] = useState('');
const [mode, setMode] = useState<ImportMode>('incremental');
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [questions, setQuestions] = useState<ImportQuestion[]>([]);
const [loading, setLoading] = useState(false);
const exampleText = [
'题型|题目类别|分值|题目内容|选项|答案|解析',
'单选|通用|5|我国首都是哪里?|北京|上海|广州|深圳|A|我国首都为北京',
'多选|通用|5|以下哪些是水果?|苹果|白菜|香蕉|西红柿|A,C,D|水果包括苹果/香蕉/西红柿',
'判断|通用|2|地球是圆的||正确|地球接近球体',
'文字描述|通用|10|请简述你对该岗位的理解||可自由作答|仅用于人工评阅',
].join('\n');
const handleParse = () => {
const result = parseTextQuestions(rawText);
setParseErrors(result.errors);
setQuestions(result.questions);
if (result.questions.length === 0) {
message.error(result.errors.length ? '解析失败,请检查格式' : '未解析到任何题目');
return;
}
if (result.errors.length > 0) {
message.warning(`解析成功 ${result.questions.length} 条,忽略 ${result.errors.length} 条错误`);
return;
}
message.success(`解析成功 ${result.questions.length}`);
};
const handleRemove = (idx: number) => {
setQuestions((prev) => prev.filter((_, i) => i !== idx));
};
const handleImport = async () => {
if (questions.length === 0) return;
Modal.confirm({
title: '确认导入',
content: mode === 'overwrite' ? '覆盖式导入将清空现有题库并导入当前列表' : '增量导入将按题目内容重复覆盖',
okText: '开始导入',
cancelText: '取消',
onOk: async () => {
setLoading(true);
try {
const res = await questionAPI.importQuestionsFromText({ mode, questions });
const stats = res.data;
message.success(`导入完成:新增${stats.inserted},覆盖${stats.updated},失败${stats.errors?.length ?? 0}`);
navigate('/admin/questions');
} catch (e: any) {
message.error(e.message || '导入失败');
} finally {
setLoading(false);
}
},
});
};
const columns = [
{
title: '序号',
key: 'index',
width: 60,
render: (_: any, __: any, index: number) => index + 1,
},
{
title: '题目内容',
dataIndex: 'content',
key: 'content',
ellipsis: true,
},
{
title: '题型',
dataIndex: 'type',
key: 'type',
width: 100,
render: (type: ImportQuestion['type']) => <Tag color="#008C8C">{questionTypeMap[type]}</Tag>,
},
{
title: '题目类别',
dataIndex: 'category',
key: 'category',
width: 120,
render: (category: string) => {
const cat = category || '通用';
return <Tag color={getCategoryColorHex(cat)}>{cat}</Tag>;
},
},
{
title: '分值',
dataIndex: 'score',
key: 'score',
width: 90,
render: (score: number) => `${score}`,
},
{
title: '答案',
dataIndex: 'answer',
key: 'answer',
width: 160,
ellipsis: true,
render: (answer: ImportQuestion['answer']) => (Array.isArray(answer) ? answer.join(' | ') : answer),
},
{
title: '解析',
dataIndex: 'analysis',
key: 'analysis',
width: 220,
ellipsis: true,
render: (analysis: ImportQuestion['analysis']) => (
<span>
<Tag color="blue"></Tag>
{analysis || '无'}
</span>
),
},
{
title: '操作',
key: 'action',
width: 80,
render: (_: any, __: any, index: number) => (
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleRemove(index)} />
),
},
];
return (
<div>
<div className="mb-6 flex items-center justify-between">
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/questions')}>
</Button>
<Typography.Title level={3} style={{ margin: 0 }}>
</Typography.Title>
</Space>
<Space>
<Radio.Group value={mode} onChange={(e) => setMode(e.target.value)}>
<Radio.Button value="incremental"></Radio.Button>
<Radio.Button value="overwrite"></Radio.Button>
</Radio.Group>
<Button
type="primary"
icon={<ImportOutlined />}
disabled={questions.length === 0}
loading={loading}
onClick={handleImport}
>
</Button>
</Space>
</div>
<Card className="shadow-sm mb-4">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space>
<Button icon={<FileTextOutlined />} onClick={() => setRawText(exampleText)}>
</Button>
<Button type="primary" onClick={handleParse} disabled={!rawText.trim()}>
</Button>
</Space>
<TextArea
value={rawText}
onChange={(e) => setRawText(e.target.value)}
placeholder={exampleText}
rows={12}
/>
{parseErrors.length > 0 && (
<Alert
type="warning"
message="解析错误(已忽略对应行)"
description={
<div style={{ maxHeight: 160, overflow: 'auto' }}>
{parseErrors.map((err) => (
<div key={err}>{err}</div>
))}
</div>
}
showIcon
/>
)}
</Space>
</Card>
<Card className="shadow-sm">
<Table
columns={columns as any}
dataSource={questions.map((q, idx) => ({ ...q, key: `${q.content}-${idx}` }))}
pagination={{ pageSize: 10, showSizeChanger: true }}
/>
</Card>
</div>
);
};
export default QuestionTextImportPage;

View File

@@ -27,9 +27,15 @@ interface QuizRecord {
taskName?: string;
}
interface UserGroup {
id: string;
name: string;
isSystem: boolean;
}
const UserManagePage = () => {
const [users, setUsers] = useState<User[]>([]);
const [userGroups, setUserGroups] = useState<any[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
@@ -85,9 +91,12 @@ const UserManagePage = () => {
const fetchUserGroups = async () => {
try {
const res = await userGroupAPI.getAll();
setUserGroups(res.data);
const groups = (res.data || []) as UserGroup[];
setUserGroups(groups);
return groups;
} catch (error) {
console.error('获取用户组失败');
return [];
}
};
@@ -110,18 +119,19 @@ const UserManagePage = () => {
setSearchKeyword(e.target.value);
};
const handleCreate = () => {
const handleCreate = async () => {
setEditingUser(null);
form.resetFields();
// Set default groups (e.g. system group)
const systemGroups = userGroups.filter(g => g.isSystem).map(g => g.id);
const groups = await fetchUserGroups();
const systemGroups = groups.filter((g) => g.isSystem).map((g) => g.id);
form.setFieldsValue({ groupIds: systemGroups });
setModalVisible(true);
};
const handleEdit = (user: User) => {
const handleEdit = async (user: User) => {
await fetchUserGroups();
setEditingUser(user);
form.setFieldsValue({
name: user.name,

View File

@@ -80,6 +80,8 @@ export const questionAPI = {
},
});
},
importQuestionsFromText: (data: { mode: 'overwrite' | 'incremental'; questions: any[] }) =>
api.post('/questions/import-text', data),
exportQuestions: (params?: { type?: string; category?: string }) =>
api.get('/questions/export', {
params,
@@ -115,6 +117,13 @@ export const adminAPI = {
api.get('/admin/tasks/history-stats', { params }),
getUpcomingTaskStats: (params?: { page?: number; limit?: number }) =>
api.get('/admin/tasks/upcoming-stats', { params }),
getAllTaskStats: (params?: {
page?: number;
limit?: number;
status?: 'completed' | 'ongoing' | 'notStarted';
endAtStart?: string;
endAtEnd?: string;
}) => api.get('/admin/tasks/all-stats', { params }),
getUserStats: () => api.get('/admin/statistics/users'),
getSubjectStats: () => api.get('/admin/statistics/subjects'),
getTaskStats: () => api.get('/admin/statistics/tasks'),

View File

@@ -0,0 +1,172 @@
export type ImportMode = 'overwrite' | 'incremental';
export type ImportQuestion = {
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
category?: string;
options?: string[];
answer: string | string[];
analysis: string;
score: number;
};
export type ParseResult = {
questions: ImportQuestion[];
errors: string[];
};
const normalizeType = (raw: string): ImportQuestion['type'] | null => {
const t = String(raw || '').trim();
if (!t) return null;
const map: Record<string, ImportQuestion['type']> = {
: 'single',
: 'single',
single: 'single',
: 'multiple',
: 'multiple',
multiple: 'multiple',
: 'judgment',
: 'judgment',
judgment: 'judgment',
: 'text',
: 'text',
: 'text',
: 'text',
: 'text',
: 'text',
: 'text',
text: 'text',
};
return map[t] ?? null;
};
const splitMulti = (raw: string) =>
String(raw || '')
.split(/[|,,、\s]+/g)
.map((s) => s.trim())
.filter(Boolean);
const normalizeJudgmentAnswer = (raw: string) => {
const v = String(raw || '').trim();
if (!v) return '';
const yes = new Set(['正确', '对', '是', 'true', 'True', 'TRUE', '1', 'Y', 'y', 'yes', 'YES']);
const no = new Set(['错误', '错', '否', '不是', 'false', 'False', 'FALSE', '0', 'N', 'n', 'no', 'NO']);
if (yes.has(v)) return '正确';
if (no.has(v)) return '错误';
return v;
};
const parseLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.startsWith('#')) return null;
if (trimmed.startsWith('题型')) return null;
const hasPipeDelimiter = trimmed.includes('|');
const hasCsvDelimiter = /\t|,|/g.test(trimmed);
const parts = hasPipeDelimiter
? trimmed.split('|').map((p) => p.trim())
: hasCsvDelimiter
? trimmed.split(/\t|,|/g).map((p) => p.trim())
: [];
if (parts.length < 4) return { error: `字段不足:${trimmed}` };
const type = normalizeType(parts[0]);
if (!type) return { error: `题型无法识别:${trimmed}` };
const category = parts[1] || '通用';
const score = Number(parts[2]);
if (!Number.isFinite(score) || score <= 0) return { error: `分值必须是正数:${trimmed}` };
const content = parts[3];
if (!content) return { error: `题目内容不能为空:${trimmed}` };
const pickDelimitedFields = () => {
if (!hasPipeDelimiter && hasCsvDelimiter) {
return {
optionsRaw: parts[4] ?? '',
answerRaw: parts[5] ?? '',
analysisRaw: parts[6] ?? '',
optionsTokens: [] as string[],
};
}
if (!hasPipeDelimiter) {
return { optionsRaw: '', answerRaw: '', analysisRaw: '', optionsTokens: [] as string[] };
}
if (parts.length === 5) {
return { optionsRaw: '', answerRaw: parts[4] ?? '', analysisRaw: '', optionsTokens: [] as string[] };
}
const analysisRaw = parts[parts.length - 1] ?? '';
const answerRaw = parts[parts.length - 2] ?? '';
const optionsTokens = parts.slice(4, Math.max(4, parts.length - 2));
return { optionsRaw: optionsTokens.join('|'), answerRaw, analysisRaw, optionsTokens };
};
const { optionsRaw, answerRaw, analysisRaw, optionsTokens } = pickDelimitedFields();
const question: ImportQuestion = { type, category, score, content, answer: '', analysis: String(analysisRaw || '').trim().slice(0, 255) };
if (type === 'single' || type === 'multiple') {
const options = hasPipeDelimiter && !hasCsvDelimiter ? optionsTokens.map((s) => s.trim()).filter(Boolean) : splitMulti(optionsRaw);
if (options.length < 2) return { error: `选项至少2个${trimmed}` };
const answerTokens = splitMulti(answerRaw);
if (answerTokens.length === 0) return { error: `答案不能为空:${trimmed}` };
const toValue = (token: string) => {
const m = token.trim().match(/^([A-Za-z])$/);
if (!m) return token;
const idx = m[1].toUpperCase().charCodeAt(0) - 65;
return options[idx] ?? token;
};
question.options = options;
const normalized = answerTokens.map(toValue).filter(Boolean);
question.answer = type === 'multiple' ? normalized : normalized[0];
return { question };
}
if (type === 'judgment') {
const a = normalizeJudgmentAnswer(answerRaw);
if (!a) return { error: `答案不能为空:${trimmed}` };
question.answer = a;
return { question };
}
const textAnswer = String(answerRaw || '').trim();
if (!textAnswer) return { error: `答案不能为空:${trimmed}` };
question.answer = textAnswer;
return { question };
};
export const parseTextQuestions = (text: string): ParseResult => {
const lines = String(text || '')
.split(/\r?\n/g)
.map((l) => l.trim())
.filter((l) => l.length > 0);
const errors: string[] = [];
const list: ImportQuestion[] = [];
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const parsed = parseLine(raw);
if (!parsed) continue;
if ('error' in parsed) {
errors.push(`${i + 1}行:${parsed.error}`);
continue;
}
if (parsed.question) list.push(parsed.question);
}
const dedup = new Map<string, ImportQuestion>();
for (const q of list) {
const key = q.content.trim();
if (!key) continue;
dedup.set(key, q);
}
return { questions: Array.from(dedup.values()), errors };
};