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

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