2025-12-18 19:07:21 +08:00
|
|
|
|
import { Request, Response } from 'express';
|
|
|
|
|
|
import { QuestionModel, CreateQuestionData, ExcelQuestionData } from '../models';
|
|
|
|
|
|
import * as XLSX from 'xlsx';
|
|
|
|
|
|
|
|
|
|
|
|
export class QuestionController {
|
|
|
|
|
|
// 获取题目列表
|
|
|
|
|
|
static async getQuestions(req: Request, res: Response) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { type, category, keyword, startDate, endDate, page = 1, limit = 10 } = req.query;
|
|
|
|
|
|
|
|
|
|
|
|
const result = await QuestionModel.findAll({
|
|
|
|
|
|
type: type as string,
|
|
|
|
|
|
category: category as string,
|
|
|
|
|
|
keyword: keyword as string,
|
|
|
|
|
|
startDate: startDate as string,
|
|
|
|
|
|
endDate: endDate as string,
|
|
|
|
|
|
limit: parseInt(limit as string),
|
|
|
|
|
|
offset: (parseInt(page as string) - 1) * parseInt(limit as string)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
data: result.questions,
|
|
|
|
|
|
pagination: {
|
|
|
|
|
|
page: parseInt(page as string),
|
|
|
|
|
|
limit: parseInt(limit as string),
|
|
|
|
|
|
total: result.total,
|
|
|
|
|
|
pages: Math.ceil(result.total / parseInt(limit as string))
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('获取题目列表失败:', error);
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: error.message || '获取题目列表失败'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取单个题目
|
|
|
|
|
|
static async getQuestion(req: Request, res: Response) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
|
|
|
|
|
|
const question = await QuestionModel.findById(id);
|
|
|
|
|
|
if (!question) {
|
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: '题目不存在'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
data: question
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('获取题目失败:', error);
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: error.message || '获取题目失败'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建题目
|
|
|
|
|
|
static async createQuestion(req: Request, res: Response) {
|
|
|
|
|
|
try {
|
2025-12-25 00:15:14 +08:00
|
|
|
|
const { content, type, category, options, answer, analysis, score } = req.body;
|
2025-12-18 19:07:21 +08:00
|
|
|
|
|
|
|
|
|
|
const questionData: CreateQuestionData = {
|
|
|
|
|
|
content,
|
|
|
|
|
|
type,
|
|
|
|
|
|
category,
|
|
|
|
|
|
options,
|
|
|
|
|
|
answer,
|
2025-12-25 00:15:14 +08:00
|
|
|
|
analysis,
|
2025-12-18 19:07:21 +08:00
|
|
|
|
score
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 验证题目数据
|
|
|
|
|
|
const errors = QuestionModel.validateQuestionData(questionData);
|
|
|
|
|
|
if (errors.length > 0) {
|
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: '数据验证失败',
|
|
|
|
|
|
errors
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const question = await QuestionModel.create(questionData);
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
data: question
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('创建题目失败:', error);
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: error.message || '创建题目失败'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新题目
|
|
|
|
|
|
static async updateQuestion(req: Request, res: Response) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { id } = req.params;
|
2025-12-25 00:15:14 +08:00
|
|
|
|
const { content, type, category, options, answer, analysis, score } = req.body;
|
2025-12-18 19:07:21 +08:00
|
|
|
|
|
|
|
|
|
|
const updateData: Partial<CreateQuestionData> = {};
|
|
|
|
|
|
if (content !== undefined) updateData.content = content;
|
|
|
|
|
|
if (type !== undefined) updateData.type = type;
|
|
|
|
|
|
if (category !== undefined) updateData.category = category;
|
|
|
|
|
|
if (options !== undefined) updateData.options = options;
|
|
|
|
|
|
if (answer !== undefined) updateData.answer = answer;
|
2025-12-25 00:15:14 +08:00
|
|
|
|
if (analysis !== undefined) updateData.analysis = analysis;
|
2025-12-18 19:07:21 +08:00
|
|
|
|
if (score !== undefined) updateData.score = score;
|
|
|
|
|
|
|
|
|
|
|
|
const question = await QuestionModel.update(id, updateData);
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
data: question
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('更新题目失败:', error);
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: error.message || '更新题目失败'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 删除题目
|
|
|
|
|
|
static async deleteQuestion(req: Request, res: Response) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
|
|
|
|
|
|
const success = await QuestionModel.delete(id);
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: '题目不存在'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
message: '删除成功'
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('删除题目失败:', error);
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: error.message || '删除题目失败'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Excel导入题目
|
|
|
|
|
|
static async importQuestions(req: Request, res: Response) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!req.file) {
|
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: '请上传Excel文件'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 读取Excel文件
|
|
|
|
|
|
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
|
|
|
|
|
const sheetName = workbook.SheetNames[0];
|
|
|
|
|
|
const worksheet = workbook.Sheets[sheetName];
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为JSON数据
|
|
|
|
|
|
const rawData = XLSX.utils.sheet_to_json(worksheet);
|
|
|
|
|
|
|
|
|
|
|
|
// 转换数据格式
|
|
|
|
|
|
const questionsData: ExcelQuestionData[] = rawData.map((row: any) => ({
|
|
|
|
|
|
content: row['题目内容'] || row['content'],
|
|
|
|
|
|
type: QuestionController.mapQuestionType(row['题型'] || row['type']),
|
|
|
|
|
|
category: row['题目类别'] || row['category'] || '通用',
|
|
|
|
|
|
answer: row['标准答案'] || row['answer'],
|
2025-12-25 00:15:14 +08:00
|
|
|
|
analysis: row['解析'] || row['analysis'] || '',
|
2025-12-18 19:07:21 +08:00
|
|
|
|
score: parseInt(row['分值'] || row['score']) || 0,
|
|
|
|
|
|
options: QuestionController.parseOptions(row['选项'] || row['options'])
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// 验证数据
|
|
|
|
|
|
const validation = QuestionModel.validateExcelData(questionsData);
|
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: 'Excel数据格式错误',
|
|
|
|
|
|
errors: validation.errors
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 批量创建题目
|
|
|
|
|
|
const result = await QuestionModel.createMany(questionsData as any[]);
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
data: {
|
|
|
|
|
|
imported: result.success,
|
|
|
|
|
|
total: questionsData.length,
|
|
|
|
|
|
errors: result.errors
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('Excel导入失败:', error);
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: error.message || 'Excel导入失败'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 00:15:14 +08:00
|
|
|
|
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 || '文本导入失败',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
// 映射题型
|
|
|
|
|
|
private static mapQuestionType(type: string): string {
|
|
|
|
|
|
const typeMap: { [key: string]: string } = {
|
|
|
|
|
|
'单选': 'single',
|
|
|
|
|
|
'多选': 'multiple',
|
|
|
|
|
|
'判断': 'judgment',
|
|
|
|
|
|
'文字描述': 'text',
|
|
|
|
|
|
'single': 'single',
|
|
|
|
|
|
'multiple': 'multiple',
|
|
|
|
|
|
'judgment': 'judgment',
|
|
|
|
|
|
'text': 'text'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return typeMap[type] || 'single';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析选项
|
|
|
|
|
|
private static parseOptions(optionsStr: string): string[] | undefined {
|
|
|
|
|
|
if (!optionsStr) return undefined;
|
|
|
|
|
|
|
|
|
|
|
|
// 支持多种分隔符:|、;、\n
|
|
|
|
|
|
const separators = ['|', ';', '\n'];
|
|
|
|
|
|
for (const separator of separators) {
|
|
|
|
|
|
if (optionsStr.includes(separator)) {
|
|
|
|
|
|
return optionsStr.split(separator).map(opt => opt.trim()).filter(opt => opt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有找到分隔符,返回单个选项
|
|
|
|
|
|
return [optionsStr.trim()];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Excel导出题目
|
|
|
|
|
|
static async exportQuestions(req: Request, res: Response) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { type, category } = req.query;
|
|
|
|
|
|
|
|
|
|
|
|
// 获取所有题目数据(使用大的limit值获取所有题目)
|
|
|
|
|
|
const result = await QuestionModel.findAll({
|
|
|
|
|
|
type: type as string,
|
|
|
|
|
|
category: category as string,
|
|
|
|
|
|
limit: 10000, // 使用大的limit值获取所有题目
|
|
|
|
|
|
offset: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const questions = result.questions;
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为Excel数据格式
|
|
|
|
|
|
const excelData = questions.map((question: any) => ({
|
|
|
|
|
|
'题目ID': question.id,
|
|
|
|
|
|
'题目内容': question.content,
|
|
|
|
|
|
'题型': this.getQuestionTypeLabel(question.type),
|
|
|
|
|
|
'题目类别': question.category || '通用',
|
|
|
|
|
|
'选项': question.options ? question.options.join('|') : '',
|
|
|
|
|
|
'标准答案': question.answer,
|
2025-12-25 00:15:14 +08:00
|
|
|
|
'解析': question.analysis || '',
|
2025-12-18 19:07:21 +08:00
|
|
|
|
'分值': question.score,
|
|
|
|
|
|
'创建时间': new Date(question.createdAt).toLocaleString()
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// 创建Excel工作簿和工作表
|
|
|
|
|
|
const workbook = XLSX.utils.book_new();
|
|
|
|
|
|
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
|
|
|
|
|
|
|
|
|
|
|
// 设置列宽
|
|
|
|
|
|
const columnWidths = [
|
|
|
|
|
|
{ wch: 10 }, // 题目ID
|
|
|
|
|
|
{ wch: 60 }, // 题目内容
|
|
|
|
|
|
{ wch: 10 }, // 题型
|
|
|
|
|
|
{ wch: 15 }, // 题目类别
|
|
|
|
|
|
{ wch: 80 }, // 选项
|
|
|
|
|
|
{ wch: 20 }, // 标准答案
|
|
|
|
|
|
{ wch: 8 }, // 分值
|
|
|
|
|
|
{ wch: 20 } // 创建时间
|
|
|
|
|
|
];
|
|
|
|
|
|
worksheet['!cols'] = columnWidths;
|
|
|
|
|
|
|
|
|
|
|
|
// 将工作表添加到工作簿
|
|
|
|
|
|
XLSX.utils.book_append_sheet(workbook, worksheet, '题目列表');
|
|
|
|
|
|
|
|
|
|
|
|
// 设置响应头
|
|
|
|
|
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
|
|
|
|
res.setHeader('Content-Disposition', `attachment; filename=questions_${new Date().getTime()}.xlsx`);
|
|
|
|
|
|
|
|
|
|
|
|
// 生成Excel文件并发送
|
|
|
|
|
|
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
|
|
|
|
|
|
res.send(excelBuffer);
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('Excel导出失败:', error);
|
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message: error.message || 'Excel导出失败'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取题型中文标签
|
|
|
|
|
|
private static getQuestionTypeLabel(type: string): string {
|
|
|
|
|
|
const typeMap: { [key: string]: string } = {
|
|
|
|
|
|
'single': '单选题',
|
|
|
|
|
|
'multiple': '多选题',
|
|
|
|
|
|
'judgment': '判断题',
|
|
|
|
|
|
'text': '文字题'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return typeMap[type] || type;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|