Files
Web_BLV_OA_Exam_Prod/api/models/examTask.ts
2025-12-23 00:35:57 +08:00

649 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { v4 as uuidv4 } from 'uuid';
import { 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<ActiveTaskStat[]> {
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 findById(id: string): Promise<ExamTask | null> {
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<ExamTask> {
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<ExamTask> {
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<void> {
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<string[]> {
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<TaskReport> {
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<ReturnType<typeof import('./question').QuestionModel.getRandomQuestions>>;
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<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
// 构建包含所有类别的数组,根据比重重复对应次数
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<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
// 尝试获取足够分数的题目
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<ExamTask[]> {
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
}));
}
}