新增文本题库导入功能,题目新增“解析”字段
This commit is contained in:
@@ -187,6 +187,55 @@ export class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllTaskStats(req: Request, res: Response) {
|
||||
try {
|
||||
const page = toPositiveInt(req.query.page, 1);
|
||||
const limit = toPositiveInt(req.query.limit, 5);
|
||||
|
||||
const statusRaw = typeof req.query.status === 'string' ? req.query.status : undefined;
|
||||
const status =
|
||||
statusRaw === 'completed' || statusRaw === 'ongoing' || statusRaw === 'notStarted'
|
||||
? statusRaw
|
||||
: undefined;
|
||||
|
||||
const endAtStart = typeof req.query.endAtStart === 'string' ? req.query.endAtStart : undefined;
|
||||
const endAtEnd = typeof req.query.endAtEnd === 'string' ? req.query.endAtEnd : undefined;
|
||||
|
||||
if (endAtStart && !Number.isFinite(Date.parse(endAtStart))) {
|
||||
return res.status(400).json({ success: false, message: 'endAtStart 参数无效' });
|
||||
}
|
||||
if (endAtEnd && !Number.isFinite(Date.parse(endAtEnd))) {
|
||||
return res.status(400).json({ success: false, message: 'endAtEnd 参数无效' });
|
||||
}
|
||||
|
||||
const { ExamTaskModel } = await import('../models/examTask');
|
||||
const result = await ExamTaskModel.getAllTasksWithStatsPaged({
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
endAtStart,
|
||||
endAtEnd,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
pages: Math.ceil(result.total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('获取任务统计失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取任务统计失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getDashboardOverview(req: Request, res: Response) {
|
||||
try {
|
||||
const { QuizModel } = await import('../models');
|
||||
|
||||
@@ -66,7 +66,7 @@ export class QuestionController {
|
||||
// 创建题目
|
||||
static async createQuestion(req: Request, res: Response) {
|
||||
try {
|
||||
const { content, type, category, options, answer, score } = req.body;
|
||||
const { content, type, category, options, answer, analysis, score } = req.body;
|
||||
|
||||
const questionData: CreateQuestionData = {
|
||||
content,
|
||||
@@ -74,6 +74,7 @@ export class QuestionController {
|
||||
category,
|
||||
options,
|
||||
answer,
|
||||
analysis,
|
||||
score
|
||||
};
|
||||
|
||||
@@ -106,7 +107,7 @@ export class QuestionController {
|
||||
static async updateQuestion(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { content, type, category, options, answer, score } = req.body;
|
||||
const { content, type, category, options, answer, analysis, score } = req.body;
|
||||
|
||||
const updateData: Partial<CreateQuestionData> = {};
|
||||
if (content !== undefined) updateData.content = content;
|
||||
@@ -114,6 +115,7 @@ export class QuestionController {
|
||||
if (category !== undefined) updateData.category = category;
|
||||
if (options !== undefined) updateData.options = options;
|
||||
if (answer !== undefined) updateData.answer = answer;
|
||||
if (analysis !== undefined) updateData.analysis = analysis;
|
||||
if (score !== undefined) updateData.score = score;
|
||||
|
||||
const question = await QuestionModel.update(id, updateData);
|
||||
@@ -181,6 +183,7 @@ export class QuestionController {
|
||||
type: QuestionController.mapQuestionType(row['题型'] || row['type']),
|
||||
category: row['题目类别'] || row['category'] || '通用',
|
||||
answer: row['标准答案'] || row['answer'],
|
||||
analysis: row['解析'] || row['analysis'] || '',
|
||||
score: parseInt(row['分值'] || row['score']) || 0,
|
||||
options: QuestionController.parseOptions(row['选项'] || row['options'])
|
||||
}));
|
||||
@@ -215,6 +218,120 @@ export class QuestionController {
|
||||
}
|
||||
}
|
||||
|
||||
static async importTextQuestions(req: Request, res: Response) {
|
||||
try {
|
||||
const mode = req.body?.mode as 'overwrite' | 'incremental';
|
||||
const rawQuestions = req.body?.questions as any[];
|
||||
|
||||
if (mode !== 'overwrite' && mode !== 'incremental') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '导入模式不合法',
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '题目列表不能为空',
|
||||
});
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const normalized = new Map<string, CreateQuestionData>();
|
||||
|
||||
const normalizeAnswer = (type: string, answer: unknown): string | string[] => {
|
||||
if (type === 'multiple') {
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.map((a) => String(a).trim()).filter(Boolean);
|
||||
}
|
||||
return String(answer || '')
|
||||
.split(/[|,,、\s]+/g)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(answer)) {
|
||||
return String(answer[0] ?? '').trim();
|
||||
}
|
||||
return String(answer ?? '').trim();
|
||||
};
|
||||
|
||||
const normalizeJudgment = (answer: string) => {
|
||||
const v = String(answer || '').trim();
|
||||
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;
|
||||
};
|
||||
|
||||
for (let i = 0; i < rawQuestions.length; i++) {
|
||||
const q = rawQuestions[i] || {};
|
||||
const content = String(q.content ?? '').trim();
|
||||
if (!content) {
|
||||
errors.push(`第${i + 1}题:题目内容不能为空`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = QuestionController.mapQuestionType(String(q.type ?? '').trim());
|
||||
const category = String(q.category ?? '通用').trim() || '通用';
|
||||
const score = Number(q.score);
|
||||
const options = Array.isArray(q.options) ? q.options.map((o: any) => String(o).trim()).filter(Boolean) : undefined;
|
||||
let answer = normalizeAnswer(type, q.answer);
|
||||
const analysis = String(q.analysis ?? '').trim();
|
||||
|
||||
if (type === 'judgment' && typeof answer === 'string') {
|
||||
answer = normalizeJudgment(answer);
|
||||
}
|
||||
|
||||
const questionData: CreateQuestionData = {
|
||||
content,
|
||||
type: type as any,
|
||||
category,
|
||||
options: type === 'single' || type === 'multiple' ? options : undefined,
|
||||
answer: answer as any,
|
||||
analysis,
|
||||
score,
|
||||
};
|
||||
|
||||
const validationErrors = QuestionModel.validateQuestionData(questionData);
|
||||
if (validationErrors.length > 0) {
|
||||
errors.push(`第${i + 1}题:${validationErrors.join(';')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.set(content, questionData);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '数据验证失败',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await QuestionModel.importFromText(mode, Array.from(normalized.values()));
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
mode,
|
||||
total: normalized.size,
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
errors: result.errors,
|
||||
cleared: result.cleared ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('文本导入失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '文本导入失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 映射题型
|
||||
private static mapQuestionType(type: string): string {
|
||||
const typeMap: { [key: string]: string } = {
|
||||
@@ -270,6 +387,7 @@ export class QuestionController {
|
||||
'题目类别': question.category || '通用',
|
||||
'选项': question.options ? question.options.join('|') : '',
|
||||
'标准答案': question.answer,
|
||||
'解析': question.analysis || '',
|
||||
'分值': question.score,
|
||||
'创建时间': new Date(question.createdAt).toLocaleString()
|
||||
}));
|
||||
|
||||
@@ -118,6 +118,7 @@ export const initDatabase = async () => {
|
||||
console.log('数据库初始化成功');
|
||||
} else {
|
||||
console.log('数据库表已存在,跳过初始化');
|
||||
await ensureColumn('questions', "analysis TEXT NOT NULL DEFAULT ''", 'analysis');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('数据库初始化失败:', error);
|
||||
|
||||
@@ -18,6 +18,7 @@ CREATE TABLE questions (
|
||||
type TEXT NOT NULL CHECK(type IN ('single', 'multiple', 'judgment', 'text')),
|
||||
options TEXT, -- JSON格式存储选项
|
||||
answer TEXT NOT NULL,
|
||||
analysis TEXT NOT NULL DEFAULT '',
|
||||
score INTEGER NOT NULL CHECK(score > 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
@@ -318,6 +318,89 @@ export class ExamTaskModel {
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
static async getAllTasksWithStatsPaged(
|
||||
input: {
|
||||
page: number;
|
||||
limit: number;
|
||||
status?: 'completed' | 'ongoing' | 'notStarted';
|
||||
endAtStart?: string;
|
||||
endAtEnd?: string;
|
||||
},
|
||||
): Promise<{ data: Array<ActiveTaskStat & { status: '已完成' | '进行中' | '未开始' }>; total: number }> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const offset = (input.page - 1) * input.limit;
|
||||
|
||||
const whereParts: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (input.status === 'completed') {
|
||||
whereParts.push('t.end_at < ?');
|
||||
params.push(nowIso);
|
||||
} else if (input.status === 'ongoing') {
|
||||
whereParts.push('t.start_at <= ? AND t.end_at >= ?');
|
||||
params.push(nowIso, nowIso);
|
||||
} else if (input.status === 'notStarted') {
|
||||
whereParts.push('t.start_at > ?');
|
||||
params.push(nowIso);
|
||||
}
|
||||
|
||||
if (input.endAtStart) {
|
||||
whereParts.push('t.end_at >= ?');
|
||||
params.push(input.endAtStart);
|
||||
}
|
||||
if (input.endAtEnd) {
|
||||
whereParts.push('t.end_at <= ?');
|
||||
params.push(input.endAtEnd);
|
||||
}
|
||||
|
||||
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const totalRow = await get(`SELECT COUNT(*) as total FROM exam_tasks t ${whereClause}`, params);
|
||||
const total = Number(totalRow?.total || 0);
|
||||
|
||||
const tasks = await all(
|
||||
`
|
||||
SELECT
|
||||
t.id, t.name as taskName, s.name as subjectName, s.total_score as totalScore,
|
||||
t.start_at as startAt, t.end_at as endAt
|
||||
FROM exam_tasks t
|
||||
JOIN exam_subjects s ON t.subject_id = s.id
|
||||
${whereClause}
|
||||
ORDER BY t.end_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
[...params, input.limit, offset],
|
||||
);
|
||||
|
||||
const data: Array<ActiveTaskStat & { status: '已完成' | '进行中' | '未开始' }> = [];
|
||||
for (const task of tasks) {
|
||||
const report = await this.getReport(task.id);
|
||||
const stat = this.buildActiveTaskStat({
|
||||
taskId: task.id,
|
||||
taskName: task.taskName,
|
||||
subjectName: task.subjectName,
|
||||
totalScore: Number(task.totalScore) || 0,
|
||||
startAt: task.startAt,
|
||||
endAt: task.endAt,
|
||||
report,
|
||||
});
|
||||
|
||||
const startMs = new Date(task.startAt).getTime();
|
||||
const endMs = new Date(task.endAt).getTime();
|
||||
const status: '已完成' | '进行中' | '未开始' =
|
||||
Number.isFinite(endMs) && endMs < nowMs
|
||||
? '已完成'
|
||||
: Number.isFinite(startMs) && startMs > nowMs
|
||||
? '未开始'
|
||||
: '进行中';
|
||||
|
||||
data.push({ ...stat, status });
|
||||
}
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
static async findById(id: string): Promise<ExamTask | null> {
|
||||
const sql = `SELECT id, name, subject_id as subjectId, start_at as startAt, end_at as endAt, created_at as createdAt, selection_config as selectionConfig FROM exam_tasks WHERE id = ?`;
|
||||
const row = await get(sql, [id]);
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Question {
|
||||
category: string;
|
||||
options?: string[];
|
||||
answer: string | string[];
|
||||
analysis: string;
|
||||
score: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -18,6 +19,7 @@ export interface CreateQuestionData {
|
||||
category?: string;
|
||||
options?: string[];
|
||||
answer: string | string[];
|
||||
analysis?: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ export interface ExcelQuestionData {
|
||||
type: string;
|
||||
category?: string;
|
||||
answer: string;
|
||||
analysis?: string;
|
||||
score: number;
|
||||
options?: string[];
|
||||
}
|
||||
@@ -37,13 +40,14 @@ export class QuestionModel {
|
||||
const optionsStr = data.options ? JSON.stringify(data.options) : null;
|
||||
const answerStr = Array.isArray(data.answer) ? JSON.stringify(data.answer) : data.answer;
|
||||
const category = data.category && data.category.trim() ? data.category.trim() : '通用';
|
||||
const analysis = String(data.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
await run(sql, [id, data.content, data.type, optionsStr, answerStr, data.score, category]);
|
||||
await run(sql, [id, data.content, data.type, optionsStr, answerStr, analysis, data.score, category]);
|
||||
return this.findById(id) as Promise<Question>;
|
||||
}
|
||||
|
||||
@@ -58,8 +62,8 @@ export class QuestionModel {
|
||||
await run('BEGIN TRANSACTION');
|
||||
|
||||
const sql = `
|
||||
INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
@@ -69,9 +73,10 @@ export class QuestionModel {
|
||||
const optionsStr = question.options ? JSON.stringify(question.options) : null;
|
||||
const answerStr = Array.isArray(question.answer) ? JSON.stringify(question.answer) : question.answer;
|
||||
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
|
||||
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
// 直接执行插入,不调用单个create方法
|
||||
await run(sql, [id, question.content, question.type, optionsStr, answerStr, question.score, category]);
|
||||
await run(sql, [id, question.content, question.type, optionsStr, answerStr, analysis, question.score, category]);
|
||||
success++;
|
||||
} catch (error: any) {
|
||||
errors.push(`第${i + 1}题: ${error.message}`);
|
||||
@@ -89,6 +94,77 @@ export class QuestionModel {
|
||||
return { success, errors };
|
||||
}
|
||||
|
||||
static async importFromText(
|
||||
mode: 'overwrite' | 'incremental',
|
||||
questions: CreateQuestionData[],
|
||||
): Promise<{
|
||||
inserted: number;
|
||||
updated: number;
|
||||
errors: string[];
|
||||
cleared?: { questions: number; quizRecords: number; quizAnswers: number };
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let cleared: { questions: number; quizRecords: number; quizAnswers: number } | undefined;
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
await run('BEGIN TRANSACTION');
|
||||
try {
|
||||
if (mode === 'overwrite') {
|
||||
const [qCount, rCount, aCount] = await Promise.all([
|
||||
get(`SELECT COUNT(*) as total FROM questions`),
|
||||
get(`SELECT COUNT(*) as total FROM quiz_records`),
|
||||
get(`SELECT COUNT(*) as total FROM quiz_answers`),
|
||||
]);
|
||||
cleared = { questions: qCount.total, quizRecords: rCount.total, quizAnswers: aCount.total };
|
||||
|
||||
await run(`DELETE FROM quiz_answers`);
|
||||
await run(`DELETE FROM quiz_records`);
|
||||
await run(`DELETE FROM questions`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const question = questions[i];
|
||||
try {
|
||||
const optionsStr = question.options ? JSON.stringify(question.options) : null;
|
||||
const answerStr = Array.isArray(question.answer) ? JSON.stringify(question.answer) : question.answer;
|
||||
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
|
||||
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
if (mode === 'incremental') {
|
||||
const existing = await get(`SELECT id FROM questions WHERE content = ?`, [question.content]);
|
||||
if (existing?.id) {
|
||||
await run(
|
||||
`UPDATE questions SET content = ?, type = ?, options = ?, answer = ?, analysis = ?, score = ?, category = ? WHERE id = ?`,
|
||||
[question.content, question.type, optionsStr, answerStr, analysis, question.score, category, existing.id],
|
||||
);
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
await run(insertSql, [id, question.content, question.type, optionsStr, answerStr, analysis, question.score, category]);
|
||||
inserted++;
|
||||
} catch (error: any) {
|
||||
errors.push(`第${i + 1}题: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await run('COMMIT');
|
||||
} catch (error) {
|
||||
await run('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { inserted, updated, errors, cleared };
|
||||
}
|
||||
|
||||
// 根据ID查找题目
|
||||
static async findById(id: string): Promise<Question | null> {
|
||||
const sql = `SELECT * FROM questions WHERE id = ?`;
|
||||
@@ -219,6 +295,11 @@ export class QuestionModel {
|
||||
fields.push('answer = ?');
|
||||
values.push(answerStr);
|
||||
}
|
||||
|
||||
if (data.analysis !== undefined) {
|
||||
fields.push('analysis = ?');
|
||||
values.push(String(data.analysis ?? '').trim().slice(0, 255));
|
||||
}
|
||||
|
||||
if (data.score !== undefined) {
|
||||
fields.push('score = ?');
|
||||
@@ -257,6 +338,7 @@ export class QuestionModel {
|
||||
category: row.category || '通用',
|
||||
options: row.options ? JSON.parse(row.options) : undefined,
|
||||
answer: this.parseAnswer(row.answer, row.type),
|
||||
analysis: String(row.analysis ?? ''),
|
||||
score: row.score,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
@@ -309,6 +391,10 @@ export class QuestionModel {
|
||||
if (data.category !== undefined && data.category.trim().length === 0) {
|
||||
errors.push('题目类别不能为空');
|
||||
}
|
||||
|
||||
if (data.analysis !== undefined && String(data.analysis).length > 255) {
|
||||
errors.push('解析长度不能超过255个字符');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface QuizAnswer {
|
||||
questionType?: string;
|
||||
correctAnswer?: string | string[];
|
||||
questionScore?: number;
|
||||
questionAnalysis?: string;
|
||||
}
|
||||
|
||||
export interface SubmitAnswerData {
|
||||
@@ -195,7 +196,7 @@ export class QuizModel {
|
||||
SELECT a.id, a.record_id as recordId, a.question_id as questionId,
|
||||
a.user_answer as userAnswer, a.score, a.is_correct as isCorrect,
|
||||
a.created_at as createdAt,
|
||||
q.content as questionContent, q.type as questionType, q.answer as correctAnswer, q.score as questionScore
|
||||
q.content as questionContent, q.type as questionType, q.answer as correctAnswer, q.score as questionScore, q.analysis as questionAnalysis
|
||||
FROM quiz_answers a
|
||||
JOIN questions q ON a.question_id = q.id
|
||||
WHERE a.record_id = ?
|
||||
@@ -215,7 +216,8 @@ export class QuizModel {
|
||||
questionContent: row.questionContent,
|
||||
questionType: row.questionType,
|
||||
correctAnswer: this.parseAnswer(row.correctAnswer, row.questionType),
|
||||
questionScore: row.questionScore
|
||||
questionScore: row.questionScore,
|
||||
questionAnalysis: row.questionAnalysis ?? ''
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ apiRouter.post('/questions', adminAuth, QuestionController.createQuestion);
|
||||
apiRouter.put('/questions/:id', adminAuth, QuestionController.updateQuestion);
|
||||
apiRouter.delete('/questions/:id', adminAuth, QuestionController.deleteQuestion);
|
||||
apiRouter.post('/questions/import', adminAuth, upload.single('file'), QuestionController.importQuestions);
|
||||
apiRouter.post('/questions/import-text', adminAuth, QuestionController.importTextQuestions);
|
||||
apiRouter.get('/questions/export', adminAuth, QuestionController.exportQuestions);
|
||||
|
||||
// 为了兼容前端可能的错误请求,添加一个不包含 /api 前缀的路由
|
||||
@@ -110,6 +111,7 @@ apiRouter.get('/admin/active-tasks', adminAuth, AdminController.getActiveTasksSt
|
||||
apiRouter.get('/admin/dashboard/overview', adminAuth, AdminController.getDashboardOverview);
|
||||
apiRouter.get('/admin/tasks/history-stats', adminAuth, AdminController.getHistoryTaskStats);
|
||||
apiRouter.get('/admin/tasks/upcoming-stats', adminAuth, AdminController.getUpcomingTaskStats);
|
||||
apiRouter.get('/admin/tasks/all-stats', adminAuth, AdminController.getAllTaskStats);
|
||||
apiRouter.put('/admin/config', adminAuth, AdminController.updateQuizConfig);
|
||||
apiRouter.get('/admin/config', adminAuth, AdminController.getQuizConfig);
|
||||
apiRouter.put('/admin/password', adminAuth, AdminController.updatePassword);
|
||||
|
||||
13
data/AI生成题目提示词.md
Normal file
13
data/AI生成题目提示词.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 格式:
|
||||
题型|题目类别|分值|题目内容|选项A|选项B|选项C|选项D|答案1,答案2
|
||||
|
||||
# 解析:
|
||||
- 题型:单选,多选,判断,文字描述
|
||||
- 分值:默认5分,根据题目难度,取值2~20分,注意:文字描述题默认0分
|
||||
- 题目内容:题目的具体内容
|
||||
- 选项:对于选择题,提供4个选项,选项之间用"|"分割,例如:北京|上海|广州|深圳
|
||||
- 答案:标准答案,例如:A,对于多选题,有多个答案,答案之间用","做分割-
|
||||
|
||||
# 示例:
|
||||
单选|通用|5|【单选题】我国的首都时哪里?|北京|上海|广州|深圳|A
|
||||
多选|软件技术|10|【多选题】下列哪些属于网络安全的基本组成部分?|防火墙|杀毒软件|数据加密|物理安全|A,B,C
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
BIN
data/~$威(Boonlive)管理层知识考核题库(1).docx
Normal file
BIN
data/~$威(Boonlive)管理层知识考核题库(1).docx
Normal file
Binary file not shown.
9
data/导入题库.csv
Normal file
9
data/导入题库.csv
Normal file
@@ -0,0 +1,9 @@
|
||||
题型,题目,选项A,选项B,选项C,选项D,答案,解析
|
||||
单选题,【单选题】在软件开发中,版本控制系统的主要作用是什么?,A. 仅用于代码备份,B. 支持多人协作开发与代码版本管理,C. 提供在线代码编辑器,D. 自动修复代码错误,B,解释:版本控制系统(如Git)是现代软件开发不可或缺的一部分,它支持多人同时工作在一个项目上而不冲突。
|
||||
单选题,【单选题】以下哪项不是云计算的典型服务模型?,A. 基础设施即服务(IaaS),B. 平台即服务(PaaS),C. 软件即服务(SaaS),D. 数据库即服务(DaaS),D,解释:虽然数据库服务可以作为云服务提供,但它不是云计算三大服务模型之一。
|
||||
多选题,【多选题】下列哪些属于网络安全的基本组成部分?,A. 防火墙,B. 杀毒软件,C. 数据加密,D. 物理安全,A;B;C,解释:网络安全包括技术措施如防火墙、杀毒软件和数据加密等。物理安全虽重要,但不属于网络层面的安全措施。
|
||||
多选题,【多选题】敏捷开发方法强调哪些方面?,A. 快速响应变化,B. 固定的需求规格说明,C. 持续交付有价值的软件,D. 客户合作,A;C;D,解释:敏捷开发重视快速响应变化、持续交付以及客户合作,而固定需求并非其核心原则。
|
||||
判断题,【判断题】IPv6地址长度为128位,极大增加了可用地址数量。,正确,,错误,,正确,解释:IPv6的设计主要是为了应对IPv4地址枯竭问题,通过将地址长度增加到128位来实现。
|
||||
判断题,【判断题】HTTPS协议比HTTP更安全,因为它使用SSL/TLS加密通信。,正确,,错误,,正确,解释:HTTPS通过SSL/TLS加密传输数据,确保了信息在网络上传输的安全性。
|
||||
文字描述题,【文字描述题】请简述什么是API,并举例说明它的应用场景。,,,,,API(应用程序编程接口)是一组定义软件组件如何交互的规则。例如,在线支付系统中的API允许商家网站与支付网关之间进行安全交易。
|
||||
文字描述题,【文字描述题】请解释虚拟机的概念及其在企业环境中的优势。,,,,,虚拟机是一种模拟计算机系统的软件实现,能够在同一硬件上运行多个操作系统实例。企业利用虚拟化可提高资源利用率、降低能耗并简化系统维护。
|
||||
|
32
openspec/changes/add-text-based-question-import/proposal.md
Normal file
32
openspec/changes/add-text-based-question-import/proposal.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Change: 题库管理新增文本导入页面(`|` 分隔)与覆盖/增量导入
|
||||
|
||||
## Why
|
||||
当前题库导入主要依赖 Excel 文件,使用门槛较高且不便于快速整理题目文本。需要提供“粘贴文本 → 解析预览 → 审阅删除 → 一键导入”的流程以提升题库维护效率。
|
||||
|
||||
## What Changes
|
||||
- 新增管理端“文本导入题库”独立页面:
|
||||
- 文本输入框粘贴题库文本
|
||||
- 解析后生成题目列表供审阅,支持删除条目
|
||||
- 支持导入模式:覆盖式导入、增量导入(按题目内容判断重复并覆盖)
|
||||
- 所有题目新增字段“解析”(0~255 字符串),用于详细解析该题的正确答案
|
||||
- 文本导入格式调整:
|
||||
- 字段分隔符从 `,` 改为 `|`,避免题干/解析中包含逗号导致字段错位
|
||||
- 选择题的 4 个备选项使用 4 个独立字段表示(而不是在单字段内再分隔)
|
||||
- 多选题的标准答案使用 `,` 分隔(因答案数量不确定,使用 `|` 易产生歧义)
|
||||
- 判断题不包含备选项字段,但仍包含标准答案字段(“被选答案”不属于题库导入字段)
|
||||
- 判断题默认分值为 0(因此无论用户是否作答/如何作答,得分均为 0)
|
||||
- 文字描述题不包含备选项字段,且不包含标准答案字段
|
||||
- 新增/调整后端导入接口以支持覆盖式/增量导入的服务端一致性处理(事务)
|
||||
- 保持现有 Excel 导入/导出能力不变
|
||||
|
||||
## Impact
|
||||
- Affected specs:
|
||||
- `openspec/specs/api_response_schema.yaml`(所有新接口继续使用统一响应包裹)
|
||||
- `openspec/specs/database_schema.yaml`(需为 `questions` 表新增字段;覆盖式导入需在事务内处理关联数据)
|
||||
- `openspec/specs/tech_stack.yaml`(不引入新技术栈)
|
||||
- Affected code:
|
||||
- 前端:`src/pages/admin/*`、`src/App.tsx`、`src/services/api.ts`
|
||||
- 后端:`api/server.ts`、`api/controllers/questionController.ts`、`api/models/question.ts`
|
||||
|
||||
## Risks / Trade-offs
|
||||
- 文字描述题“无标准答案”将影响现有自动判分逻辑,需要明确导入后的判分策略与展示方式
|
||||
@@ -0,0 +1,66 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Text-Based Question Import Page
|
||||
系统 MUST 在管理端提供“文本导入题库”的独立页面,支持管理员粘贴文本题库并解析生成题目列表供审阅;管理员 MUST 能删除不正确条目后再提交导入。
|
||||
|
||||
#### Scenario: Paste, parse, review, and delete before import
|
||||
- **GIVEN** 管理员进入“文本导入题库”页面
|
||||
- **WHEN** 管理员粘贴题库文本并触发解析
|
||||
- **THEN** 系统 MUST 展示解析后的题目列表用于审阅
|
||||
- **AND** 系统 MUST 支持管理员删除列表中的任意条目
|
||||
|
||||
### Requirement: Text Import Format Uses Pipe Delimiter
|
||||
系统 MUST 使用 `|` 作为文本导入字段分隔符,以避免题干中包含逗号时影响解析;选择题的备选项 MUST 使用 4 个独立字段表示;多选题的答案 MUST 使用 `,` 分隔。
|
||||
|
||||
#### Scenario: Parse line with commas in content
|
||||
- **GIVEN** 管理员导入的题目内容包含逗号
|
||||
- **WHEN** 系统按 `|` 解析字段
|
||||
- **THEN** 系统 MUST 正确识别题目内容而不因逗号截断字段
|
||||
|
||||
### Requirement: Import Modes For Text Import
|
||||
系统 MUST 支持以下导入模式:
|
||||
- 覆盖式导入:导入前清理现有题库数据,并导入新的题目集合
|
||||
- 增量导入:以题目内容为重复判断依据;若题目内容重复,系统 MUST 覆盖该题目的内容相关字段(题型、类别、选项、答案、分值)
|
||||
|
||||
#### Scenario: Overwrite import
|
||||
- **GIVEN** 管理员已完成题目列表审阅
|
||||
- **WHEN** 管理员选择“覆盖式导入”并提交
|
||||
- **THEN** 系统 MUST 清理现有题库数据并导入新题目集合
|
||||
|
||||
#### Scenario: Incremental import with overwrite by content
|
||||
- **GIVEN** 系统中已存在题目 `content = X`
|
||||
- **WHEN** 管理员选择“增量导入”并提交包含 `content = X` 的题目
|
||||
- **THEN** 系统 MUST 以题目内容为匹配依据覆盖该题目的题型、类别、选项、答案与分值
|
||||
|
||||
### Requirement: Questions Have Analysis Field
|
||||
系统中每道题目 MUST 包含“解析”字段(0~255 字符串),用于详细解析该题的正确答案;文本导入与后续题库管理编辑 MUST 支持维护该字段。
|
||||
|
||||
#### Scenario: Persist analysis from import
|
||||
- **GIVEN** 管理员导入的题目包含“解析”
|
||||
- **WHEN** 导入完成
|
||||
- **THEN** 系统 MUST 在题库中保存该题的“解析”
|
||||
|
||||
### Requirement: Judgment And Text Question Import Rules
|
||||
系统 MUST 在文本导入时按以下规则解析不同题型:
|
||||
- 判断题 MUST 不包含备选项字段,但 MUST 包含标准答案字段
|
||||
- 判断题 MUST 默认分值为 0
|
||||
- 文字描述题 MUST 不包含备选项字段,且 MUST 不包含标准答案字段
|
||||
|
||||
#### Scenario: Import judgment question without options
|
||||
- **GIVEN** 管理员导入判断题行不包含备选项字段
|
||||
- **WHEN** 系统解析导入文本
|
||||
- **THEN** 系统 MUST 解析成功并将标准答案保存为题目答案字段
|
||||
- **AND** 系统 MUST 将该判断题分值保存为 0
|
||||
|
||||
#### Scenario: Import text question without standard answer
|
||||
- **GIVEN** 管理员导入文字描述题行不包含标准答案字段
|
||||
- **WHEN** 系统解析导入文本
|
||||
- **THEN** 系统 MUST 解析成功并将题目答案字段保存为空字符串
|
||||
|
||||
### Requirement: Text Import API Uses Unified Envelope
|
||||
系统 MUST 提供用于文本导入的管理端接口,并且接口响应 MUST 使用统一的响应包裹结构(包含 `success`,错误时包含 `message`,必要时包含 `errors`)。
|
||||
|
||||
#### Scenario: Import API success response
|
||||
- **GIVEN** 管理员提交合法的文本解析结果与导入模式
|
||||
- **WHEN** 后端导入完成
|
||||
- **THEN** 系统 MUST 返回 `success: true` 且在 `data` 中包含导入结果统计
|
||||
20
openspec/changes/add-text-based-question-import/tasks.md
Normal file
20
openspec/changes/add-text-based-question-import/tasks.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## 1. 文本导入页面
|
||||
- [ ] 1.1 新增“文本导入题库”路由与入口按钮
|
||||
- [ ] 1.2 支持粘贴文本并解析为题目列表
|
||||
- [ ] 1.3 支持列表审阅与删除条目
|
||||
- [ ] 1.4 支持选择导入模式并提交导入
|
||||
- [ ] 1.5 支持“解析”字段的展示与编辑
|
||||
|
||||
## 2. 后端接口
|
||||
- [ ] 2.1 新增文本导入接口并保持统一响应结构
|
||||
- [ ] 2.2 实现覆盖式导入(事务内清理并导入)
|
||||
- [ ] 2.3 实现增量导入(按题目内容匹配并覆盖)
|
||||
- [ ] 2.4 返回导入结果统计(新增/覆盖/跳过/失败明细)
|
||||
- [ ] 2.5 为题目新增“解析”字段并完成数据库兼容迁移
|
||||
- [ ] 2.6 调整文本导入解析规则(字段分隔符与不同题型字段结构)
|
||||
- [ ] 2.7 调整题目校验规则(判断题/文字描述题的答案要求)
|
||||
- [ ] 2.8 判断题分值规则:文本导入默认保存为 0 分
|
||||
|
||||
## 3. 测试与校验
|
||||
- [ ] 3.1 添加接口测试覆盖覆盖式与增量导入(含新格式与“解析”字段)
|
||||
- [ ] 3.2 运行 `npm run check` 与 `npm run build`
|
||||
@@ -82,6 +82,11 @@ tables:
|
||||
type: TEXT
|
||||
nullable: false
|
||||
notes: "多选题答案可能为 JSON 字符串或可解析为数组的字符串"
|
||||
analysis:
|
||||
type: TEXT
|
||||
nullable: false
|
||||
default: "''"
|
||||
notes: "题目解析(0~255 字符串)"
|
||||
score:
|
||||
type: INTEGER
|
||||
nullable: false
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node dist/api/server.js",
|
||||
"test": "node --import tsx --test test/admin-task-stats.test.ts",
|
||||
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -155,6 +155,56 @@ test('管理员任务分页统计接口返回结构正确', async () => {
|
||||
assert.equal(upcoming.json?.pagination?.pages, 1);
|
||||
assert.equal(upcoming.json?.data?.[0]?.taskName, '未开始任务');
|
||||
|
||||
const allStats = await jsonFetch(baseUrl, '/api/admin/tasks/all-stats?page=1&limit=5');
|
||||
assert.equal(allStats.status, 200);
|
||||
assert.equal(allStats.json?.success, true);
|
||||
assert.ok(Array.isArray(allStats.json?.data));
|
||||
assert.equal(allStats.json?.pagination?.page, 1);
|
||||
assert.equal(allStats.json?.pagination?.limit, 5);
|
||||
assert.equal(allStats.json?.pagination?.total, 3);
|
||||
assert.equal(allStats.json?.pagination?.pages, 1);
|
||||
|
||||
const byName = (name: string) => (allStats.json?.data as any[]).find((d) => d.taskName === name);
|
||||
assert.equal(byName('历史任务')?.status, '已完成');
|
||||
assert.equal(byName('未开始任务')?.status, '未开始');
|
||||
assert.equal(byName('进行中任务')?.status, '进行中');
|
||||
|
||||
const allCompleted = await jsonFetch(baseUrl, '/api/admin/tasks/all-stats?page=1&limit=5&status=completed');
|
||||
assert.equal(allCompleted.status, 200);
|
||||
assert.equal(allCompleted.json?.success, true);
|
||||
assert.equal(allCompleted.json?.pagination?.total, 1);
|
||||
assert.equal(allCompleted.json?.data?.[0]?.taskName, '历史任务');
|
||||
assert.equal(allCompleted.json?.data?.[0]?.status, '已完成');
|
||||
|
||||
const allOngoing = await jsonFetch(baseUrl, '/api/admin/tasks/all-stats?page=1&limit=5&status=ongoing');
|
||||
assert.equal(allOngoing.status, 200);
|
||||
assert.equal(allOngoing.json?.success, true);
|
||||
assert.equal(allOngoing.json?.pagination?.total, 1);
|
||||
assert.equal(allOngoing.json?.data?.[0]?.taskName, '进行中任务');
|
||||
assert.equal(allOngoing.json?.data?.[0]?.status, '进行中');
|
||||
|
||||
const allNotStarted = await jsonFetch(baseUrl, '/api/admin/tasks/all-stats?page=1&limit=5&status=notStarted');
|
||||
assert.equal(allNotStarted.status, 200);
|
||||
assert.equal(allNotStarted.json?.success, true);
|
||||
assert.equal(allNotStarted.json?.pagination?.total, 1);
|
||||
assert.equal(allNotStarted.json?.data?.[0]?.taskName, '未开始任务');
|
||||
assert.equal(allNotStarted.json?.data?.[0]?.status, '未开始');
|
||||
|
||||
const inHistoryEndAtRange = await jsonFetch(
|
||||
baseUrl,
|
||||
`/api/admin/tasks/all-stats?page=1&limit=5&endAtStart=${encodeURIComponent(
|
||||
new Date(now - 2.6 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
)}&endAtEnd=${encodeURIComponent(new Date(now - 1.9 * 24 * 60 * 60 * 1000).toISOString())}`,
|
||||
);
|
||||
assert.equal(inHistoryEndAtRange.status, 200);
|
||||
assert.equal(inHistoryEndAtRange.json?.success, true);
|
||||
assert.equal(inHistoryEndAtRange.json?.pagination?.total, 1);
|
||||
assert.equal(inHistoryEndAtRange.json?.data?.[0]?.taskName, '历史任务');
|
||||
|
||||
const invalidEndAtStart = await jsonFetch(baseUrl, '/api/admin/tasks/all-stats?endAtStart=not-a-date');
|
||||
assert.equal(invalidEndAtStart.status, 400);
|
||||
assert.equal(invalidEndAtStart.json?.success, false);
|
||||
|
||||
const invalidPageFallback = await jsonFetch(baseUrl, '/api/admin/tasks/history-stats?page=0&limit=0');
|
||||
assert.equal(invalidPageFallback.status, 200);
|
||||
assert.equal(invalidPageFallback.json?.pagination?.page, 1);
|
||||
@@ -163,4 +213,3 @@ test('管理员任务分页统计接口返回结构正确', async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
262
test/question-text-import.test.ts
Normal file
262
test/question-text-import.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_PATH = ':memory:';
|
||||
|
||||
const jsonFetch = async (
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { status: res.status, json, text };
|
||||
};
|
||||
|
||||
test('题库文本导入增量模式按内容覆盖', async () => {
|
||||
const { initDatabase, run, get } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const oldId = randomUUID();
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[oldId, '重复题目', 'single', JSON.stringify(['A', 'B']), 'A', 5, '通用'],
|
||||
);
|
||||
|
||||
const res = await jsonFetch(baseUrl, '/api/questions/import-text', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
mode: 'incremental',
|
||||
questions: [
|
||||
{
|
||||
content: '重复题目',
|
||||
type: 'multiple',
|
||||
category: '数学',
|
||||
options: ['选项A', '选项B', '选项C'],
|
||||
answer: ['选项A', '选项C'],
|
||||
analysis: '解析:这是一道示例题',
|
||||
score: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json?.success, true);
|
||||
assert.equal(res.json?.data?.inserted, 0);
|
||||
assert.equal(res.json?.data?.updated, 1);
|
||||
|
||||
const row = await get(`SELECT * FROM questions WHERE content = ?`, ['重复题目']);
|
||||
assert.equal(row.id, oldId);
|
||||
assert.equal(row.type, 'multiple');
|
||||
assert.equal(row.category, '数学');
|
||||
assert.equal(row.score, 10);
|
||||
assert.equal(row.analysis, '解析:这是一道示例题');
|
||||
assert.equal(JSON.parse(row.options).length, 3);
|
||||
assert.deepEqual(JSON.parse(row.answer), ['选项A', '选项C']);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('题库文本导入覆盖模式清空题库与答题记录', async () => {
|
||||
const { initDatabase, run, get } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const userId = randomUUID();
|
||||
const subjectId = randomUUID();
|
||||
const oldQuestionId = randomUUID();
|
||||
const recordId = randomUUID();
|
||||
const answerId = randomUUID();
|
||||
|
||||
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
|
||||
userId,
|
||||
'测试用户',
|
||||
`138${Math.floor(Math.random() * 1e8).toString().padStart(8, '0')}`,
|
||||
'',
|
||||
]);
|
||||
|
||||
await run(
|
||||
`INSERT INTO exam_subjects (id, name, type_ratios, category_ratios, total_score, duration_minutes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
subjectId,
|
||||
'测试科目',
|
||||
JSON.stringify({ single: 100 }),
|
||||
JSON.stringify({ 通用: 100 }),
|
||||
100,
|
||||
60,
|
||||
],
|
||||
);
|
||||
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[oldQuestionId, '旧题目', 'single', JSON.stringify(['A', 'B']), 'A', 5, '通用'],
|
||||
);
|
||||
|
||||
await run(
|
||||
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[recordId, userId, subjectId, null, 5, 1, 1, new Date().toISOString()],
|
||||
);
|
||||
|
||||
await run(
|
||||
`INSERT INTO quiz_answers (id, record_id, question_id, user_answer, score, is_correct, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[answerId, recordId, oldQuestionId, 'A', 5, 1, new Date().toISOString()],
|
||||
);
|
||||
|
||||
const res = await jsonFetch(baseUrl, '/api/questions/import-text', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
mode: 'overwrite',
|
||||
questions: [
|
||||
{
|
||||
content: '新题目',
|
||||
type: 'single',
|
||||
category: '通用',
|
||||
options: ['A', 'B'],
|
||||
answer: 'A',
|
||||
analysis: '解析:新题目的说明',
|
||||
score: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json?.success, true);
|
||||
assert.equal(res.json?.data?.inserted, 1);
|
||||
assert.equal(res.json?.data?.updated, 0);
|
||||
|
||||
const questionCount = await get(`SELECT COUNT(*) as total FROM questions`);
|
||||
const recordCount = await get(`SELECT COUNT(*) as total FROM quiz_records`);
|
||||
const answerCount = await get(`SELECT COUNT(*) as total FROM quiz_answers`);
|
||||
|
||||
assert.equal(questionCount.total, 1);
|
||||
assert.equal(recordCount.total, 0);
|
||||
assert.equal(answerCount.total, 0);
|
||||
|
||||
const row = await get(`SELECT * FROM questions WHERE content = ?`, ['新题目']);
|
||||
assert.equal(row.analysis, '解析:新题目的说明');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('答题记录详情应返回题目解析字段', async () => {
|
||||
const { initDatabase, run } = await import('../api/database');
|
||||
await initDatabase();
|
||||
|
||||
const { app } = await import('../api/server');
|
||||
const server = app.listen(0);
|
||||
|
||||
try {
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const userId = randomUUID();
|
||||
const questionId = randomUUID();
|
||||
const recordId = randomUUID();
|
||||
const answerId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
|
||||
userId,
|
||||
'测试用户',
|
||||
'13800138000',
|
||||
'',
|
||||
]);
|
||||
|
||||
await run(
|
||||
`INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[questionId, '带解析题目', 'single', JSON.stringify(['A', 'B']), 'A', '解析内容', 5, '通用'],
|
||||
);
|
||||
|
||||
await run(
|
||||
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[recordId, userId, null, null, 5, 1, 1, createdAt],
|
||||
);
|
||||
|
||||
await run(
|
||||
`INSERT INTO quiz_answers (id, record_id, question_id, user_answer, score, is_correct, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[answerId, recordId, questionId, 'A', 5, 1, createdAt],
|
||||
);
|
||||
|
||||
const res = await jsonFetch(baseUrl, `/api/quiz/records/detail/${recordId}`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.json?.success, true);
|
||||
assert.equal(res.json?.data?.record?.id, recordId);
|
||||
assert.equal(Array.isArray(res.json?.data?.answers), true);
|
||||
assert.equal(res.json?.data?.answers?.[0]?.questionAnalysis, '解析内容');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('前端文本解析支持 | 分隔格式', async () => {
|
||||
const { parseTextQuestions } = await import('../src/utils/questionTextImport');
|
||||
|
||||
const input = [
|
||||
'题型|题目类别|分值|题目内容|选项|答案|解析',
|
||||
'单选|通用|5|我国首都是哪里?|北京|上海|广州|深圳|A|我国首都为北京',
|
||||
'多选|通用|5|以下哪些是水果?|苹果|白菜|香蕉|西红柿|A,C,D|水果包括苹果/香蕉/西红柿',
|
||||
'判断|通用|2|地球是圆的||正确|地球接近球体',
|
||||
].join('\n');
|
||||
|
||||
const res = parseTextQuestions(input);
|
||||
assert.deepEqual(res.errors, []);
|
||||
assert.equal(res.questions.length, 3);
|
||||
|
||||
const single = res.questions.find((q: any) => q.type === 'single');
|
||||
assert.ok(single);
|
||||
assert.deepEqual(single.options, ['北京', '上海', '广州', '深圳']);
|
||||
assert.equal(single.answer, '北京');
|
||||
assert.equal(single.analysis, '我国首都为北京');
|
||||
|
||||
const multiple = res.questions.find((q: any) => q.type === 'multiple');
|
||||
assert.ok(multiple);
|
||||
assert.deepEqual(multiple.options, ['苹果', '白菜', '香蕉', '西红柿']);
|
||||
assert.deepEqual(multiple.answer, ['苹果', '香蕉', '西红柿']);
|
||||
assert.equal(multiple.analysis, '水果包括苹果/香蕉/西红柿');
|
||||
|
||||
const judgment = res.questions.find((q: any) => q.type === 'judgment');
|
||||
assert.ok(judgment);
|
||||
assert.equal(judgment.answer, '正确');
|
||||
assert.equal(judgment.analysis, '地球接近球体');
|
||||
});
|
||||
Reference in New Issue
Block a user