diff --git a/api/controllers/adminController.ts b/api/controllers/adminController.ts index a3b99d8..fc6b164 100644 --- a/api/controllers/adminController.ts +++ b/api/controllers/adminController.ts @@ -105,6 +105,25 @@ export class AdminController { } } + // 获取活跃任务统计数据 + static async getActiveTasksStats(req: Request, res: Response) { + try { + const { ExamTaskModel } = await import('../models/examTask'); + const stats = await ExamTaskModel.getActiveTasksWithStats(); + + res.json({ + success: true, + data: stats + }); + } catch (error: any) { + console.error('获取活跃任务统计数据失败:', error); + res.status(500).json({ + success: false, + message: error.message || '获取活跃任务统计数据失败' + }); + } + } + // 修改管理员密码 static async updatePassword(req: Request, res: Response) { try { diff --git a/api/controllers/adminUserController.ts b/api/controllers/adminUserController.ts index ec7d9c9..a7fe3fb 100644 --- a/api/controllers/adminUserController.ts +++ b/api/controllers/adminUserController.ts @@ -83,6 +83,67 @@ export class AdminUserController { } } + // 更新用户信息 + static async updateUser(req: Request, res: Response) { + try { + const { id } = req.params; + const { name, phone, password } = req.body; + + const user = await UserModel.findById(id); + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + // 准备更新数据 + const updateData: Partial<{ name: string; phone: string; password: string }> = {}; + if (name !== undefined) updateData.name = name; + if (phone !== undefined) updateData.phone = phone; + if (password !== undefined) updateData.password = password; + + // 更新用户 + const updatedUser = await UserModel.update(id, updateData); + + res.json({ + success: true, + data: updatedUser + }); + } catch (error: any) { + // 处理手机号已存在的错误 + if (error.message === '手机号已存在') { + return res.status(400).json({ + success: false, + message: '手机号已存在' + }); + } + + // 处理SQLITE_CONSTRAINT_UNIQUE错误 + if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + return res.status(400).json({ + success: false, + message: '手机号已存在' + }); + } + + // 处理数据验证错误 + if (error.message.includes('姓名长度必须在2-20个字符之间') || + error.message.includes('手机号格式不正确')) { + return res.status(400).json({ + success: false, + message: error.message + }); + } + + // 处理其他错误 + res.status(500).json({ + success: false, + message: error.message || '更新用户失败' + }); + } + } + static async importUsers(req: Request, res: Response) { try { const file = (req as any).file; diff --git a/api/controllers/examTaskController.ts b/api/controllers/examTaskController.ts index 7d1baa7..349d4f6 100644 --- a/api/controllers/examTaskController.ts +++ b/api/controllers/examTaskController.ts @@ -141,4 +141,29 @@ export class ExamTaskController { }); } } + + static async getTaskUsers(req: Request, res: Response) { + try { + const { id } = req.params; + + if (!id) { + return res.status(400).json({ + success: false, + message: '任务ID不能为空' + }); + } + + const userIds = await ExamTaskModel.getTaskUsers(id); + + res.json({ + success: true, + data: userIds + }); + } catch (error: any) { + res.status(500).json({ + success: false, + message: error.message || '获取任务用户失败' + }); + } + } } \ No newline at end of file diff --git a/api/controllers/quizController.ts b/api/controllers/quizController.ts index 012cec7..46ac1d9 100644 --- a/api/controllers/quizController.ts +++ b/api/controllers/quizController.ts @@ -43,23 +43,142 @@ export class QuizController { }); } - const questions: Awaited> = []; - - for (const [type, ratio] of Object.entries(subject.typeRatios)) { - if (ratio <= 0) continue; - const typeScore = Math.floor((ratio / 100) * subject.totalScore); - const avgScore = 10; - const count = Math.max(1, Math.round(typeScore / avgScore)); - - const categories = Object.entries(subject.categoryRatios) - .filter(([, r]) => r > 0) - .map(([c]) => c); - - const qs = await QuestionModel.getRandomQuestions(type as any, count, categories); - questions.push(...qs); + let questions: Question[] = []; + const remainingScore = subject.totalScore; + + // 构建包含所有类别的数组,根据比重重复对应次数 + const allCategories: string[] = []; + for (const [category, catRatio] of Object.entries(subject.categoryRatios)) { + if (catRatio > 0) { + // 根据比重计算该类别应占的总题目数比例 + const count = Math.round((catRatio / 100) * 100); // 放大100倍避免小数问题 + for (let i = 0; i < count; i++) { + allCategories.push(category); + } + } + } + + // 确保总题目数至少为1 + if (allCategories.length === 0) { + allCategories.push('通用'); + } + + // 按题型分配题目 + for (const [type, typeRatio] of Object.entries(subject.typeRatios)) { + if (typeRatio <= 0) continue; + + // 计算该题型应占的总分 + const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore); + let currentTypeScore = 0; + let typeQuestions: Question[] = []; + + // 尝试获取足够分数的题目 + while (currentTypeScore < targetTypeScore) { + // 随机选择一个类别 + const randomCategory = allCategories[Math.floor(Math.random() * allCategories.length)]; + + // 获取该类型和类别的随机题目 + const availableQuestions = await QuestionModel.getRandomQuestions( + type as any, + 10, // 一次获取多个,提高效率 + [randomCategory] + ); + + if (availableQuestions.length === 0) { + break; // 该类型/类别没有题目,跳过 + } + + // 过滤掉已选题目 + const availableUnselected = availableQuestions.filter(q => + !questions.some(selected => selected.id === q.id) + ); + + if (availableUnselected.length === 0) { + break; // 没有可用的新题目了 + } + + // 选择分数最接近剩余需求的题目 + const remainingForType = targetTypeScore - currentTypeScore; + const selectedQuestion = availableUnselected.reduce((prev, curr) => { + const prevDiff = Math.abs(remainingForType - prev.score); + const currDiff = Math.abs(remainingForType - curr.score); + return currDiff < prevDiff ? curr : prev; + }); + + // 添加到题型题目列表 + typeQuestions.push(selectedQuestion); + currentTypeScore += selectedQuestion.score; + + // 防止无限循环 + if (typeQuestions.length > 100) { + break; + } + } + + questions.push(...typeQuestions); + } + + // 如果总分不足,尝试补充题目 + let totalScore = questions.reduce((sum, q) => sum + q.score, 0); + while (totalScore < subject.totalScore) { + // 获取所有类型的随机题目 + const allTypes = Object.keys(subject.typeRatios).filter(type => subject.typeRatios[type] > 0); + if (allTypes.length === 0) break; + + const randomType = allTypes[Math.floor(Math.random() * allTypes.length)]; + const availableQuestions = await QuestionModel.getRandomQuestions( + randomType as any, + 10, + allCategories + ); + + if (availableQuestions.length === 0) break; + + // 过滤掉已选题目 + const availableUnselected = availableQuestions.filter(q => + !questions.some(selected => selected.id === q.id) + ); + + if (availableUnselected.length === 0) break; + + // 选择分数最接近剩余需求的题目 + const remainingScore = subject.totalScore - totalScore; + const selectedQuestion = availableUnselected.reduce((prev, curr) => { + const prevDiff = Math.abs(remainingScore - prev.score); + const currDiff = Math.abs(remainingScore - curr.score); + return currDiff < prevDiff ? curr : prev; + }); + + questions.push(selectedQuestion); + totalScore += selectedQuestion.score; + + // 防止无限循环 + if (questions.length > 200) { + break; + } + } + + // 如果总分超过,尝试移除一些题目 + while (totalScore > subject.totalScore) { + // 找到最接近剩余差值的题目 + const excessScore = totalScore - subject.totalScore; + let closestIndex = -1; + let closestDiff = Infinity; + + for (let i = 0; i < questions.length; i++) { + const diff = Math.abs(questions[i].score - excessScore); + if (diff < closestDiff) { + closestDiff = diff; + closestIndex = i; + } + } + + if (closestIndex === -1) break; + + // 移除该题目 + totalScore -= questions[closestIndex].score; + questions.splice(closestIndex, 1); } - - const totalScore = questions.reduce((sum, q) => sum + q.score, 0); res.json({ success: true, diff --git a/api/controllers/userController.ts b/api/controllers/userController.ts index 3f25333..6042053 100644 --- a/api/controllers/userController.ts +++ b/api/controllers/userController.ts @@ -30,6 +30,15 @@ export class UserController { }); } catch (error: any) { console.error('创建用户失败:', error); + + // 处理手机号唯一约束错误 + if (error.code === 'SQLITE_CONSTRAINT' || error.message.includes('手机号已存在')) { + return res.status(400).json({ + success: false, + message: '该手机号已被注册,请使用其他手机号' + }); + } + res.status(500).json({ success: false, message: error.message || '创建用户失败' @@ -115,4 +124,30 @@ export class UserController { }); } } + + static async getUsersByName(req: Request, res: Response) { + try { + const { name } = req.params; + + if (!name || typeof name !== 'string') { + return res.status(400).json({ + success: false, + message: '姓名不能为空' + }); + } + + const users = await UserModel.findByName(name); + + res.json({ + success: true, + data: users + }); + } catch (error: any) { + console.error('根据姓名查询用户失败:', error); + res.status(500).json({ + success: false, + message: error.message || '根据姓名查询用户失败' + }); + } + } } \ No newline at end of file diff --git a/api/models/examTask.ts b/api/models/examTask.ts index 2b4a02c..5d3d802 100644 --- a/api/models/examTask.ts +++ b/api/models/examTask.ts @@ -40,9 +40,27 @@ export interface TaskReport { }>; } +export interface ActiveTaskStat { + taskId: string; + taskName: string; + subjectName: string; + totalUsers: number; + completedUsers: number; + completionRate: number; + passRate: number; + excellentRate: number; + startAt: string; + endAt: string; +} + export class ExamTaskModel { - static async findAll(): Promise { - const sql = ` + static async findAll(): Promise<(TaskWithSubject & { + completedUsers: number; + passRate: number; + excellentRate: number; + })[]> { + // 1. 先获取所有任务的基本信息 + const baseTasks = await all(` SELECT t.id, t.name, @@ -51,14 +69,112 @@ export class ExamTaskModel { t.end_at as endAt, t.created_at as createdAt, s.name as subjectName, - COUNT(DISTINCT etu.user_id) as userCount + COUNT(DISTINCT etu.user_id) as userCount, + s.total_score as totalScore FROM exam_tasks t JOIN exam_subjects s ON t.subject_id = s.id LEFT JOIN exam_task_users etu ON t.id = etu.task_id GROUP BY t.id ORDER BY t.created_at DESC - `; - return query(sql); + `); + + // 2. 为每个任务计算完成人数、合格率和优秀率 + const tasksWithStats: any[] = []; + for (const task of baseTasks) { + // 获取该任务的详细报表数据 + const report = await this.getReport(task.id); + + // 计算合格率(得分率60%以上) + const passingUsers = report.details.filter((d: any) => { + if (d.score === null) return false; + return (d.score / task.totalScore) >= 0.6; + }).length; + + const passRate = report.totalUsers > 0 + ? Math.round((passingUsers / report.totalUsers) * 100) + : 0; + + // 计算优秀率(得分率80%以上) + const excellentUsers = report.details.filter((d: any) => { + if (d.score === null) return false; + return (d.score / task.totalScore) >= 0.8; + }).length; + + const excellentRate = report.totalUsers > 0 + ? Math.round((excellentUsers / report.totalUsers) * 100) + : 0; + + tasksWithStats.push({ + ...task, + completedUsers: report.completedUsers, + passRate, + excellentRate + }); + } + + return tasksWithStats; + } + + static async getActiveTasksWithStats(): Promise { + const now = new Date().toISOString(); + + // 1. 获取当前时间有效的任务,包括开始和结束时间 + const activeTasks = 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 + WHERE t.start_at <= ? AND t.end_at >= ? + ORDER BY t.created_at DESC + `, [now, now]); + + const stats: ActiveTaskStat[] = []; + + for (const task of activeTasks) { + // 2. 获取每个任务的详细报告数据 + const report = await this.getReport(task.id); + + // 3. 计算完成率 + const completionRate = report.totalUsers > 0 + ? Math.round((report.completedUsers / report.totalUsers) * 100) + : 0; + + // 4. 计算合格率(得分率60%以上) + const passingUsers = report.details.filter(d => { + if (d.score === null) return false; + return (d.score / task.totalScore) >= 0.6; + }).length; + + const passRate = report.totalUsers > 0 + ? Math.round((passingUsers / report.totalUsers) * 100) + : 0; + + // 5. 计算优秀率(得分率80%以上) + const excellentUsers = report.details.filter(d => { + if (d.score === null) return false; + return (d.score / task.totalScore) >= 0.8; + }).length; + + const excellentRate = report.totalUsers > 0 + ? Math.round((excellentUsers / report.totalUsers) * 100) + : 0; + + stats.push({ + taskId: task.id, + taskName: task.taskName, + subjectName: task.subjectName, + totalUsers: report.totalUsers, + completedUsers: report.completedUsers, + completionRate, + passRate, + excellentRate, + startAt: task.startAt, + endAt: task.endAt + }); + } + + return stats; } static async findById(id: string): Promise { @@ -140,6 +256,16 @@ export class ExamTaskModel { await run(`DELETE FROM exam_tasks WHERE id = ?`, [id]); } + static async getTaskUsers(taskId: string): Promise { + const rows = await all(` + SELECT user_id as userId + FROM exam_task_users + WHERE task_id = ? + `, [taskId]); + + return rows.map(row => row.userId); + } + static async getReport(taskId: string): Promise { const task = await this.findById(taskId); if (!task) throw new Error('任务不存在'); @@ -209,23 +335,141 @@ export class ExamTaskModel { if (!subject) throw new Error('科目不存在'); const { QuestionModel } = await import('./question'); - const questions: Awaited> = []; - - for (const [type, ratio] of Object.entries(subject.typeRatios)) { - if (ratio <= 0) continue; - const typeScore = Math.floor((ratio / 100) * subject.totalScore); - const avgScore = 10; - const count = Math.max(1, Math.round(typeScore / avgScore)); - - const categories = Object.entries(subject.categoryRatios) - .filter(([, r]) => r > 0) - .map(([c]) => c); - - const qs = await QuestionModel.getRandomQuestions(type as any, count, categories); - questions.push(...qs); + let questions: Awaited> = []; + + // 构建包含所有类别的数组,根据比重重复对应次数 + const allCategories: string[] = []; + for (const [category, catRatio] of Object.entries(subject.categoryRatios)) { + if (catRatio > 0) { + // 根据比重计算该类别应占的总题目数比例 + const count = Math.round((catRatio / 100) * 100); // 放大100倍避免小数问题 + for (let i = 0; i < count; i++) { + allCategories.push(category); + } + } + } + + // 确保总题目数至少为1 + if (allCategories.length === 0) { + allCategories.push('通用'); + } + + // 按题型分配题目 + for (const [type, typeRatio] of Object.entries(subject.typeRatios)) { + if (typeRatio <= 0) continue; + + // 计算该题型应占的总分 + const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore); + let currentTypeScore = 0; + let typeQuestions: Awaited> = []; + + // 尝试获取足够分数的题目 + while (currentTypeScore < targetTypeScore) { + // 随机选择一个类别 + const randomCategory = allCategories[Math.floor(Math.random() * allCategories.length)]; + + // 获取该类型和类别的随机题目 + const availableQuestions = await QuestionModel.getRandomQuestions( + type as any, + 10, // 一次获取多个,提高效率 + [randomCategory] + ); + + if (availableQuestions.length === 0) { + break; // 该类型/类别没有题目,跳过 + } + + // 过滤掉已选题目 + const availableUnselected = availableQuestions.filter(q => + !questions.some(selected => selected.id === q.id) + ); + + if (availableUnselected.length === 0) { + break; // 没有可用的新题目了 + } + + // 选择分数最接近剩余需求的题目 + const remainingForType = targetTypeScore - currentTypeScore; + const selectedQuestion = availableUnselected.reduce((prev, curr) => { + const prevDiff = Math.abs(remainingForType - prev.score); + const currDiff = Math.abs(remainingForType - curr.score); + return currDiff < prevDiff ? curr : prev; + }); + + // 添加到题型题目列表 + typeQuestions.push(selectedQuestion); + currentTypeScore += selectedQuestion.score; + + // 防止无限循环 + if (typeQuestions.length > 100) { + break; + } + } + + questions.push(...typeQuestions); + } + + // 如果总分不足,尝试补充题目 + let totalScore = questions.reduce((sum, q) => sum + q.score, 0); + while (totalScore < subject.totalScore) { + // 获取所有类型的随机题目 + const allTypes = Object.keys(subject.typeRatios).filter(type => subject.typeRatios[type] > 0); + if (allTypes.length === 0) break; + + const randomType = allTypes[Math.floor(Math.random() * allTypes.length)]; + const availableQuestions = await QuestionModel.getRandomQuestions( + randomType as any, + 10, + allCategories + ); + + if (availableQuestions.length === 0) break; + + // 过滤掉已选题目 + const availableUnselected = availableQuestions.filter(q => + !questions.some(selected => selected.id === q.id) + ); + + if (availableUnselected.length === 0) break; + + // 选择分数最接近剩余需求的题目 + const remainingScore = subject.totalScore - totalScore; + const selectedQuestion = availableUnselected.reduce((prev, curr) => { + const prevDiff = Math.abs(remainingScore - prev.score); + const currDiff = Math.abs(remainingScore - curr.score); + return currDiff < prevDiff ? curr : prev; + }); + + questions.push(selectedQuestion); + totalScore += selectedQuestion.score; + + // 防止无限循环 + if (questions.length > 200) { + break; + } + } + + // 如果总分超过,尝试移除一些题目 + while (totalScore > subject.totalScore) { + // 找到最接近剩余差值的题目 + const excessScore = totalScore - subject.totalScore; + let closestIndex = -1; + let closestDiff = Infinity; + + for (let i = 0; i < questions.length; i++) { + const diff = Math.abs(questions[i].score - excessScore); + if (diff < closestDiff) { + closestDiff = diff; + closestIndex = i; + } + } + + if (closestIndex === -1) break; + + // 移除该题目 + totalScore -= questions[closestIndex].score; + questions.splice(closestIndex, 1); } - - const totalScore = questions.reduce((sum, q) => sum + q.score, 0); return { questions, @@ -241,9 +485,9 @@ export class ExamTaskModel { FROM exam_tasks t INNER JOIN exam_task_users tu ON t.id = tu.task_id INNER JOIN exam_subjects s ON t.subject_id = s.id - WHERE tu.user_id = ? AND t.start_at <= ? AND t.end_at >= ? + WHERE tu.user_id = ? AND t.start_at <= ? ORDER BY t.start_at DESC - `, [userId, now, now]); + `, [userId, now]); return rows.map(row => ({ id: row.id, diff --git a/api/models/question.ts b/api/models/question.ts index 12b6a07..faa12a5 100644 --- a/api/models/question.ts +++ b/api/models/question.ts @@ -47,18 +47,43 @@ export class QuestionModel { return this.findById(id) as Promise; } - // 批量创建题目 + // 批量创建题目 - 优化为使用事务批量插入 static async createMany(questions: CreateQuestionData[]): Promise<{ success: number; errors: string[] }> { const errors: string[] = []; let success = 0; - for (let i = 0; i < questions.length; i++) { - try { - await this.create(questions[i]); - success++; - } catch (error: any) { - errors.push(`第${i + 1}题: ${error.message}`); + // 使用事务提高性能 + try { + // 开始事务 + await run('BEGIN TRANSACTION'); + + const sql = ` + INSERT INTO questions (id, content, type, options, answer, 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 answerStr = Array.isArray(question.answer) ? JSON.stringify(question.answer) : question.answer; + const category = question.category && question.category.trim() ? question.category.trim() : '通用'; + + // 直接执行插入,不调用单个create方法 + await run(sql, [id, question.content, question.type, optionsStr, answerStr, 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 }; diff --git a/api/models/questionCategory.ts b/api/models/questionCategory.ts index 129a3ab..27cdc2b 100644 --- a/api/models/questionCategory.ts +++ b/api/models/questionCategory.ts @@ -8,9 +8,56 @@ export interface QuestionCategory { } export class QuestionCategoryModel { + // 获取所有题目类别,包括从题目表中聚合的新类别 static async findAll(): Promise { - const sql = `SELECT id, name, created_at as createdAt FROM question_categories ORDER BY created_at DESC`; - return query(sql); + try { + // 1. 首先从题目表中聚合所有唯一类别 + const questionCategoriesSql = ` + SELECT DISTINCT category as name + FROM questions + WHERE category IS NOT NULL AND category != '' + `; + const questionCategories = await query(questionCategoriesSql); + + // 2. 获取现有类别表中的类别 + const existingCategoriesSql = ` + SELECT id, name, created_at as createdAt + FROM question_categories + ORDER BY created_at DESC + `; + const existingCategories = await query(existingCategoriesSql); + + // 3. 创建现有类别名称的映射,用于快速查找 + const existingCategoryNames = new Set(existingCategories.map(cat => cat.name)); + + // 4. 找出题目表中存在但类别表中不存在的新类别 + const newCategories = questionCategories.filter(qCat => !existingCategoryNames.has(qCat.name)); + + // 5. 批量创建新类别 + if (newCategories.length > 0) { + await run('BEGIN TRANSACTION'); + + const createSql = `INSERT INTO question_categories (id, name) VALUES (?, ?)`; + for (const newCat of newCategories) { + await run(createSql, [uuidv4(), newCat.name]); + } + + await run('COMMIT'); + + // 6. 重新获取所有类别,包括新创建的 + return this.findAll(); + } + + // 如果没有新类别,直接返回现有类别 + return existingCategories; + } catch (error: any) { + // 如果事务失败,回滚 + await run('ROLLBACK'); + console.error('获取题目类别失败:', error); + // 回退到原始逻辑 + const sql = `SELECT id, name, created_at as createdAt FROM question_categories ORDER BY created_at DESC`; + return query(sql); + } } static async findById(id: string): Promise { diff --git a/api/models/quiz.ts b/api/models/quiz.ts index 55be9d9..96eb94c 100644 --- a/api/models/quiz.ts +++ b/api/models/quiz.ts @@ -142,13 +142,15 @@ export class QuizModel { } // 获取所有答题记录(管理员用) - static async findAllRecords(limit = 10, offset = 0): Promise<{ records: QuizRecord[]; total: number }> { + static async findAllRecords(limit = 10, offset = 0): Promise<{ records: any[]; total: number }> { const recordsSql = ` SELECT r.id, r.user_id as userId, u.name as userName, u.phone as userPhone, r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount, - r.created_at as createdAt + r.created_at as createdAt, r.subject_id as subjectId, s.name as subjectName, + r.task_id as taskId FROM quiz_records r JOIN users u ON r.user_id = u.id + LEFT JOIN exam_subjects s ON r.subject_id = s.id ORDER BY r.created_at DESC LIMIT ? OFFSET ? `; @@ -160,8 +162,29 @@ export class QuizModel { get(countSql) ]); + // 对于每条记录,计算该考试任务的参与人数 + const processedRecords = await Promise.all(records.map(async (record) => { + let examCount = 0; + if (record.taskId) { + // 统计该任务的参与人数 + const taskCountSql = `SELECT COUNT(DISTINCT user_id) as count FROM quiz_records WHERE task_id = ?`; + const taskCountResult = await get(taskCountSql, [record.taskId]); + examCount = taskCountResult.count || 0; + } else if (record.subjectId) { + // 统计该科目的参与人数 + const subjectCountSql = `SELECT COUNT(DISTINCT user_id) as count FROM quiz_records WHERE subject_id = ?`; + const subjectCountResult = await get(subjectCountSql, [record.subjectId]); + examCount = subjectCountResult.count || 0; + } + + return { + ...record, + examCount + }; + })); + return { - records, + records: processedRecords, total: countResult.total }; } @@ -215,7 +238,7 @@ export class QuizModel { averageScore: number; typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>; }> { - const totalUsersSql = `SELECT COUNT(DISTINCT user_id) as total FROM quiz_records`; + const totalUsersSql = `SELECT COUNT(*) as total FROM users`; const totalRecordsSql = `SELECT COUNT(*) as total FROM quiz_records`; const averageScoreSql = `SELECT AVG(total_score) as average FROM quiz_records`; diff --git a/api/models/user.ts b/api/models/user.ts index 3a04a90..ed211ad 100644 --- a/api/models/user.ts +++ b/api/models/user.ts @@ -43,6 +43,58 @@ export class UserModel { await run(`DELETE FROM users WHERE id = ?`, [id]); } + // 更新用户信息 + static async update(id: string, data: Partial): Promise { + // 验证数据 + const errors = this.validateUserData(data as CreateUserData); + if (errors.length > 0) { + throw new Error(errors.join(', ')); + } + + // 检查手机号唯一性 + if (data.phone !== undefined) { + const existingUser = await this.findByPhone(data.phone); + if (existingUser && existingUser.id !== id) { + throw new Error('手机号已存在'); + } + } + + // 构建更新字段 + const fields: string[] = []; + const values: any[] = []; + + if (data.name !== undefined) { + fields.push('name = ?'); + values.push(data.name); + } + + if (data.phone !== undefined) { + fields.push('phone = ?'); + values.push(data.phone); + } + + if (data.password !== undefined) { + fields.push('password = ?'); + values.push(data.password); + } + + if (fields.length === 0) { + return this.findById(id) as Promise; + } + + values.push(id); + const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = ?`; + + await run(sql, values); + const updatedUser = await this.findById(id); + + if (!updatedUser) { + throw new Error('用户不存在'); + } + + return updatedUser; + } + static async findById(id: string): Promise { const sql = `SELECT id, name, phone, password, created_at as createdAt FROM users WHERE id = ?`; const user = await get(sql, [id]); @@ -55,6 +107,12 @@ export class UserModel { return user || null; } + static async findByName(name: string): Promise { + const sql = `SELECT id, name, phone, password, created_at as createdAt FROM users WHERE name = ?`; + const users = await query(sql, [name]); + return users || []; + } + static async findAll(limit = 10, offset = 0): Promise<{ users: User[]; total: number }> { const usersSql = ` SELECT id, name, phone, password, created_at as createdAt @@ -75,15 +133,21 @@ export class UserModel { }; } - static validateUserData(data: CreateUserData): string[] { + // 验证用户数据,支持部分数据验证 + static validateUserData(data: Partial): string[] { const errors: string[] = []; - if (!data.name || data.name.length < 2 || data.name.length > 20) { - errors.push('姓名长度必须在2-20个字符之间'); + // 只验证提供了的数据 + if (data.name !== undefined) { + if (data.name.length < 2 || data.name.length > 20) { + errors.push('姓名长度必须在2-20个字符之间'); + } } - if (!data.phone || !/^1[3-9]\d{9}$/.test(data.phone)) { - errors.push('手机号格式不正确,请输入11位中国手机号'); + if (data.phone !== undefined) { + if (!/^1[3-9]\d{9}$/.test(data.phone)) { + errors.push('手机号格式不正确,请输入11位中国手机号'); + } } return errors; diff --git a/api/server.ts b/api/server.ts index 57224fb..22ea1e5 100644 --- a/api/server.ts +++ b/api/server.ts @@ -38,6 +38,7 @@ const apiRouter = express.Router(); apiRouter.post('/users', UserController.createUser); apiRouter.get('/users/:id', UserController.getUser); apiRouter.post('/users/validate', UserController.validateUserInfo); +apiRouter.get('/users/name/:name', UserController.getUsersByName); // 题库管理 apiRouter.get('/questions', QuestionController.getQuestions); @@ -66,6 +67,8 @@ apiRouter.delete('/admin/subjects/:id', adminAuth, ExamSubjectController.deleteS // 考试任务 apiRouter.get('/exam-tasks', ExamTaskController.getTasks); +apiRouter.get('/admin/tasks', adminAuth, ExamTaskController.getTasks); +apiRouter.get('/admin/tasks/:id/users', adminAuth, ExamTaskController.getTaskUsers); apiRouter.get('/exam-tasks/user/:userId', ExamTaskController.getUserTasks); apiRouter.post('/admin/tasks', adminAuth, ExamTaskController.createTask); apiRouter.put('/admin/tasks/:id', adminAuth, ExamTaskController.updateTask); @@ -74,6 +77,7 @@ apiRouter.get('/admin/tasks/:id/report', adminAuth, ExamTaskController.getTaskRe // 用户管理 apiRouter.get('/admin/users', adminAuth, AdminUserController.getUsers); +apiRouter.put('/admin/users/:id', adminAuth, AdminUserController.updateUser); apiRouter.delete('/admin/users', adminAuth, AdminUserController.deleteUser); apiRouter.get('/admin/users/export', adminAuth, AdminUserController.exportUsers); apiRouter.post('/admin/users/import', adminAuth, upload.single('file'), AdminUserController.importUsers); @@ -89,9 +93,10 @@ apiRouter.get('/quiz/records', adminAuth, QuizController.getAllRecords); // 管理员相关 apiRouter.post('/admin/login', AdminController.login); -apiRouter.get('/admin/config', adminAuth, AdminController.getQuizConfig); -apiRouter.put('/admin/config', adminAuth, AdminController.updateQuizConfig); apiRouter.get('/admin/statistics', adminAuth, AdminController.getStatistics); +apiRouter.get('/admin/active-tasks', adminAuth, AdminController.getActiveTasksStats); +apiRouter.put('/admin/config', adminAuth, AdminController.updateQuizConfig); +apiRouter.get('/admin/config', adminAuth, AdminController.getQuizConfig); apiRouter.put('/admin/password', adminAuth, AdminController.updatePassword); apiRouter.get('/admin/configs', adminAuth, AdminController.getAllConfigs); diff --git a/data/survey.db b/data/survey.db index 75c6416..fdabc19 100644 Binary files a/data/survey.db and b/data/survey.db differ diff --git a/data/~$题库导出_1766067180492.xlsx b/data/~$题库导出_1766067180492.xlsx new file mode 100644 index 0000000..0049f58 Binary files /dev/null and b/data/~$题库导出_1766067180492.xlsx differ diff --git a/data/题库导出_1766067180492.xlsx b/data/题库导出_1766067180492.xlsx new file mode 100644 index 0000000..5cf9381 Binary files /dev/null and b/data/题库导出_1766067180492.xlsx differ diff --git a/openspec/changes/fix-quizpage-useeffect-bug/proposal.md b/openspec/changes/fix-quizpage-useeffect-bug/proposal.md new file mode 100644 index 0000000..27e198d --- /dev/null +++ b/openspec/changes/fix-quizpage-useeffect-bug/proposal.md @@ -0,0 +1,13 @@ +# Change: Fix QuizPage useEffect Bug + +## Why +在QuizPage.tsx中,useEffect钩子末尾错误地调用了clearQuiz()函数,导致从SubjectSelectionPage传递过来的题目数据被立即清除,引发"Cannot read properties of undefined (reading 'type')"错误。 + +## What Changes +- 移除QuizPage.tsx useEffect钩子末尾的clearQuiz()调用 +- 修改清除逻辑,只清除answers对象而保留题目数据 +- 添加对currentQuestion的空值检查,确保组件正确渲染 + +## Impact +- Affected specs: quiz +- Affected code: src/pages/QuizPage.tsx \ No newline at end of file diff --git a/openspec/changes/fix-quizpage-useeffect-bug/specs/quiz/spec.md b/openspec/changes/fix-quizpage-useeffect-bug/specs/quiz/spec.md new file mode 100644 index 0000000..a98e437 --- /dev/null +++ b/openspec/changes/fix-quizpage-useeffect-bug/specs/quiz/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements +### Requirement: Quiz Page State Management +The system SHALL preserve question data when navigating to QuizPage from other pages, and only clear答题状态(answers) to ensure proper component rendering. + +#### Scenario: Navigation from SubjectSelectionPage +- **WHEN** user selects a subject and navigates to QuizPage +- **THEN** the system SHALL preserve the questions data +- **THEN** the system SHALL clear only the answers state +- **THEN** the system SHALL render the first question correctly + +### Requirement: Quiz Page Error Handling +The system SHALL properly handle null or undefined question data to prevent runtime errors during rendering. + +#### Scenario: Null Question Data +- **WHEN** currentQuestion is null or undefined +- **THEN** the system SHALL display a loading state instead of crashing +- **THEN** the system SHALL NOT attempt to access properties of undefined objects \ No newline at end of file diff --git a/openspec/changes/fix-quizpage-useeffect-bug/tasks.md b/openspec/changes/fix-quizpage-useeffect-bug/tasks.md new file mode 100644 index 0000000..18f69b8 --- /dev/null +++ b/openspec/changes/fix-quizpage-useeffect-bug/tasks.md @@ -0,0 +1,8 @@ +## 1. Implementation +- [x] 1.1 移除QuizPage.tsx useEffect钩子末尾的clearQuiz()调用 +- [x] 1.2 修改清除逻辑,只清除answers对象而保留题目数据 +- [x] 1.3 添加对currentQuestion的空值检查,确保组件正确渲染 + +## 2. Validation +- [x] 2.1 运行项目确保bug已修复 +- [x] 2.2 验证从SubjectSelectionPage可以正常跳转到QuizPage并显示题目 \ No newline at end of file diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 664ba7d..0adf89a 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -55,11 +55,7 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => { icon: , label: '用户管理', }, - { - key: '/admin/config', - icon: , - label: '抽题配置', - }, + { key: '/admin/statistics', icon: , diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index f4be04f..0d0cf4e 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -18,6 +18,7 @@ const HomePage = () => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [historyOptions, setHistoryOptions] = useState<{ value: string; label: string; phone: string }[]>([]); + const [isSearching, setIsSearching] = useState(false); useEffect(() => { // 加载历史记录 @@ -32,13 +33,19 @@ const HomePage = () => { const saveToHistory = (name: string, phone: string) => { const history: LoginHistory[] = JSON.parse(localStorage.getItem('loginHistory') || '[]'); // 移除已存在的同名记录(为了更新位置到最前,或者保持最新) - // 简单起见,如果已存在,先移除 const filtered = history.filter(item => item.name !== name); // 添加到头部 filtered.unshift({ name, phone }); // 保留前5条 const newHistory = filtered.slice(0, 5); localStorage.setItem('loginHistory', JSON.stringify(newHistory)); + + // 更新本地历史选项 + setHistoryOptions(newHistory.map(item => ({ + value: item.name, + label: item.name, + phone: item.phone + }))); }; const handleNameSelect = (value: string, option: any) => { @@ -47,6 +54,38 @@ const HomePage = () => { } }; + const handleNameChange = async (value: string) => { + if (!value) return; + + // 先检查本地历史记录 + const localOption = historyOptions.find(option => option.value === value); + if (localOption && localOption.phone) { + form.setFieldsValue({ phone: localOption.phone }); + return; + } + + // 本地没有则从服务器查询 + try { + setIsSearching(true); + const response = await userAPI.getUsersByName(value) as any; + + if (response.success && response.data && response.data.length > 0) { + // 假设返回的是数组,取第一个匹配的用户 + const user = response.data[0]; + if (user && user.phone) { + form.setFieldsValue({ phone: user.phone }); + // 将查询结果保存到本地历史记录 + saveToHistory(value, user.phone); + } + } + } catch (error: any) { + console.error('查询用户失败:', error); + // 查询失败不提示用户,保持原有逻辑 + } finally { + setIsSearching(false); + } + }; + const handleSubmit = async (values: { name: string; phone: string; password?: string }) => { try { setLoading(true); @@ -59,7 +98,7 @@ const HomePage = () => { } // 创建用户或登录 - const response = await userAPI.createUser(values) as any; + const response = await userAPI.validateUserInfo(values) as any; if (response.success) { setUser(response.data); @@ -107,6 +146,7 @@ const HomePage = () => { diff --git a/src/pages/admin/AdminDashboardPage.tsx b/src/pages/admin/AdminDashboardPage.tsx index 4db71f6..c7d58b1 100644 --- a/src/pages/admin/AdminDashboardPage.tsx +++ b/src/pages/admin/AdminDashboardPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Card, Row, Col, Statistic, Button, Table, message } from 'antd'; +import { Card, Row, Col, Statistic, Button, Table, message, Tooltip } from 'antd'; import { UserOutlined, QuestionCircleOutlined, @@ -8,6 +8,7 @@ import { } from '@ant-design/icons'; import { adminAPI } from '../../services/api'; import { formatDateTime } from '../../utils/validation'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend } from 'recharts'; interface Statistics { totalUsers: number; @@ -24,11 +25,27 @@ interface RecentRecord { correctCount: number; totalCount: number; createdAt: string; + subjectName?: string; + examCount?: number; +} + +interface ActiveTaskStat { + taskId: string; + taskName: string; + subjectName: string; + totalUsers: number; + completedUsers: number; + completionRate: number; + passRate: number; + excellentRate: number; + startAt: string; + endAt: string; } const AdminDashboardPage = () => { const [statistics, setStatistics] = useState(null); const [recentRecords, setRecentRecords] = useState([]); + const [activeTasks, setActiveTasks] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { @@ -38,12 +55,16 @@ const AdminDashboardPage = () => { const fetchDashboardData = async () => { try { setLoading(true); - const statsResponse = await adminAPI.getStatistics(); + // 并行获取所有数据,提高性能 + const [statsResponse, recordsResponse, activeTasksResponse] = await Promise.all([ + adminAPI.getStatistics(), + fetchRecentRecords(), + adminAPI.getActiveTasksStats() + ]); + setStatistics(statsResponse.data); - - // 获取最近10条答题记录 - const recordsResponse = await fetchRecentRecords(); setRecentRecords(recordsResponse); + setActiveTasks(activeTasksResponse.data); } catch (error: any) { message.error(error.message || '获取数据失败'); } finally { @@ -89,6 +110,18 @@ const AdminDashboardPage = () => { return {rate}%; }, }, + { + title: '考试科目', + dataIndex: 'subjectName', + key: 'subjectName', + render: (subjectName?: string) => subjectName || '', + }, + { + title: '考试人数', + dataIndex: 'examCount', + key: 'examCount', + render: (examCount?: number) => examCount || '', + }, { title: '答题时间', dataIndex: 'createdAt', @@ -173,6 +206,113 @@ const AdminDashboardPage = () => { )} + {/* 当前有效考试任务统计 */} + {activeTasks.length > 0 && ( + + { + // 计算考试进度百分率 + const now = new Date(); + const start = new Date(record.startAt); + const end = new Date(record.endAt); + + // 计算总时长(毫秒) + const totalDuration = end.getTime() - start.getTime(); + // 计算已经过去的时长(毫秒) + const elapsedDuration = now.getTime() - start.getTime(); + // 计算进度百分率,确保在0-100之间 + const progress = Math.max(0, Math.min(100, Math.round((elapsedDuration / totalDuration) * 100))); + + return ( +
+
+
+
+ {progress}% +
+ ); + }, + }, + { + title: '考试人数统计', + key: 'statistics', + render: (_: any, record: ActiveTaskStat) => { + // 计算各类人数 + const total = record.totalUsers; + const completed = record.completedUsers; + const passed = Math.round(completed * (record.passRate / 100)); + const excellent = Math.round(completed * (record.excellentRate / 100)); + const incomplete = total - completed; + + // 准备饼图数据 + const pieData = [ + { name: '已完成', value: completed, color: '#1890ff' }, + { name: '合格', value: passed, color: '#52c41a' }, + { name: '优秀', value: excellent, color: '#fa8c16' }, + { name: '未完成', value: incomplete, color: '#d9d9d9' } + ]; + + // 只显示有数据的项 + const filteredData = pieData.filter(item => item.value > 0); + + return ( +
+ + + `${name}:${value}`} + > + {filteredData.map((entry, index) => ( + + ))} + + [`${value} 人`, '数量']} /> + `${value} ${entry.payload?.value || 0} 人`} /> + + +
+ ); + }, + }, + ]} + dataSource={activeTasks} + rowKey="taskId" + loading={loading} + pagination={false} + size="small" + /> + + )} + {/* 最近答题记录 */}
{ + try { + const records = localStorage.getItem(LOGIN_RECORDS_KEY); + return records ? JSON.parse(records) : []; + } catch (error) { + console.error('获取登录记录失败:', error); + return []; + } +}; + +// 保存登录记录到本地存储 - 不再保存密码 +const saveLoginRecord = (record: LoginRecord) => { + try { + const records = getLoginRecords(); + // 移除相同用户名的旧记录 + const filteredRecords = records.filter(r => r.username !== record.username); + // 添加新记录到开头 + const updatedRecords = [record, ...filteredRecords].slice(0, MAX_RECORDS); + localStorage.setItem(LOGIN_RECORDS_KEY, JSON.stringify(updatedRecords)); + } catch (error) { + console.error('保存登录记录失败:', error); + } +}; + const AdminLoginPage = () => { const navigate = useNavigate(); const { setAdmin } = useAdmin(); const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + const [loginRecords, setLoginRecords] = useState([]); + + // 初始化获取登录记录 + useEffect(() => { + setLoginRecords(getLoginRecords()); + }, []); const handleSubmit = async (values: { username: string; password: string }) => { try { @@ -15,6 +58,15 @@ const AdminLoginPage = () => { const response = await adminAPI.login(values) as any; if (response.success) { + // 保存登录记录 - 不再保存密码 + saveLoginRecord({ + username: values.username, + timestamp: Date.now() + }); + + // 更新状态 + setLoginRecords(getLoginRecords()); + setAdmin({ username: values.username, token: response.data.token @@ -29,6 +81,14 @@ const AdminLoginPage = () => { } }; + // 处理从下拉列表选择历史记录 - 不再自动填充密码 + const handleSelectRecord = (record: LoginRecord) => { + form.setFieldsValue({ + username: record.username, + password: '' // 清空密码,必须每次手动输入 + }); + }; + return (
@@ -42,7 +102,37 @@ const AdminLoginPage = () => { layout="vertical" onFinish={handleSubmit} autoComplete="off" + form={form} > + {/* 最近登录记录下拉选择 */} + {loginRecords.length > 0 && ( + + @@ -65,11 +157,14 @@ const AdminLoginPage = () => { { required: true, message: '请输入密码' }, { min: 6, message: '密码至少6个字符' } ]} + className="mb-4" > diff --git a/src/pages/admin/ExamSubjectPage.tsx b/src/pages/admin/ExamSubjectPage.tsx index 71a60b6..9feb2f8 100644 --- a/src/pages/admin/ExamSubjectPage.tsx +++ b/src/pages/admin/ExamSubjectPage.tsx @@ -1,8 +1,24 @@ import React, { useState, useEffect } from 'react'; import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, InputNumber, Card, Checkbox, Progress, Row, Col } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons'; import api from '../../services/api'; +interface Question { + id: string; + content: string; + type: string; + options?: string[]; + answer: string | string[]; + score: number; + category: string; +} + +interface QuizPreview { + questions: Question[]; + totalScore: number; + timeLimit: number; +} + interface ExamSubject { id: string; name: string; @@ -25,6 +41,21 @@ const ExamSubjectPage = () => { const [modalVisible, setModalVisible] = useState(false); const [editingSubject, setEditingSubject] = useState(null); const [form] = Form.useForm(); + // 浏览考题相关状态 + const [previewVisible, setPreviewVisible] = useState(false); + const [quizPreview, setQuizPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [currentSubject, setCurrentSubject] = useState(null); + // 引入状态管理来跟踪实时的比例配置 + const [typeRatios, setTypeRatios] = useState>({ + single: 40, + multiple: 30, + judgment: 20, + text: 10 + }); + const [categoryRatios, setCategoryRatios] = useState>({ + 通用: 100 + }); // 题型配置 const questionTypes = [ @@ -58,9 +89,16 @@ const ExamSubjectPage = () => { setEditingSubject(null); form.resetFields(); // 设置默认值 + const defaultTypeRatios = { single: 40, multiple: 30, judgment: 20, text: 10 }; + const defaultCategoryRatios: Record = { 通用: 100 }; + + // 初始化状态 + setTypeRatios(defaultTypeRatios); + setCategoryRatios(defaultCategoryRatios); + form.setFieldsValue({ - typeRatios: { single: 40, multiple: 30, judgment: 20, text: 10 }, - categoryRatios: { 通用: 100 }, + typeRatios: defaultTypeRatios, + categoryRatios: defaultCategoryRatios, totalScore: 100, timeLimitMinutes: 60, }); @@ -69,12 +107,27 @@ const ExamSubjectPage = () => { const handleEdit = (subject: ExamSubject) => { setEditingSubject(subject); + + // 初始化状态,确保所有类别都有比重值 + const initialTypeRatios = subject.typeRatios || { single: 40, multiple: 30, judgment: 20, text: 10 }; + + // 初始化类别比重,确保所有类别都有值 + const initialCategoryRatios: Record = { 通用: 100 }; + // 合并现有类别比重 + if (subject.categoryRatios) { + Object.assign(initialCategoryRatios, subject.categoryRatios); + } + + // 确保状态与表单值正确同步 + setTypeRatios(initialTypeRatios); + setCategoryRatios(initialCategoryRatios); + form.setFieldsValue({ name: subject.name, totalScore: subject.totalScore, timeLimitMinutes: subject.timeLimitMinutes, - typeRatios: subject.typeRatios, - categoryRatios: subject.categoryRatios, + typeRatios: initialTypeRatios, + categoryRatios: initialCategoryRatios, }); setModalVisible(true); }; @@ -91,22 +144,33 @@ const ExamSubjectPage = () => { const handleModalOk = async () => { try { - const values = await form.validateFields(); + // 首先验证状态中的值,确保总和为100% - // 验证题型比重总和 - const typeTotal = Object.values(values.typeRatios).reduce((sum: number, val) => sum + val, 0); - if (typeTotal !== 100) { + // 验证题型比重总和(使用状态中的值,允许±0.01的精度误差) + const typeTotal = Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0); + console.log('题型比重总和(状态):', typeTotal); + if (Math.abs(typeTotal - 100) > 0.01) { message.error('题型比重总和必须为100%'); return; } - // 验证类别比重总和 - const categoryTotal = Object.values(values.categoryRatios).reduce((sum: number, val) => sum + val, 0); - if (categoryTotal !== 100) { + // 验证类别比重总和(使用状态中的值,允许±0.01的精度误差) + const categoryTotal = Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0); + console.log('类别比重总和(状态):', categoryTotal); + if (Math.abs(categoryTotal - 100) > 0.01) { message.error('题目类别比重总和必须为100%'); return; } + // 然后才获取表单值,确保表单验证通过 + const values = await form.validateFields(); + + // 确保表单值与状态同步 + values.typeRatios = typeRatios; + values.categoryRatios = categoryRatios; + + console.log('最终提交的表单值:', values); + if (editingSubject) { await api.put(`/admin/subjects/${editingSubject.id}`, values); message.success('更新成功'); @@ -118,21 +182,46 @@ const ExamSubjectPage = () => { fetchSubjects(); } catch (error) { message.error('操作失败'); + console.error('操作失败:', error); } }; const handleTypeRatioChange = (type: string, value: number) => { - const currentRatios = form.getFieldValue('typeRatios') || {}; - const newRatios = { ...currentRatios, [type]: value }; + const newRatios = { ...typeRatios, [type]: value }; + setTypeRatios(newRatios); form.setFieldsValue({ typeRatios: newRatios }); }; const handleCategoryRatioChange = (category: string, value: number) => { - const currentRatios = form.getFieldValue('categoryRatios') || {}; - const newRatios = { ...currentRatios, [category]: value }; + const newRatios = { ...categoryRatios, [category]: value }; + console.log('修改类别比重:', category, value, '新的类别比重:', newRatios); + console.log('类别比重总和:', Object.values(newRatios).reduce((sum: number, val) => sum + val, 0)); + setCategoryRatios(newRatios); form.setFieldsValue({ categoryRatios: newRatios }); }; + // 浏览考题处理函数 + const handleBrowseQuestions = async (subject: ExamSubject) => { + try { + setPreviewLoading(true); + setCurrentSubject(subject); + + // 调用生成试卷API获取随机题目 + const response = await api.post('/quiz/generate', { + userId: 'admin-preview', // 使用临时用户ID + subjectId: subject.id + }); + + setQuizPreview(response.data); + setPreviewVisible(true); + } catch (error) { + message.error('生成预览题目失败'); + console.error('生成预览题目失败:', error); + } finally { + setPreviewLoading(false); + } + }; + const columns = [ { title: '科目名称', @@ -156,19 +245,77 @@ const ExamSubjectPage = () => { dataIndex: 'typeRatios', key: 'typeRatios', render: (ratios: Record) => ( -
- {ratios && Object.entries(ratios).map(([type, ratio]) => { - const typeConfig = questionTypes.find(t => t.key === type); - return ( -
- {typeConfig?.label || type} - {ratio}% -
- ); - })} +
+
+ {ratios && Object.entries(ratios).map(([type, ratio]) => { + const typeConfig = questionTypes.find(t => t.key === type); + return ( +
+ ); + })} +
+
+ {ratios && Object.entries(ratios).map(([type, ratio]) => { + const typeConfig = questionTypes.find(t => t.key === type); + return ( +
+ + {typeConfig?.label || type} + {ratio}% +
+ ); + })} +
), }, + { + title: '题目类别分布', + dataIndex: 'categoryRatios', + key: 'categoryRatios', + render: (ratios: Record) => { + // 生成不同的颜色数组 + const colors = ['#1890ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1', '#eb2f96', '#fa8c16', '#a0d911']; + return ( +
+
+ {ratios && Object.entries(ratios).map(([category, ratio], index) => ( +
+ ))} +
+
+ {ratios && Object.entries(ratios).map(([category, ratio], index) => ( +
+ + {category} + {ratio}% +
+ ))} +
+
+ ); + }, + }, { title: '创建时间', dataIndex: 'createdAt', @@ -187,6 +334,13 @@ const ExamSubjectPage = () => { > 编辑 + handleDelete(record.id)} @@ -223,6 +377,7 @@ const ExamSubjectPage = () => { }} /> + {/* 编辑/新增科目模态框 */} { - + + 题型比重配置 + sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}> + 总计:{Object.values(typeRatios).reduce((sum: number, val) => sum + val, 0)}% + +
+ } + className="mb-4" + >
{questionTypes.map((type) => { - const currentRatios = form.getFieldValue('typeRatios') || {}; - const ratio = currentRatios[type.key] || 0; + const ratio = typeRatios[type.key] || 0; return (
@@ -302,19 +467,25 @@ const ExamSubjectPage = () => {
); })} -
- 总计:{Object.values(form.getFieldValue('typeRatios') || {}).reduce((sum: number, val) => sum + val, 0)}% -
- + + 题目类别比重配置 + sum + val, 0) === 100 ? 'text-green-600' : 'text-red-600'}`}> + 总计:{Object.values(categoryRatios).reduce((sum: number, val) => sum + val, 0)}% + +
+ } + >
{categories.map((category) => { - const currentRatios = form.getFieldValue('categoryRatios') || {}; - const ratio = currentRatios[category.name] || 0; + const ratio = categoryRatios[category.name] || 0; return (
@@ -341,14 +512,100 @@ const ExamSubjectPage = () => {
); })} -
- 总计:{Object.values(form.getFieldValue('categoryRatios') || {}).reduce((sum: number, val) => sum + val, 0)}% -
+ + {/* 浏览考题模态框 */} + setPreviewVisible(false)} + width={900} + footer={null} + bodyStyle={{ maxHeight: '70vh', overflowY: 'auto' }} + > + {previewLoading ? ( +
+
+

正在生成随机考题...

+
+ ) : quizPreview ? ( +
+ +
+
+ 总分: + {quizPreview.totalScore} 分 +
+
+ 时间限制: + {quizPreview.timeLimit} 分钟 +
+
+ 题目数量: + {quizPreview.questions.length} 道 +
+
+
+ +
+ {quizPreview.questions.map((question, index) => ( + +
+

+ 第 {index + 1} 题({question.score} 分) + + {question.type === 'single' ? '单选题' : + question.type === 'multiple' ? '多选题' : + question.type === 'judgment' ? '判断题' : '文字题'} + +

+ {question.category} +
+ +
{question.content}
+ + {question.options && question.options.length > 0 && ( +
+ {question.options.map((option, optIndex) => ( +
+ +
+ ))} +
+ )} + +
+ 参考答案: + + {Array.isArray(question.answer) ? + // 多选题:直接拼接答案,不需要转换 + question.answer.join(', ') : + question.type === 'judgment' ? + // 判断题:A=正确,B=错误 + (question.answer === 'A' ? '正确' : '错误') : + // 单选题:直接显示答案,不需要转换 + question.answer} + +
+
+ ))} +
+
+ ) : ( +
+ 暂无考题数据 +
+ )} +
); }; diff --git a/src/pages/admin/ExamTaskPage.tsx b/src/pages/admin/ExamTaskPage.tsx index cdbb71e..4ff091c 100644 --- a/src/pages/admin/ExamTaskPage.tsx +++ b/src/pages/admin/ExamTaskPage.tsx @@ -12,6 +12,9 @@ interface ExamTask { startAt: string; endAt: string; userCount: number; + completedUsers: number; + passRate: number; + excellentRate: number; createdAt: string; } @@ -65,15 +68,31 @@ const ExamTaskPage = () => { setModalVisible(true); }; - const handleEdit = (task: ExamTask) => { + const handleEdit = async (task: ExamTask) => { setEditingTask(task); - form.setFieldsValue({ - name: task.name, - subjectId: task.subjectId, - startAt: dayjs(task.startAt), - endAt: dayjs(task.endAt), - userIds: [], - }); + try { + // 获取任务已分配的用户列表 + const userIdsRes = await api.get(`/admin/tasks/${task.id}/users`); + const userIds = userIdsRes.data; + + form.setFieldsValue({ + name: task.name, + subjectId: task.subjectId, + startAt: dayjs(task.startAt), + endAt: dayjs(task.endAt), + userIds: userIds, + }); + } catch (error) { + message.error('获取任务用户失败'); + // 即使获取失败,也要打开模态框,只是用户列表为空 + form.setFieldsValue({ + name: task.name, + subjectId: task.subjectId, + startAt: dayjs(task.startAt), + endAt: dayjs(task.endAt), + userIds: [], + }); + } setModalVisible(true); }; @@ -143,12 +162,68 @@ const ExamTaskPage = () => { key: 'endAt', render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'), }, + { + title: '考试进程', + dataIndex: ['startAt', 'endAt'], + key: 'progress', + render: (_: any, record: ExamTask) => { + const now = dayjs(); + const start = dayjs(record.startAt); + const end = dayjs(record.endAt); + + let progress = 0; + if (now < start) { + // 尚未开始 + progress = 0; + } else if (now > end) { + // 已结束 + progress = 100; + } else { + // 进行中 + const totalDuration = end.diff(start, 'millisecond'); + const elapsedDuration = now.diff(start, 'millisecond'); + progress = Math.round((elapsedDuration / totalDuration) * 100); + } + + return ( +
+
+ {progress}% +
+
+
+
+
+ ); + }, + }, { title: '参与人数', dataIndex: 'userCount', key: 'userCount', render: (count: number) => `${count} 人`, }, + { + title: '已完成人数', + dataIndex: 'completedUsers', + key: 'completedUsers', + render: (count: number) => `${count} 人`, + }, + { + title: '合格率', + dataIndex: 'passRate', + key: 'passRate', + render: (rate: number) => `${rate}%`, + }, + { + title: '优秀率', + dataIndex: 'excellentRate', + key: 'excellentRate', + render: (rate: number) => `${rate}%`, + }, { title: '创建时间', dataIndex: 'createdAt', @@ -231,7 +306,17 @@ const ExamTaskPage = () => { label="考试科目" rules={[{ required: true, message: '请选择考试科目' }]} > - { + const value = option?.children as string; + return value.toLowerCase().includes(input.toLowerCase()); + }} + dropdownStyle={{ maxHeight: 300, overflow: 'auto' }} + virtual + > {subjects.map((subject) => ( {subject.name} @@ -273,6 +358,15 @@ const ExamTaskPage = () => { mode="multiple" placeholder="请选择参与用户" style={{ width: '100%' }} + showSearch + filterOption={(input, option) => { + const value = option?.children as string; + return value.toLowerCase().includes(input.toLowerCase()); + }} + maxTagCount={3} + maxTagPlaceholder={(count) => `+${count} 个用户`} + dropdownStyle={{ maxHeight: 400, overflow: 'auto' }} + virtual > {users.map((user) => ( @@ -288,7 +382,8 @@ const ExamTaskPage = () => { title="任务报表" open={reportModalVisible} onCancel={() => setReportModalVisible(false)} - footer={null} + onOk={() => setReportModalVisible(false)} + okText="关闭" width={800} > {reportData && ( diff --git a/src/pages/admin/QuestionCategoryPage.tsx b/src/pages/admin/QuestionCategoryPage.tsx index 2f5d8ea..8bc9770 100644 --- a/src/pages/admin/QuestionCategoryPage.tsx +++ b/src/pages/admin/QuestionCategoryPage.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Table, Button, Input, Space, message, Popconfirm, Modal, Form } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { useLocation } from 'react-router-dom'; import api from '../../services/api'; interface QuestionCategory { @@ -15,6 +16,7 @@ const QuestionCategoryPage = () => { const [modalVisible, setModalVisible] = useState(false); const [editingCategory, setEditingCategory] = useState(null); const [form] = Form.useForm(); + const location = useLocation(); // 添加路由监听 const fetchCategories = async () => { setLoading(true); @@ -28,9 +30,15 @@ const QuestionCategoryPage = () => { } }; + // 当路由变化或组件挂载时重新获取类别列表 useEffect(() => { fetchCategories(); - }, []); + }, [location.pathname]); // 监听路由变化 + + // 手动刷新类别列表 + const handleRefresh = () => { + fetchCategories(); + }; const handleCreate = () => { setEditingCategory(null); @@ -114,9 +122,14 @@ const QuestionCategoryPage = () => {

题目类别管理

- + + + +
{ const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + // 使用state来跟踪实时配置值 + const [configValues, setConfigValues] = useState({ + singleRatio: 40, + multipleRatio: 30, + judgmentRatio: 20, + textRatio: 10, + totalScore: 100 + }); useEffect(() => { fetchConfig(); @@ -23,7 +31,9 @@ const QuizConfigPage = () => { try { setLoading(true); const response = await adminAPI.getQuizConfig(); - form.setFieldsValue(response.data); + const config = response.data; + form.setFieldsValue(config); + setConfigValues(config); // 更新state } catch (error: any) { message.error(error.message || '获取配置失败'); } finally { @@ -35,9 +45,10 @@ const QuizConfigPage = () => { try { setSaving(true); - // 验证比例总和 + // 验证比例总和(添加容错,允许±0.01的精度误差) const totalRatio = values.singleRatio + values.multipleRatio + values.judgmentRatio + values.textRatio; - if (totalRatio !== 100) { + console.log('题型比例总和:', totalRatio); + if (Math.abs(totalRatio - 100) > 0.01) { message.error('题型比例总和必须为100%'); return; } @@ -50,16 +61,18 @@ const QuizConfigPage = () => { await adminAPI.updateQuizConfig(values); message.success('配置更新成功'); + setConfigValues(values); // 更新state } catch (error: any) { message.error(error.message || '更新配置失败'); + console.error('更新配置失败:', error); } finally { setSaving(false); } }; const onValuesChange = (changedValues: any, allValues: QuizConfig) => { - // 实时更新进度条 - form.setFieldsValue(allValues); + // 只更新state,不调用setFieldsValue,避免循环更新 + setConfigValues(allValues); }; const getProgressColor = (ratio: number) => { @@ -75,7 +88,18 @@ const QuizConfigPage = () => {

设置各题型的比例和试卷总分

- + + 抽题配置 + + 比例总和:{configValues.singleRatio + configValues.multipleRatio + configValues.judgmentRatio + configValues.textRatio}% + + + } + >
{
@@ -132,8 +156,8 @@ const QuizConfigPage = () => { @@ -157,8 +181,8 @@ const QuizConfigPage = () => { @@ -182,8 +206,8 @@ const QuizConfigPage = () => { @@ -212,20 +236,6 @@ const QuizConfigPage = () => { - - -
- 比例总和: - - {(form.getFieldValue('singleRatio') || 0) + - (form.getFieldValue('multipleRatio') || 0) + - (form.getFieldValue('judgmentRatio') || 0) + - (form.getFieldValue('textRatio') || 0)}% - -
- - - @@ -233,21 +369,30 @@ const UserManagePage = () => { return (
-
-

用户管理

- - - - - - - + + + + + +
{ onChange={handleTableChange} /> + {/* 答题记录面板 */} + {selectedUser && ( +
+

+ {selectedUser.name}的答题记录 + +

+
text || '无科目', + }, + { + title: '考试任务', + dataIndex: 'taskName', + key: 'taskName', + render: (text: string) => text || '无任务', + }, + { + title: '总分', + dataIndex: 'totalScore', + key: 'totalScore', + render: (score: number) => score || 0, + }, + { + title: '得分', + dataIndex: 'obtainedScore', + key: 'obtainedScore', + render: (score: number, record: QuizRecord) => { + // 确保score有默认值 + const actualScore = score || 0; + const totalScore = record.totalScore || 0; + return ( + = totalScore * 0.6 ? 'text-green-600' : 'text-red-600'}`}> + {actualScore} + + ); + }, + }, + { + title: '得分率', + dataIndex: 'scoreRate', + key: 'scoreRate', + render: (_: any, record: QuizRecord) => { + const obtainedScore = record.obtainedScore || 0; + const totalScore = record.totalScore || 0; + const rate = totalScore > 0 ? (obtainedScore / totalScore) * 100 : 0; + return `${rate.toFixed(1)}%`; + }, + }, + { + title: '考试时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (time: string) => new Date(time).toLocaleString(), + }, + ]} + dataSource={userRecords} + rowKey="id" + loading={recordsLoading} + pagination={recordsPagination} + onChange={handleRecordsPaginationChange} + scroll={{ x: 'max-content' }} + onRow={(record) => ({ + onClick: () => handleViewRecordDetail(record.id), + style: { cursor: 'pointer' }, + })} + /> + + )} + { + + {/* 记录详情弹窗 */} + + {recordDetail && ( +
+ {/* 考试基本信息 */} +
+
+
+ + {recordDetail.subjectName || '无科目'} +
+
+ + {recordDetail.taskName || '无任务'} +
+
+ + {recordDetail.totalScore || 0} +
+
+ + {recordDetail.obtainedScore || 0} +
+
+ + {new Date(recordDetail.createdAt).toLocaleString()} +
+
+
+ + {/* 题目列表 */} +

题目详情

+
+ {recordDetail.questions && recordDetail.questions.map((item: any, index: number) => ( +
+
+
+ 第{index + 1}题 + + {item.question.type === 'single' ? '单选题' : + item.question.type === 'multiple' ? '多选题' : + item.question.type === 'judgment' ? '判断题' : '文字题'} + + {item.score}分 + + {item.isCorrect ? '答对' : '答错'} + +
+
+ +
+

题目:{item.question.content}

+
+ + {/* 显示选项(如果有) */} + {item.question.options && item.question.options.length > 0 && ( +
+

选项:

+
+ {item.question.options.map((option: string, optIndex: number) => ( +
+ + {String.fromCharCode(65 + optIndex)} + + {option} +
+ ))} +
+
+ )} + + {/* 显示答案 */} +
+
+ 正确答案: + {Array.isArray(item.question.answer) ? item.question.answer.join(', ') : item.question.answer} +
+
+ 你的答案: + {Array.isArray(item.userAnswer) ? item.userAnswer.join(', ') : item.userAnswer || '未作答'} +
+
+
+ ))} +
+
+ )} +
); }; diff --git a/src/services/api.ts b/src/services/api.ts index e883f60..a545b1d 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -4,7 +4,7 @@ const API_BASE_URL = '/api'; const api = axios.create({ baseURL: API_BASE_URL, - timeout: 10000, + timeout: 30000, // 增加超时时间到30秒 headers: { 'Content-Type': 'application/json', }, @@ -60,6 +60,7 @@ export const userAPI = { createUser: (data: { name: string; phone: string; password?: string }) => api.post('/users', data), getUser: (id: string) => api.get(`/users/${id}`), validateUserInfo: (data: { name: string; phone: string }) => api.post('/users/validate', data), + getUsersByName: (name: string) => api.get(`/users/name/${name}`), }; // 题目相关API @@ -108,6 +109,7 @@ export const adminAPI = { getQuizConfig: () => api.get('/admin/config'), updateQuizConfig: (data: any) => api.put('/admin/config', data), getStatistics: () => api.get('/admin/statistics'), + getActiveTasksStats: () => api.get('/admin/active-tasks'), updatePassword: (data: { username: string; oldPassword: string; newPassword: string }) => api.put('/admin/password', data), };