import { v4 as uuidv4 } from 'uuid'; import { get, query, run, all } from '../database'; export interface ExamTask { id: string; name: string; subjectId: string; startAt: string; endAt: string; createdAt: string; selectionConfig?: string; // JSON string } export interface ExamTaskUser { id: string; taskId: string; userId: string; createdAt: string; } export interface TaskWithSubject extends ExamTask { subjectName: string; userCount: number; } export interface TaskReport { taskId: string; taskName: string; subjectName: string; totalUsers: number; completedUsers: number; averageScore: number; topScore: number; lowestScore: number; details: Array<{ userId: string; userName: string; userPhone: string; score: number | null; completedAt: string | null; }>; } 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 { private static buildActiveTaskStat(input: { taskId: string; taskName: string; subjectName: string; totalScore: number; startAt: string; endAt: string; report: TaskReport; }): ActiveTaskStat { const { report } = input; const completionRate = report.totalUsers > 0 ? Math.round((report.completedUsers / report.totalUsers) * 100) : 0; const passingUsers = report.details.filter((d) => { if (d.score === null) return false; return d.score / input.totalScore >= 0.6; }).length; const passRate = report.totalUsers > 0 ? Math.round((passingUsers / report.totalUsers) * 100) : 0; const excellentUsers = report.details.filter((d) => { if (d.score === null) return false; return d.score / input.totalScore >= 0.8; }).length; const excellentRate = report.totalUsers > 0 ? Math.round((excellentUsers / report.totalUsers) * 100) : 0; return { taskId: input.taskId, taskName: input.taskName, subjectName: input.subjectName, totalUsers: report.totalUsers, completedUsers: report.completedUsers, completionRate, passRate, excellentRate, startAt: input.startAt, endAt: input.endAt, }; } static async findAll(): Promise<(TaskWithSubject & { completedUsers: number; passRate: number; excellentRate: number; })[]> { // 1. 先获取所有任务的基本信息 const baseTasks = await all(` SELECT t.id, t.name, t.subject_id as subjectId, t.start_at as startAt, t.end_at as endAt, t.created_at as createdAt, s.name as subjectName, 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 `); // 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 getHistoryTasksWithStatsPaged( page: number, limit: number, ): Promise<{ data: ActiveTaskStat[]; total: number }> { const now = new Date().toISOString(); const offset = (page - 1) * limit; const totalRow = await get( `SELECT COUNT(*) as total FROM exam_tasks t WHERE t.end_at < ?`, [now], ); 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 WHERE t.end_at < ? ORDER BY t.end_at DESC LIMIT ? OFFSET ? `, [now, limit, offset], ); const data: ActiveTaskStat[] = []; for (const task of tasks) { const report = await this.getReport(task.id); data.push( this.buildActiveTaskStat({ taskId: task.id, taskName: task.taskName, subjectName: task.subjectName, totalScore: Number(task.totalScore) || 0, startAt: task.startAt, endAt: task.endAt, report, }), ); } return { data, total }; } static async getUpcomingTasksWithStatsPaged( page: number, limit: number, ): Promise<{ data: ActiveTaskStat[]; total: number }> { const now = new Date().toISOString(); const offset = (page - 1) * limit; const totalRow = await get( `SELECT COUNT(*) as total FROM exam_tasks t WHERE t.start_at > ?`, [now], ); 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 WHERE t.start_at > ? ORDER BY t.start_at ASC LIMIT ? OFFSET ? `, [now, limit, offset], ); const data: ActiveTaskStat[] = []; for (const task of tasks) { const report = await this.getReport(task.id); data.push( this.buildActiveTaskStat({ taskId: task.id, taskName: task.taskName, subjectName: task.subjectName, totalScore: Number(task.totalScore) || 0, startAt: task.startAt, endAt: task.endAt, report, }), ); } 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]); return row || null; } static async create(data: { name: string; subjectId: string; startAt: string; endAt: string; userIds: string[]; selectionConfig?: string; }): Promise { if (!data.name.trim()) throw new Error('任务名称不能为空'); if (!data.userIds.length) throw new Error('至少选择一位用户'); const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(data.subjectId)); if (!subject) throw new Error('科目不存在'); const id = uuidv4(); const sqlTask = ` INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config) VALUES (?, ?, ?, ?, ?, ?) `; const sqlTaskUser = ` INSERT INTO exam_task_users (id, task_id, user_id) VALUES (?, ?, ?) `; await run(sqlTask, [id, data.name.trim(), data.subjectId, data.startAt, data.endAt, data.selectionConfig || null]); for (const userId of data.userIds) { await run(sqlTaskUser, [uuidv4(), id, userId]); } return (await this.findById(id)) as ExamTask; } static async update(id: string, data: { name: string; subjectId: string; startAt: string; endAt: string; userIds: string[]; selectionConfig?: string; }): Promise { const existing = await this.findById(id); if (!existing) throw new Error('任务不存在'); if (!data.name.trim()) throw new Error('任务名称不能为空'); if (!data.userIds.length) throw new Error('至少选择一位用户'); const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(data.subjectId)); if (!subject) throw new Error('科目不存在'); await run(`UPDATE exam_tasks SET name = ?, subject_id = ?, start_at = ?, end_at = ?, selection_config = ? WHERE id = ?`, [ data.name.trim(), data.subjectId, data.startAt, data.endAt, data.selectionConfig || null, id ]); await run(`DELETE FROM exam_task_users WHERE task_id = ?`, [id]); for (const userId of data.userIds) { await run(`INSERT INTO exam_task_users (id, task_id, user_id) VALUES (?, ?, ?)`, [uuidv4(), id, userId]); } return (await this.findById(id)) as ExamTask; } static async delete(id: string): Promise { const existing = await this.findById(id); if (!existing) throw new Error('任务不存在'); 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('任务不存在'); const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(task.subjectId)); if (!subject) throw new Error('科目不存在'); const sqlUsers = ` SELECT u.id as userId, u.name as userName, u.phone as userPhone, qr.total_score as score, qr.created_at as completedAt FROM exam_task_users etu JOIN users u ON etu.user_id = u.id LEFT JOIN quiz_records qr ON u.id = qr.user_id AND qr.task_id = ? WHERE etu.task_id = ? `; const rows = await query(sqlUsers, [taskId, taskId]); const details = rows.map((r) => ({ userId: r.userId, userName: r.userName, userPhone: r.userPhone, score: r.score !== null ? r.score : null, completedAt: r.completedAt || null })); const completedUsers = details.filter((d) => d.score !== null).length; const scores = details.map((d) => d.score).filter((s) => s !== null) as number[]; return { taskId, taskName: task.name, subjectName: subject.name, totalUsers: details.length, completedUsers, averageScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0, topScore: scores.length > 0 ? Math.max(...scores) : 0, lowestScore: scores.length > 0 ? Math.min(...scores) : 0, details }; } static async generateQuizQuestions(taskId: string, userId: string): Promise<{ questions: Awaited>; totalScore: number; timeLimitMinutes: number; }> { const task = await this.findById(taskId); if (!task) throw new Error('任务不存在'); const now = new Date(); if (now < new Date(task.startAt) || now > new Date(task.endAt)) { throw new Error('当前时间不在任务有效范围内'); } const isAssigned = await get( `SELECT 1 FROM exam_task_users WHERE task_id = ? AND user_id = ?`, [taskId, userId] ); if (!isAssigned) throw new Error('用户未被分派到此任务'); const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(task.subjectId)); if (!subject) throw new Error('科目不存在'); const { QuestionModel } = await import('./question'); 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 as keyof typeof subject.typeRatios] > 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); } return { questions, totalScore, timeLimitMinutes: subject.timeLimitMinutes }; } static async getUserTasks(userId: string): Promise { const now = new Date().toISOString(); const rows = await all(` SELECT t.*, s.name as subjectName, s.totalScore, s.timeLimitMinutes 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 <= ? ORDER BY t.start_at DESC `, [userId, now]); return rows.map(row => ({ id: row.id, name: row.name, subjectId: row.subject_id, startAt: row.start_at, endAt: row.end_at, createdAt: row.created_at, subjectName: row.subjectName, totalScore: row.totalScore, timeLimitMinutes: row.timeLimitMinutes })); } }