From dc9fc169ec3a75072d5972987886368b33793e5c Mon Sep 17 00:00:00 2001 From: MomoWen Date: Thu, 25 Dec 2025 00:15:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=96=87=E6=9C=AC=E9=A2=98?= =?UTF-8?q?=E5=BA=93=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=A2=98?= =?UTF-8?q?=E7=9B=AE=E6=96=B0=E5=A2=9E=E2=80=9C=E8=A7=A3=E6=9E=90=E2=80=9D?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/adminController.ts | 49 ++++ api/controllers/questionController.ts | 122 +++++++- api/database/index.ts | 1 + api/database/init.sql | 1 + api/models/examTask.ts | 83 ++++++ api/models/question.ts | 98 ++++++- api/models/quiz.ts | 6 +- api/server.ts | 2 + data/AI生成题目提示词.md | 13 + data/survey.db | Bin 647168 -> 647168 bytes ...~$威(Boonlive)管理层知识考核题库(1).docx | Bin 0 -> 162 bytes data/导入题库.csv | 9 + .../proposal.md | 32 +++ .../specs/admin-portal/spec.md | 66 +++++ .../add-text-based-question-import/tasks.md | 20 ++ openspec/specs/database_schema.yaml | 5 + package.json | 2 +- src/App.tsx | 2 + src/contexts/QuizContext.tsx | 3 +- src/pages/QuizPage.tsx | 23 ++ src/pages/ResultPage.tsx | 7 + src/pages/admin/AdminDashboardPage.tsx | 253 ++++++++--------- src/pages/admin/ExamSubjectPage.tsx | 8 +- src/pages/admin/QuestionManagePage.tsx | 16 +- src/pages/admin/QuestionTextImportPage.tsx | 214 ++++++++++++++ src/pages/admin/UserManagePage.tsx | 22 +- src/services/api.ts | 9 + src/utils/questionTextImport.ts | 172 ++++++++++++ test/admin-task-stats.test.ts | 51 +++- test/question-text-import.test.ts | 262 ++++++++++++++++++ 30 files changed, 1386 insertions(+), 165 deletions(-) create mode 100644 data/AI生成题目提示词.md create mode 100644 data/~$威(Boonlive)管理层知识考核题库(1).docx create mode 100644 data/导入题库.csv create mode 100644 openspec/changes/add-text-based-question-import/proposal.md create mode 100644 openspec/changes/add-text-based-question-import/specs/admin-portal/spec.md create mode 100644 openspec/changes/add-text-based-question-import/tasks.md create mode 100644 src/pages/admin/QuestionTextImportPage.tsx create mode 100644 src/utils/questionTextImport.ts create mode 100644 test/question-text-import.test.ts 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 bfe6a92aa5b424ef330cb6d1e69d5c339da9a585..4d9bc465fc6b289dac5ea7409f21dd2f018b03b7 100644 GIT binary patch delta 3308 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 diff --git a/data/~$威(Boonlive)管理层知识考核题库(1).docx b/data/~$威(Boonlive)管理层知识考核题库(1).docx new file mode 100644 index 0000000000000000000000000000000000000000..0a94eda85df15b6728fd1f0d046cd43bce20b2d2 GIT binary patch literal 162 zcmZQ^EXvPgAQiAMI5HG5$;isU$jky^ bGcd3)qySAyW~czlK-eW} /> } /> } /> + } /> } /> } /> } /> 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="个" + />
- + +