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

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

View File

@@ -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');

View File

@@ -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()
}));

View File

@@ -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);

View File

@@ -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

View File

@@ -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]);

View File

@@ -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;
}

View File

@@ -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 ?? ''
}));
}

View File

@@ -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);

View File

@@ -0,0 +1,13 @@
# 格式:
题型|题目类别|分值|题目内容|选项A|选项B|选项C|选项D|答案1,答案2
# 解析:
- 题型:单选,多选,判断,文字描述
- 分值默认5分根据题目难度取值2~20分注意文字描述题默认0分
- 题目内容:题目的具体内容
- 选项对于选择题提供4个选项选项之间用"|"分割,例如:北京|上海|广州|深圳
- 答案标准答案例如A对于多选题有多个答案答案之间用","做分割-
# 示例:
单选|通用|5|【单选题】我国的首都时哪里?|北京|上海|广州|深圳|A
多选|软件技术|10|【多选题】下列哪些属于网络安全的基本组成部分?|防火墙|杀毒软件|数据加密|物理安全|A,B,C

Binary file not shown.

9
data/导入题库.csv Normal file
View 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允许商家网站与支付网关之间进行安全交易。
文字描述题,【文字描述题】请解释虚拟机的概念及其在企业环境中的优势。,,,,,虚拟机是一种模拟计算机系统的软件实现,能够在同一硬件上运行多个操作系统实例。企业利用虚拟化可提高资源利用率、降低能耗并简化系统维护。
1 题型,题目,选项A,选项B,选项C,选项D,答案,解析
2 单选题,【单选题】在软件开发中,版本控制系统的主要作用是什么?,A. 仅用于代码备份,B. 支持多人协作开发与代码版本管理,C. 提供在线代码编辑器,D. 自动修复代码错误,B,解释:版本控制系统(如Git)是现代软件开发不可或缺的一部分,它支持多人同时工作在一个项目上而不冲突。
3 单选题,【单选题】以下哪项不是云计算的典型服务模型?,A. 基础设施即服务(IaaS),B. 平台即服务(PaaS),C. 软件即服务(SaaS),D. 数据库即服务(DaaS),D,解释:虽然数据库服务可以作为云服务提供,但它不是云计算三大服务模型之一。
4 多选题,【多选题】下列哪些属于网络安全的基本组成部分?,A. 防火墙,B. 杀毒软件,C. 数据加密,D. 物理安全,A;B;C,解释:网络安全包括技术措施如防火墙、杀毒软件和数据加密等。物理安全虽重要,但不属于网络层面的安全措施。
5 多选题,【多选题】敏捷开发方法强调哪些方面?,A. 快速响应变化,B. 固定的需求规格说明,C. 持续交付有价值的软件,D. 客户合作,A;C;D,解释:敏捷开发重视快速响应变化、持续交付以及客户合作,而固定需求并非其核心原则。
6 判断题,【判断题】IPv6地址长度为128位,极大增加了可用地址数量。,正确,,错误,,正确,解释:IPv6的设计主要是为了应对IPv4地址枯竭问题,通过将地址长度增加到128位来实现。
7 判断题,【判断题】HTTPS协议比HTTP更安全,因为它使用SSL/TLS加密通信。,正确,,错误,,正确,解释:HTTPS通过SSL/TLS加密传输数据,确保了信息在网络上传输的安全性。
8 文字描述题,【文字描述题】请简述什么是API,并举例说明它的应用场景。,,,,,API(应用程序编程接口)是一组定义软件组件如何交互的规则。例如,在线支付系统中的API允许商家网站与支付网关之间进行安全交易。
9 文字描述题,【文字描述题】请解释虚拟机的概念及其在企业环境中的优势。,,,,,虚拟机是一种模拟计算机系统的软件实现,能够在同一硬件上运行多个操作系统实例。企业利用虚拟化可提高资源利用率、降低能耗并简化系统维护。

View 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
- 文字描述题“无标准答案”将影响现有自动判分逻辑,需要明确导入后的判分策略与展示方式

View File

@@ -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` 中包含导入结果统计

View 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`

View File

@@ -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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()));
}
});

View 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, '地球接近球体');
});