diff --git a/api/controllers/adminController.ts b/api/controllers/adminController.ts index 23adec0..d3885a6 100644 --- a/api/controllers/adminController.ts +++ b/api/controllers/adminController.ts @@ -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'); diff --git a/api/controllers/questionController.ts b/api/controllers/questionController.ts index 8bf9c05..c2b7ef4 100644 --- a/api/controllers/questionController.ts +++ b/api/controllers/questionController.ts @@ -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 = {}; 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(); + + 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() })); diff --git a/api/database/index.ts b/api/database/index.ts index 5d8cdc1..9dfc5dc 100644 --- a/api/database/index.ts +++ b/api/database/index.ts @@ -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); diff --git a/api/database/init.sql b/api/database/init.sql index 5da7530..b6c6879 100644 --- a/api/database/init.sql +++ b/api/database/init.sql @@ -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 diff --git a/api/models/examTask.ts b/api/models/examTask.ts index 814811f..35c91af 100644 --- a/api/models/examTask.ts +++ b/api/models/examTask.ts @@ -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; 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 = []; + 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 { 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]); diff --git a/api/models/question.ts b/api/models/question.ts index faa12a5..0e042d1 100644 --- a/api/models/question.ts +++ b/api/models/question.ts @@ -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; } @@ -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 { 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; } diff --git a/api/models/quiz.ts b/api/models/quiz.ts index 96eb94c..9deb640 100644 --- a/api/models/quiz.ts +++ b/api/models/quiz.ts @@ -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 ?? '' })); } diff --git a/api/server.ts b/api/server.ts index df81ac0..447c992 100644 --- a/api/server.ts +++ b/api/server.ts @@ -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); diff --git a/data/AI生成题目提示词.md b/data/AI生成题目提示词.md new file mode 100644 index 0000000..6e005db --- /dev/null +++ b/data/AI生成题目提示词.md @@ -0,0 +1,13 @@ +# 格式: + 题型|题目类别|分值|题目内容|选项A|选项B|选项C|选项D|答案1,答案2 + +# 解析: + - 题型:单选,多选,判断,文字描述 + - 分值:默认5分,根据题目难度,取值2~20分,注意:文字描述题默认0分 + - 题目内容:题目的具体内容 + - 选项:对于选择题,提供4个选项,选项之间用"|"分割,例如:北京|上海|广州|深圳 + - 答案:标准答案,例如:A,对于多选题,有多个答案,答案之间用","做分割- + +# 示例: + 单选|通用|5|【单选题】我国的首都时哪里?|北京|上海|广州|深圳|A + 多选|软件技术|10|【多选题】下列哪些属于网络安全的基本组成部分?|防火墙|杀毒软件|数据加密|物理安全|A,B,C \ No newline at end of file diff --git a/data/survey.db b/data/survey.db index bfe6a92..4d9bc46 100644 Binary files a/data/survey.db and b/data/survey.db differ diff --git a/data/~$威(Boonlive)管理层知识考核题库(1).docx b/data/~$威(Boonlive)管理层知识考核题库(1).docx new file mode 100644 index 0000000..0a94eda Binary files /dev/null and b/data/~$威(Boonlive)管理层知识考核题库(1).docx differ diff --git a/data/导入题库.csv b/data/导入题库.csv new file mode 100644 index 0000000..4cbb47f --- /dev/null +++ b/data/导入题库.csv @@ -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允许商家网站与支付网关之间进行安全交易。 +文字描述题,【文字描述题】请解释虚拟机的概念及其在企业环境中的优势。,,,,,虚拟机是一种模拟计算机系统的软件实现,能够在同一硬件上运行多个操作系统实例。企业利用虚拟化可提高资源利用率、降低能耗并简化系统维护。 \ No newline at end of file diff --git a/openspec/changes/add-text-based-question-import/proposal.md b/openspec/changes/add-text-based-question-import/proposal.md new file mode 100644 index 0000000..2300724 --- /dev/null +++ b/openspec/changes/add-text-based-question-import/proposal.md @@ -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 +- 文字描述题“无标准答案”将影响现有自动判分逻辑,需要明确导入后的判分策略与展示方式 diff --git a/openspec/changes/add-text-based-question-import/specs/admin-portal/spec.md b/openspec/changes/add-text-based-question-import/specs/admin-portal/spec.md new file mode 100644 index 0000000..868143e --- /dev/null +++ b/openspec/changes/add-text-based-question-import/specs/admin-portal/spec.md @@ -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` 中包含导入结果统计 diff --git a/openspec/changes/add-text-based-question-import/tasks.md b/openspec/changes/add-text-based-question-import/tasks.md new file mode 100644 index 0000000..8b25bd5 --- /dev/null +++ b/openspec/changes/add-text-based-question-import/tasks.md @@ -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` diff --git a/openspec/specs/database_schema.yaml b/openspec/specs/database_schema.yaml index 3c72fb8..5119a03 100644 --- a/openspec/specs/database_schema.yaml +++ b/openspec/specs/database_schema.yaml @@ -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 diff --git a/package.json b/package.json index c4049d5..a85ddda 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index 40859e2..ead6d5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/contexts/QuizContext.tsx b/src/contexts/QuizContext.tsx index 7127158..1d242da 100644 --- a/src/contexts/QuizContext.tsx +++ b/src/contexts/QuizContext.tsx @@ -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; -}; \ No newline at end of file +}; diff --git a/src/pages/QuizPage.tsx b/src/pages/QuizPage.tsx index c9396f0..d2d4bab 100644 --- a/src/pages/QuizPage.tsx +++ b/src/pages/QuizPage.tsx @@ -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(null); const [subjectId, setSubjectId] = useState(''); const [taskId, setTaskId] = useState(''); + 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)} + {String(currentQuestion.analysis ?? '').trim() ? ( +
+ + {showAnalysis ? ( +
+ {currentQuestion.analysis} +
+ ) : null} +
+ ) : null} + {/* 操作按钮 */}
)} + {String(answer.questionAnalysis ?? '').trim() ? ( +
+ 解析: + {answer.questionAnalysis} +
+ ) : null}
本题分值:{answer.questionScore || 0} 分,你的得分:{answer.score} 分 diff --git a/src/pages/admin/AdminDashboardPage.tsx b/src/pages/admin/AdminDashboardPage.tsx index 6b4847d..886c518 100644 --- a/src/pages/admin/AdminDashboardPage.tsx +++ b/src/pages/admin/AdminDashboardPage.tsx @@ -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(null); const [recentRecords, setRecentRecords] = useState([]); - const [historyTasks, setHistoryTasks] = useState([]); - const [upcomingTasks, setUpcomingTasks] = useState([]); - const [historyPagination, setHistoryPagination] = useState({ - page: 1, - limit: 5, - total: 0, - }); - const [upcomingPagination, setUpcomingPagination] = useState({ + const [taskStats, setTaskStats] = useState([]); + 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) => ( {value} {entry.payload.value} @@ -362,6 +356,7 @@ const AdminDashboardPage = () => { navigate('/admin/users')} + styles={{ body: { padding: 16 } }} > { onClick={() => navigate('/admin/question-bank')} styles={{ body: { padding: 16 } }} > -
-
题库统计
- -
-
- - - i.value > 0)} - cx="50%" - cy="50%" - innerRadius={25} - outerRadius={40} - paddingAngle={2} - dataKey="value" - > - {categoryPieData.map((_, index) => ( - - ))} - - [`${value} 题`, '数量']} /> - - -
+ } + valueStyle={{ color: '#008C8C' }} + suffix="题" + />
@@ -413,6 +387,7 @@ const AdminDashboardPage = () => { navigate('/admin/subjects')} + styles={{ body: { padding: 16 } }} > { onClick={() => navigate('/admin/exam-tasks')} styles={{ body: { padding: 16 } }} > -
-
考试任务
- -
-
- - - - {taskStatusPieData.map((entry, index) => ( - - ))} - - [`${value} 个`, '数量']} /> - - -
+ } + valueStyle={{ color: '#008C8C' }} + suffix="个" + />
- + +