第一版提交,答题功能OK,题库管理待完善
This commit is contained in:
67
api/app.ts
Normal file
67
api/app.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* This is a API server
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type Request,
|
||||
type Response,
|
||||
type NextFunction,
|
||||
} from 'express'
|
||||
import cors from 'cors'
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
import { fileURLToPath } from 'url'
|
||||
import authRoutes from './routes/auth.js'
|
||||
|
||||
// for esm mode
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// load env
|
||||
dotenv.config()
|
||||
|
||||
const app: express.Application = express()
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
||||
|
||||
/**
|
||||
* API Routes
|
||||
*/
|
||||
app.use('/api/auth', authRoutes)
|
||||
|
||||
/**
|
||||
* health
|
||||
*/
|
||||
app.use(
|
||||
'/api/health',
|
||||
(req: Request, res: Response, next: NextFunction): void => {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'ok',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* error handler middleware
|
||||
*/
|
||||
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Server internal error',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 404 handler
|
||||
*/
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'API not found',
|
||||
})
|
||||
})
|
||||
|
||||
export default app
|
||||
162
api/controllers/adminController.ts
Normal file
162
api/controllers/adminController.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SystemConfigModel } from '../models';
|
||||
|
||||
export class AdminController {
|
||||
// 管理员登录
|
||||
static async login(req: Request, res: Response) {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '用户名和密码不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = await SystemConfigModel.validateAdminLogin(username, password);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误'
|
||||
});
|
||||
}
|
||||
|
||||
// 这里可以生成JWT token,简化处理直接返回成功
|
||||
res.json({
|
||||
success: true,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
username,
|
||||
token: 'admin-token' // 简化处理
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('管理员登录失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '登录失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取抽题配置
|
||||
static async getQuizConfig(req: Request, res: Response) {
|
||||
try {
|
||||
const config = await SystemConfigModel.getQuizConfig();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: config
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('获取抽题配置失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取抽题配置失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新抽题配置
|
||||
static async updateQuizConfig(req: Request, res: Response) {
|
||||
try {
|
||||
const { singleRatio, multipleRatio, judgmentRatio, textRatio, totalScore } = req.body;
|
||||
|
||||
const config = {
|
||||
singleRatio,
|
||||
multipleRatio,
|
||||
judgmentRatio,
|
||||
textRatio,
|
||||
totalScore
|
||||
};
|
||||
|
||||
await SystemConfigModel.updateQuizConfig(config);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '抽题配置更新成功'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('更新抽题配置失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '更新抽题配置失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
static async getStatistics(req: Request, res: Response) {
|
||||
try {
|
||||
const { QuizModel } = await import('../models');
|
||||
const statistics = await QuizModel.getStatistics();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statistics
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取统计数据失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 修改管理员密码
|
||||
static async updatePassword(req: Request, res: Response) {
|
||||
try {
|
||||
const { username, oldPassword, newPassword } = req.body;
|
||||
|
||||
if (!username || !oldPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数不完整'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isValid = await SystemConfigModel.validateAdminLogin(username, oldPassword);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '原密码错误'
|
||||
});
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
await SystemConfigModel.updateAdminPassword(username, newPassword);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '密码修改成功'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('修改密码失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '修改密码失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有配置(管理员用)
|
||||
static async getAllConfigs(req: Request, res: Response) {
|
||||
try {
|
||||
const configs = await SystemConfigModel.getAllConfigs();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: configs
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('获取配置失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取配置失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
198
api/controllers/adminUserController.ts
Normal file
198
api/controllers/adminUserController.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user';
|
||||
import { QuizModel } from '../models/quiz';
|
||||
|
||||
export class AdminUserController {
|
||||
static async getUsers(req: Request, res: Response) {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
|
||||
const result = await UserModel.findAll(limit, (page - 1) * limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.users.map((u) => ({ ...u, password: u.password ?? '' })),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
pages: Math.ceil(result.total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取用户列表失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteUser(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'userId不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
await UserModel.delete(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '删除用户失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async exportUsers(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await UserModel.findAll(10000, 0);
|
||||
const data = result.users.map((u) => ({
|
||||
ID: u.id,
|
||||
姓名: u.name,
|
||||
手机号: u.phone,
|
||||
密码: u.password ?? '',
|
||||
注册时间: u.createdAt
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '导出用户失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async importUsers(req: Request, res: Response) {
|
||||
try {
|
||||
const file = (req as any).file;
|
||||
if (!file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请上传Excel文件'
|
||||
});
|
||||
}
|
||||
|
||||
const XLSX = await import('xlsx');
|
||||
const workbook = XLSX.read(file.buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(worksheet) as any[];
|
||||
|
||||
const errors: string[] = [];
|
||||
let imported = 0;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
const name = row['姓名'] || row['name'];
|
||||
const phone = row['手机号'] || row['phone'];
|
||||
const password = row['密码'] || row['password'] || '';
|
||||
|
||||
if (!name || !phone) {
|
||||
errors.push(`第${i + 2}行:姓名或手机号缺失`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await UserModel.create({ name, phone, password });
|
||||
imported++;
|
||||
} catch (error: any) {
|
||||
if (error.message === '手机号已存在') {
|
||||
errors.push(`第${i + 2}行:手机号重复`);
|
||||
} else {
|
||||
errors.push(`第${i + 2}行:${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
imported,
|
||||
total: rows.length,
|
||||
errors
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '导入用户失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getUserRecords(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
|
||||
const result = await QuizModel.findRecordsByUserId(userId, limit, (page - 1) * limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.records,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
pages: Math.ceil(result.total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取用户答题记录失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getRecordDetail(req: Request, res: Response) {
|
||||
try {
|
||||
const { recordId } = req.params;
|
||||
|
||||
const record = await QuizModel.findRecordById(recordId);
|
||||
if (!record) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '答题记录不存在'
|
||||
});
|
||||
}
|
||||
|
||||
const answers = await QuizModel.findAnswersByRecordId(recordId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
record,
|
||||
answers
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取答题记录详情失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
174
api/controllers/backupController.ts
Normal file
174
api/controllers/backupController.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { UserModel, QuestionModel, QuizModel } from '../models';
|
||||
|
||||
export class BackupController {
|
||||
// 导出用户数据
|
||||
static async exportUsers(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await UserModel.findAll(10000, 0);
|
||||
const usersData = result.users.map(user => ({
|
||||
ID: user.id,
|
||||
姓名: user.name,
|
||||
手机号: user.phone,
|
||||
创建时间: user.createdAt
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: usersData
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '导出用户数据失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出问题数据
|
||||
static async exportQuestions(req: Request, res: Response) {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
|
||||
const result = await QuestionModel.findAll({
|
||||
type: type as string,
|
||||
limit: 10000,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
const questionsData = result.questions.map(question => ({
|
||||
ID: question.id,
|
||||
题目内容: question.content,
|
||||
题型: question.type,
|
||||
题目类别: question.category,
|
||||
选项: question.options ? question.options.join('|') : '',
|
||||
标准答案: Array.isArray(question.answer) ? question.answer.join(',') : question.answer,
|
||||
分值: question.score,
|
||||
创建时间: question.createdAt
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: questionsData
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '导出问题数据失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出答题记录
|
||||
static async exportRecords(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await QuizModel.findAllRecords(10000, 0);
|
||||
const recordsData = result.records.map((record: any) => ({
|
||||
ID: record.id,
|
||||
用户ID: record.userId,
|
||||
用户名: record.userName,
|
||||
手机号: record.userPhone,
|
||||
总得分: record.totalScore,
|
||||
正确题数: record.correctCount,
|
||||
总题数: record.totalCount,
|
||||
答题时间: record.createdAt
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: recordsData
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '导出答题记录失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出答题答案
|
||||
static async exportAnswers(req: Request, res: Response) {
|
||||
try {
|
||||
// 这里简化处理,实际应该分页获取所有答案
|
||||
const answersData: any[] = [];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: answersData
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '导出答题答案失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 数据恢复
|
||||
static async restoreData(req: Request, res: Response) {
|
||||
try {
|
||||
const { users, questions, records, answers } = req.body;
|
||||
|
||||
// 数据验证
|
||||
if (!users && !questions && !records && !answers) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '没有可恢复的数据'
|
||||
});
|
||||
}
|
||||
|
||||
let restoredCount = {
|
||||
users: 0,
|
||||
questions: 0,
|
||||
records: 0,
|
||||
answers: 0
|
||||
};
|
||||
|
||||
// 恢复用户数据
|
||||
if (users && users.length > 0) {
|
||||
for (const user of users) {
|
||||
try {
|
||||
await UserModel.create({
|
||||
name: user.姓名 || user.name,
|
||||
phone: user.手机号 || user.phone
|
||||
});
|
||||
restoredCount.users++;
|
||||
} catch (error) {
|
||||
console.log('用户已存在,跳过:', user.手机号 || user.phone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复题目数据
|
||||
if (questions && questions.length > 0) {
|
||||
for (const question of questions) {
|
||||
try {
|
||||
await QuestionModel.create({
|
||||
content: question.题目内容 || question.content,
|
||||
type: question.题型 || question.type,
|
||||
category: question.题目类别 || question.category || '通用',
|
||||
options: question.选项 ? (question.选项 as string).split('|') : question.options,
|
||||
answer: question.标准答案 || question.answer,
|
||||
score: question.分值 || question.score
|
||||
});
|
||||
restoredCount.questions++;
|
||||
} catch (error) {
|
||||
console.log('题目创建失败,跳过:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '数据恢复成功',
|
||||
data: restoredCount
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '数据恢复失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
87
api/controllers/examSubjectController.ts
Normal file
87
api/controllers/examSubjectController.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ExamSubjectModel } from '../models/examSubject';
|
||||
|
||||
export class ExamSubjectController {
|
||||
static async getSubjects(req: Request, res: Response) {
|
||||
try {
|
||||
const subjects = await ExamSubjectModel.findAll();
|
||||
res.json({
|
||||
success: true,
|
||||
data: subjects
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取考试科目失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async createSubject(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, totalScore, timeLimitMinutes, typeRatios, categoryRatios } = req.body;
|
||||
|
||||
const subject = await ExamSubjectModel.create({
|
||||
name,
|
||||
totalScore,
|
||||
timeLimitMinutes,
|
||||
typeRatios,
|
||||
categoryRatios
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: subject
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || '新增考试科目失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async updateSubject(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, totalScore, timeLimitMinutes, typeRatios, categoryRatios } = req.body;
|
||||
|
||||
const subject = await ExamSubjectModel.update(id, {
|
||||
name,
|
||||
totalScore,
|
||||
timeLimitMinutes,
|
||||
typeRatios,
|
||||
categoryRatios
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: subject
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '更新考试科目失败';
|
||||
res.status(message === '科目不存在' ? 404 : 400).json({
|
||||
success: false,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteSubject(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await ExamSubjectModel.delete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '删除考试科目失败';
|
||||
res.status(message === '科目不存在' ? 404 : 400).json({
|
||||
success: false,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
144
api/controllers/examTaskController.ts
Normal file
144
api/controllers/examTaskController.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ExamTaskModel } from '../models/examTask';
|
||||
|
||||
export class ExamTaskController {
|
||||
static async getTasks(req: Request, res: Response) {
|
||||
try {
|
||||
const tasks = await ExamTaskModel.findAll();
|
||||
res.json({
|
||||
success: true,
|
||||
data: tasks
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取考试任务失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async createTask(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, subjectId, startAt, endAt, userIds } = req.body;
|
||||
|
||||
if (!name || !subjectId || !startAt || !endAt || !Array.isArray(userIds) || userIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数不完整或用户列表为空'
|
||||
});
|
||||
}
|
||||
|
||||
const task = await ExamTaskModel.create({
|
||||
name,
|
||||
subjectId,
|
||||
startAt,
|
||||
endAt,
|
||||
userIds
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: task
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || '新增考试任务失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async updateTask(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, subjectId, startAt, endAt, userIds } = req.body;
|
||||
|
||||
if (!name || !subjectId || !startAt || !endAt || !Array.isArray(userIds) || userIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数不完整或用户列表为空'
|
||||
});
|
||||
}
|
||||
|
||||
const task = await ExamTaskModel.update(id, {
|
||||
name,
|
||||
subjectId,
|
||||
startAt,
|
||||
endAt,
|
||||
userIds
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: task
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '更新考试任务失败';
|
||||
res.status(message === '任务不存在' ? 404 : 400).json({
|
||||
success: false,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteTask(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await ExamTaskModel.delete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '删除考试任务失败';
|
||||
res.status(message === '任务不存在' ? 404 : 400).json({
|
||||
success: false,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getTaskReport(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const report = await ExamTaskModel.getReport(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: report
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '获取任务报表失败';
|
||||
res.status(message === '任务不存在' ? 404 : 400).json({
|
||||
success: false,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getUserTasks(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '用户ID不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = await ExamTaskModel.getUserTasks(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tasks
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取用户任务失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
10
api/controllers/index.ts
Normal file
10
api/controllers/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// 导出所有控制器
|
||||
export { UserController } from './userController';
|
||||
export { QuestionController } from './questionController';
|
||||
export { QuizController } from './quizController';
|
||||
export { AdminController } from './adminController';
|
||||
export { BackupController } from './backupController';
|
||||
export { QuestionCategoryController } from './questionCategoryController';
|
||||
export { ExamSubjectController } from './examSubjectController';
|
||||
export { ExamTaskController } from './examTaskController';
|
||||
export { AdminUserController } from './adminUserController';
|
||||
86
api/controllers/questionCategoryController.ts
Normal file
86
api/controllers/questionCategoryController.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { QuestionCategoryModel } from '../models/questionCategory';
|
||||
|
||||
export class QuestionCategoryController {
|
||||
static async getCategories(req: Request, res: Response) {
|
||||
try {
|
||||
const categories = await QuestionCategoryModel.findAll();
|
||||
res.json({
|
||||
success: true,
|
||||
data: categories
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取题目类别失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async createCategory(req: Request, res: Response) {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '类别名称不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const category = await QuestionCategoryModel.create(name);
|
||||
res.json({
|
||||
success: true,
|
||||
data: category
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || '新增题目类别失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async updateCategory(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '类别名称不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const category = await QuestionCategoryModel.update(id, name);
|
||||
res.json({
|
||||
success: true,
|
||||
data: category
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '更新题目类别失败';
|
||||
res.status(message === '类别不存在' ? 404 : 400).json({
|
||||
success: false,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteCategory(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await QuestionCategoryModel.delete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '删除题目类别失败';
|
||||
res.status(message === '类别不存在' ? 404 : 400).json({
|
||||
success: false,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
324
api/controllers/questionController.ts
Normal file
324
api/controllers/questionController.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { QuestionModel, CreateQuestionData, ExcelQuestionData } from '../models';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export class QuestionController {
|
||||
// 获取题目列表
|
||||
static async getQuestions(req: Request, res: Response) {
|
||||
try {
|
||||
const { type, category, keyword, startDate, endDate, page = 1, limit = 10 } = req.query;
|
||||
|
||||
const result = await QuestionModel.findAll({
|
||||
type: type as string,
|
||||
category: category as string,
|
||||
keyword: keyword as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
limit: parseInt(limit as string),
|
||||
offset: (parseInt(page as string) - 1) * parseInt(limit as string)
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.questions,
|
||||
pagination: {
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
total: result.total,
|
||||
pages: Math.ceil(result.total / parseInt(limit as string))
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('获取题目列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取题目列表失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个题目
|
||||
static async getQuestion(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const question = await QuestionModel.findById(id);
|
||||
if (!question) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '题目不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: question
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('获取题目失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取题目失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建题目
|
||||
static async createQuestion(req: Request, res: Response) {
|
||||
try {
|
||||
const { content, type, category, options, answer, score } = req.body;
|
||||
|
||||
const questionData: CreateQuestionData = {
|
||||
content,
|
||||
type,
|
||||
category,
|
||||
options,
|
||||
answer,
|
||||
score
|
||||
};
|
||||
|
||||
// 验证题目数据
|
||||
const errors = QuestionModel.validateQuestionData(questionData);
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '数据验证失败',
|
||||
errors
|
||||
});
|
||||
}
|
||||
|
||||
const question = await QuestionModel.create(questionData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: question
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('创建题目失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '创建题目失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新题目
|
||||
static async updateQuestion(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { content, type, category, options, answer, score } = req.body;
|
||||
|
||||
const updateData: Partial<CreateQuestionData> = {};
|
||||
if (content !== undefined) updateData.content = content;
|
||||
if (type !== undefined) updateData.type = type;
|
||||
if (category !== undefined) updateData.category = category;
|
||||
if (options !== undefined) updateData.options = options;
|
||||
if (answer !== undefined) updateData.answer = answer;
|
||||
if (score !== undefined) updateData.score = score;
|
||||
|
||||
const question = await QuestionModel.update(id, updateData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: question
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('更新题目失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '更新题目失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
static async deleteQuestion(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const success = await QuestionModel.delete(id);
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '题目不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('删除题目失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '删除题目失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Excel导入题目
|
||||
static async importQuestions(req: Request, res: Response) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请上传Excel文件'
|
||||
});
|
||||
}
|
||||
|
||||
// 读取Excel文件
|
||||
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// 转换为JSON数据
|
||||
const rawData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
// 转换数据格式
|
||||
const questionsData: ExcelQuestionData[] = rawData.map((row: any) => ({
|
||||
content: row['题目内容'] || row['content'],
|
||||
type: QuestionController.mapQuestionType(row['题型'] || row['type']),
|
||||
category: row['题目类别'] || row['category'] || '通用',
|
||||
answer: row['标准答案'] || row['answer'],
|
||||
score: parseInt(row['分值'] || row['score']) || 0,
|
||||
options: QuestionController.parseOptions(row['选项'] || row['options'])
|
||||
}));
|
||||
|
||||
// 验证数据
|
||||
const validation = QuestionModel.validateExcelData(questionsData);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Excel数据格式错误',
|
||||
errors: validation.errors
|
||||
});
|
||||
}
|
||||
|
||||
// 批量创建题目
|
||||
const result = await QuestionModel.createMany(questionsData as any[]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
imported: result.success,
|
||||
total: questionsData.length,
|
||||
errors: result.errors
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Excel导入失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || 'Excel导入失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 映射题型
|
||||
private static mapQuestionType(type: string): string {
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'单选': 'single',
|
||||
'多选': 'multiple',
|
||||
'判断': 'judgment',
|
||||
'文字描述': 'text',
|
||||
'single': 'single',
|
||||
'multiple': 'multiple',
|
||||
'judgment': 'judgment',
|
||||
'text': 'text'
|
||||
};
|
||||
|
||||
return typeMap[type] || 'single';
|
||||
}
|
||||
|
||||
// 解析选项
|
||||
private static parseOptions(optionsStr: string): string[] | undefined {
|
||||
if (!optionsStr) return undefined;
|
||||
|
||||
// 支持多种分隔符:|、;、\n
|
||||
const separators = ['|', ';', '\n'];
|
||||
for (const separator of separators) {
|
||||
if (optionsStr.includes(separator)) {
|
||||
return optionsStr.split(separator).map(opt => opt.trim()).filter(opt => opt);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到分隔符,返回单个选项
|
||||
return [optionsStr.trim()];
|
||||
}
|
||||
|
||||
// Excel导出题目
|
||||
static async exportQuestions(req: Request, res: Response) {
|
||||
try {
|
||||
const { type, category } = req.query;
|
||||
|
||||
// 获取所有题目数据(使用大的limit值获取所有题目)
|
||||
const result = await QuestionModel.findAll({
|
||||
type: type as string,
|
||||
category: category as string,
|
||||
limit: 10000, // 使用大的limit值获取所有题目
|
||||
offset: 0
|
||||
});
|
||||
|
||||
const questions = result.questions;
|
||||
|
||||
// 转换为Excel数据格式
|
||||
const excelData = questions.map((question: any) => ({
|
||||
'题目ID': question.id,
|
||||
'题目内容': question.content,
|
||||
'题型': this.getQuestionTypeLabel(question.type),
|
||||
'题目类别': question.category || '通用',
|
||||
'选项': question.options ? question.options.join('|') : '',
|
||||
'标准答案': question.answer,
|
||||
'分值': question.score,
|
||||
'创建时间': new Date(question.createdAt).toLocaleString()
|
||||
}));
|
||||
|
||||
// 创建Excel工作簿和工作表
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.json_to_sheet(excelData);
|
||||
|
||||
// 设置列宽
|
||||
const columnWidths = [
|
||||
{ wch: 10 }, // 题目ID
|
||||
{ wch: 60 }, // 题目内容
|
||||
{ wch: 10 }, // 题型
|
||||
{ wch: 15 }, // 题目类别
|
||||
{ wch: 80 }, // 选项
|
||||
{ wch: 20 }, // 标准答案
|
||||
{ wch: 8 }, // 分值
|
||||
{ wch: 20 } // 创建时间
|
||||
];
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
// 将工作表添加到工作簿
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, '题目列表');
|
||||
|
||||
// 设置响应头
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=questions_${new Date().getTime()}.xlsx`);
|
||||
|
||||
// 生成Excel文件并发送
|
||||
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
|
||||
res.send(excelBuffer);
|
||||
} catch (error: any) {
|
||||
console.error('Excel导出失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || 'Excel导出失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取题型中文标签
|
||||
private static getQuestionTypeLabel(type: string): string {
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'single': '单选题',
|
||||
'multiple': '多选题',
|
||||
'judgment': '判断题',
|
||||
'text': '文字题'
|
||||
};
|
||||
|
||||
return typeMap[type] || type;
|
||||
}
|
||||
}
|
||||
273
api/controllers/quizController.ts
Normal file
273
api/controllers/quizController.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { QuestionModel, QuizModel, SystemConfigModel } from '../models';
|
||||
|
||||
export class QuizController {
|
||||
static async generateQuiz(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId, subjectId, taskId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '用户ID不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
const { ExamTaskModel } = await import('../models/examTask');
|
||||
const result = await ExamTaskModel.generateQuizQuestions(taskId, userId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
questions: result.questions,
|
||||
totalScore: result.totalScore,
|
||||
timeLimit: result.timeLimitMinutes
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subjectId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'subjectId或taskId必须提供其一'
|
||||
});
|
||||
}
|
||||
|
||||
const { ExamSubjectModel } = await import('../models/examSubject');
|
||||
const subject = await ExamSubjectModel.findById(subjectId);
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '考试科目不存在'
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
questions,
|
||||
totalScore,
|
||||
timeLimit: subject.timeLimitMinutes
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '生成试卷失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async submitQuiz(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId, subjectId, taskId, answers } = req.body;
|
||||
|
||||
if (!userId || !answers || !Array.isArray(answers)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数不完整'
|
||||
});
|
||||
}
|
||||
|
||||
const processedAnswers = [];
|
||||
for (const answer of answers) {
|
||||
const question = await QuestionModel.findById(answer.questionId);
|
||||
if (!question) {
|
||||
processedAnswers.push(answer);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
const optionCount = question.options ? question.options.length : 0;
|
||||
const unitScore = optionCount > 0 ? question.score / optionCount : 0;
|
||||
let userAnsList: string[] = [];
|
||||
if (Array.isArray(answer.userAnswer)) {
|
||||
userAnsList = answer.userAnswer;
|
||||
} else if (typeof answer.userAnswer === 'string') {
|
||||
try {
|
||||
userAnsList = JSON.parse(answer.userAnswer);
|
||||
} catch (e) {
|
||||
userAnsList = [answer.userAnswer];
|
||||
}
|
||||
}
|
||||
|
||||
let correctAnsList: string[] = [];
|
||||
if (Array.isArray(question.answer)) {
|
||||
correctAnsList = question.answer;
|
||||
} else if (typeof question.answer === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(question.answer);
|
||||
if (Array.isArray(parsed)) correctAnsList = parsed;
|
||||
else correctAnsList = [question.answer];
|
||||
} catch {
|
||||
correctAnsList = [question.answer];
|
||||
}
|
||||
}
|
||||
|
||||
const userSet = new Set(userAnsList);
|
||||
const correctSet = new Set(correctAnsList);
|
||||
let isFullCorrect = true;
|
||||
if (userSet.size !== correctSet.size) {
|
||||
isFullCorrect = false;
|
||||
} else {
|
||||
for (const a of userSet) {
|
||||
if (!correctSet.has(a)) {
|
||||
isFullCorrect = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFullCorrect) {
|
||||
answer.score = question.score;
|
||||
answer.isCorrect = true;
|
||||
} else {
|
||||
let tempScore = 0;
|
||||
for (const uAns of userAnsList) {
|
||||
if (correctSet.has(uAns)) {
|
||||
tempScore += unitScore;
|
||||
} else {
|
||||
tempScore -= unitScore;
|
||||
}
|
||||
}
|
||||
let finalScore = Math.max(0, tempScore);
|
||||
finalScore = Math.round(finalScore * 10) / 10;
|
||||
answer.score = finalScore;
|
||||
answer.isCorrect = false;
|
||||
}
|
||||
} else if (question.type === 'single' || question.type === 'judgment') {
|
||||
const isCorrect = answer.userAnswer === question.answer;
|
||||
answer.score = isCorrect ? question.score : 0;
|
||||
answer.isCorrect = isCorrect;
|
||||
}
|
||||
|
||||
processedAnswers.push(answer);
|
||||
}
|
||||
|
||||
const result = await QuizModel.submitQuiz({ userId, answers: processedAnswers });
|
||||
|
||||
if (subjectId || taskId) {
|
||||
const sql = `
|
||||
UPDATE quiz_records
|
||||
SET subject_id = ?, task_id = ?
|
||||
WHERE id = ?
|
||||
`;
|
||||
await import('../database').then(({ run }) => run(sql, [subjectId || null, taskId || null, result.record.id]));
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
recordId: result.record.id,
|
||||
totalScore: result.record.totalScore,
|
||||
correctCount: result.record.correctCount,
|
||||
totalCount: result.record.totalCount
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '提交答题失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getUserRecords(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
|
||||
const result = await QuizModel.findRecordsByUserId(userId, limit, (page - 1) * limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.records,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
pages: Math.ceil(result.total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取答题记录失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getRecordDetail(req: Request, res: Response) {
|
||||
try {
|
||||
const { recordId } = req.params;
|
||||
|
||||
const record = await QuizModel.findRecordById(recordId);
|
||||
if (!record) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '答题记录不存在'
|
||||
});
|
||||
}
|
||||
|
||||
const answers = await QuizModel.findAnswersByRecordId(recordId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
record,
|
||||
answers
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取答题记录详情失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllRecords(req: Request, res: Response) {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
|
||||
const result = await QuizModel.findAllRecords(limit, (page - 1) * limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.records,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: result.total,
|
||||
pages: Math.ceil(result.total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取答题记录失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
118
api/controllers/userController.ts
Normal file
118
api/controllers/userController.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user';
|
||||
|
||||
export class UserController {
|
||||
static async createUser(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, phone, password } = req.body;
|
||||
|
||||
if (!name || !phone) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '姓名和手机号不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const errors = UserModel.validateUserData({ name, phone, password });
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '数据验证失败',
|
||||
errors
|
||||
});
|
||||
}
|
||||
|
||||
const user = await UserModel.create({ name, phone, password });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('创建用户失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '创建用户失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async getUser(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const user = await UserModel.findById(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取用户信息失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async validateUserInfo(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, phone, password } = req.body;
|
||||
|
||||
if (!name || !phone) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '姓名和手机号不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const errors = UserModel.validateUserData({ name, phone, password });
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '数据验证失败',
|
||||
errors
|
||||
});
|
||||
}
|
||||
|
||||
const existingUser = await UserModel.findByPhone(phone);
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.password && existingUser.password !== password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '密码错误'
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingUser.password && password) {
|
||||
await UserModel.updatePasswordById(existingUser.id, password);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: existingUser
|
||||
});
|
||||
} else {
|
||||
const newUser = await UserModel.create({ name, phone, password });
|
||||
res.json({
|
||||
success: true,
|
||||
data: newUser
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('验证用户信息失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '验证用户信息失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
78
api/controllers/userQuizController.ts
Normal file
78
api/controllers/userQuizController.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ExamTaskModel } from '../models/examTask';
|
||||
|
||||
export class UserQuizController {
|
||||
static async generateQuiz(req: Request, res: Response) {
|
||||
try {
|
||||
const { userId, subjectId, taskId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '用户ID不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
const result = await ExamTaskModel.generateQuizQuestions(taskId, userId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
questions: result.questions,
|
||||
totalScore: result.totalScore,
|
||||
timeLimit: result.timeLimitMinutes
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subjectId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'subjectId或taskId必须提供其一'
|
||||
});
|
||||
}
|
||||
|
||||
const { QuestionModel, ExamSubjectModel } = await import('../models');
|
||||
const subject = await ExamSubjectModel.findById(subjectId);
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '考试科目不存在'
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
questions,
|
||||
totalScore,
|
||||
timeLimit: subject.timeLimitMinutes
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '生成试卷失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
185
api/database/index.ts
Normal file
185
api/database/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
|
||||
const DEFAULT_DB_PATH = path.join(DEFAULT_DB_DIR, 'survey.db');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || DEFAULT_DB_PATH;
|
||||
const DB_DIR = path.dirname(DB_PATH);
|
||||
|
||||
// 确保数据目录存在
|
||||
if (!fs.existsSync(DB_DIR)) {
|
||||
fs.mkdirSync(DB_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建数据库连接
|
||||
export const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
console.error('数据库连接失败:', err);
|
||||
} else {
|
||||
console.log('数据库连接成功');
|
||||
}
|
||||
});
|
||||
|
||||
// 启用外键约束
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
const exec = (sql: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.exec(sql, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const tableExists = async (tableName: string): Promise<boolean> => {
|
||||
const row = await get(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
[tableName]
|
||||
);
|
||||
return Boolean(row);
|
||||
};
|
||||
|
||||
const columnExists = async (tableName: string, columnName: string): Promise<boolean> => {
|
||||
const columns = await query(`PRAGMA table_info(${tableName})`);
|
||||
return columns.some((col: any) => col.name === columnName);
|
||||
};
|
||||
|
||||
const ensureColumn = async (tableName: string, columnDefSql: string, columnName: string) => {
|
||||
if (!(await columnExists(tableName, columnName))) {
|
||||
await exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnDefSql}`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTable = async (createTableSql: string) => {
|
||||
await exec(createTableSql);
|
||||
};
|
||||
|
||||
const ensureIndex = async (createIndexSql: string) => {
|
||||
await exec(createIndexSql);
|
||||
};
|
||||
|
||||
const migrateDatabase = async () => {
|
||||
await ensureColumn('users', `password TEXT NOT NULL DEFAULT ''`, 'password');
|
||||
|
||||
await ensureColumn('questions', `category TEXT NOT NULL DEFAULT '通用'`, 'category');
|
||||
await run(`UPDATE questions SET category = '通用' WHERE category IS NULL OR category = ''`);
|
||||
|
||||
await ensureTable(`
|
||||
CREATE TABLE IF NOT EXISTS question_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await run(
|
||||
`INSERT OR IGNORE INTO question_categories (id, name) VALUES ('default', '通用')`
|
||||
);
|
||||
|
||||
await ensureTable(`
|
||||
CREATE TABLE IF NOT EXISTS exam_subjects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
type_ratios TEXT NOT NULL,
|
||||
category_ratios TEXT NOT NULL,
|
||||
total_score INTEGER NOT NULL,
|
||||
duration_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await ensureTable(`
|
||||
CREATE TABLE IF NOT EXISTS exam_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
start_at DATETIME NOT NULL,
|
||||
end_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (subject_id) REFERENCES exam_subjects(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await ensureTable(`
|
||||
CREATE TABLE IF NOT EXISTS exam_task_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id, user_id),
|
||||
FOREIGN KEY (task_id) REFERENCES exam_tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
if (await tableExists('quiz_records')) {
|
||||
await ensureColumn('quiz_records', `subject_id TEXT`, 'subject_id');
|
||||
await ensureColumn('quiz_records', `task_id TEXT`, 'task_id');
|
||||
}
|
||||
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_questions_category ON questions(category);`);
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_exam_tasks_subject_id ON exam_tasks(subject_id);`);
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_exam_task_users_task_id ON exam_task_users(task_id);`);
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_exam_task_users_user_id ON exam_task_users(user_id);`);
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_quiz_records_subject_id ON quiz_records(subject_id);`);
|
||||
await ensureIndex(`CREATE INDEX IF NOT EXISTS idx_quiz_records_task_id ON quiz_records(task_id);`);
|
||||
};
|
||||
|
||||
// 数据库初始化函数
|
||||
export const initDatabase = async () => {
|
||||
const initSQL = fs.readFileSync(path.join(process.cwd(), 'api', 'database', 'init.sql'), 'utf8');
|
||||
|
||||
const hasUsersTable = await tableExists('users');
|
||||
if (!hasUsersTable) {
|
||||
await exec(initSQL);
|
||||
console.log('数据库初始化成功');
|
||||
} else {
|
||||
console.log('数据库已初始化,准备执行迁移检查');
|
||||
}
|
||||
|
||||
await migrateDatabase();
|
||||
};
|
||||
|
||||
// 数据库查询工具函数
|
||||
export const query = (sql: string, params: any[] = []): Promise<any[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// all函数是query函数的别名,用于向后兼容
|
||||
export const all = query;
|
||||
|
||||
export const run = (sql: string, params: any[] = []): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ id: this.lastID.toString() });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const get = (sql: string, params: any[] = []): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
131
api/database/init.sql
Normal file
131
api/database/init.sql
Normal file
@@ -0,0 +1,131 @@
|
||||
-- 用户表
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL CHECK(length(name) >= 2 AND length(name) <= 20),
|
||||
phone TEXT UNIQUE NOT NULL CHECK(length(phone) = 11 AND phone LIKE '1%' AND substr(phone, 2, 1) BETWEEN '3' AND '9'),
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建用户表索引
|
||||
CREATE INDEX idx_users_phone ON users(phone);
|
||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
||||
|
||||
-- 题目表
|
||||
CREATE TABLE questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('single', 'multiple', 'judgment', 'text')),
|
||||
options TEXT, -- JSON格式存储选项
|
||||
answer TEXT NOT NULL,
|
||||
score INTEGER NOT NULL CHECK(score > 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建题目表索引
|
||||
CREATE INDEX idx_questions_type ON questions(type);
|
||||
CREATE INDEX idx_questions_score ON questions(score);
|
||||
CREATE INDEX idx_questions_category ON questions(category);
|
||||
|
||||
-- 题目类别表
|
||||
CREATE TABLE question_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO question_categories (id, name) VALUES ('default', '通用');
|
||||
|
||||
-- 考试科目表
|
||||
CREATE TABLE exam_subjects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
type_ratios TEXT NOT NULL,
|
||||
category_ratios TEXT NOT NULL,
|
||||
total_score INTEGER NOT NULL,
|
||||
duration_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 考试任务表
|
||||
CREATE TABLE exam_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
start_at DATETIME NOT NULL,
|
||||
end_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (subject_id) REFERENCES exam_subjects(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exam_tasks_subject_id ON exam_tasks(subject_id);
|
||||
|
||||
-- 考试任务参与用户表
|
||||
CREATE TABLE exam_task_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id, user_id),
|
||||
FOREIGN KEY (task_id) REFERENCES exam_tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exam_task_users_task_id ON exam_task_users(task_id);
|
||||
CREATE INDEX idx_exam_task_users_user_id ON exam_task_users(user_id);
|
||||
|
||||
-- 答题记录表
|
||||
CREATE TABLE quiz_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
subject_id TEXT,
|
||||
task_id TEXT,
|
||||
total_score INTEGER NOT NULL,
|
||||
correct_count INTEGER NOT NULL,
|
||||
total_count INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (subject_id) REFERENCES exam_subjects(id),
|
||||
FOREIGN KEY (task_id) REFERENCES exam_tasks(id)
|
||||
);
|
||||
|
||||
-- 创建答题记录表索引
|
||||
CREATE INDEX idx_quiz_records_user_id ON quiz_records(user_id);
|
||||
CREATE INDEX idx_quiz_records_created_at ON quiz_records(created_at);
|
||||
CREATE INDEX idx_quiz_records_subject_id ON quiz_records(subject_id);
|
||||
CREATE INDEX idx_quiz_records_task_id ON quiz_records(task_id);
|
||||
|
||||
-- 答题答案表
|
||||
CREATE TABLE quiz_answers (
|
||||
id TEXT PRIMARY KEY,
|
||||
record_id TEXT NOT NULL,
|
||||
question_id TEXT NOT NULL,
|
||||
user_answer TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
is_correct BOOLEAN NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (record_id) REFERENCES quiz_records(id),
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||
);
|
||||
|
||||
-- 创建答题答案表索引
|
||||
CREATE INDEX idx_quiz_answers_record_id ON quiz_answers(record_id);
|
||||
CREATE INDEX idx_quiz_answers_question_id ON quiz_answers(question_id);
|
||||
|
||||
-- 系统配置表
|
||||
CREATE TABLE system_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
config_type TEXT UNIQUE NOT NULL,
|
||||
config_value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 初始化抽题配置
|
||||
INSERT INTO system_configs (id, config_type, config_value) VALUES
|
||||
('1', 'quiz_config', '{"singleRatio":40,"multipleRatio":30,"judgmentRatio":20,"textRatio":10,"totalScore":100}');
|
||||
|
||||
-- 初始化管理员账号
|
||||
INSERT INTO system_configs (id, config_type, config_value) VALUES
|
||||
('2', 'admin_user', '{"username":"admin","password":"admin123"}');
|
||||
9
api/index.ts
Normal file
9
api/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
|
||||
*/
|
||||
// import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||
import app from './app.js';
|
||||
|
||||
export default function handler(req: any, res: any) {
|
||||
return app(req, res);
|
||||
}
|
||||
104
api/middlewares/index.ts
Normal file
104
api/middlewares/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import multer from 'multer';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// 文件上传配置
|
||||
const storage = multer.memoryStorage();
|
||||
|
||||
export const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB限制
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// 只允许Excel文件
|
||||
const allowedTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel'
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只允许上传Excel文件'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理中间件
|
||||
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('错误:', err);
|
||||
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件大小不能超过10MB'
|
||||
});
|
||||
}
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件上传失败'
|
||||
});
|
||||
}
|
||||
|
||||
if (err.message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
};
|
||||
|
||||
// 管理员认证中间件(简化版)
|
||||
export const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
// 简化处理,接受任何 Bearer 令牌或无令牌访问
|
||||
// 实际生产环境应该使用JWT token验证
|
||||
const token = req.headers.authorization;
|
||||
|
||||
// 允许任何带有 Bearer 前缀的令牌,或者无令牌访问
|
||||
if (token && token.startsWith('Bearer ')) {
|
||||
next();
|
||||
} else {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '未授权访问'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 请求日志中间件
|
||||
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// 响应格式化中间件
|
||||
export const responseFormatter = (req: Request, res: Response, next: NextFunction) => {
|
||||
const originalJson = res.json;
|
||||
|
||||
res.json = function(data: any) {
|
||||
// 如果数据已经是标准格式,直接返回
|
||||
if (data && typeof data === 'object' && 'success' in data) {
|
||||
return originalJson.call(this, data);
|
||||
}
|
||||
|
||||
// 否则包装成标准格式
|
||||
return originalJson.call(this, {
|
||||
success: true,
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
218
api/models/examSubject.ts
Normal file
218
api/models/examSubject.ts
Normal 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
260
api/models/examTask.ts
Normal 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
8
api/models/index.ts
Normal 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
319
api/models/question.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
72
api/models/questionCategory.ts
Normal file
72
api/models/questionCategory.ts
Normal 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
246
api/models/quiz.ts
Normal 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
121
api/models/systemConfig.ts
Normal 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
91
api/models/user.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
api/routes/auth.ts
Normal file
33
api/routes/auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* This is a user authentication API route demo.
|
||||
* Handle user registration, login, token management, etc.
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* User Login
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
router.post('/register', async (req: Request, res: Response): Promise<void> => {
|
||||
// TODO: Implement register logic
|
||||
})
|
||||
|
||||
/**
|
||||
* User Login
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', async (req: Request, res: Response): Promise<void> => {
|
||||
// TODO: Implement login logic
|
||||
})
|
||||
|
||||
/**
|
||||
* User Logout
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
router.post('/logout', async (req: Request, res: Response): Promise<void> => {
|
||||
// TODO: Implement logout logic
|
||||
})
|
||||
|
||||
export default router
|
||||
135
api/server.ts
Normal file
135
api/server.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { initDatabase } from './database';
|
||||
import {
|
||||
UserController,
|
||||
QuestionController,
|
||||
QuizController,
|
||||
AdminController,
|
||||
BackupController,
|
||||
QuestionCategoryController,
|
||||
ExamSubjectController,
|
||||
ExamTaskController,
|
||||
AdminUserController
|
||||
} from './controllers';
|
||||
import {
|
||||
upload,
|
||||
errorHandler,
|
||||
adminAuth,
|
||||
requestLogger,
|
||||
responseFormatter
|
||||
} from './middlewares';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(requestLogger);
|
||||
app.use(responseFormatter);
|
||||
|
||||
// API路由
|
||||
const apiRouter = express.Router();
|
||||
|
||||
// 用户相关
|
||||
apiRouter.post('/users', UserController.createUser);
|
||||
apiRouter.get('/users/:id', UserController.getUser);
|
||||
apiRouter.post('/users/validate', UserController.validateUserInfo);
|
||||
|
||||
// 题库管理
|
||||
apiRouter.get('/questions', QuestionController.getQuestions);
|
||||
apiRouter.get('/questions/:id', QuestionController.getQuestion);
|
||||
apiRouter.post('/questions', adminAuth, QuestionController.createQuestion);
|
||||
apiRouter.put('/questions/:id', adminAuth, QuestionController.updateQuestion);
|
||||
apiRouter.delete('/questions/:id', adminAuth, QuestionController.deleteQuestion);
|
||||
apiRouter.post('/questions/import', adminAuth, upload.single('file'), QuestionController.importQuestions);
|
||||
apiRouter.get('/questions/export', adminAuth, QuestionController.exportQuestions);
|
||||
|
||||
// 为了兼容前端可能的错误请求,添加一个不包含 /api 前缀的路由
|
||||
app.get('/questions/export', adminAuth, QuestionController.exportQuestions);
|
||||
|
||||
// 题目类别
|
||||
apiRouter.get('/question-categories', QuestionCategoryController.getCategories);
|
||||
apiRouter.post('/admin/question-categories', adminAuth, QuestionCategoryController.createCategory);
|
||||
apiRouter.put('/admin/question-categories/:id', adminAuth, QuestionCategoryController.updateCategory);
|
||||
apiRouter.delete('/admin/question-categories/:id', adminAuth, QuestionCategoryController.deleteCategory);
|
||||
|
||||
// 考试科目
|
||||
apiRouter.get('/exam-subjects', ExamSubjectController.getSubjects);
|
||||
apiRouter.get('/admin/subjects', adminAuth, ExamSubjectController.getSubjects);
|
||||
apiRouter.post('/admin/subjects', adminAuth, ExamSubjectController.createSubject);
|
||||
apiRouter.put('/admin/subjects/:id', adminAuth, ExamSubjectController.updateSubject);
|
||||
apiRouter.delete('/admin/subjects/:id', adminAuth, ExamSubjectController.deleteSubject);
|
||||
|
||||
// 考试任务
|
||||
apiRouter.get('/exam-tasks', ExamTaskController.getTasks);
|
||||
apiRouter.get('/exam-tasks/user/:userId', ExamTaskController.getUserTasks);
|
||||
apiRouter.post('/admin/tasks', adminAuth, ExamTaskController.createTask);
|
||||
apiRouter.put('/admin/tasks/:id', adminAuth, ExamTaskController.updateTask);
|
||||
apiRouter.delete('/admin/tasks/:id', adminAuth, ExamTaskController.deleteTask);
|
||||
apiRouter.get('/admin/tasks/:id/report', adminAuth, ExamTaskController.getTaskReport);
|
||||
|
||||
// 用户管理
|
||||
apiRouter.get('/admin/users', adminAuth, AdminUserController.getUsers);
|
||||
apiRouter.delete('/admin/users', adminAuth, AdminUserController.deleteUser);
|
||||
apiRouter.get('/admin/users/export', adminAuth, AdminUserController.exportUsers);
|
||||
apiRouter.post('/admin/users/import', adminAuth, upload.single('file'), AdminUserController.importUsers);
|
||||
apiRouter.get('/admin/users/:userId/records', adminAuth, AdminUserController.getUserRecords);
|
||||
apiRouter.get('/admin/quiz/records/detail/:recordId', adminAuth, AdminUserController.getRecordDetail);
|
||||
|
||||
// 答题相关
|
||||
apiRouter.post('/quiz/generate', QuizController.generateQuiz);
|
||||
apiRouter.post('/quiz/submit', QuizController.submitQuiz);
|
||||
apiRouter.get('/quiz/records/:userId', QuizController.getUserRecords);
|
||||
apiRouter.get('/quiz/records/detail/:recordId', QuizController.getRecordDetail);
|
||||
apiRouter.get('/quiz/records', adminAuth, QuizController.getAllRecords);
|
||||
|
||||
// 管理员相关
|
||||
apiRouter.post('/admin/login', AdminController.login);
|
||||
apiRouter.get('/admin/config', adminAuth, AdminController.getQuizConfig);
|
||||
apiRouter.put('/admin/config', adminAuth, AdminController.updateQuizConfig);
|
||||
apiRouter.get('/admin/statistics', adminAuth, AdminController.getStatistics);
|
||||
apiRouter.put('/admin/password', adminAuth, AdminController.updatePassword);
|
||||
apiRouter.get('/admin/configs', adminAuth, AdminController.getAllConfigs);
|
||||
|
||||
// 数据备份和恢复
|
||||
apiRouter.get('/admin/export/users', adminAuth, BackupController.exportUsers);
|
||||
apiRouter.get('/admin/export/questions', adminAuth, BackupController.exportQuestions);
|
||||
apiRouter.get('/admin/export/records', adminAuth, BackupController.exportRecords);
|
||||
apiRouter.get('/admin/export/answers', adminAuth, BackupController.exportAnswers);
|
||||
apiRouter.post('/admin/restore', adminAuth, BackupController.restoreData);
|
||||
|
||||
// 应用API路由
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
// 静态文件服务
|
||||
app.use(express.static(path.join(process.cwd(), 'dist')));
|
||||
|
||||
// 前端路由(SPA支持)
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
app.use(errorHandler);
|
||||
|
||||
// 启动服务器
|
||||
async function startServer() {
|
||||
try {
|
||||
await initDatabase();
|
||||
console.log('数据库初始化完成');
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`服务器运行在端口 ${PORT}`);
|
||||
console.log(`API文档: http://localhost:${PORT}/api`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('启动服务器失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
Reference in New Issue
Block a user