219 lines
6.5 KiB
TypeScript
219 lines
6.5 KiB
TypeScript
|
|
import { v4 as uuidv4 } from 'uuid';
|
||
|
|
import { get, query, run } from '../database';
|
||
|
|
|
||
|
|
export type QuestionType = 'single' | 'multiple' | 'judgment' | 'text';
|
||
|
|
|
||
|
|
export interface ExamSubject {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
totalScore: number;
|
||
|
|
timeLimitMinutes: number;
|
||
|
|
typeRatios: Record<QuestionType, number>;
|
||
|
|
categoryRatios: Record<string, number>;
|
||
|
|
createdAt: string;
|
||
|
|
updatedAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
type RawSubjectRow = {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
typeRatios: string;
|
||
|
|
categoryRatios: string;
|
||
|
|
totalScore: number;
|
||
|
|
timeLimitMinutes: number;
|
||
|
|
createdAt: string;
|
||
|
|
updatedAt: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const parseJson = <T>(value: string, fallback: T): T => {
|
||
|
|
try {
|
||
|
|
return JSON.parse(value) as T;
|
||
|
|
} catch {
|
||
|
|
return fallback;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const validateRatiosSum100 = (ratios: Record<string, number>, label: string) => {
|
||
|
|
const values = Object.values(ratios);
|
||
|
|
if (values.length === 0) throw new Error(`${label}不能为空`);
|
||
|
|
if (values.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0)) {
|
||
|
|
throw new Error(`${label}必须是非负数字`);
|
||
|
|
}
|
||
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
||
|
|
if (Math.abs(sum - 100) > 0.01) {
|
||
|
|
throw new Error(`${label}总和必须为100`);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
export class ExamSubjectModel {
|
||
|
|
static async findAll(): Promise<ExamSubject[]> {
|
||
|
|
const sql = `
|
||
|
|
SELECT
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
type_ratios as typeRatios,
|
||
|
|
category_ratios as categoryRatios,
|
||
|
|
total_score as totalScore,
|
||
|
|
duration_minutes as timeLimitMinutes,
|
||
|
|
created_at as createdAt,
|
||
|
|
updated_at as updatedAt
|
||
|
|
FROM exam_subjects
|
||
|
|
ORDER BY created_at DESC
|
||
|
|
`;
|
||
|
|
|
||
|
|
const rows: RawSubjectRow[] = await query(sql);
|
||
|
|
return rows.map((row) => ({
|
||
|
|
id: row.id,
|
||
|
|
name: row.name,
|
||
|
|
totalScore: row.totalScore,
|
||
|
|
timeLimitMinutes: row.timeLimitMinutes,
|
||
|
|
typeRatios: parseJson<Record<QuestionType, number>>(row.typeRatios, {
|
||
|
|
single: 40,
|
||
|
|
multiple: 30,
|
||
|
|
judgment: 20,
|
||
|
|
text: 10
|
||
|
|
}),
|
||
|
|
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
|
||
|
|
createdAt: row.createdAt,
|
||
|
|
updatedAt: row.updatedAt
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
static async findById(id: string): Promise<ExamSubject | null> {
|
||
|
|
const sql = `
|
||
|
|
SELECT
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
type_ratios as typeRatios,
|
||
|
|
category_ratios as categoryRatios,
|
||
|
|
total_score as totalScore,
|
||
|
|
duration_minutes as timeLimitMinutes,
|
||
|
|
created_at as createdAt,
|
||
|
|
updated_at as updatedAt
|
||
|
|
FROM exam_subjects
|
||
|
|
WHERE id = ?
|
||
|
|
`;
|
||
|
|
|
||
|
|
const row: RawSubjectRow | undefined = await get(sql, [id]);
|
||
|
|
if (!row) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: row.id,
|
||
|
|
name: row.name,
|
||
|
|
totalScore: row.totalScore,
|
||
|
|
timeLimitMinutes: row.timeLimitMinutes,
|
||
|
|
typeRatios: parseJson<Record<QuestionType, number>>(row.typeRatios, {
|
||
|
|
single: 40,
|
||
|
|
multiple: 30,
|
||
|
|
judgment: 20,
|
||
|
|
text: 10
|
||
|
|
}),
|
||
|
|
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
|
||
|
|
createdAt: row.createdAt,
|
||
|
|
updatedAt: row.updatedAt
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
static async create(data: {
|
||
|
|
name: string;
|
||
|
|
totalScore: number;
|
||
|
|
timeLimitMinutes?: number;
|
||
|
|
typeRatios: Record<QuestionType, number>;
|
||
|
|
categoryRatios?: Record<string, number>;
|
||
|
|
}): Promise<ExamSubject> {
|
||
|
|
const name = data.name.trim();
|
||
|
|
if (!name) throw new Error('科目名称不能为空');
|
||
|
|
if (!data.totalScore || data.totalScore <= 0) throw new Error('总分必须为正整数');
|
||
|
|
|
||
|
|
const timeLimitMinutes = data.timeLimitMinutes ?? 60;
|
||
|
|
if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)');
|
||
|
|
|
||
|
|
validateRatiosSum100(data.typeRatios, '题型比重');
|
||
|
|
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 };
|
||
|
|
validateRatiosSum100(categoryRatios, '题目类别比重');
|
||
|
|
|
||
|
|
const id = uuidv4();
|
||
|
|
const sql = `
|
||
|
|
INSERT INTO exam_subjects (
|
||
|
|
id, name, type_ratios, category_ratios, total_score, duration_minutes, updated_at
|
||
|
|
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||
|
|
`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await run(sql, [
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
JSON.stringify(data.typeRatios),
|
||
|
|
JSON.stringify(categoryRatios),
|
||
|
|
data.totalScore,
|
||
|
|
timeLimitMinutes
|
||
|
|
]);
|
||
|
|
} catch (error: any) {
|
||
|
|
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||
|
|
throw new Error('科目名称已存在');
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (await this.findById(id)) as ExamSubject;
|
||
|
|
}
|
||
|
|
|
||
|
|
static async update(id: string, data: {
|
||
|
|
name: string;
|
||
|
|
totalScore: number;
|
||
|
|
timeLimitMinutes?: number;
|
||
|
|
typeRatios: Record<QuestionType, number>;
|
||
|
|
categoryRatios?: Record<string, number>;
|
||
|
|
}): Promise<ExamSubject> {
|
||
|
|
const existing = await this.findById(id);
|
||
|
|
if (!existing) throw new Error('科目不存在');
|
||
|
|
|
||
|
|
const name = data.name.trim();
|
||
|
|
if (!name) throw new Error('科目名称不能为空');
|
||
|
|
if (!data.totalScore || data.totalScore <= 0) throw new Error('总分必须为正整数');
|
||
|
|
|
||
|
|
const timeLimitMinutes = data.timeLimitMinutes ?? 60;
|
||
|
|
if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)');
|
||
|
|
|
||
|
|
validateRatiosSum100(data.typeRatios, '题型比重');
|
||
|
|
const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 };
|
||
|
|
validateRatiosSum100(categoryRatios, '题目类别比重');
|
||
|
|
|
||
|
|
const sql = `
|
||
|
|
UPDATE exam_subjects
|
||
|
|
SET name = ?, type_ratios = ?, category_ratios = ?, total_score = ?, duration_minutes = ?, updated_at = datetime('now')
|
||
|
|
WHERE id = ?
|
||
|
|
`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await run(sql, [
|
||
|
|
name,
|
||
|
|
JSON.stringify(data.typeRatios),
|
||
|
|
JSON.stringify(categoryRatios),
|
||
|
|
data.totalScore,
|
||
|
|
timeLimitMinutes,
|
||
|
|
id
|
||
|
|
]);
|
||
|
|
} catch (error: any) {
|
||
|
|
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||
|
|
throw new Error('科目名称已存在');
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (await this.findById(id)) as ExamSubject;
|
||
|
|
}
|
||
|
|
|
||
|
|
static async delete(id: string): Promise<void> {
|
||
|
|
const existing = await this.findById(id);
|
||
|
|
if (!existing) throw new Error('科目不存在');
|
||
|
|
|
||
|
|
const taskCount = await get(`SELECT COUNT(*) as total FROM exam_tasks WHERE subject_id = ?`, [id]);
|
||
|
|
if (taskCount && taskCount.total > 0) {
|
||
|
|
throw new Error('该科目已被考试任务使用,无法删除');
|
||
|
|
}
|
||
|
|
|
||
|
|
await run(`DELETE FROM exam_subjects WHERE id = ?`, [id]);
|
||
|
|
}
|
||
|
|
}
|