Files
Web_BLV_OA_Exam_Prod/api/models/question.ts
XuJiacheng eb4504960e feat: 更新构建流程,添加 API 构建脚本和 SQL 文件复制脚本
- 修改 package.json,更新构建命令,添加 postbuild 脚本以复制 init.sql 文件。
- 新增 scripts/build-api.mjs,使用 esbuild 构建 API 代码。
- 新增 scripts/copy-init-sql.mjs,复制数据库初始化 SQL 文件到构建输出目录。
- 在 SubjectSelectionPage 组件中添加 totalScore 属性,增加历史最高分状态显示功能。
- 在 ExamSubjectPage 和 QuestionManagePage 中优化判断题答案处理逻辑。
- 在 OptionList 组件中将判断题选项文本从 'T' 和 'F' 改为 '对' 和 '错'。
- 在 QuizFooter 组件中调整样式,增加按钮和文本的可读性。
- 新增用户默认组测试用例,验证新用户创建后自动加入“全体用户”系统组。
- 新增 tsconfig.api.json,配置 API 相关 TypeScript 编译选项。
- 移除 vite.config.ts 中的 global 定义。
2025-12-30 20:33:14 +08:00

463 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { v4 as uuidv4 } from 'uuid';
import { query, run, get } from '../database';
export interface Question {
id: string;
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
category: string;
options?: string[];
answer: string | string[];
analysis: string;
score: number;
createdAt: string;
}
export interface CreateQuestionData {
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
category?: string;
options?: string[];
answer: string | string[];
analysis?: string;
score: number;
}
export interface ExcelQuestionData {
content: string;
type: string;
category?: string;
answer: string;
analysis?: string;
score: number;
options?: string[];
}
export class QuestionModel {
private static normalizeJudgmentAnswer(raw: unknown): string {
const v = String(raw ?? '').trim();
if (!v) return v;
// 兼容历史存储与导入A/B、T/F、true/false、1/0 等
const yes = new Set(['A', 'T', 'TRUE', 'True', 'true', '1', '正确', '对', '是', 'Y', 'y', 'YES', 'yes']);
const no = new Set(['B', 'F', 'FALSE', 'False', 'false', '0', '错误', '错', '否', '不是', 'N', 'n', 'NO', 'no']);
if (yes.has(v)) return '正确';
if (no.has(v)) return '错误';
return v;
}
// 创建题目
static async create(data: CreateQuestionData): Promise<Question> {
const id = uuidv4();
const optionsStr = data.options ? JSON.stringify(data.options) : null;
const normalizedAnswer =
data.type === 'judgment' && !Array.isArray(data.answer)
? this.normalizeJudgmentAnswer(data.answer)
: data.answer;
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
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, analysis, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
await run(sql, [id, data.content, data.type, optionsStr, answerStr, analysis, data.score, category]);
return this.findById(id) as Promise<Question>;
}
// 批量创建题目 - 优化为使用事务批量插入
static async createMany(questions: CreateQuestionData[]): Promise<{ success: number; errors: string[] }> {
const errors: string[] = [];
let success = 0;
// 使用事务提高性能
try {
// 开始事务
await run('BEGIN TRANSACTION');
const sql = `
INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
for (let i = 0; i < questions.length; i++) {
try {
const question = questions[i];
const id = uuidv4();
const optionsStr = question.options ? JSON.stringify(question.options) : null;
const normalizedAnswer =
question.type === 'judgment' && !Array.isArray(question.answer)
? this.normalizeJudgmentAnswer(question.answer)
: question.answer;
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
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, analysis, question.score, category]);
success++;
} catch (error: any) {
errors.push(`${i + 1}题: ${error.message}`);
}
}
// 提交事务
await run('COMMIT');
} catch (error: any) {
// 回滚事务
await run('ROLLBACK');
errors.push(`事务错误: ${error.message}`);
}
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 normalizedAnswer =
question.type === 'judgment' && !Array.isArray(question.answer)
? this.normalizeJudgmentAnswer(question.answer)
: question.answer;
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
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 = ?`;
const question = await get(sql, [id]);
if (!question) return null;
return this.formatQuestion(question);
}
// 根据题目内容查找题目
static async findByContent(content: string): Promise<Question | null> {
const sql = `SELECT * FROM questions WHERE content = ?`;
const question = await get(sql, [content]);
if (!question) return null;
return this.formatQuestion(question);
}
// 获取题目列表(支持筛选和分页)
static async findAll(filters: {
type?: string;
category?: string;
keyword?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
} = {}): Promise<{ questions: Question[]; total: number }> {
const { type, category, keyword, startDate, endDate, limit = 10, offset = 0 } = filters;
const whereParts: string[] = [];
const params: any[] = [];
if (type) {
whereParts.push('type = ?');
params.push(type);
}
if (category) {
whereParts.push('category = ?');
params.push(category);
}
if (keyword) {
whereParts.push('content LIKE ?');
params.push(`%${keyword}%`);
}
if (startDate) {
whereParts.push('created_at >= ?');
params.push(`${startDate} 00:00:00`);
}
if (endDate) {
whereParts.push('created_at <= ?');
params.push(`${endDate} 23:59:59`);
}
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
const questionsSql = `
SELECT * FROM questions
${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`;
const countSql = `
SELECT COUNT(*) as total FROM questions ${whereClause}
`;
const [questions, countResult] = await Promise.all([
query(questionsSql, [...params, limit, offset]),
get(countSql, params)
]);
return {
questions: questions.map((q) => this.formatQuestion(q)),
total: countResult.total
};
}
// 随机获取题目(按类型和数量)
static async getRandomQuestions(type: string, count: number, categories?: string[]): Promise<Question[]> {
const whereParts: string[] = ['type = ?'];
const params: any[] = [type];
if (categories && categories.length > 0) {
whereParts.push(`category IN (${categories.map(() => '?').join(',')})`);
params.push(...categories);
}
const sql = `
SELECT * FROM questions
WHERE ${whereParts.join(' AND ')}
ORDER BY RANDOM()
LIMIT ?
`;
const questions = await query(sql, [...params, count]);
return questions.map((q) => this.formatQuestion(q));
}
// 更新题目
static async update(id: string, data: Partial<CreateQuestionData>): Promise<Question> {
const fields: string[] = [];
const values: any[] = [];
if (data.content) {
fields.push('content = ?');
values.push(data.content);
}
if (data.type) {
fields.push('type = ?');
values.push(data.type);
}
if (data.options !== undefined) {
fields.push('options = ?');
values.push(data.options ? JSON.stringify(data.options) : null);
}
if (data.answer !== undefined) {
const normalizedAnswer =
data.type === 'judgment' && !Array.isArray(data.answer)
? this.normalizeJudgmentAnswer(data.answer)
: data.answer;
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
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 = ?');
values.push(data.score);
}
if (data.category !== undefined) {
fields.push('category = ?');
values.push(data.category && data.category.trim() ? data.category.trim() : '通用');
}
if (fields.length === 0) {
throw new Error('没有要更新的字段');
}
values.push(id);
const sql = `UPDATE questions SET ${fields.join(', ')} WHERE id = ?`;
await run(sql, values);
return this.findById(id) as Promise<Question>;
}
// 删除题目
static async delete(id: string): Promise<boolean> {
const sql = `DELETE FROM questions WHERE id = ?`;
const result = await run(sql, [id]);
return result.id !== undefined;
}
// 格式化题目数据
private static formatQuestion(row: any): Question {
return {
id: row.id,
content: row.content,
type: row.type,
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
};
}
// 解析答案
private static parseAnswer(answerStr: string, type: string): string | string[] {
if (type === 'multiple') {
try {
return JSON.parse(answerStr);
} catch {
return answerStr;
}
}
if (type === 'judgment') {
return this.normalizeJudgmentAnswer(answerStr);
}
return answerStr;
}
// 验证题目数据
static validateQuestionData(data: CreateQuestionData): string[] {
const errors: string[] = [];
// 验证题目内容
if (!data.content || data.content.trim().length === 0) {
errors.push('题目内容不能为空');
}
// 验证题型
const validTypes = ['single', 'multiple', 'judgment', 'text'];
if (!validTypes.includes(data.type)) {
errors.push('题型必须是 single、multiple、judgment 或 text');
}
// 验证选项
if (data.type === 'single' || data.type === 'multiple') {
if (!data.options || data.options.length < 2) {
errors.push('单选题和多选题必须至少包含2个选项');
}
}
// 验证答案
if (!data.answer) {
errors.push('答案不能为空');
}
// 验证分值
if (!data.score || data.score <= 0) {
errors.push('分值必须是正数');
}
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;
}
// 验证Excel数据格式
static validateExcelData(data: ExcelQuestionData[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
data.forEach((row, index) => {
if (!row.content) {
errors.push(`${index + 1}行:题目内容不能为空`);
}
const validTypes = ['single', 'multiple', 'judgment', 'text'];
if (!validTypes.includes(row.type)) {
errors.push(`${index + 1}行:题型必须是 single、multiple、judgment 或 text`);
}
if (!row.answer) {
errors.push(`${index + 1}行:答案不能为空`);
}
if (!row.score || row.score <= 0) {
errors.push(`${index + 1}行:分值必须是正数`);
}
});
return {
valid: errors.length === 0,
errors
};
}
}