2025-12-18 19:07:21 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 00:58:58 +08:00
|
|
|
|
export interface ActiveTaskStat {
|
|
|
|
|
|
taskId: string;
|
|
|
|
|
|
taskName: string;
|
|
|
|
|
|
subjectName: string;
|
|
|
|
|
|
totalUsers: number;
|
|
|
|
|
|
completedUsers: number;
|
|
|
|
|
|
completionRate: number;
|
|
|
|
|
|
passRate: number;
|
|
|
|
|
|
excellentRate: number;
|
|
|
|
|
|
startAt: string;
|
|
|
|
|
|
endAt: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
export class ExamTaskModel {
|
2025-12-19 00:58:58 +08:00
|
|
|
|
static async findAll(): Promise<(TaskWithSubject & {
|
|
|
|
|
|
completedUsers: number;
|
|
|
|
|
|
passRate: number;
|
|
|
|
|
|
excellentRate: number;
|
|
|
|
|
|
})[]> {
|
|
|
|
|
|
// 1. 先获取所有任务的基本信息
|
|
|
|
|
|
const baseTasks = await all(`
|
2025-12-18 19:07:21 +08:00
|
|
|
|
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,
|
2025-12-19 00:58:58 +08:00
|
|
|
|
COUNT(DISTINCT etu.user_id) as userCount,
|
|
|
|
|
|
s.total_score as totalScore
|
2025-12-18 19:07:21 +08:00
|
|
|
|
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
|
2025-12-19 00:58:58 +08:00
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
2025-12-18 19:07:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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[];
|
|
|
|
|
|
}): 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)
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
|
|
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[];
|
|
|
|
|
|
}): 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 = ? WHERE id = ?`, [
|
|
|
|
|
|
data.name.trim(),
|
|
|
|
|
|
data.subjectId,
|
|
|
|
|
|
data.startAt,
|
|
|
|
|
|
data.endAt,
|
|
|
|
|
|
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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 00:58:58 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
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');
|
2025-12-19 00:58:58 +08:00
|
|
|
|
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] > 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);
|
2025-12-18 19:07:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2025-12-19 00:58:58 +08:00
|
|
|
|
WHERE tu.user_id = ? AND t.start_at <= ?
|
2025-12-18 19:07:21 +08:00
|
|
|
|
ORDER BY t.start_at DESC
|
2025-12-19 00:58:58 +08:00
|
|
|
|
`, [userId, now]);
|
2025-12-18 19:07:21 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|