新增文本题库导入功能,题目新增“解析”字段
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> 分
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
214
src/pages/admin/QuestionTextImportPage.tsx
Normal file
214
src/pages/admin/QuestionTextImportPage.tsx
Normal 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
172
src/utils/questionTextImport.ts
Normal file
172
src/utils/questionTextImport.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user