260 lines
7.9 KiB
TypeScript
260 lines
7.9 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 class ExamTaskModel {
|
||
|
|
static async findAll(): Promise<TaskWithSubject[]> {
|
||
|
|
const sql = `
|
||
|
|
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
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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]);
|
||
|
|
}
|
||
|
|
|
||
|
|
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');
|
||
|
|
const questions: Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
const totalScore = questions.reduce((sum, q) => sum + q.score, 0);
|
||
|
|
|
||
|
|
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 <= ? AND t.end_at >= ?
|
||
|
|
ORDER BY t.start_at DESC
|
||
|
|
`, [userId, now, 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
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|