新增文本题库导入功能,题目新增“解析”字段
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user