第一版提交,答题功能OK,题库管理待完善

This commit is contained in:
2025-12-18 19:07:21 +08:00
parent e5600535be
commit ba252b2f56
93 changed files with 20431 additions and 1 deletions

218
api/models/examSubject.ts Normal file
View File

@@ -0,0 +1,218 @@
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]);
}
}

260
api/models/examTask.ts Normal file
View File

@@ -0,0 +1,260 @@
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
}));
}
}

8
api/models/index.ts Normal file
View File

@@ -0,0 +1,8 @@
// 导出所有模型
export { UserModel, type User, type CreateUserData } from './user';
export { QuestionModel, type Question, type CreateQuestionData, type ExcelQuestionData } from './question';
export { QuizModel, type QuizRecord, type QuizAnswer, type SubmitQuizData } from './quiz';
export { SystemConfigModel, type SystemConfig, type QuizConfig, type AdminUser } from './systemConfig';
export { QuestionCategoryModel, type QuestionCategory } from './questionCategory';
export { ExamSubjectModel, type ExamSubject } from './examSubject';
export { ExamTaskModel, type ExamTask, type TaskWithSubject, type TaskReport } from './examTask';

319
api/models/question.ts Normal file
View File

@@ -0,0 +1,319 @@
import { v4 as uuidv4 } from 'uuid';
import { query, run, get } from '../database';
export interface Question {
id: string;
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
category: string;
options?: string[];
answer: string | string[];
score: number;
createdAt: string;
}
export interface CreateQuestionData {
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
category?: string;
options?: string[];
answer: string | string[];
score: number;
}
export interface ExcelQuestionData {
content: string;
type: string;
category?: string;
answer: string;
score: number;
options?: string[];
}
export class QuestionModel {
// 创建题目
static async create(data: CreateQuestionData): Promise<Question> {
const id = uuidv4();
const optionsStr = data.options ? JSON.stringify(data.options) : null;
const answerStr = Array.isArray(data.answer) ? JSON.stringify(data.answer) : data.answer;
const category = data.category && data.category.trim() ? data.category.trim() : '通用';
const sql = `
INSERT INTO questions (id, content, type, options, answer, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
await run(sql, [id, data.content, data.type, optionsStr, answerStr, data.score, category]);
return this.findById(id) as Promise<Question>;
}
// 批量创建题目
static async createMany(questions: CreateQuestionData[]): Promise<{ success: number; errors: string[] }> {
const errors: string[] = [];
let success = 0;
for (let i = 0; i < questions.length; i++) {
try {
await this.create(questions[i]);
success++;
} catch (error: any) {
errors.push(`${i + 1}题: ${error.message}`);
}
}
return { success, errors };
}
// 根据ID查找题目
static async findById(id: string): Promise<Question | null> {
const sql = `SELECT * FROM questions WHERE id = ?`;
const question = await get(sql, [id]);
if (!question) return null;
return this.formatQuestion(question);
}
// 根据题目内容查找题目
static async findByContent(content: string): Promise<Question | null> {
const sql = `SELECT * FROM questions WHERE content = ?`;
const question = await get(sql, [content]);
if (!question) return null;
return this.formatQuestion(question);
}
// 获取题目列表(支持筛选和分页)
static async findAll(filters: {
type?: string;
category?: string;
keyword?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
} = {}): Promise<{ questions: Question[]; total: number }> {
const { type, category, keyword, startDate, endDate, limit = 10, offset = 0 } = filters;
const whereParts: string[] = [];
const params: any[] = [];
if (type) {
whereParts.push('type = ?');
params.push(type);
}
if (category) {
whereParts.push('category = ?');
params.push(category);
}
if (keyword) {
whereParts.push('content LIKE ?');
params.push(`%${keyword}%`);
}
if (startDate) {
whereParts.push('created_at >= ?');
params.push(`${startDate} 00:00:00`);
}
if (endDate) {
whereParts.push('created_at <= ?');
params.push(`${endDate} 23:59:59`);
}
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
const questionsSql = `
SELECT * FROM questions
${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`;
const countSql = `
SELECT COUNT(*) as total FROM questions ${whereClause}
`;
const [questions, countResult] = await Promise.all([
query(questionsSql, [...params, limit, offset]),
get(countSql, params)
]);
return {
questions: questions.map((q) => this.formatQuestion(q)),
total: countResult.total
};
}
// 随机获取题目(按类型和数量)
static async getRandomQuestions(type: string, count: number, categories?: string[]): Promise<Question[]> {
const whereParts: string[] = ['type = ?'];
const params: any[] = [type];
if (categories && categories.length > 0) {
whereParts.push(`category IN (${categories.map(() => '?').join(',')})`);
params.push(...categories);
}
const sql = `
SELECT * FROM questions
WHERE ${whereParts.join(' AND ')}
ORDER BY RANDOM()
LIMIT ?
`;
const questions = await query(sql, [...params, count]);
return questions.map((q) => this.formatQuestion(q));
}
// 更新题目
static async update(id: string, data: Partial<CreateQuestionData>): Promise<Question> {
const fields: string[] = [];
const values: any[] = [];
if (data.content) {
fields.push('content = ?');
values.push(data.content);
}
if (data.type) {
fields.push('type = ?');
values.push(data.type);
}
if (data.options !== undefined) {
fields.push('options = ?');
values.push(data.options ? JSON.stringify(data.options) : null);
}
if (data.answer !== undefined) {
const answerStr = Array.isArray(data.answer) ? JSON.stringify(data.answer) : data.answer;
fields.push('answer = ?');
values.push(answerStr);
}
if (data.score !== undefined) {
fields.push('score = ?');
values.push(data.score);
}
if (data.category !== undefined) {
fields.push('category = ?');
values.push(data.category && data.category.trim() ? data.category.trim() : '通用');
}
if (fields.length === 0) {
throw new Error('没有要更新的字段');
}
values.push(id);
const sql = `UPDATE questions SET ${fields.join(', ')} WHERE id = ?`;
await run(sql, values);
return this.findById(id) as Promise<Question>;
}
// 删除题目
static async delete(id: string): Promise<boolean> {
const sql = `DELETE FROM questions WHERE id = ?`;
const result = await run(sql, [id]);
return result.id !== undefined;
}
// 格式化题目数据
private static formatQuestion(row: any): Question {
return {
id: row.id,
content: row.content,
type: row.type,
category: row.category || '通用',
options: row.options ? JSON.parse(row.options) : undefined,
answer: this.parseAnswer(row.answer, row.type),
score: row.score,
createdAt: row.created_at
};
}
// 解析答案
private static parseAnswer(answerStr: string, type: string): string | string[] {
if (type === 'multiple') {
try {
return JSON.parse(answerStr);
} catch {
return answerStr;
}
}
return answerStr;
}
// 验证题目数据
static validateQuestionData(data: CreateQuestionData): string[] {
const errors: string[] = [];
// 验证题目内容
if (!data.content || data.content.trim().length === 0) {
errors.push('题目内容不能为空');
}
// 验证题型
const validTypes = ['single', 'multiple', 'judgment', 'text'];
if (!validTypes.includes(data.type)) {
errors.push('题型必须是 single、multiple、judgment 或 text');
}
// 验证选项
if (data.type === 'single' || data.type === 'multiple') {
if (!data.options || data.options.length < 2) {
errors.push('单选题和多选题必须至少包含2个选项');
}
}
// 验证答案
if (!data.answer) {
errors.push('答案不能为空');
}
// 验证分值
if (!data.score || data.score <= 0) {
errors.push('分值必须是正数');
}
if (data.category !== undefined && data.category.trim().length === 0) {
errors.push('题目类别不能为空');
}
return errors;
}
// 验证Excel数据格式
static validateExcelData(data: ExcelQuestionData[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
data.forEach((row, index) => {
if (!row.content) {
errors.push(`${index + 1}行:题目内容不能为空`);
}
const validTypes = ['single', 'multiple', 'judgment', 'text'];
if (!validTypes.includes(row.type)) {
errors.push(`${index + 1}行:题型必须是 single、multiple、judgment 或 text`);
}
if (!row.answer) {
errors.push(`${index + 1}行:答案不能为空`);
}
if (!row.score || row.score <= 0) {
errors.push(`${index + 1}行:分值必须是正数`);
}
});
return {
valid: errors.length === 0,
errors
};
}
}

View File

@@ -0,0 +1,72 @@
import { v4 as uuidv4 } from 'uuid';
import { get, query, run } from '../database';
export interface QuestionCategory {
id: string;
name: string;
createdAt: string;
}
export class QuestionCategoryModel {
static async findAll(): Promise<QuestionCategory[]> {
const sql = `SELECT id, name, created_at as createdAt FROM question_categories ORDER BY created_at DESC`;
return query(sql);
}
static async findById(id: string): Promise<QuestionCategory | null> {
const sql = `SELECT id, name, created_at as createdAt FROM question_categories WHERE id = ?`;
const row = await get(sql, [id]);
return row || null;
}
static async create(name: string): Promise<QuestionCategory> {
const trimmed = name.trim();
if (!trimmed) throw new Error('类别名称不能为空');
const id = uuidv4();
const sql = `INSERT INTO question_categories (id, name) VALUES (?, ?)`;
try {
await run(sql, [id, trimmed]);
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new Error('类别名称已存在');
}
throw error;
}
return (await this.findById(id)) as QuestionCategory;
}
static async update(id: string, name: string): Promise<QuestionCategory> {
if (id === 'default') throw new Error('默认类别不允许修改');
const existing = await this.findById(id);
if (!existing) throw new Error('类别不存在');
const trimmed = name.trim();
if (!trimmed) throw new Error('类别名称不能为空');
try {
await run(`UPDATE question_categories SET name = ? WHERE id = ?`, [trimmed, id]);
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new Error('类别名称已存在');
}
throw error;
}
await run(`UPDATE questions SET category = ? WHERE category = ?`, [trimmed, existing.name]);
return (await this.findById(id)) as QuestionCategory;
}
static async delete(id: string): Promise<void> {
if (id === 'default') throw new Error('默认类别不允许删除');
const existing = await this.findById(id);
if (!existing) throw new Error('类别不存在');
await run(`UPDATE questions SET category = '通用' WHERE category = ?`, [existing.name]);
await run(`DELETE FROM question_categories WHERE id = ?`, [id]);
}
}

246
api/models/quiz.ts Normal file
View File

@@ -0,0 +1,246 @@
import { v4 as uuidv4 } from 'uuid';
import { query, run, get } from '../database';
import { Question } from './question';
export interface QuizRecord {
id: string;
userId: string;
totalScore: number;
correctCount: number;
totalCount: number;
createdAt: string;
}
export interface QuizAnswer {
id: string;
recordId: string;
questionId: string;
userAnswer: string | string[];
score: number;
isCorrect: boolean;
createdAt: string;
questionContent?: string;
questionType?: string;
correctAnswer?: string | string[];
questionScore?: number;
}
export interface SubmitAnswerData {
questionId: string;
userAnswer: string | string[];
score: number;
isCorrect: boolean;
}
export interface SubmitQuizData {
userId: string;
answers: SubmitAnswerData[];
}
export class QuizModel {
// 创建答题记录
static async createRecord(data: { userId: string; totalScore: number; correctCount: number; totalCount: number }): Promise<QuizRecord> {
const id = uuidv4();
const sql = `
INSERT INTO quiz_records (id, user_id, total_score, correct_count, total_count)
VALUES (?, ?, ?, ?, ?)
`;
await run(sql, [id, data.userId, data.totalScore, data.correctCount, data.totalCount]);
return this.findRecordById(id) as Promise<QuizRecord>;
}
// 创建答题答案
static async createAnswer(data: Omit<QuizAnswer, 'id' | 'createdAt'>): Promise<QuizAnswer> {
const id = uuidv4();
const userAnswerStr = Array.isArray(data.userAnswer) ? JSON.stringify(data.userAnswer) : data.userAnswer;
const sql = `
INSERT INTO quiz_answers (id, record_id, question_id, user_answer, score, is_correct)
VALUES (?, ?, ?, ?, ?, ?)
`;
await run(sql, [id, data.recordId, data.questionId, userAnswerStr, data.score, data.isCorrect]);
return {
id,
recordId: data.recordId,
questionId: data.questionId,
userAnswer: data.userAnswer,
score: data.score,
isCorrect: data.isCorrect,
createdAt: new Date().toISOString()
};
}
// 批量创建答题答案
static async createAnswers(recordId: string, answers: SubmitAnswerData[]): Promise<QuizAnswer[]> {
const createdAnswers: QuizAnswer[] = [];
for (const answer of answers) {
const createdAnswer = await this.createAnswer({
recordId,
questionId: answer.questionId,
userAnswer: answer.userAnswer,
score: answer.score,
isCorrect: answer.isCorrect
});
createdAnswers.push(createdAnswer);
}
return createdAnswers;
}
// 提交答题
static async submitQuiz(data: SubmitQuizData): Promise<{ record: QuizRecord; answers: QuizAnswer[] }> {
const totalScore = data.answers.reduce((sum, answer) => sum + answer.score, 0);
const correctCount = data.answers.filter(answer => answer.isCorrect).length;
const totalCount = data.answers.length;
// 创建答题记录
const record = await this.createRecord({
userId: data.userId,
totalScore,
correctCount,
totalCount
});
// 创建答题答案
const answers = await this.createAnswers(record.id, data.answers);
return { record, answers };
}
// 根据ID查找答题记录
static async findRecordById(id: string): Promise<QuizRecord | null> {
const sql = `SELECT id, user_id as userId, total_score as totalScore, correct_count as correctCount, total_count as totalCount, created_at as createdAt FROM quiz_records WHERE id = ?`;
const record = await get(sql, [id]);
return record || null;
}
// 获取用户的答题记录
static async findRecordsByUserId(userId: string, limit = 10, offset = 0): Promise<{ records: QuizRecord[]; total: number }> {
const recordsSql = `
SELECT id, user_id as userId, total_score as totalScore, correct_count as correctCount, total_count as totalCount, created_at as createdAt
FROM quiz_records
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`;
const countSql = `SELECT COUNT(*) as total FROM quiz_records WHERE user_id = ?`;
const [records, countResult] = await Promise.all([
query(recordsSql, [userId, limit, offset]),
get(countSql, [userId])
]);
return {
records,
total: countResult.total
};
}
// 获取所有答题记录(管理员用)
static async findAllRecords(limit = 10, offset = 0): Promise<{ records: QuizRecord[]; total: number }> {
const recordsSql = `
SELECT r.id, r.user_id as userId, u.name as userName, u.phone as userPhone,
r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount,
r.created_at as createdAt
FROM quiz_records r
JOIN users u ON r.user_id = u.id
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?
`;
const countSql = `SELECT COUNT(*) as total FROM quiz_records`;
const [records, countResult] = await Promise.all([
query(recordsSql, [limit, offset]),
get(countSql)
]);
return {
records,
total: countResult.total
};
}
// 获取答题答案详情
static async findAnswersByRecordId(recordId: string): Promise<QuizAnswer[]> {
const sql = `
SELECT a.id, a.record_id as recordId, a.question_id as questionId,
a.user_answer as userAnswer, a.score, a.is_correct as isCorrect,
a.created_at as createdAt,
q.content as questionContent, q.type as questionType, q.answer as correctAnswer, q.score as questionScore
FROM quiz_answers a
JOIN questions q ON a.question_id = q.id
WHERE a.record_id = ?
ORDER BY a.created_at ASC
`;
const answers = await query(sql, [recordId]);
return answers.map(row => ({
id: row.id,
recordId: row.recordId,
questionId: row.questionId,
userAnswer: this.parseAnswer(row.userAnswer, row.questionType),
score: row.score,
isCorrect: Boolean(row.isCorrect),
createdAt: row.createdAt,
questionContent: row.questionContent,
questionType: row.questionType,
correctAnswer: this.parseAnswer(row.correctAnswer, row.questionType),
questionScore: row.questionScore
}));
}
// 解析答案
private static parseAnswer(answer: string, type: string): string | string[] {
if (type === 'multiple' || type === 'checkbox') {
try {
return JSON.parse(answer);
} catch (e) {
return answer;
}
}
return answer;
}
// 获取统计数据
static async getStatistics(): Promise<{
totalUsers: number;
totalRecords: number;
averageScore: number;
typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>;
}> {
const totalUsersSql = `SELECT COUNT(DISTINCT user_id) as total FROM quiz_records`;
const totalRecordsSql = `SELECT COUNT(*) as total FROM quiz_records`;
const averageScoreSql = `SELECT AVG(total_score) as average FROM quiz_records`;
const typeStatsSql = `
SELECT q.type,
COUNT(*) as total,
SUM(CASE WHEN qa.is_correct = 1 THEN 1 ELSE 0 END) as correct,
ROUND(SUM(CASE WHEN qa.is_correct = 1 THEN 1.0 ELSE 0.0 END) * 100 / COUNT(*), 2) as correctRate
FROM quiz_answers qa
JOIN questions q ON qa.question_id = q.id
GROUP BY q.type
`;
const [totalUsers, totalRecords, averageScore, typeStats] = await Promise.all([
get(totalUsersSql),
get(totalRecordsSql),
get(averageScoreSql),
query(typeStatsSql)
]);
return {
totalUsers: totalUsers.total,
totalRecords: totalRecords.total,
averageScore: Math.round(averageScore.average * 100) / 100,
typeStats
};
}
}

121
api/models/systemConfig.ts Normal file
View File

@@ -0,0 +1,121 @@
import { get, run, query } from '../database';
import { v4 as uuidv4 } from 'uuid';
export interface SystemConfig {
id: string;
configType: string;
configValue: any;
updatedAt: string;
}
export interface QuizConfig {
singleRatio: number;
multipleRatio: number;
judgmentRatio: number;
textRatio: number;
totalScore: number;
}
export interface AdminUser {
username: string;
password: string;
}
export class SystemConfigModel {
// 获取配置
static async getConfig(configType: string): Promise<any> {
const sql = `SELECT config_value as configValue FROM system_configs WHERE config_type = ?`;
const result = await get(sql, [configType]);
if (!result) {
return null;
}
try {
return JSON.parse(result.configValue);
} catch {
return result.configValue;
}
}
// 更新配置
static async updateConfig(configType: string, configValue: any): Promise<void> {
const valueStr = typeof configValue === 'string' ? configValue : JSON.stringify(configValue);
const sql = `
INSERT OR REPLACE INTO system_configs (id, config_type, config_value, updated_at)
VALUES (
COALESCE((SELECT id FROM system_configs WHERE config_type = ?), ?),
?, ?, datetime('now')
)
`;
await run(sql, [configType, uuidv4(), configType, valueStr]);
}
// 获取抽题配置
static async getQuizConfig(): Promise<QuizConfig> {
const config = await this.getConfig('quiz_config');
return config || {
singleRatio: 40,
multipleRatio: 30,
judgmentRatio: 20,
textRatio: 10,
totalScore: 100
};
}
// 更新抽题配置
static async updateQuizConfig(config: QuizConfig): Promise<void> {
// 验证比例总和
const totalRatio = config.singleRatio + config.multipleRatio + config.judgmentRatio + config.textRatio;
if (totalRatio !== 100) {
throw new Error('题型比例总和必须为100%');
}
// 验证分值
if (config.totalScore <= 0) {
throw new Error('总分必须大于0');
}
await this.updateConfig('quiz_config', config);
}
// 获取管理员用户
static async getAdminUser(): Promise<AdminUser | null> {
const config = await this.getConfig('admin_user');
return config;
}
// 验证管理员登录
static async validateAdminLogin(username: string, password: string): Promise<boolean> {
const adminUser = await this.getAdminUser();
return adminUser?.username === username && adminUser?.password === password;
}
// 更新管理员密码
static async updateAdminPassword(username: string, newPassword: string): Promise<void> {
await this.updateConfig('admin_user', { username, password: newPassword });
}
// 获取所有配置(管理员用)
static async getAllConfigs(): Promise<SystemConfig[]> {
const sql = `SELECT id, config_type as configType, config_value as configValue, updated_at as updatedAt FROM system_configs ORDER BY config_type`;
const configs = await query(sql);
return configs.map((config: any) => ({
id: config.id,
configType: config.configType,
configValue: this.parseConfigValue(config.configValue),
updatedAt: config.updatedAt
}));
}
// 解析配置值
private static parseConfigValue(value: string): any {
try {
return JSON.parse(value);
} catch {
return value;
}
}
}

91
api/models/user.ts Normal file
View File

@@ -0,0 +1,91 @@
import { v4 as uuidv4 } from 'uuid';
import { query, run, get } from '../database';
export interface User {
id: string;
name: string;
phone: string;
password?: string;
createdAt: string;
}
export interface CreateUserData {
name: string;
phone: string;
password?: string;
}
export class UserModel {
static async create(data: CreateUserData): Promise<User> {
const id = uuidv4();
const sql = `
INSERT INTO users (id, name, phone, password)
VALUES (?, ?, ?, ?)
`;
try {
await run(sql, [id, data.name, data.phone, data.password || '']);
return this.findById(id) as Promise<User>;
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new Error('手机号已存在');
}
throw error;
}
}
static async updatePasswordById(id: string, password: string): Promise<void> {
const sql = `UPDATE users SET password = ? WHERE id = ?`;
await run(sql, [password, id]);
}
static async delete(id: string): Promise<void> {
await run(`DELETE FROM users WHERE id = ?`, [id]);
}
static async findById(id: string): Promise<User | null> {
const sql = `SELECT id, name, phone, password, created_at as createdAt FROM users WHERE id = ?`;
const user = await get(sql, [id]);
return user || null;
}
static async findByPhone(phone: string): Promise<User | null> {
const sql = `SELECT id, name, phone, password, created_at as createdAt FROM users WHERE phone = ?`;
const user = await get(sql, [phone]);
return user || null;
}
static async findAll(limit = 10, offset = 0): Promise<{ users: User[]; total: number }> {
const usersSql = `
SELECT id, name, phone, password, created_at as createdAt
FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`;
const countSql = `SELECT COUNT(*) as total FROM users`;
const [users, countResult] = await Promise.all([
query(usersSql, [limit, offset]),
get(countSql)
]);
return {
users,
total: countResult.total
};
}
static validateUserData(data: CreateUserData): string[] {
const errors: string[] = [];
if (!data.name || data.name.length < 2 || data.name.length > 20) {
errors.push('姓名长度必须在2-20个字符之间');
}
if (!data.phone || !/^1[3-9]\d{9}$/.test(data.phone)) {
errors.push('手机号格式不正确请输入11位中国手机号');
}
return errors;
}
}