732 lines
22 KiB
TypeScript
732 lines
22 KiB
TypeScript
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 getAllTasksWithStatsPaged(
|
||
input: {
|
||
page: number;
|
||
limit: number;
|
||
status?: 'completed' | 'ongoing' | 'notStarted';
|
||
endAtStart?: string;
|
||
endAtEnd?: string;
|
||
},
|
||
): Promise<{ data: Array<ActiveTaskStat & { status: '已完成' | '进行中' | '未开始' }>; 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<ActiveTaskStat & { status: '已完成' | '进行中' | '未开始' }> = [];
|
||
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<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
|
||
}));
|
||
}
|
||
}
|