fix(dev): bind frontend to 127.0.0.1:8080 and avoid EACCES\nfix(backend): bind server to 127.0.0.1:8081, add permissive CORS whitelist\nfix(auth): login form UX remove default username, clarify placeholder, add test account autofill\nchore(api): set frontend API_BASE_URL to 127.0.0.1:8081\nrefactor(assignments): lifecycle/state logic and archive endpoint\nfeat(analytics): add exam stats endpoint and client method\nchore(lint): add eslint configs

This commit is contained in:
Nexus Dev
2025-11-30 21:55:28 +08:00
parent 38244630a7
commit 4b84a09538
63 changed files with 8478 additions and 3694 deletions

View File

@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@prisma/client": "^5.22.0",
"axios": "^1.13.2",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
@@ -718,6 +719,23 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -792,6 +810,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -850,6 +880,15 @@
"ms": "2.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -949,6 +988,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1070,6 +1124,42 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1186,6 +1276,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1471,6 +1576,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
@@ -1497,6 +1603,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",

View File

@@ -22,23 +22,24 @@
"license": "MIT",
"dependencies": {
"@prisma/client": "^5.22.0",
"express": "^4.21.1",
"axios": "^1.13.2",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"bcryptjs": "^2.4.3",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.8",
"uuid": "^11.0.3"
"uuid": "^11.0.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.1",
"@types/cors": "^2.8.17",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.1",
"@types/uuid": "^10.0.0",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
}

View File

@@ -328,9 +328,11 @@ model Exam {
id String @id @default(uuid()) @db.VarChar(36)
subjectId String @map("subject_id") @db.VarChar(36)
title String @db.VarChar(200)
examType String @default("Uncategorized") @map("exam_type") @db.VarChar(50) // e.g. Midterm, Final, Unit, Weekly
totalScore Decimal @map("total_score") @default(0) @db.Decimal(5, 1)
suggestedDuration Int @map("suggested_duration")
status ExamStatus @default(Draft)
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
createdBy String @map("created_by") @db.VarChar(36)
@@ -402,7 +404,8 @@ model Assignment {
endTime DateTime @map("end_time")
allowLateSubmission Boolean @map("allow_late_submission") @default(false)
autoScoreEnabled Boolean @map("auto_score_enabled") @default(true)
status AssignmentStatus @default(Active)
createdAt DateTime @default(now()) @map("created_at")
createdBy String @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
@@ -425,6 +428,7 @@ model StudentSubmission {
assignmentId String @map("assignment_id") @db.VarChar(36)
studentId String @map("student_id") @db.VarChar(36)
submissionStatus SubmissionStatus @map("submission_status") @default(Pending)
startedAt DateTime? @map("started_at")
submitTime DateTime? @map("submit_time")
timeSpentSeconds Int? @map("time_spent_seconds")
totalScore Decimal? @map("total_score") @db.Decimal(5, 1)
@@ -478,6 +482,11 @@ enum SubmissionStatus {
Graded
}
enum AssignmentStatus {
Active
Archived
}
enum JudgementResult {
Correct
Incorrect

View File

@@ -1,66 +1,14 @@
import { Response } from 'express';
import { PrismaClient } from '@prisma/client';
import { analyticsService } from '../services/analytics.service';
import { AuthRequest } from '../middleware/auth.middleware';
const prisma = new PrismaClient();
// 获取班级表现(平均分趋势)
export const getClassPerformance = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
// 获取教师管理的班级
const classes = await prisma.class.findMany({
where: {
OR: [
{ headTeacherId: userId },
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
],
isDeleted: false
},
select: { id: true, name: true }
});
const classIds = classes.map(c => c.id);
// 获取最近的5次作业/考试
const assignments = await prisma.assignment.findMany({
where: {
classId: { in: classIds },
isDeleted: false
},
orderBy: { endTime: 'desc' },
take: 5,
include: {
submissions: {
where: { submissionStatus: 'Graded' },
select: { totalScore: true }
}
}
});
// 按时间正序排列
assignments.reverse();
const labels = assignments.map(a => a.title);
const data = assignments.map(a => {
const scores = a.submissions.map(s => Number(s.totalScore));
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0;
return Number(avg.toFixed(1));
});
res.json({
labels,
datasets: [
{
label: '班级平均分',
data,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.5)',
}
]
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const result = await analyticsService.getClassPerformance(req.userId);
res.json(result);
} catch (error) {
console.error('Get class performance error:', error);
res.status(500).json({ error: 'Failed to get class performance' });
@@ -70,51 +18,32 @@ export const getClassPerformance = async (req: AuthRequest, res: Response) => {
// 获取学生成长(个人成绩趋势)
export const getStudentGrowth = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
// 获取学生最近的5次已批改提交
const submissions = await prisma.studentSubmission.findMany({
where: {
studentId: userId,
submissionStatus: 'Graded',
isDeleted: false
},
orderBy: { submitTime: 'desc' },
take: 5,
include: { assignment: true }
});
submissions.reverse();
const labels = submissions.map(s => s.assignment.title);
const data = submissions.map(s => Number(s.totalScore));
res.json({
labels,
datasets: [
{
label: '我的成绩',
data,
borderColor: 'rgb(53, 162, 235)',
backgroundColor: 'rgba(53, 162, 235, 0.5)',
}
]
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const result = await analyticsService.getStudentGrowth(req.userId);
res.json(result);
} catch (error) {
console.error('Get student growth error:', error);
res.status(500).json({ error: 'Failed to get student growth' });
}
};
// 获取学生统计数据(已完成、代办、平均分、学习时长)
export const getStudentStats = async (req: AuthRequest, res: Response) => {
try {
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const stats = await analyticsService.getStudentStats(req.userId);
res.json(stats);
} catch (error) {
console.error('Get student stats error:', error);
res.status(500).json({ error: 'Failed to get student stats' });
}
};
// 获取班级能力雷达图
export const getRadar = async (req: AuthRequest, res: Response) => {
try {
// 模拟数据,因为目前没有明确的能力维度字段
res.json({
indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力','个人表现'],
values: [85, 78, 92, 88, 75,99]
});
const result = await analyticsService.getRadar();
res.json(result);
} catch (error) {
console.error('Get radar error:', error);
res.status(500).json({ error: 'Failed to get radar data' });
@@ -124,11 +53,8 @@ export const getRadar = async (req: AuthRequest, res: Response) => {
// 获取学生能力雷达图
export const getStudentRadar = async (req: AuthRequest, res: Response) => {
try {
// 模拟数据
res.json({
indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力'],
values: [80, 85, 90, 82, 78]
});
const result = await analyticsService.getStudentRadar();
res.json(result);
} catch (error) {
console.error('Get student radar error:', error);
res.status(500).json({ error: 'Failed to get student radar data' });
@@ -138,60 +64,8 @@ export const getStudentRadar = async (req: AuthRequest, res: Response) => {
// 获取成绩分布
export const getScoreDistribution = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
// 获取教师管理的班级
const classes = await prisma.class.findMany({
where: {
OR: [
{ headTeacherId: userId },
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
],
isDeleted: false
},
select: { id: true }
});
const classIds = classes.map(c => c.id);
if (classIds.length === 0) {
return res.json([]);
}
// 获取这些班级的作业
const assignments = await prisma.assignment.findMany({
where: { classId: { in: classIds }, isDeleted: false },
select: { id: true }
});
const assignmentIds = assignments.map(a => a.id);
// 获取所有已批改作业的分数
const submissions = await prisma.studentSubmission.findMany({
where: {
assignmentId: { in: assignmentIds },
submissionStatus: 'Graded',
isDeleted: false
},
select: { totalScore: true }
});
const scores = submissions.map(s => Number(s.totalScore));
const distribution = [
{ range: '0-60', count: 0 },
{ range: '60-70', count: 0 },
{ range: '70-80', count: 0 },
{ range: '80-90', count: 0 },
{ range: '90-100', count: 0 }
];
scores.forEach(score => {
if (score < 60) distribution[0].count++;
else if (score < 70) distribution[1].count++;
else if (score < 80) distribution[2].count++;
else if (score < 90) distribution[3].count++;
else distribution[4].count++;
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const distribution = await analyticsService.getScoreDistribution(req.userId);
res.json(distribution);
} catch (error) {
console.error('Get score distribution error:', error);
@@ -202,89 +76,9 @@ export const getScoreDistribution = async (req: AuthRequest, res: Response) => {
// 获取教师统计数据(活跃学生、平均分、待批改、及格率)
export const getTeacherStats = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
// 获取教师管理的班级
const classes = await prisma.class.findMany({
where: {
OR: [
{ headTeacherId: userId },
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
],
isDeleted: false
},
select: { id: true }
});
const classIds = classes.map(c => c.id);
if (classIds.length === 0) {
return res.json({
activeStudents: 0,
averageScore: 0,
pendingGrading: 0,
passRate: 0
});
}
// 1. 活跃学生数:这些班级中的学生总数
const activeStudents = await prisma.classMember.count({
where: {
classId: { in: classIds },
roleInClass: 'Student',
isDeleted: false
}
});
// 2. 获取这些班级的作业
const assignments = await prisma.assignment.findMany({
where: {
classId: { in: classIds },
isDeleted: false
},
select: { id: true }
});
const assignmentIds = assignments.map(a => a.id);
// 3. 待批改数
const pendingGrading = await prisma.studentSubmission.count({
where: {
assignmentId: { in: assignmentIds },
submissionStatus: 'Submitted',
isDeleted: false
}
});
// 4. 已批改的提交(用于计算平均分和及格率)
const gradedSubmissions = await prisma.studentSubmission.findMany({
where: {
assignmentId: { in: assignmentIds },
submissionStatus: 'Graded',
isDeleted: false
},
select: { totalScore: true }
});
let averageScore = 0;
let passRate = 0;
if (gradedSubmissions.length > 0) {
const scores = gradedSubmissions.map(s => Number(s.totalScore));
const sum = scores.reduce((a, b) => a + b, 0);
averageScore = Number((sum / scores.length).toFixed(1));
const passedCount = scores.filter(score => score >= 60).length;
passRate = Number(((passedCount / scores.length) * 100).toFixed(1));
}
res.json({
activeStudents,
averageScore,
pendingGrading,
passRate
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const stats = await analyticsService.getTeacherStats(req.userId);
res.json(stats);
} catch (error) {
console.error('Get teacher stats error:', error);
res.status(500).json({ error: 'Failed to get teacher stats' });
@@ -293,84 +87,9 @@ export const getTeacherStats = async (req: AuthRequest, res: Response) => {
export const getExamStats = async (req: AuthRequest, res: Response) => {
try {
const { id: examId } = req.params as any;
const assignments = await prisma.assignment.findMany({
where: { examId, isDeleted: false },
select: { id: true }
});
const assignmentIds = assignments.map(a => a.id);
const gradedSubmissions = await prisma.studentSubmission.findMany({
where: { assignmentId: { in: assignmentIds }, submissionStatus: 'Graded', isDeleted: false },
select: { id: true, totalScore: true }
});
const scores = gradedSubmissions.map(s => Number(s.totalScore));
const averageScore = scores.length > 0 ? Number((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)) : 0;
const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
const minScore = scores.length > 0 ? Math.min(...scores) : 0;
const passRate = scores.length > 0 ? Number(((scores.filter(s => s >= 60).length / scores.length) * 100).toFixed(1)) : 0;
const distribution = [
{ range: '0-60', count: 0 },
{ range: '60-70', count: 0 },
{ range: '70-80', count: 0 },
{ range: '80-90', count: 0 },
{ range: '90-100', count: 0 }
];
scores.forEach(score => {
if (score < 60) distribution[0].count++;
else if (score < 70) distribution[1].count++;
else if (score < 80) distribution[2].count++;
else if (score < 90) distribution[3].count++;
else distribution[4].count++;
});
const examNodes = await prisma.examNode.findMany({
where: { examId, isDeleted: false },
select: {
id: true,
questionId: true,
question: { select: { content: true, difficulty: true, questionType: true } }
}
});
const nodeIds = examNodes.map(n => n.id);
const submissionIds = gradedSubmissions.map(s => s.id);
const details = await prisma.submissionDetail.findMany({
where: { examNodeId: { in: nodeIds }, submissionId: { in: submissionIds }, isDeleted: false },
select: { examNodeId: true, judgement: true }
});
const statsMap = new Map<string, { total: number; wrong: number }>();
for (const d of details) {
const s = statsMap.get(d.examNodeId) || { total: 0, wrong: 0 };
s.total += 1;
if (d.judgement === 'Incorrect') s.wrong += 1;
statsMap.set(d.examNodeId, s);
}
const wrongQuestions = examNodes.map(n => {
const s = statsMap.get(n.id) || { total: 0, wrong: 0 };
const errorRate = s.total > 0 ? Math.round((s.wrong / s.total) * 100) : 0;
return {
id: n.questionId || n.id,
content: n.question?.content || '',
errorRate,
difficulty: n.question?.difficulty || 0,
type: n.question?.questionType || 'Unknown'
};
}).sort((a, b) => b.errorRate - a.errorRate).slice(0, 20);
res.json({
averageScore,
passRate,
maxScore,
minScore,
scoreDistribution: distribution,
wrongQuestions
});
const { id } = req.params as any;
const result = await analyticsService.getExamStats(id);
res.json(result);
} catch (error) {
console.error('Get exam stats error:', error);
res.status(500).json({ error: 'Failed to get exam stats' });

View File

@@ -1,84 +1,19 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
import { assignmentService } from '../services/assignment.service';
// GET /api/assignments/teaching
// 获取我教的班级的作业列表(教师视角)
// 获取发布的作业列表(教师视角)
export const getTeachingAssignments = async (req: AuthRequest, res: Response) => {
try {
// 查询我作为教师的所有班级
const myClasses = await prisma.classMember.findMany({
where: {
userId: req.userId!,
roleInClass: 'Teacher',
isDeleted: false
},
select: { classId: true }
});
const classIds = myClasses.map(m => m.classId);
if (classIds.length === 0) {
return res.json({ items: [], totalCount: 0, pageIndex: 1, pageSize: 10 });
}
// 查询这些班级的作业
const assignments = await prisma.assignment.findMany({
where: {
classId: { in: classIds },
isDeleted: false
},
include: {
exam: {
select: {
title: true,
totalScore: true
}
},
class: {
include: {
grade: true
}
},
_count: {
select: { submissions: true }
},
submissions: {
where: {
submissionStatus: { in: ['Submitted', 'Graded'] }
},
select: { id: true }
}
},
orderBy: { createdAt: 'desc' }
});
// 格式化返回数据
const items = assignments.map(assignment => {
const totalCount = assignment._count.submissions;
const submittedCount = assignment.submissions.length;
return {
id: assignment.id,
title: assignment.title,
examTitle: assignment.exam.title,
className: assignment.class.name,
gradeName: assignment.class.grade.name,
submittedCount,
totalCount,
status: new Date() > assignment.endTime ? 'Closed' : 'Active',
dueDate: assignment.endTime.toISOString(),
createdAt: assignment.createdAt.toISOString()
};
});
res.json({
items,
totalCount: items.length,
pageIndex: 1,
pageSize: 10
const { classId, examType, subjectId, status } = req.query;
const result = await assignmentService.getTeachingAssignments(req.userId!, {
classId: classId as string,
examType: examType as string,
subjectId: subjectId as string,
status: status as string
});
res.json(result);
} catch (error) {
console.error('Get teaching assignments error:', error);
res.status(500).json({ error: 'Failed to get teaching assignments' });
@@ -89,51 +24,13 @@ export const getTeachingAssignments = async (req: AuthRequest, res: Response) =>
// 获取我的作业列表(学生视角)
export const getStudentAssignments = async (req: AuthRequest, res: Response) => {
try {
// 查询我作为学生的所有提交记录
const submissions = await prisma.studentSubmission.findMany({
where: {
studentId: req.userId!,
isDeleted: false
},
include: {
assignment: {
include: {
exam: {
select: {
title: true,
totalScore: true
}
},
class: {
include: {
grade: true
}
}
}
}
},
orderBy: { createdAt: 'desc' }
});
// 格式化返回数据
const items = submissions.map(submission => ({
id: submission.assignment.id,
title: submission.assignment.title,
examTitle: submission.assignment.exam.title,
className: submission.assignment.class.name,
startTime: submission.assignment.startTime.toISOString(),
endTime: submission.assignment.endTime.toISOString(),
status: submission.submissionStatus,
score: submission.totalScore ? Number(submission.totalScore) : null,
submitTime: submission.submitTime?.toISOString() || null
}));
res.json({
items,
totalCount: items.length,
pageIndex: 1,
pageSize: 10
});
const filters = {
subjectId: req.query.subjectId as string,
examType: req.query.examType as string,
status: req.query.status as string
};
const result = await assignmentService.getStudentAssignments(req.userId!, filters);
res.json(result);
} catch (error) {
console.error('Get student assignments error:', error);
res.status(500).json({ error: 'Failed to get student assignments' });
@@ -145,165 +42,93 @@ export const getStudentAssignments = async (req: AuthRequest, res: Response) =>
export const createAssignment = async (req: AuthRequest, res: Response) => {
try {
const { examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled } = req.body;
if (!examId || !classId || !title || !startTime || !endTime) {
return res.status(400).json({ error: 'Missing required fields' });
}
// 验证试卷存在且已发布
const exam = await prisma.exam.findUnique({
where: { id: examId, isDeleted: false }
});
if (!exam) {
return res.status(404).json({ error: 'Exam not found' });
try {
const result = await assignmentService.createAssignment(req.userId!, { examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled });
res.json(result);
} catch (e: any) {
if (e.message === 'Exam not found') return res.status(404).json({ error: e.message });
if (e.message === 'You are not a teacher of this class') return res.status(403).json({ error: e.message });
if (e.message.includes('Exam must be published')) return res.status(400).json({ error: e.message });
if (e.message === 'Invalid startTime or endTime') return res.status(400).json({ error: e.message });
if (e.message === 'startTime must be earlier than endTime') return res.status(400).json({ error: e.message });
throw e;
}
if (exam.status !== 'Published') {
return res.status(400).json({ error: 'Exam must be published before creating assignment' });
}
// 验证我是该班级的教师
const membership = await prisma.classMember.findFirst({
where: {
classId,
userId: req.userId!,
roleInClass: 'Teacher',
isDeleted: false
}
});
if (!membership) {
return res.status(403).json({ error: 'You are not a teacher of this class' });
}
// 获取班级所有学生
const students = await prisma.classMember.findMany({
where: {
classId,
roleInClass: 'Student',
isDeleted: false
},
select: { userId: true }
});
// 创建作业
const assignmentId = uuidv4();
const assignment = await prisma.assignment.create({
data: {
id: assignmentId,
examId,
classId,
title,
startTime: new Date(startTime),
endTime: new Date(endTime),
allowLateSubmission: allowLateSubmission ?? false,
autoScoreEnabled: autoScoreEnabled ?? true,
createdBy: req.userId!,
updatedBy: req.userId!
}
});
// 为所有学生创建提交记录
const submissionPromises = students.map(student =>
prisma.studentSubmission.create({
data: {
id: uuidv4(),
assignmentId,
studentId: student.userId,
submissionStatus: 'Pending',
createdBy: req.userId!,
updatedBy: req.userId!
}
})
);
await Promise.all(submissionPromises);
res.json({
id: assignment.id,
title: assignment.title,
message: `Assignment created successfully for ${students.length} students`
});
} catch (error) {
console.error('Create assignment error:', error);
res.status(500).json({ error: 'Failed to create assignment' });
}
};
// PUT /api/assignments/:id
// 更新作业信息(如截止时间)
export const updateAssignment = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const result = await assignmentService.updateAssignment(req.userId!, id, req.body);
res.json(result);
} catch (error: any) {
console.error('Update assignment error:', error);
if (error.message === 'Assignment not found') return res.status(404).json({ error: error.message });
if (error.message === 'You are not a teacher of this class') return res.status(403).json({ error: error.message });
if (error.message === 'startTime must be earlier than endTime') return res.status(400).json({ error: error.message });
res.status(500).json({ error: 'Failed to update assignment' });
}
};
// DELETE /api/assignments/:id
export const deleteAssignment = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const result = await assignmentService.deleteAssignment(req.userId!, id);
res.json(result);
} catch (error: any) {
console.error('Delete assignment error:', error);
if (error.message === 'Assignment not found') return res.status(404).json({ error: error.message });
if (error.message === 'You are not a teacher of this class') return res.status(403).json({ error: error.message });
res.status(500).json({ error: 'Failed to delete assignment' });
}
};
// POST /api/assignments/:id/archive
export const archiveAssignment = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const result = await assignmentService.archiveAssignment(req.userId!, id);
res.json(result);
} catch (error: any) {
console.error('Archive assignment error:', error);
res.status(500).json({ error: 'Failed to archive assignment' });
}
};
// GET /api/assignments/:id/analysis
// 获取作业详细分析(试卷详解 + 数据总览)
export const getAssignmentAnalysis = async (req: AuthRequest, res: Response) => {
try {
const result = await assignmentService.getAssignmentAnalysis(req.userId!, req.params.id);
res.json(result);
} catch (error) {
console.error('Get assignment analysis error:', error);
res.status(500).json({ error: 'Failed to get assignment analysis' });
}
};
// GET /api/assignments/:id/stats
// 获取作业统计信息
export const getAssignmentStats = async (req: AuthRequest, res: Response) => {
try {
const { id: assignmentId } = req.params;
// 验证作业存在
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false },
include: {
class: true
}
});
if (!assignment) {
return res.status(404).json({ error: 'Assignment not found' });
const { id } = req.params;
try {
const result = await assignmentService.getAssignmentStats(req.userId!, id);
res.json(result);
} catch (e: any) {
if (e.message === 'Assignment not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
throw e;
}
// 验证权限(教师)
const isMember = await prisma.classMember.findFirst({
where: {
classId: assignment.classId,
userId: req.userId!,
roleInClass: 'Teacher',
isDeleted: false
}
});
if (!isMember) {
return res.status(403).json({ error: 'Permission denied' });
}
// 统计提交情况
const submissions = await prisma.studentSubmission.findMany({
where: {
assignmentId,
isDeleted: false
},
select: {
submissionStatus: true,
totalScore: true
}
});
const totalCount = submissions.length;
const submittedCount = submissions.filter(s =>
s.submissionStatus === 'Submitted' || s.submissionStatus === 'Graded'
).length;
const gradedCount = submissions.filter(s => s.submissionStatus === 'Graded').length;
// 计算平均分(只统计已批改的)
const gradedScores = submissions
.filter(s => s.submissionStatus === 'Graded' && s.totalScore !== null)
.map(s => Number(s.totalScore));
const averageScore = gradedScores.length > 0
? gradedScores.reduce((sum, score) => sum + score, 0) / gradedScores.length
: 0;
const maxScore = gradedScores.length > 0 ? Math.max(...gradedScores) : 0;
const minScore = gradedScores.length > 0 ? Math.min(...gradedScores) : 0;
res.json({
totalStudents: totalCount,
submittedCount,
gradedCount,
pendingCount: totalCount - submittedCount,
averageScore: Math.round(averageScore * 10) / 10,
maxScore,
minScore,
passRate: 0, // TODO: 需要定义及格线
scoreDistribution: [] // TODO: 可以实现分数段分布
});
} catch (error) {
console.error('Get assignment stats error:', error);
res.status(500).json({ error: 'Failed to get assignment stats' });

View File

@@ -1,18 +1,12 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/prisma';
import { commonService } from '../services/common.service';
// 获取消息列表
export const getMessages = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const messages = await prisma.message.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const messages = await commonService.getMessages(req.userId);
res.json(messages);
} catch (error) {
console.error('Get messages error:', error);
@@ -24,18 +18,14 @@ export const getMessages = async (req: AuthRequest, res: Response) => {
export const markMessageRead = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const userId = req.userId;
const message = await prisma.message.findUnique({ where: { id } });
if (!message) return res.status(404).json({ error: 'Message not found' });
if (message.userId !== userId) return res.status(403).json({ error: 'Forbidden' });
await prisma.message.update({
where: { id },
data: { isRead: true }
});
res.json({ success: true });
try {
const result = await commonService.markMessageRead(req.userId!, id);
res.json(result);
} catch (e: any) {
if (e.message === 'Message not found') return res.status(404).json({ error: e.message });
if (e.message === 'Forbidden') return res.status(403).json({ error: e.message });
throw e;
}
} catch (error) {
console.error('Mark message read error:', error);
res.status(500).json({ error: 'Failed to mark message read' });
@@ -45,26 +35,14 @@ export const markMessageRead = async (req: AuthRequest, res: Response) => {
// 创建消息
export const createMessage = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
const { title, content, type } = req.body;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
if (!title || !content) {
return res.status(400).json({ error: 'Title and content are required' });
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
try {
const message = await commonService.createMessage(req.userId!, req.body);
res.json(message);
} catch (e: any) {
if (e.message === 'Title and content are required') return res.status(400).json({ error: e.message });
throw e;
}
const message = await prisma.message.create({
data: {
userId,
title,
content,
type: type || 'System',
senderName: 'Me',
isRead: false
}
});
res.json(message);
} catch (error) {
console.error('Create message error:', error);
res.status(500).json({ error: 'Failed to create message' });
@@ -74,42 +52,14 @@ export const createMessage = async (req: AuthRequest, res: Response) => {
// 获取日程
export const getSchedule = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
// 获取用户关联的班级
const user = await prisma.applicationUser.findUnique({
where: { id: userId },
include: {
classMemberships: {
include: { class: true }
}
}
});
if (!user) return res.status(404).json({ error: 'User not found' });
const classIds = user.classMemberships.map(cm => cm.classId);
// 获取这些班级的日程
const schedules = await prisma.schedule.findMany({
where: { classId: { in: classIds } },
include: { class: true }
});
const scheduleDtos = schedules.map(s => ({
id: s.id,
startTime: s.startTime,
endTime: s.endTime,
className: s.class.name,
subject: s.subject,
room: s.room || '',
isToday: s.dayOfWeek === new Date().getDay(),
dayOfWeek: s.dayOfWeek,
period: s.period
}));
res.json(scheduleDtos);
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
try {
const data = await commonService.getSchedule(req.userId);
res.json(data);
} catch (e: any) {
if (e.message === 'User not found') return res.status(404).json({ error: e.message });
throw e;
}
} catch (error) {
console.error('Get schedule error:', error);
res.status(500).json({ error: 'Failed to get schedule' });
@@ -118,45 +68,20 @@ export const getSchedule = async (req: AuthRequest, res: Response) => {
// 获取周日程
export const getWeekSchedule = async (req: AuthRequest, res: Response) => {
// 复用 getSchedule 逻辑,因为我们返回了所有日程
return getSchedule(req, res);
};
// 添加日程 (仅教师)
export const addEvent = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
const { subject, className, classId, room, dayOfWeek, period, startTime, endTime } = req.body;
let resolvedClassId: string | null = null;
if (classId) {
const clsById = await prisma.class.findUnique({ where: { id: classId } });
if (!clsById) return res.status(404).json({ error: 'Class not found' });
resolvedClassId = clsById.id;
} else if (className) {
const clsByName = await prisma.class.findFirst({ where: { name: className } });
if (!clsByName) return res.status(404).json({ error: 'Class not found' });
resolvedClassId = clsByName.id;
} else {
return res.status(400).json({ error: 'classId or className is required' });
try {
const result = await commonService.addEvent(req.userId!, req.body);
res.status(201).json(result);
} catch (e: any) {
if (e.message === 'Class not found') return res.status(404).json({ error: e.message });
if (e.message === 'classId or className is required') return res.status(400).json({ error: e.message });
throw e;
}
// 检查权限 (简化:假设所有教师都可以添加)
// 实际应检查是否是该班级的教师
await prisma.schedule.create({
data: {
classId: resolvedClassId!,
subject,
room,
dayOfWeek,
period,
startTime,
endTime
}
});
res.status(201).json({ success: true });
} catch (error) {
console.error('Add event error:', error);
res.status(500).json({ error: 'Failed to add event' });
@@ -167,8 +92,8 @@ export const addEvent = async (req: AuthRequest, res: Response) => {
export const deleteEvent = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
await prisma.schedule.delete({ where: { id } });
res.json({ success: true });
const result = await commonService.deleteEvent(id);
res.json(result);
} catch (error) {
console.error('Delete event error:', error);
res.status(500).json({ error: 'Failed to delete event' });

View File

@@ -0,0 +1,17 @@
import { Request, Response } from 'express';
import { configService } from '../services/config.service';
export const testDbConnection = async (req: Request, res: Response) => {
try {
const { host, port, user, password, database } = req.body || {};
try {
const result = await configService.testDbConnection({ host, port, user, password, database });
return res.json(result);
} catch (e: any) {
if (e.message === 'Missing required fields') return res.status(400).json({ error: e.message });
return res.status(500).json({ error: e.message || 'Connection failed' });
}
} catch (e: any) {
return res.status(500).json({ error: e?.message || 'Connection failed' });
}
};

View File

@@ -1,22 +1,12 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/prisma';
import { curriculumService } from '../services/curriculum.service';
// GET /api/curriculum/subjects
// 获取学科列表
export const getSubjects = async (req: AuthRequest, res: Response) => {
try {
const subjects = await prisma.subject.findMany({
where: { isDeleted: false },
select: {
id: true,
name: true,
code: true,
icon: true
},
orderBy: { name: 'asc' }
});
const subjects = await curriculumService.getSubjects();
res.json(subjects);
} catch (error) {
console.error('Get subjects error:', error);
@@ -30,90 +20,13 @@ export const getSubjects = async (req: AuthRequest, res: Response) => {
export const getTextbookTree = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
// 尝试作为 textbook ID 查找
let textbook = await prisma.textbook.findUnique({
where: { id, isDeleted: false },
include: {
units: {
where: { isDeleted: false },
include: {
lessons: {
where: { isDeleted: false },
include: {
knowledgePoints: {
where: { isDeleted: false },
orderBy: { difficulty: 'asc' }
}
},
orderBy: { sortOrder: 'asc' }
}
},
orderBy: { sortOrder: 'asc' }
}
}
});
// 如果找不到,尝试作为 subject ID 查找第一个教材
if (!textbook) {
textbook = await prisma.textbook.findFirst({
where: { subjectId: id, isDeleted: false },
include: {
units: {
where: { isDeleted: false },
include: {
lessons: {
where: { isDeleted: false },
include: {
knowledgePoints: {
where: { isDeleted: false },
orderBy: { difficulty: 'asc' }
}
},
orderBy: { sortOrder: 'asc' }
}
},
orderBy: { sortOrder: 'asc' }
}
}
});
try {
const result = await curriculumService.getTextbookTree(id);
res.json(result);
} catch (e: any) {
if (e.message === 'Textbook not found') return res.status(404).json({ error: e.message });
throw e;
}
if (!textbook) {
return res.status(404).json({ error: 'Textbook not found' });
}
// 格式化返回数据
const units = textbook.units.map(unit => ({
id: unit.id,
textbookId: unit.textbookId,
name: unit.name,
sortOrder: unit.sortOrder,
lessons: unit.lessons.map(lesson => ({
id: lesson.id,
unitId: lesson.unitId,
name: lesson.name,
sortOrder: lesson.sortOrder,
knowledgePoints: lesson.knowledgePoints.map(kp => ({
id: kp.id,
lessonId: kp.lessonId,
name: kp.name,
difficulty: kp.difficulty,
description: kp.description
}))
}))
}));
res.json({
textbook: {
id: textbook.id,
name: textbook.name,
publisher: textbook.publisher,
versionYear: textbook.versionYear,
coverUrl: textbook.coverUrl
},
units
});
} catch (error) {
console.error('Get textbook tree error:', error);
res.status(500).json({ error: 'Failed to get textbook tree' });
@@ -126,17 +39,7 @@ export const getTextbookTree = async (req: AuthRequest, res: Response) => {
export const getTextbooksBySubject = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const textbooks = await prisma.textbook.findMany({
where: { subjectId: id, isDeleted: false },
select: {
id: true,
name: true,
publisher: true,
versionYear: true,
coverUrl: true
},
orderBy: { name: 'asc' }
});
const textbooks = await curriculumService.getTextbooksBySubject(id);
res.json(textbooks);
} catch (error) {
console.error('Get textbooks error:', error);
@@ -147,21 +50,8 @@ export const getTextbooksBySubject = async (req: AuthRequest, res: Response) =>
// POST /api/curriculum/textbooks
export const createTextbook = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { subjectId, name, publisher, versionYear, coverUrl } = req.body;
const textbook = await prisma.textbook.create({
data: {
subjectId,
name,
publisher,
versionYear,
coverUrl: coverUrl || '',
createdBy: userId,
updatedBy: userId
}
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const textbook = await curriculumService.createTextbook(req.userId!, req.body);
res.json(textbook);
} catch (error) {
console.error('Create textbook error:', error);
@@ -173,11 +63,7 @@ export const createTextbook = async (req: AuthRequest, res: Response) => {
export const updateTextbook = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { name, publisher, versionYear, coverUrl } = req.body;
const textbook = await prisma.textbook.update({
where: { id },
data: { name, publisher, versionYear, coverUrl }
});
const textbook = await curriculumService.updateTextbook(id, req.body);
res.json(textbook);
} catch (error) {
console.error('Update textbook error:', error);
@@ -189,11 +75,8 @@ export const updateTextbook = async (req: AuthRequest, res: Response) => {
export const deleteTextbook = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
await prisma.textbook.update({
where: { id },
data: { isDeleted: true }
});
res.json({ success: true });
const result = await curriculumService.deleteTextbook(id);
res.json(result);
} catch (error) {
console.error('Delete textbook error:', error);
res.status(500).json({ error: 'Failed to delete textbook' });
@@ -205,19 +88,8 @@ export const deleteTextbook = async (req: AuthRequest, res: Response) => {
// POST /api/curriculum/units
export const createUnit = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { textbookId, name, sortOrder } = req.body;
const unit = await prisma.textbookUnit.create({
data: {
textbookId,
name,
sortOrder: sortOrder || 0,
createdBy: userId,
updatedBy: userId
}
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const unit = await curriculumService.createUnit(req.userId!, req.body);
res.json(unit);
} catch (error) {
console.error('Create unit error:', error);
@@ -229,11 +101,7 @@ export const createUnit = async (req: AuthRequest, res: Response) => {
export const updateUnit = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { name, sortOrder } = req.body;
const unit = await prisma.textbookUnit.update({
where: { id },
data: { name, sortOrder }
});
const unit = await curriculumService.updateUnit(id, req.body);
res.json(unit);
} catch (error) {
console.error('Update unit error:', error);
@@ -245,11 +113,8 @@ export const updateUnit = async (req: AuthRequest, res: Response) => {
export const deleteUnit = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
await prisma.textbookUnit.update({
where: { id },
data: { isDeleted: true }
});
res.json({ success: true });
const result = await curriculumService.deleteUnit(id);
res.json(result);
} catch (error) {
console.error('Delete unit error:', error);
res.status(500).json({ error: 'Failed to delete unit' });
@@ -261,19 +126,8 @@ export const deleteUnit = async (req: AuthRequest, res: Response) => {
// POST /api/curriculum/lessons
export const createLesson = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { unitId, name, sortOrder } = req.body;
const lesson = await prisma.textbookLesson.create({
data: {
unitId,
name,
sortOrder: sortOrder || 0,
createdBy: userId,
updatedBy: userId
}
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const lesson = await curriculumService.createLesson(req.userId!, req.body);
res.json(lesson);
} catch (error) {
console.error('Create lesson error:', error);
@@ -285,11 +139,7 @@ export const createLesson = async (req: AuthRequest, res: Response) => {
export const updateLesson = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { name, sortOrder } = req.body;
const lesson = await prisma.textbookLesson.update({
where: { id },
data: { name, sortOrder }
});
const lesson = await curriculumService.updateLesson(id, req.body);
res.json(lesson);
} catch (error) {
console.error('Update lesson error:', error);
@@ -301,11 +151,8 @@ export const updateLesson = async (req: AuthRequest, res: Response) => {
export const deleteLesson = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
await prisma.textbookLesson.update({
where: { id },
data: { isDeleted: true }
});
res.json({ success: true });
const result = await curriculumService.deleteLesson(id);
res.json(result);
} catch (error) {
console.error('Delete lesson error:', error);
res.status(500).json({ error: 'Failed to delete lesson' });
@@ -317,20 +164,8 @@ export const deleteLesson = async (req: AuthRequest, res: Response) => {
// POST /api/curriculum/knowledge-points
export const createKnowledgePoint = async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { lessonId, name, difficulty, description } = req.body;
const point = await prisma.knowledgePoint.create({
data: {
lessonId,
name,
difficulty: difficulty || 1,
description: description || '',
createdBy: userId,
updatedBy: userId
}
});
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
const point = await curriculumService.createKnowledgePoint(req.userId!, req.body);
res.json(point);
} catch (error) {
console.error('Create knowledge point error:', error);
@@ -342,11 +177,7 @@ export const createKnowledgePoint = async (req: AuthRequest, res: Response) => {
export const updateKnowledgePoint = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { name, difficulty, description } = req.body;
const point = await prisma.knowledgePoint.update({
where: { id },
data: { name, difficulty, description }
});
const point = await curriculumService.updateKnowledgePoint(id, req.body);
res.json(point);
} catch (error) {
console.error('Update knowledge point error:', error);
@@ -358,11 +189,8 @@ export const updateKnowledgePoint = async (req: AuthRequest, res: Response) => {
export const deleteKnowledgePoint = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
await prisma.knowledgePoint.update({
where: { id },
data: { isDeleted: true }
});
res.json({ success: true });
const result = await curriculumService.deleteKnowledgePoint(id);
res.json(result);
} catch (error) {
console.error('Delete knowledge point error:', error);
res.status(500).json({ error: 'Failed to delete knowledge point' });

View File

@@ -5,10 +5,14 @@ import { examService } from '../services/exam.service';
// GET /api/exams
export const getExams = async (req: AuthRequest, res: Response) => {
try {
const { subjectId, status } = req.query;
const { subjectId, status, scope, page, pageSize, examType } = req.query;
const result = await examService.getExams(req.userId!, {
subjectId: subjectId as string,
status: status as string
status: status as string,
scope: scope as 'mine' | 'public',
page: page ? Number(page) : 1,
pageSize: pageSize ? Number(pageSize) : 20,
examType: examType as string
});
res.json(result);
} catch (error) {

View File

@@ -1,72 +1,20 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
import { gradingService } from '../services/grading.service';
// GET /api/grading/:assignmentId/list
// 获取作业的所有学生提交列表
export const getSubmissions = async (req: AuthRequest, res: Response) => {
try {
const { assignmentId } = req.params;
// 验证作业存在
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false },
include: { class: true }
});
if (!assignment) {
return res.status(404).json({ error: 'Assignment not found' });
try {
const items = await gradingService.getSubmissions(req.userId!, assignmentId);
res.json(items);
} catch (e: any) {
if (e.message === 'Assignment not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
throw e;
}
// 验证权限(必须是班级教师)
const isMember = await prisma.classMember.findFirst({
where: {
classId: assignment.classId,
userId: req.userId!,
roleInClass: 'Teacher',
isDeleted: false
}
});
if (!isMember) {
return res.status(403).json({ error: 'Permission denied' });
}
// 获取所有提交
const submissions = await prisma.studentSubmission.findMany({
where: {
assignmentId,
isDeleted: false
},
include: {
student: {
select: {
id: true,
realName: true,
studentId: true,
avatarUrl: true
}
}
},
orderBy: [
{ submissionStatus: 'asc' }, // 待批改的在前
{ submitTime: 'desc' }
]
});
// 格式化返回数据
const items = submissions.map(submission => ({
id: submission.id,
studentName: submission.student.realName,
studentId: submission.student.studentId,
avatarUrl: submission.student.avatarUrl,
status: submission.submissionStatus,
score: submission.totalScore ? Number(submission.totalScore) : null,
submitTime: submission.submitTime?.toISOString() || null
}));
res.json(items);
} catch (error) {
console.error('Get submissions error:', error);
res.status(500).json({ error: 'Failed to get submissions' });
@@ -78,109 +26,14 @@ export const getSubmissions = async (req: AuthRequest, res: Response) => {
export const getPaperForGrading = async (req: AuthRequest, res: Response) => {
try {
const { submissionId } = req.params;
// 获取提交记录
const submission = await prisma.studentSubmission.findUnique({
where: { id: submissionId, isDeleted: false },
include: {
student: {
select: {
realName: true,
studentId: true
}
},
assignment: {
include: {
exam: {
include: {
nodes: {
where: { isDeleted: false },
include: {
question: {
include: {
knowledgePoints: {
select: {
knowledgePoint: {
select: { name: true }
}
}
}
}
}
},
orderBy: { sortOrder: 'asc' }
}
}
},
class: true
}
},
details: {
include: {
examNode: {
include: {
question: true
}
}
}
}
}
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found' });
try {
const result = await gradingService.getPaperForGrading(req.userId!, submissionId);
res.json(result);
} catch (e: any) {
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
throw e;
}
// 验证权限
const isMember = await prisma.classMember.findFirst({
where: {
classId: submission.assignment.classId,
userId: req.userId!,
roleInClass: 'Teacher',
isDeleted: false
}
});
if (!isMember) {
return res.status(403).json({ error: 'Permission denied' });
}
// 构建答题详情(包含学生答案和批改信息)
const nodes = submission.assignment.exam.nodes.map(node => {
const detail = submission.details.find(d => d.examNodeId === node.id);
return {
examNodeId: node.id,
questionId: node.questionId,
questionContent: node.question?.content,
questionType: node.question?.questionType,
// 构造完整的 question 对象以供前端使用
question: node.question ? {
id: node.question.id,
content: node.question.content,
type: node.question.questionType,
difficulty: node.question.difficulty,
answer: node.question.answer,
parse: node.question.explanation,
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
} : undefined,
score: Number(node.score),
studentAnswer: detail?.studentAnswer || null,
studentScore: detail?.score ? Number(detail.score) : null,
judgement: detail?.judgement || null,
teacherComment: detail?.teacherComment || null
};
});
res.json({
submissionId: submission.id,
studentName: submission.student.realName,
studentId: submission.student.studentId,
status: submission.submissionStatus,
totalScore: submission.totalScore ? Number(submission.totalScore) : null,
submitTime: submission.submitTime?.toISOString() || null,
nodes
});
} catch (error) {
console.error('Get paper for grading error:', error);
res.status(500).json({ error: 'Failed to get paper' });
@@ -192,114 +45,20 @@ export const getPaperForGrading = async (req: AuthRequest, res: Response) => {
export const submitGrade = async (req: AuthRequest, res: Response) => {
try {
const { submissionId } = req.params;
const { grades } = req.body; // Array of { examNodeId, score, judgement, teacherComment }
if (!grades || !Array.isArray(grades)) {
return res.status(400).json({ error: 'Invalid grades data' });
const { grades } = req.body;
try {
const result = await gradingService.submitGrade(req.userId!, submissionId, grades);
res.json(result);
} catch (e: any) {
if (e.message === 'Invalid grades data') return res.status(400).json({ error: e.message });
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
if (e.message === 'Submission has not been submitted') return res.status(400).json({ error: e.message });
if (e.message === 'Assignment archived') return res.status(400).json({ error: e.message });
if (e.message === 'Invalid exam node') return res.status(400).json({ error: e.message });
if (e.message === 'Cannot grade before deadline') return res.status(400).json({ error: e.message });
throw e;
}
// 获取提交记录
const submission = await prisma.studentSubmission.findUnique({
where: { id: submissionId, isDeleted: false },
include: {
assignment: {
include: {
class: true,
exam: {
include: {
nodes: true
}
}
}
}
}
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found' });
}
// 验证权限
const isMember = await prisma.classMember.findFirst({
where: {
classId: submission.assignment.classId,
userId: req.userId!,
roleInClass: 'Teacher',
isDeleted: false
}
});
if (!isMember) {
return res.status(403).json({ error: 'Permission denied' });
}
// 更新或创建批改详情
const updatePromises = grades.map(async (grade: any) => {
const { examNodeId, score, judgement, teacherComment } = grade;
// 查找或创建 SubmissionDetail
const existingDetail = await prisma.submissionDetail.findFirst({
where: {
submissionId,
examNodeId,
isDeleted: false
}
});
if (existingDetail) {
return prisma.submissionDetail.update({
where: { id: existingDetail.id },
data: {
score,
judgement,
teacherComment,
updatedBy: req.userId!
}
});
} else {
return prisma.submissionDetail.create({
data: {
id: uuidv4(),
submissionId,
examNodeId,
score,
judgement,
teacherComment,
createdBy: req.userId!,
updatedBy: req.userId!
}
});
}
});
await Promise.all(updatePromises);
// 重新计算总分
const allDetails = await prisma.submissionDetail.findMany({
where: {
submissionId,
isDeleted: false
}
});
const totalScore = allDetails.reduce((sum, detail) => {
return sum + (detail.score ? Number(detail.score) : 0);
}, 0);
// 更新提交状态
await prisma.studentSubmission.update({
where: { id: submissionId },
data: {
submissionStatus: 'Graded',
totalScore,
updatedBy: req.userId!
}
});
res.json({
message: 'Grading submitted successfully',
totalScore
});
} catch (error) {
console.error('Submit grade error:', error);
res.status(500).json({ error: 'Failed to submit grading' });

View File

@@ -1,23 +1,11 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
import { generateInviteCode } from '../utils/helpers';
import { orgService } from '../services/org.service';
// GET /api/org/schools
export const getSchools = async (req: AuthRequest, res: Response) => {
try {
const schools = await prisma.school.findMany({
where: { isDeleted: false },
select: {
id: true,
name: true,
regionCode: true,
address: true
},
orderBy: { name: 'asc' }
});
const schools = await orgService.getSchools();
res.json(schools);
} catch (error) {
console.error('Get schools error:', error);
@@ -30,44 +18,7 @@ export const getSchools = async (req: AuthRequest, res: Response) => {
export const getMyClasses = async (req: AuthRequest, res: Response) => {
try {
const { role } = req.query; // 可选:筛选角色
// 通过 ClassMember 关联查询
const memberships = await prisma.classMember.findMany({
where: {
userId: req.userId!,
isDeleted: false,
...(role && { roleInClass: role as any })
},
include: {
class: {
include: {
grade: {
include: {
school: true
}
},
_count: {
select: { members: true }
}
}
}
}
});
// 格式化返回数据
const classes = memberships.map(membership => {
const cls = membership.class;
return {
id: cls.id,
name: cls.name,
gradeName: cls.grade.name,
schoolName: cls.grade.school.name,
inviteCode: cls.inviteCode,
studentCount: cls._count.members,
myRole: membership.roleInClass // 我在这个班级的角色
};
});
const classes = await orgService.getMyClasses(req.userId!, role as string | undefined);
res.json(classes);
} catch (error) {
console.error('Get my classes error:', error);
@@ -84,54 +35,13 @@ export const createClass = async (req: AuthRequest, res: Response) => {
if (!name || !gradeId) {
return res.status(400).json({ error: 'Missing required fields: name, gradeId' });
}
// 验证年级是否存在
const grade = await prisma.grade.findUnique({
where: { id: gradeId, isDeleted: false },
include: { school: true }
});
if (!grade) {
return res.status(404).json({ error: 'Grade not found' });
try {
const result = await orgService.createClass(req.userId!, name, gradeId);
res.json(result);
} catch (e: any) {
if (e.message === 'Grade not found') return res.status(404).json({ error: e.message });
throw e;
}
// 生成唯一邀请码
const inviteCode = await generateInviteCode();
// 创建班级
const classId = uuidv4();
const newClass = await prisma.class.create({
data: {
id: classId,
gradeId,
name,
inviteCode,
headTeacherId: req.userId,
createdBy: req.userId!,
updatedBy: req.userId!
}
});
// 自动将创建者添加为班级教师
await prisma.classMember.create({
data: {
id: uuidv4(),
classId,
userId: req.userId!,
roleInClass: 'Teacher',
createdBy: req.userId!,
updatedBy: req.userId!
}
});
res.json({
id: newClass.id,
name: newClass.name,
gradeName: grade.name,
schoolName: grade.school.name,
inviteCode: newClass.inviteCode,
studentCount: 1 // 当前只有创建者一个成员
});
} catch (error) {
console.error('Create class error:', error);
res.status(500).json({ error: 'Failed to create class' });
@@ -147,55 +57,14 @@ export const joinClass = async (req: AuthRequest, res: Response) => {
if (!inviteCode) {
return res.status(400).json({ error: 'Missing invite code' });
}
// 查找班级
const targetClass = await prisma.class.findUnique({
where: { inviteCode, isDeleted: false },
include: {
grade: {
include: { school: true }
}
}
});
if (!targetClass) {
return res.status(404).json({ error: 'Invalid invite code' });
try {
const result = await orgService.joinClass(req.userId!, inviteCode);
res.json({ message: 'Successfully joined the class', class: result });
} catch (e: any) {
if (e.message === 'Invalid invite code') return res.status(404).json({ error: e.message });
if (e.message === 'You are already a member of this class') return res.status(400).json({ error: e.message });
throw e;
}
// 检查是否已经是班级成员
const existingMember = await prisma.classMember.findFirst({
where: {
classId: targetClass.id,
userId: req.userId!,
isDeleted: false
}
});
if (existingMember) {
return res.status(400).json({ error: 'You are already a member of this class' });
}
// 添加为班级学生
await prisma.classMember.create({
data: {
id: uuidv4(),
classId: targetClass.id,
userId: req.userId!,
roleInClass: 'Student',
createdBy: req.userId!,
updatedBy: req.userId!
}
});
res.json({
message: 'Successfully joined the class',
class: {
id: targetClass.id,
name: targetClass.name,
gradeName: targetClass.grade.name,
schoolName: targetClass.grade.school.name
}
});
} catch (error) {
console.error('Join class error:', error);
res.status(500).json({ error: 'Failed to join class' });
@@ -207,99 +76,14 @@ export const joinClass = async (req: AuthRequest, res: Response) => {
export const getClassMembers = async (req: AuthRequest, res: Response) => {
try {
const { id: classId } = req.params;
// 验证班级存在
const targetClass = await prisma.class.findUnique({
where: { id: classId, isDeleted: false }
});
if (!targetClass) {
return res.status(404).json({ error: 'Class not found' });
try {
const formattedMembers = await orgService.getClassMembers(req.userId!, classId);
res.json(formattedMembers);
} catch (e: any) {
if (e.message === 'Class not found') return res.status(404).json({ error: e.message });
if (e.message === 'You are not a member of this class') return res.status(403).json({ error: e.message });
throw e;
}
// 验证当前用户是否是班级成员
const isMember = await prisma.classMember.findFirst({
where: {
classId,
userId: req.userId!,
isDeleted: false
}
});
if (!isMember) {
return res.status(403).json({ error: 'You are not a member of this class' });
}
const members = await prisma.classMember.findMany({
where: {
classId,
isDeleted: false
},
include: {
user: {
select: {
id: true,
realName: true,
studentId: true,
avatarUrl: true,
gender: true
}
}
},
orderBy: [
{ roleInClass: 'asc' }, // 教师在前
{ createdAt: 'asc' }
]
});
const assignmentsCount = await prisma.assignment.count({
where: { classId }
});
const formattedMembers = await Promise.all(members.map(async member => {
const submissions = await prisma.studentSubmission.findMany({
where: {
studentId: member.user.id,
assignment: { classId }
},
select: {
totalScore: true,
submissionStatus: true,
submitTime: true
},
orderBy: { submitTime: 'desc' },
take: 5
});
const recentTrendRaw = submissions.map(s => s.totalScore ? Number(s.totalScore) : 0);
const recentTrend = recentTrendRaw.concat(Array(Math.max(0, 5 - recentTrendRaw.length)).fill(0)).slice(0,5);
const completedCount = await prisma.studentSubmission.count({
where: {
studentId: member.user.id,
assignment: { classId },
submissionStatus: { in: ['Submitted', 'Graded'] }
}
});
const attendanceRate = assignmentsCount > 0 ? Math.round((completedCount / assignmentsCount) * 100) : 0;
const latestScore = submissions[0]?.totalScore ? Number(submissions[0].totalScore) : null;
const status = latestScore !== null ? (latestScore >= 90 ? 'Excellent' : (latestScore < 60 ? 'AtRisk' : 'Active')) : 'Active';
return {
id: member.user.id,
studentId: member.user.studentId,
realName: member.user.realName,
avatarUrl: member.user.avatarUrl,
gender: member.user.gender,
role: member.roleInClass,
recentTrend,
status,
attendanceRate
};
}));
res.json(formattedMembers);
} catch (error) {
console.error('Get class members error:', error);
res.status(500).json({ error: 'Failed to get class members' });

View File

@@ -1,104 +1,13 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
import { questionService } from '../services/question.service';
// POST /api/questions/search
// 简单的题目搜索(按科目、难度筛选)
export const searchQuestions = async (req: AuthRequest, res: Response) => {
try {
const {
subjectId,
questionType,
difficulty, // exact match (legacy)
difficultyMin,
difficultyMax,
keyword,
createdBy, // 'me' or specific userId
sortBy = 'latest', // 'latest' | 'popular'
page = 1,
pageSize = 10
} = req.body;
const skip = (page - 1) * pageSize;
const where: any = {
isDeleted: false,
...(subjectId && { subjectId }),
...(questionType && { questionType }),
...(keyword && { content: { contains: keyword } }),
};
// Difficulty range
if (difficultyMin || difficultyMax) {
where.difficulty = {};
if (difficultyMin) where.difficulty.gte = difficultyMin;
if (difficultyMax) where.difficulty.lte = difficultyMax;
} else if (difficulty) {
where.difficulty = difficulty;
}
// CreatedBy filter
if (createdBy === 'me') {
where.createdBy = req.userId;
} else if (createdBy) {
where.createdBy = createdBy;
}
// Sorting
let orderBy: any = { createdAt: 'desc' };
if (sortBy === 'popular') {
orderBy = { usageCount: 'desc' }; // Assuming usageCount exists, otherwise fallback to createdAt
}
// 查询题目
const [questions, totalCount] = await Promise.all([
prisma.question.findMany({
where,
select: {
id: true,
content: true,
questionType: true,
difficulty: true,
answer: true,
explanation: true,
createdAt: true,
createdBy: true,
knowledgePoints: {
select: {
knowledgePoint: {
select: {
name: true
}
}
}
}
},
skip,
take: pageSize,
orderBy
}),
prisma.question.count({ where })
]);
// 映射到前端 DTO
const items = questions.map(q => ({
id: q.id,
content: q.content,
type: q.questionType,
difficulty: q.difficulty,
answer: q.answer,
parse: q.explanation,
knowledgePoints: q.knowledgePoints.map(kp => kp.knowledgePoint.name),
isMyQuestion: q.createdBy === req.userId
}));
res.json({
items,
totalCount,
pageIndex: page,
pageSize
});
const result = await questionService.search(req.userId!, req.body);
res.json(result);
} catch (error) {
console.error('Search questions error:', error);
res.status(500).json({ error: 'Failed to search questions' });
@@ -109,36 +18,13 @@ export const searchQuestions = async (req: AuthRequest, res: Response) => {
// 创建题目
export const createQuestion = async (req: AuthRequest, res: Response) => {
try {
const { subjectId, content, questionType, difficulty = 3, answer, explanation, optionsConfig, knowledgePoints } = req.body;
if (!subjectId || !content || !questionType || !answer) {
return res.status(400).json({ error: 'Missing required fields' });
try {
const result = await questionService.create(req.userId!, req.body);
res.json(result);
} catch (e: any) {
if (e.message === 'Missing required fields') return res.status(400).json({ error: e.message });
throw e;
}
const questionId = uuidv4();
// Handle knowledge points connection if provided
// This is a simplified version, ideally we should resolve KP IDs first
const question = await prisma.question.create({
data: {
id: questionId,
subjectId,
content,
questionType,
difficulty,
answer,
explanation,
optionsConfig: optionsConfig || null,
createdBy: req.userId!,
updatedBy: req.userId!
}
});
res.json({
id: question.id,
message: 'Question created successfully'
});
} catch (error) {
console.error('Create question error:', error);
res.status(500).json({ error: 'Failed to create question' });
@@ -149,32 +35,14 @@ export const createQuestion = async (req: AuthRequest, res: Response) => {
export const updateQuestion = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { content, questionType, difficulty, answer, explanation, optionsConfig } = req.body;
const question = await prisma.question.findUnique({ where: { id } });
if (!question) return res.status(404).json({ error: 'Question not found' });
// Only creator can update (or admin)
if (question.createdBy !== req.userId) {
// For now, let's assume strict ownership.
// In real app, check role.
return res.status(403).json({ error: 'Permission denied' });
try {
const result = await questionService.update(req.userId!, id, req.body);
res.json(result);
} catch (e: any) {
if (e.message === 'Question not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
throw e;
}
await prisma.question.update({
where: { id },
data: {
content,
questionType,
difficulty,
answer,
explanation,
optionsConfig: optionsConfig || null,
updatedBy: req.userId!
}
});
res.json({ message: 'Question updated successfully' });
} catch (error) {
console.error('Update question error:', error);
res.status(500).json({ error: 'Failed to update question' });
@@ -185,20 +53,14 @@ export const updateQuestion = async (req: AuthRequest, res: Response) => {
export const deleteQuestion = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const question = await prisma.question.findUnique({ where: { id } });
if (!question) return res.status(404).json({ error: 'Question not found' });
if (question.createdBy !== req.userId) {
return res.status(403).json({ error: 'Permission denied' });
try {
const result = await questionService.softDelete(req.userId!, id);
res.json(result);
} catch (e: any) {
if (e.message === 'Question not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
throw e;
}
await prisma.question.update({
where: { id },
data: { isDeleted: true }
});
res.json({ message: 'Question deleted successfully' });
} catch (error) {
console.error('Delete question error:', error);
res.status(500).json({ error: 'Failed to delete question' });
@@ -208,26 +70,14 @@ export const deleteQuestion = async (req: AuthRequest, res: Response) => {
// POST /api/questions/parse-text
export const parseText = async (req: AuthRequest, res: Response) => {
try {
const { text } = req.body;
if (!text) return res.status(400).json({ error: 'Text is required' });
// 简单的模拟解析逻辑
// 假设每行是一个题目,或者用空行分隔
const questions = text.split(/\n\s*\n/).map((block: string) => {
const lines = block.trim().split('\n');
const content = lines[0];
const options = lines.slice(1).filter((l: string) => /^[A-D]\./.test(l));
return {
content: content,
type: options.length > 0 ? 'SingleChoice' : 'Subjective',
options: options.length > 0 ? options : undefined,
answer: 'A', // 默认答案
parse: '解析暂无'
};
});
res.json(questions);
try {
const { text } = req.body;
const questions = questionService.parseText(text);
res.json(questions);
} catch (e: any) {
if (e.message === 'Text is required') return res.status(400).json({ error: e.message });
throw e;
}
} catch (error) {
console.error('Parse text error:', error);
res.status(500).json({ error: 'Failed to parse text' });

View File

@@ -1,229 +1,75 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
import { calculateRank } from '../utils/helpers';
import { submissionService } from '../services/submission.service';
// GET /api/submissions/:assignmentId/paper
// 学生获取答题卡
export const getStudentPaper = async (req: AuthRequest, res: Response) => {
try {
const { assignmentId } = req.params;
// 获取作业信息
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false },
include: {
exam: {
include: {
nodes: {
where: { isDeleted: false },
include: {
question: {
include: {
knowledgePoints: {
select: {
knowledgePoint: {
select: { name: true }
}
}
}
}
}
},
orderBy: { sortOrder: 'asc' }
}
}
}
}
});
if (!assignment) {
return res.status(404).json({ error: 'Assignment not found' });
try {
const paper = await submissionService.getStudentPaper(req.userId!, assignmentId);
res.json(paper);
} catch (e: any) {
if (e.message === 'Assignment not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
if (e.message === 'Assignment archived') return res.status(400).json({ error: e.message });
if (e.message === 'Assignment has not started yet') return res.status(400).json({ error: e.message });
if (e.message === 'Assignment has ended') return res.status(400).json({ error: e.message });
throw e;
}
// 验证作业时间
const now = new Date();
if (now < assignment.startTime) {
return res.status(400).json({ error: 'Assignment has not started yet' });
}
if (now > assignment.endTime && !assignment.allowLateSubmission) {
return res.status(400).json({ error: 'Assignment has ended' });
}
// 查找或创建学生提交记录
let submission = await prisma.studentSubmission.findFirst({
where: {
assignmentId,
studentId: req.userId!,
isDeleted: false
},
include: {
details: true
}
});
if (!submission) {
// 创建新的提交记录
submission = await prisma.studentSubmission.create({
data: {
id: uuidv4(),
assignmentId,
studentId: req.userId!,
submissionStatus: 'Pending',
createdBy: req.userId!,
updatedBy: req.userId!
},
include: {
details: true
}
});
}
// 构建试卷结构(树形)
const buildTree = (nodes: any[], parentId: string | null = null): any[] => {
return nodes
.filter(node => node.parentNodeId === parentId)
.map(node => {
const detail = submission!.details.find(d => d.examNodeId === node.id);
return {
id: node.id,
nodeType: node.nodeType,
title: node.title,
description: node.description,
questionId: node.questionId,
questionContent: node.question?.content,
questionType: node.question?.questionType,
// 构造完整的 question 对象以供前端使用
question: node.question ? {
id: node.question.id,
content: node.question.content,
type: node.question.questionType,
difficulty: node.question.difficulty,
answer: node.question.answer,
parse: node.question.explanation,
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name),
options: (() => {
const cfg: any = (node as any).question?.optionsConfig;
if (!cfg) return [];
try {
if (Array.isArray(cfg)) return cfg.map((v: any) => String(v));
if (cfg.options && Array.isArray(cfg.options)) return cfg.options.map((v: any) => String(v));
if (typeof cfg === 'object') {
return Object.keys(cfg).sort().map(k => String(cfg[k]));
}
return [];
} catch {
return [];
}
})()
} : undefined,
score: Number(node.score),
sortOrder: node.sortOrder,
studentAnswer: detail?.studentAnswer || null,
children: buildTree(nodes, node.id)
};
});
};
const rootNodes = buildTree(assignment.exam.nodes);
res.json({
examId: assignment.exam.id,
title: assignment.title,
duration: assignment.exam.suggestedDuration,
totalScore: Number(assignment.exam.totalScore),
startTime: assignment.startTime.toISOString(),
endTime: assignment.endTime.toISOString(),
submissionId: submission.id,
status: submission.submissionStatus,
rootNodes
});
} catch (error) {
console.error('Get student paper error:', error);
res.status(500).json({ error: 'Failed to get student paper' });
}
};
// POST /api/submissions/:assignmentId/save
// 学生保存进度(不提交)
export const saveProgress = async (req: AuthRequest, res: Response) => {
try {
const { assignmentId } = req.params;
const { answers } = req.body;
try {
const result = await submissionService.saveProgress(req.userId!, assignmentId, answers);
res.json(result);
} catch (e: any) {
if (e.message === 'Invalid answers data') return res.status(400).json({ error: e.message });
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
if (e.message === 'Assignment has not started yet') return res.status(400).json({ error: e.message });
if (e.message === 'Assignment archived') return res.status(400).json({ error: e.message });
if (e.message === 'Cannot save progress after deadline') return res.status(400).json({ error: e.message });
if (e.message === 'Invalid exam node') return res.status(400).json({ error: e.message });
if (e.message === 'Cannot save progress for submitted assignment') return res.status(400).json({ error: e.message });
throw e;
}
} catch (error) {
console.error('Save progress error:', error);
res.status(500).json({ error: 'Failed to save progress' });
}
};
// POST /api/submissions/:assignmentId/submit
// 学生提交答案
export const submitAnswers = async (req: AuthRequest, res: Response) => {
try {
const { assignmentId } = req.params;
const { answers, timeSpent } = req.body; // answers: Array of { examNodeId, studentAnswer }
if (!answers || !Array.isArray(answers)) {
return res.status(400).json({ error: 'Invalid answers data' });
try {
const result = await submissionService.submitAnswers(req.userId!, assignmentId, answers, timeSpent);
res.json(result);
} catch (e: any) {
if (e.message === 'Invalid answers data') return res.status(400).json({ error: e.message });
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
if (e.message === 'Assignment has not started yet') return res.status(400).json({ error: e.message });
if (e.message === 'Cannot submit after deadline') return res.status(400).json({ error: e.message });
if (e.message === 'Assignment archived') return res.status(400).json({ error: e.message });
if (e.message === 'Invalid exam node') return res.status(400).json({ error: e.message });
if (e.message === 'Already submitted') return res.status(400).json({ error: e.message });
throw e;
}
// 获取提交记录
const submission = await prisma.studentSubmission.findFirst({
where: {
assignmentId,
studentId: req.userId!,
isDeleted: false
}
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found' });
}
// 批量创建/更新答题详情
const updatePromises = answers.map(async (answer: any) => {
const { examNodeId, studentAnswer } = answer;
const existingDetail = await prisma.submissionDetail.findFirst({
where: {
submissionId: submission.id,
examNodeId,
isDeleted: false
}
});
if (existingDetail) {
return prisma.submissionDetail.update({
where: { id: existingDetail.id },
data: {
studentAnswer,
updatedBy: req.userId!
}
});
} else {
return prisma.submissionDetail.create({
data: {
id: uuidv4(),
submissionId: submission.id,
examNodeId,
studentAnswer,
createdBy: req.userId!,
updatedBy: req.userId!
}
});
}
});
await Promise.all(updatePromises);
// 更新提交状态
await prisma.studentSubmission.update({
where: { id: submission.id },
data: {
submissionStatus: 'Submitted',
submitTime: new Date(),
timeSpentSeconds: timeSpent || null,
updatedBy: req.userId!
}
});
// TODO: 如果开启自动批改,这里可以实现自动评分逻辑
res.json({
message: 'Answers submitted successfully',
submissionId: submission.id
});
} catch (error) {
console.error('Submit answers error:', error);
res.status(500).json({ error: 'Failed to submit answers' });
@@ -235,110 +81,14 @@ export const submitAnswers = async (req: AuthRequest, res: Response) => {
export const getSubmissionResult = async (req: AuthRequest, res: Response) => {
try {
const { submissionId } = req.params;
// 获取提交记录
const submission = await prisma.studentSubmission.findUnique({
where: { id: submissionId, isDeleted: false },
include: {
assignment: {
include: {
exam: {
include: {
nodes: {
where: { isDeleted: false },
include: {
question: {
include: {
knowledgePoints: {
select: {
knowledgePoint: {
select: { name: true }
}
}
}
}
}
},
orderBy: { sortOrder: 'asc' }
}
}
}
}
},
details: {
include: {
examNode: {
include: {
question: true
}
}
}
}
}
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found' });
try {
const result = await submissionService.getSubmissionResult(req.userId!, submissionId);
res.json(result);
} catch (e: any) {
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
throw e;
}
// 验证是本人的提交
if (submission.studentId !== req.userId) {
return res.status(403).json({ error: 'Permission denied' });
}
// 如果还没有批改,返回未批改状态
if (submission.submissionStatus !== 'Graded') {
return res.json({
submissionId: submission.id,
status: submission.submissionStatus,
message: 'Your submission has not been graded yet'
});
}
// 计算排名
const totalScore = Number(submission.totalScore || 0);
const { rank, totalStudents, beatRate } = await calculateRank(
submission.assignmentId,
totalScore
);
// 构建答题详情
const nodes = submission.assignment.exam.nodes.map(node => {
const detail = submission.details.find(d => d.examNodeId === node.id);
return {
examNodeId: node.id,
questionId: node.questionId,
questionContent: node.question?.content,
questionType: node.question?.questionType,
// 构造完整的 question 对象以供前端使用
question: node.question ? {
id: node.question.id,
content: node.question.content,
type: node.question.questionType,
difficulty: node.question.difficulty,
answer: node.question.answer,
parse: node.question.explanation,
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
} : undefined,
score: Number(node.score),
studentScore: detail?.score ? Number(detail.score) : null,
studentAnswer: detail?.studentAnswer || null,
autoCheckResult: detail?.judgement === 'Correct',
teacherComment: detail?.teacherComment || null
};
});
res.json({
submissionId: submission.id,
studentName: 'Me', // 学生看自己的结果
totalScore,
rank,
totalStudents,
beatRate,
submitTime: submission.submitTime?.toISOString() || null,
nodes
});
} catch (error) {
console.error('Get submission result error:', error);
res.status(500).json({ error: 'Failed to get submission result' });
@@ -348,90 +98,13 @@ export const getSubmissionResult = async (req: AuthRequest, res: Response) => {
export const getSubmissionResultByAssignment = async (req: AuthRequest, res: Response) => {
try {
const { assignmentId } = req.params;
const submission = await prisma.studentSubmission.findFirst({
where: { assignmentId, studentId: req.userId!, isDeleted: false },
include: {
assignment: {
include: {
exam: {
include: {
nodes: {
where: { isDeleted: false },
include: {
question: {
include: {
knowledgePoints: {
select: { knowledgePoint: { select: { name: true } } }
}
}
}
},
orderBy: { sortOrder: 'asc' }
}
}
}
}
},
details: {
include: {
examNode: { include: { question: true } }
}
}
}
});
if (!submission) {
return res.status(404).json({ error: 'Submission not found' });
try {
const result = await submissionService.getSubmissionResultByAssignment(req.userId!, assignmentId);
res.json(result);
} catch (e: any) {
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
throw e;
}
if (submission.submissionStatus !== 'Graded') {
return res.json({
submissionId: submission.id,
status: submission.submissionStatus,
message: 'Your submission has not been graded yet'
});
}
const totalScore = Number(submission.totalScore || 0);
const { rank, totalStudents, beatRate } = await calculateRank(
submission.assignmentId,
totalScore
);
const nodes = submission.assignment.exam.nodes.map(node => {
const detail = submission.details.find(d => d.examNodeId === node.id);
return {
examNodeId: node.id,
questionId: node.questionId,
questionContent: node.question?.content,
questionType: node.question?.questionType,
question: node.question ? {
id: node.question.id,
content: node.question.content,
type: node.question.questionType,
difficulty: node.question.difficulty,
answer: node.question.answer,
parse: node.question.explanation,
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
} : undefined,
score: Number(node.score),
studentScore: detail?.score ? Number(detail.score) : null,
studentAnswer: detail?.studentAnswer || null,
autoCheckResult: detail?.judgement === 'Correct',
teacherComment: detail?.teacherComment || null
};
});
res.json({
submissionId: submission.id,
studentName: 'Me',
totalScore,
rank,
totalStudents,
beatRate,
submitTime: submission.submitTime?.toISOString() || null,
nodes
});
} catch (error) {
console.error('Get submission result by assignment error:', error);
res.status(500).json({ error: 'Failed to get submission result' });

View File

@@ -5,6 +5,7 @@ import authRoutes from './routes/auth.routes';
import examRoutes from './routes/exam.routes';
import analyticsRoutes from './routes/analytics.routes';
import commonRoutes from './routes/common.routes';
import configRoutes from './routes/config.routes';
import orgRouter from './routes/org.routes';
import curriculumRouter from './routes/curriculum.routes';
import questionRouter from './routes/question.routes';
@@ -16,11 +17,23 @@ import gradingRouter from './routes/grading.routes';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
const PORT = Number(process.env.PORT) || 8081;
const HOST = process.env.HOST || '127.0.0.1';
// 中间件
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
origin: (origin, callback) => {
const allowed = [
process.env.CORS_ORIGIN || '',
'http://127.0.0.1:8080',
'http://localhost:8080',
'http://localhost:3000'
].filter(Boolean);
if (!origin || allowed.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
app.use(express.json());
@@ -34,6 +47,7 @@ app.use((req, res, next) => {
// API路由
app.use('/api/auth', authRoutes);
app.use('/api/config', configRoutes);
app.use('/api/org', orgRouter);
app.use('/api/curriculum', curriculumRouter);
app.use('/api/questions', questionRouter);
@@ -64,8 +78,8 @@ app.use((err: any, req: express.Request, res: express.Response, next: express.Ne
});
// 启动服务器
app.listen(PORT, () => {
console.log(`✅ Server running on http://localhost:${PORT}`);
app.listen(PORT, HOST as any, () => {
console.log(`✅ Server running on http://${HOST}:${PORT}`);
console.log(`📊 Environment: ${process.env.NODE_ENV}`);
console.log(`🔗 CORS enabled for: ${process.env.CORS_ORIGIN}`);
});

View File

@@ -3,6 +3,7 @@ import { authenticate } from '../middleware/auth.middleware';
import {
getClassPerformance,
getStudentGrowth,
getStudentStats,
getRadar,
getStudentRadar,
getScoreDistribution,
@@ -17,6 +18,7 @@ router.use(authenticate);
router.get('/class/performance', getClassPerformance);
router.get('/student/growth', getStudentGrowth);
router.get('/student/stats', getStudentStats);
router.get('/radar', getRadar);
router.get('/student/radar', getStudentRadar);
router.get('/distribution', getScoreDistribution);

View File

@@ -7,6 +7,10 @@ const router = Router();
router.get('/teaching', authenticate, assignmentController.getTeachingAssignments);
router.get('/learning', authenticate, assignmentController.getStudentAssignments);
router.post('/', authenticate, assignmentController.createAssignment);
router.put('/:id', authenticate, assignmentController.updateAssignment);
router.delete('/:id', authenticate, assignmentController.deleteAssignment);
router.post('/:id/archive', authenticate, assignmentController.archiveAssignment);
router.get('/:id/analysis', authenticate, assignmentController.getAssignmentAnalysis);
router.get('/:id/stats', authenticate, assignmentController.getAssignmentStats);
export default router;

View File

@@ -14,18 +14,12 @@ const router = Router();
router.use(authenticate);
// Messages
router.get('/messages', getMessages);
router.post('/messages/:id/read', markMessageRead);
router.post('/messages', createMessage);
// Schedule
router.get('/schedule/week', getWeekSchedule);
router.get('/common/schedule/week', getWeekSchedule);
router.get('/common/schedule', getSchedule); // For realCommonService compatibility
router.post('/schedule', addEvent);
router.delete('/schedule/:id', deleteEvent);
// Compatibility for frontend realScheduleService which posts to /common/schedule
router.get('/common/schedule', getSchedule);
router.post('/common/schedule', addEvent);
router.delete('/common/schedule/:id', deleteEvent);

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
import { testDbConnection } from '../controllers/config.controller';
const router = Router();
router.post('/db', testDbConnection);
export default router;

View File

@@ -6,6 +6,7 @@ const router = Router();
router.get('/:assignmentId/paper', authenticate, submissionController.getStudentPaper);
router.post('/:assignmentId/submit', authenticate, submissionController.submitAnswers);
router.post('/:assignmentId/save', authenticate, submissionController.saveProgress);
router.get('/:submissionId/result', authenticate, submissionController.getSubmissionResult);
router.get('/by-assignment/:assignmentId/result', authenticate, submissionController.getSubmissionResultByAssignment);

View File

@@ -0,0 +1,214 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class AnalyticsService {
async getClassPerformance(userId: string) {
const classes = await prisma.class.findMany({
where: {
OR: [
{ headTeacherId: userId },
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
],
isDeleted: false
},
select: { id: true, name: true }
});
const classIds = classes.map(c => c.id);
const assignments = await prisma.assignment.findMany({
where: { classId: { in: classIds }, isDeleted: false },
orderBy: { endTime: 'desc' },
take: 5,
include: { submissions: { where: { submissionStatus: 'Graded' }, select: { totalScore: true } } }
});
assignments.reverse();
const labels = assignments.map(a => a.title);
const data = assignments.map(a => {
const scores = a.submissions.map(s => Number(s.totalScore));
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0;
return Number(avg.toFixed(1));
});
return {
labels,
datasets: [{ label: '班级平均分', data, borderColor: 'rgb(75, 192, 192)', backgroundColor: 'rgba(75, 192, 192, 0.5)' }]
};
}
async getStudentGrowth(userId: string) {
const submissions = await prisma.studentSubmission.findMany({
where: { studentId: userId, submissionStatus: 'Graded', isDeleted: false },
orderBy: { submitTime: 'desc' },
take: 5,
include: { assignment: true }
});
submissions.reverse();
const labels = submissions.map(s => s.assignment.title);
const myScores = submissions.map(s => Number(s.totalScore));
// 计算对应作业的班级平均分
const avgScores: number[] = [];
for (const s of submissions) {
const graded = await prisma.studentSubmission.findMany({
where: { assignmentId: s.assignmentId, submissionStatus: 'Graded', isDeleted: false },
select: { totalScore: true }
});
const scores = graded.map(g => Number(g.totalScore));
const avg = scores.length > 0 ? Number((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)) : 0;
avgScores.push(avg);
}
return {
labels,
datasets: [
{ label: '我的成绩', data: myScores, borderColor: 'rgb(53, 162, 235)', backgroundColor: 'rgba(53, 162, 235, 0.5)' },
{ label: '班级平均', data: avgScores, borderColor: 'rgb(75, 192, 192)', backgroundColor: 'rgba(75, 192, 192, 0.3)' }
]
};
}
async getStudentStats(userId: string) {
const submissions = await prisma.studentSubmission.findMany({
where: { studentId: userId, isDeleted: false },
select: { submissionStatus: true, totalScore: true, timeSpentSeconds: true }
});
const completed = submissions.filter(s => s.submissionStatus === 'Submitted' || s.submissionStatus === 'Graded').length;
const todo = submissions.filter(s => s.submissionStatus === 'Pending').length;
const gradedSubmissions = submissions.filter(s => s.submissionStatus === 'Graded');
let averageScore = 0;
if (gradedSubmissions.length > 0) {
const sum = gradedSubmissions.reduce((a, b) => a + Number(b.totalScore || 0), 0);
averageScore = Number((sum / gradedSubmissions.length).toFixed(1));
}
const totalSeconds = submissions.reduce((a, b) => a + (b.timeSpentSeconds || 0), 0);
const studyDuration = Math.round(totalSeconds / 3600); // Hours
return { completed, todo, average: averageScore, studyDuration };
}
async getRadar() {
return { indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力','个人表现'], values: [85, 78, 92, 88, 75, 99] };
}
async getStudentRadar() {
return { indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力'], values: [80, 85, 90, 82, 78] };
}
async getScoreDistribution(userId: string) {
const classes = await prisma.class.findMany({
where: {
OR: [
{ headTeacherId: userId },
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
],
isDeleted: false
},
select: { id: true }
});
const classIds = classes.map(c => c.id);
if (classIds.length === 0) return [];
const assignments = await prisma.assignment.findMany({ where: { classId: { in: classIds }, isDeleted: false }, select: { id: true } });
const assignmentIds = assignments.map(a => a.id);
const submissions = await prisma.studentSubmission.findMany({
where: { assignmentId: { in: assignmentIds }, submissionStatus: 'Graded', isDeleted: false },
select: { totalScore: true }
});
const scores = submissions.map(s => Number(s.totalScore));
const distribution = [
{ range: '0-60', count: 0 },
{ range: '60-70', count: 0 },
{ range: '70-80', count: 0 },
{ range: '80-90', count: 0 },
{ range: '90-100', count: 0 }
];
scores.forEach(score => {
if (score < 60) distribution[0].count++;
else if (score < 70) distribution[1].count++;
else if (score < 80) distribution[2].count++;
else if (score < 90) distribution[3].count++;
else distribution[4].count++;
});
return distribution;
}
async getTeacherStats(userId: string) {
const classes = await prisma.class.findMany({
where: {
OR: [
{ headTeacherId: userId },
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
],
isDeleted: false
},
select: { id: true }
});
const classIds = classes.map(c => c.id);
if (classIds.length === 0) {
return { activeStudents: 0, averageScore: 0, pendingGrading: 0, passRate: 0 };
}
const activeStudents = await prisma.classMember.count({ where: { classId: { in: classIds }, roleInClass: 'Student', isDeleted: false } });
const assignments = await prisma.assignment.findMany({ where: { classId: { in: classIds }, isDeleted: false }, select: { id: true } });
const assignmentIds = assignments.map(a => a.id);
const pendingGrading = await prisma.studentSubmission.count({ where: { assignmentId: { in: assignmentIds }, submissionStatus: 'Submitted', isDeleted: false } });
const gradedSubmissions = await prisma.studentSubmission.findMany({ where: { assignmentId: { in: assignmentIds }, submissionStatus: 'Graded', isDeleted: false }, select: { totalScore: true } });
let averageScore = 0;
let passRate = 0;
if (gradedSubmissions.length > 0) {
const scores = gradedSubmissions.map(s => Number(s.totalScore));
const sum = scores.reduce((a, b) => a + b, 0);
averageScore = Number((sum / scores.length).toFixed(1));
const passedCount = scores.filter(score => score >= 60).length;
passRate = Number(((passedCount / scores.length) * 100).toFixed(1));
}
return { activeStudents, averageScore, pendingGrading, passRate };
}
async getExamStats(examId: string) {
const assignments = await prisma.assignment.findMany({ where: { examId, isDeleted: false }, select: { id: true } });
const assignmentIds = assignments.map(a => a.id);
const gradedSubmissions = await prisma.studentSubmission.findMany({ where: { assignmentId: { in: assignmentIds }, submissionStatus: 'Graded', isDeleted: false }, select: { id: true, totalScore: true } });
const scores = gradedSubmissions.map(s => Number(s.totalScore));
const averageScore = scores.length > 0 ? Number((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)) : 0;
const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
const minScore = scores.length > 0 ? Math.min(...scores) : 0;
const passRate = scores.length > 0 ? Number(((scores.filter(s => s >= 60).length / scores.length) * 100).toFixed(1)) : 0;
const distribution = [
{ range: '0-60', count: 0 },
{ range: '60-70', count: 0 },
{ range: '70-80', count: 0 },
{ range: '80-90', count: 0 },
{ range: '90-100', count: 0 }
];
scores.forEach(score => {
if (score < 60) distribution[0].count++;
else if (score < 70) distribution[1].count++;
else if (score < 80) distribution[2].count++;
else if (score < 90) distribution[3].count++;
else distribution[4].count++;
});
const examNodes = await prisma.examNode.findMany({
where: { examId, isDeleted: false },
select: { id: true, questionId: true, question: { select: { content: true, difficulty: true, questionType: true } } }
});
const nodeIds = examNodes.map(n => n.id);
const submissionIds = gradedSubmissions.map(s => s.id);
const details = await prisma.submissionDetail.findMany({ where: { examNodeId: { in: nodeIds }, submissionId: { in: submissionIds }, isDeleted: false }, select: { examNodeId: true, judgement: true } });
const statsMap = new Map<string, { total: number; wrong: number }>();
for (const d of details) {
const s = statsMap.get(d.examNodeId) || { total: 0, wrong: 0 };
s.total += 1;
if (d.judgement === 'Incorrect') s.wrong += 1;
statsMap.set(d.examNodeId, s);
}
const wrongQuestions = examNodes.map(n => {
const s = statsMap.get(n.id) || { total: 0, wrong: 0 };
const errorRate = s.total > 0 ? Math.round((s.wrong / s.total) * 100) : 0;
return { id: n.questionId || n.id, content: n.question?.content || '', errorRate, difficulty: n.question?.difficulty || 0, type: n.question?.questionType || 'Unknown' };
}).sort((a, b) => b.errorRate - a.errorRate).slice(0, 20);
return { averageScore, passRate, maxScore, minScore, scoreDistribution: distribution, wrongQuestions };
}
}
export const analyticsService = new AnalyticsService();

View File

@@ -0,0 +1,483 @@
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
export class AssignmentService {
async getTeachingAssignments(userId: string, filters?: { classId?: string; examType?: string; subjectId?: string; status?: string }) {
const myClasses = await prisma.classMember.findMany({
where: { userId, roleInClass: 'Teacher', isDeleted: false },
select: { classId: true }
});
const myClassIds = myClasses.map(m => m.classId);
if (myClassIds.length === 0) {
return { items: [], totalCount: 0, pageIndex: 1, pageSize: 10 };
}
const where: any = {
classId: { in: myClassIds },
isDeleted: false
};
if (filters?.classId && filters.classId !== 'all') {
where.classId = filters.classId;
}
// For nested exam filters (examType, subjectId), we might need to filter after fetch or use nested where if supported (Prisma supports nested where in findMany for relations, but let's check).
// Prisma supports filtering on relations.
if ((filters?.examType && filters.examType !== 'all') || (filters?.subjectId && filters.subjectId !== 'all')) {
where.exam = {
...(filters.examType && filters.examType !== 'all' ? { examType: filters.examType } : {}),
...(filters.subjectId && filters.subjectId !== 'all' ? { subjectId: filters.subjectId } : {})
};
}
// Status filter logic
// 'Active' -> endTime > now
// 'ToGrade' -> has submissions with status 'Submitted'
// 'Graded' -> endTime < now (Closed) OR maybe manually marked? Let's use endTime for 'Ended'/'Graded' context or just 'Closed'.
// The user asked for "已批改" (Graded), "未批改" (Ungraded), "进行中" (In Progress).
// Let's map:
// - In Progress: endTime > now
// - Ungraded: Has submissions where status = 'Submitted'
// - Graded: All submissions are 'Graded' AND endTime < now? Or just "Completed" tab.
// For simplicity in SQL/Prisma:
// It is hard to filter "has submissions with status Submitted" efficiently in a single where without complex queries.
// Let's fetch and filter in memory for status, or use basic time-based status for DB and refine in memory.
const assignments = await prisma.assignment.findMany({
where,
include: {
exam: { select: { title: true, totalScore: true, examType: true, subject: { select: { name: true } } } },
class: { include: { grade: true } },
_count: { select: { submissions: true } },
submissions: {
select: { id: true, submissionStatus: true }
}
},
orderBy: { createdAt: 'desc' }
});
let items = assignments.map(a => {
const now = new Date();
const isExpired = now > a.endTime;
const isArchived = a.status === 'Archived';
// Determine UI status for Teacher
// Active: Not expired and not archived
// Grading: Expired and not archived
// Ended: Archived
let uiStatus = 'Active';
if (isArchived) {
uiStatus = 'Ended';
} else if (isExpired) {
uiStatus = 'Grading';
}
// Has pending grading: check if any submission is Submitted, regardless of assignment status
// But strictly, teacher only cares about this in Grading phase, but showing it always is fine.
const hasPendingGrading = a.submissions.some(s => s.submissionStatus === 'Submitted');
return {
id: a.id,
title: a.title,
examTitle: a.exam.title,
subjectName: a.exam.subject.name,
examType: a.exam.examType,
className: a.class.name,
gradeName: a.class.grade.name,
submittedCount: a.submissions.filter(s => s.submissionStatus !== 'Pending').length,
totalCount: a._count.submissions,
status: uiStatus,
hasPendingGrading,
dueDate: a.endTime.toISOString(),
createdAt: a.createdAt.toISOString()
};
});
// Apply status filter in memory
if (filters?.status && filters.status !== 'all') {
if (filters.status === 'Active') {
items = items.filter(i => i.status === 'Active');
} else if (filters.status === 'ToGrade') {
items = items.filter(i => i.status === 'Grading' && i.hasPendingGrading);
} else if (filters.status === 'Graded') {
items = items.filter(i => i.status === 'Ended');
}
}
return { items, totalCount: items.length, pageIndex: 1, pageSize: 10 };
}
async getStudentAssignments(userId: string, filters?: { subjectId?: string; examType?: string; status?: string }) {
const where: any = { studentId: userId, isDeleted: false };
// Status filter
if (filters?.status) {
if (filters.status === 'Pending') {
where.submissionStatus = 'Pending';
} else if (filters.status === 'Completed') {
where.submissionStatus = { in: ['Submitted', 'Graded'] };
}
}
const submissions = await prisma.studentSubmission.findMany({
where,
include: {
assignment: {
include: {
exam: {
select: {
title: true,
totalScore: true,
suggestedDuration: true,
examType: true,
subjectId: true,
subject: { select: { name: true } },
_count: { select: { nodes: true } }
}
},
class: {
include: {
grade: true,
members: {
where: { roleInClass: 'Teacher', isDeleted: false },
include: { user: { select: { realName: true } } },
take: 1
}
}
}
}
}
},
orderBy: { createdAt: 'desc' }
});
// Client-side filtering for nested properties if not efficiently filterable in query
let filteredSubmissions = submissions;
if (filters?.subjectId && filters.subjectId !== 'all') {
filteredSubmissions = filteredSubmissions.filter(s => s.assignment.exam.subjectId === filters.subjectId);
}
if (filters?.examType && filters.examType !== 'all') {
filteredSubmissions = filteredSubmissions.filter(s => s.assignment.exam.examType === filters.examType);
}
const items = filteredSubmissions.map(s => {
const now = new Date();
const isExpired = now > s.assignment.endTime;
const isArchived = s.assignment.status === 'Archived';
let status = s.submissionStatus;
// Determine UI Status
let uiStatus: string = status as unknown as string;
const hasSubmitted = status === 'Submitted' || status === 'Graded';
if (isArchived) {
uiStatus = 'Completed';
} else if (isExpired) {
if (s.assignment.allowLateSubmission && !hasSubmitted) {
// If late submission allowed and NOT submitted, keep it Pending/InProgress
// But if user already submitted, then it is Grading (waiting for grade)
if (status === 'Pending' && (s as any).startedAt) {
uiStatus = 'InProgress';
} else {
uiStatus = 'Pending'; // Or 'Late'? But UI handles Pending well.
}
} else {
// Normal flow: Expired -> Grading
uiStatus = 'Grading';
}
} else {
// Not expired
if (status === 'Submitted') {
uiStatus = 'Submitted';
} else if (status === 'Pending') {
if ((s as any).startedAt) {
uiStatus = 'InProgress';
} else {
uiStatus = 'Pending';
}
} else if (status === 'Graded') {
// Auto-graded but not expired -> Submitted (waiting for deadline)
uiStatus = 'Submitted';
}
}
const teacherName = s.assignment.class.members[0]?.user.realName || '';
return {
id: s.assignment.id,
title: s.assignment.title,
examTitle: s.assignment.exam.title,
subjectName: s.assignment.exam.subject.name,
examType: s.assignment.exam.examType,
teacherName: teacherName,
duration: s.assignment.exam.suggestedDuration,
questionCount: s.assignment.exam._count.nodes,
totalScore: s.assignment.exam.totalScore,
className: s.assignment.class.name,
startTime: s.assignment.startTime.toISOString(),
endTime: s.assignment.endTime.toISOString(),
status: uiStatus,
score: s.totalScore ? Number(s.totalScore) : null,
submitTime: s.submitTime?.toISOString() || null,
isSubmitted: hasSubmitted
};
});
return { items, totalCount: items.length, pageIndex: 1, pageSize: 1000 }; // Increased pageSize
}
async createAssignment(userId: string, data: {
examId: string;
classId: string;
title: string;
startTime: string;
endTime: string;
allowLateSubmission?: boolean;
autoScoreEnabled?: boolean;
}) {
const { examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled } = data;
const start = new Date(startTime);
const end = new Date(endTime);
if (isNaN(start.getTime()) || isNaN(end.getTime())) throw new Error('Invalid startTime or endTime');
if (start >= end) throw new Error('startTime must be earlier than endTime');
const exam = await prisma.exam.findUnique({ where: { id: examId, isDeleted: false } });
if (!exam) throw new Error('Exam not found');
if (exam.status !== 'Published') throw new Error('Exam must be published before creating assignment');
const membership = await prisma.classMember.findFirst({
where: { classId, userId, roleInClass: 'Teacher', isDeleted: false }
});
if (!membership) throw new Error('You are not a teacher of this class');
const students = await prisma.classMember.findMany({
where: { classId, roleInClass: 'Student', isDeleted: false },
select: { userId: true }
});
const assignmentId = uuidv4();
const assignment = await prisma.assignment.create({
data: {
id: assignmentId,
examId,
classId,
title,
startTime: new Date(startTime),
endTime: new Date(endTime),
allowLateSubmission: allowLateSubmission ?? false,
autoScoreEnabled: autoScoreEnabled ?? true,
createdBy: userId,
updatedBy: userId
}
});
await Promise.all(students.map(s => prisma.studentSubmission.create({
data: {
id: uuidv4(),
assignmentId,
studentId: s.userId,
submissionStatus: 'Pending',
createdBy: userId,
updatedBy: userId
}
})));
return { id: assignment.id, title: assignment.title, message: `Assignment created successfully for ${students.length} students` };
}
async updateAssignment(userId: string, assignmentId: string, data: {
title?: string;
startTime?: string;
endTime?: string;
allowLateSubmission?: boolean;
autoScoreEnabled?: boolean;
}) {
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false }
});
if (!assignment) throw new Error('Assignment not found');
const membership = await prisma.classMember.findFirst({
where: { classId: assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false }
});
if (!membership) throw new Error('You are not a teacher of this class');
const start = data.startTime ? new Date(data.startTime) : undefined;
const end = data.endTime ? new Date(data.endTime) : undefined;
if (start && end && start >= end) throw new Error('startTime must be earlier than endTime');
const updated = await prisma.assignment.update({
where: { id: assignmentId },
data: {
title: data.title,
startTime: start,
endTime: end,
allowLateSubmission: data.allowLateSubmission,
autoScoreEnabled: data.autoScoreEnabled,
updatedBy: userId
}
});
return updated;
}
async archiveAssignment(userId: string, assignmentId: string) {
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false }
});
if (!assignment) throw new Error('Assignment not found');
const membership = await prisma.classMember.findFirst({
where: { classId: assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false }
});
if (!membership) throw new Error('You are not a teacher of this class');
const updated = await prisma.assignment.update({
where: { id: assignmentId },
data: {
status: 'Archived',
updatedBy: userId
}
});
return updated;
}
async deleteAssignment(userId: string, assignmentId: string) {
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false }
});
if (!assignment) throw new Error('Assignment not found');
const membership = await prisma.classMember.findFirst({
where: { classId: assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false }
});
if (!membership) throw new Error('You are not a teacher of this class');
await prisma.assignment.update({
where: { id: assignmentId },
data: { isDeleted: true, updatedBy: userId }
});
return { message: 'Assignment deleted successfully' };
}
async getAssignmentAnalysis(userId: string, assignmentId: string) {
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false },
include: { class: true, exam: { select: { title: true, totalScore: true } } }
});
if (!assignment) throw new Error('Assignment not found');
const isMember = await prisma.classMember.findFirst({
where: { classId: assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false }
});
if (!isMember) throw new Error('Permission denied');
const submissions = await prisma.studentSubmission.findMany({
where: { assignmentId, isDeleted: false },
include: {
student: { select: { realName: true } },
details: {
where: { isDeleted: false }
}
}
});
const examNodes = await prisma.examNode.findMany({
where: { examId: assignment.examId, nodeType: 'Question', isDeleted: false },
include: {
question: {
include: { knowledgePoints: { include: { knowledgePoint: true } } }
}
},
orderBy: { sortOrder: 'asc' }
});
const questionStats = examNodes.map(node => {
const details = submissions.flatMap(s => s.details.filter(d => d.examNodeId === node.id));
const totalAnswers = details.length;
const errorDetails = details.filter(d => d.judgement === 'Incorrect');
const errorCount = errorDetails.length;
return {
id: node.id,
title: node.title || node.question?.content || 'Question',
questionId: node.questionId,
score: Number(node.score),
totalAnswers,
errorCount,
errorRate: totalAnswers > 0 ? Math.round((errorCount / totalAnswers) * 100) : 0,
correctAnswer: node.question?.answer || '',
knowledgePoints: node.question?.knowledgePoints.map(kp => kp.knowledgePoint.name) || [],
wrongSubmissions: errorDetails.map(d => {
const sub = submissions.find(s => s.id === d.submissionId);
return {
studentName: sub?.student.realName || 'Unknown',
studentAnswer: d.studentAnswer || ''
};
})
};
});
// Overall Stats
const totalStudents = submissions.length;
const submittedCount = submissions.filter(s => s.submissionStatus === 'Submitted' || s.submissionStatus === 'Graded').length;
const gradedScores = submissions.filter(s => s.submissionStatus === 'Graded' && s.totalScore !== null).map(s => Number(s.totalScore));
const averageScore = gradedScores.length > 0 ? gradedScores.reduce((sum, score) => sum + score, 0) / gradedScores.length : 0;
// Knowledge Point Analysis
const kpStats: Record<string, { total: number, wrong: number }> = {};
questionStats.forEach(q => {
q.knowledgePoints.forEach(kp => {
if (!kpStats[kp]) kpStats[kp] = { total: 0, wrong: 0 };
kpStats[kp].total += q.totalAnswers;
kpStats[kp].wrong += q.errorCount;
});
});
const knowledgePointAnalysis = Object.entries(kpStats).map(([name, stats]) => ({
name,
errorRate: stats.total > 0 ? Math.round((stats.wrong / stats.total) * 100) : 0
}));
return {
overview: {
title: assignment.title,
examTitle: assignment.exam.title,
totalStudents,
submittedCount,
averageScore: Math.round(averageScore * 10) / 10,
maxScore: gradedScores.length > 0 ? Math.max(...gradedScores) : 0,
minScore: gradedScores.length > 0 ? Math.min(...gradedScores) : 0,
},
questions: questionStats,
knowledgePoints: knowledgePointAnalysis
};
}
async getAssignmentStats(userId: string, assignmentId: string) {
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false },
include: { class: true }
});
if (!assignment) throw new Error('Assignment not found');
const isMember = await prisma.classMember.findFirst({
where: { classId: assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false }
});
if (!isMember) throw new Error('Permission denied');
const submissions = await prisma.studentSubmission.findMany({
where: { assignmentId, isDeleted: false },
select: { submissionStatus: true, totalScore: true }
});
const totalCount = submissions.length;
const submittedCount = submissions.filter(s => s.submissionStatus === 'Submitted' || s.submissionStatus === 'Graded').length;
const gradedScores = submissions.filter(s => s.submissionStatus === 'Graded' && s.totalScore !== null).map(s => Number(s.totalScore));
const averageScore = gradedScores.length > 0 ? gradedScores.reduce((sum, score) => sum + score, 0) / gradedScores.length : 0;
const maxScore = gradedScores.length > 0 ? Math.max(...gradedScores) : 0;
const minScore = gradedScores.length > 0 ? Math.min(...gradedScores) : 0;
return {
totalStudents: totalCount,
submittedCount,
gradedCount: submissions.filter(s => s.submissionStatus === 'Graded').length,
pendingCount: totalCount - submittedCount,
averageScore: Math.round(averageScore * 10) / 10,
maxScore,
minScore,
passRate: 0,
scoreDistribution: []
};
}
}
export const assignmentService = new AssignmentService();

View File

@@ -0,0 +1,80 @@
import prisma from '../utils/prisma';
export class CommonService {
async getMessages(userId: string) {
return prisma.message.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } });
}
async markMessageRead(userId: string, id: string) {
const message = await prisma.message.findUnique({ where: { id } });
if (!message) throw new Error('Message not found');
if (message.userId !== userId) throw new Error('Forbidden');
await prisma.message.update({ where: { id }, data: { isRead: true } });
return { success: true };
}
async createMessage(userId: string, data: { title: string; content: string; type?: string }) {
const { title, content, type } = data;
if (!title || !content) throw new Error('Title and content are required');
return prisma.message.create({
data: { userId, title, content, type: type || 'System', senderName: 'Me', isRead: false }
});
}
async getSchedule(userId: string) {
const user = await prisma.applicationUser.findUnique({
where: { id: userId },
include: { classMemberships: { include: { class: true } } }
});
if (!user) throw new Error('User not found');
const classIds = user.classMemberships.map(cm => cm.classId);
const schedules = await prisma.schedule.findMany({ where: { classId: { in: classIds } }, include: { class: true } });
return schedules.map(s => ({
id: s.id,
startTime: s.startTime,
endTime: s.endTime,
className: s.class.name,
subject: s.subject,
room: s.room || '',
isToday: s.dayOfWeek === new Date().getDay(),
dayOfWeek: s.dayOfWeek,
period: s.period
}));
}
async addEvent(userId: string, data: {
subject: string;
className?: string;
classId?: string;
room?: string;
dayOfWeek: number;
period: number;
startTime: string;
endTime: string;
}) {
const { subject, className, classId, room, dayOfWeek, period, startTime, endTime } = data;
let resolvedClassId: string | null = null;
if (classId) {
const clsById = await prisma.class.findUnique({ where: { id: classId } });
if (!clsById) throw new Error('Class not found');
resolvedClassId = clsById.id;
} else if (className) {
const clsByName = await prisma.class.findFirst({ where: { name: className } });
if (!clsByName) throw new Error('Class not found');
resolvedClassId = clsByName.id;
} else {
throw new Error('classId or className is required');
}
await prisma.schedule.create({
data: { classId: resolvedClassId!, subject, room, dayOfWeek, period, startTime, endTime }
});
return { success: true };
}
async deleteEvent(id: string) {
await prisma.schedule.delete({ where: { id } });
return { success: true };
}
}
export const commonService = new CommonService();

View File

@@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client';
export class ConfigService {
async testDbConnection(params: { host: string; port?: number; user: string; password?: string; database: string }) {
const { host, port, user, password, database } = params;
if (!host || !user || !database) throw new Error('Missing required fields');
const dsn = `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password || '')}@${host}:${Number(port) || 3306}/${database}`;
const client = new PrismaClient({ datasources: { db: { url: dsn } } });
try {
await client.$connect();
await client.$disconnect();
return { message: 'Connection successful' };
} catch (err: any) {
await client.$disconnect().catch(() => {});
throw new Error(err?.message || 'Connection failed');
}
}
}
export const configService = new ConfigService();

View File

@@ -0,0 +1,121 @@
import prisma from '../utils/prisma';
export class CurriculumService {
async getSubjects() {
return prisma.subject.findMany({ where: { isDeleted: false }, select: { id: true, name: true, code: true, icon: true }, orderBy: { name: 'asc' } });
}
async getTextbookTree(id: string) {
let textbook = await prisma.textbook.findUnique({
where: { id, isDeleted: false },
include: {
units: {
where: { isDeleted: false },
include: {
lessons: {
where: { isDeleted: false },
include: { knowledgePoints: { where: { isDeleted: false }, orderBy: { difficulty: 'asc' } } },
orderBy: { sortOrder: 'asc' }
}
},
orderBy: { sortOrder: 'asc' }
}
}
});
if (!textbook) {
textbook = await prisma.textbook.findFirst({
where: { subjectId: id, isDeleted: false },
include: {
units: {
where: { isDeleted: false },
include: {
lessons: {
where: { isDeleted: false },
include: { knowledgePoints: { where: { isDeleted: false }, orderBy: { difficulty: 'asc' } } },
orderBy: { sortOrder: 'asc' }
}
},
orderBy: { sortOrder: 'asc' }
}
}
});
}
if (!textbook) throw new Error('Textbook not found');
const units = textbook.units.map(unit => ({
id: unit.id,
textbookId: unit.textbookId,
name: unit.name,
sortOrder: unit.sortOrder,
lessons: unit.lessons.map(lesson => ({
id: lesson.id,
unitId: lesson.unitId,
name: lesson.name,
sortOrder: lesson.sortOrder,
knowledgePoints: lesson.knowledgePoints.map(kp => ({ id: kp.id, lessonId: kp.lessonId, name: kp.name, difficulty: kp.difficulty, description: kp.description }))
}))
}));
return { textbook: { id: textbook.id, name: textbook.name, publisher: textbook.publisher, versionYear: textbook.versionYear, coverUrl: textbook.coverUrl }, units };
}
async getTextbooksBySubject(subjectId: string) {
return prisma.textbook.findMany({ where: { subjectId, isDeleted: false }, select: { id: true, name: true, publisher: true, versionYear: true, coverUrl: true }, orderBy: { name: 'asc' } });
}
async createTextbook(userId: string, data: { subjectId: string; name: string; publisher: string; versionYear: string; coverUrl?: string }) {
const { subjectId, name, publisher, versionYear, coverUrl } = data;
return prisma.textbook.create({ data: { subjectId, name, publisher, versionYear, coverUrl: coverUrl || '', createdBy: userId, updatedBy: userId } });
}
async updateTextbook(id: string, data: { name?: string; publisher?: string; versionYear?: string; coverUrl?: string }) {
return prisma.textbook.update({ where: { id }, data });
}
async deleteTextbook(id: string) {
await prisma.textbook.update({ where: { id }, data: { isDeleted: true } });
return { success: true };
}
async createUnit(userId: string, data: { textbookId: string; name: string; sortOrder?: number }) {
const { textbookId, name, sortOrder } = data;
return prisma.textbookUnit.create({ data: { textbookId, name, sortOrder: sortOrder || 0, createdBy: userId, updatedBy: userId } });
}
async updateUnit(id: string, data: { name?: string; sortOrder?: number }) {
return prisma.textbookUnit.update({ where: { id }, data });
}
async deleteUnit(id: string) {
await prisma.textbookUnit.update({ where: { id }, data: { isDeleted: true } });
return { success: true };
}
async createLesson(userId: string, data: { unitId: string; name: string; sortOrder?: number }) {
const { unitId, name, sortOrder } = data;
return prisma.textbookLesson.create({ data: { unitId, name, sortOrder: sortOrder || 0, createdBy: userId, updatedBy: userId } });
}
async updateLesson(id: string, data: { name?: string; sortOrder?: number }) {
return prisma.textbookLesson.update({ where: { id }, data });
}
async deleteLesson(id: string) {
await prisma.textbookLesson.update({ where: { id }, data: { isDeleted: true } });
return { success: true };
}
async createKnowledgePoint(userId: string, data: { lessonId: string; name: string; difficulty?: number; description?: string }) {
const { lessonId, name, difficulty, description } = data;
return prisma.knowledgePoint.create({ data: { lessonId, name, difficulty: difficulty || 1, description: description || '', createdBy: userId, updatedBy: userId } });
}
async updateKnowledgePoint(id: string, data: { name?: string; difficulty?: number; description?: string }) {
return prisma.knowledgePoint.update({ where: { id }, data });
}
async deleteKnowledgePoint(id: string) {
await prisma.knowledgePoint.update({ where: { id }, data: { isDeleted: true } });
return { success: true };
}
}
export const curriculumService = new CurriculumService();

View File

@@ -2,30 +2,54 @@ import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
export class ExamService {
async getExams(userId: string, query: { subjectId?: string; status?: string }) {
const { subjectId, status } = query;
async getExams(userId: string, query: { subjectId?: string; status?: string; scope?: 'mine' | 'public'; page?: number; pageSize?: number; examType?: string }) {
const { subjectId, status, scope = 'mine', page = 1, pageSize = 20, examType } = query;
const exams = await prisma.exam.findMany({
where: {
createdBy: userId,
isDeleted: false,
...(subjectId && { subjectId }),
...(status && { status: status as any })
},
select: {
id: true,
title: true,
subjectId: true,
totalScore: true,
suggestedDuration: true,
status: true,
createdAt: true,
_count: {
select: { nodes: true }
}
},
orderBy: { createdAt: 'desc' }
});
const where: any = {
isDeleted: false,
...(subjectId && { subjectId }),
...(status && { status: status as any }),
...(examType && { examType })
};
if (scope === 'mine') {
where.createdBy = userId;
} else {
where.status = 'Published';
// Optionally exclude own exams from public list if desired, or keep them
}
const skip = (page - 1) * pageSize;
const [exams, totalCount] = await Promise.all([
prisma.exam.findMany({
where,
select: {
id: true,
title: true,
subjectId: true,
totalScore: true,
suggestedDuration: true,
status: true,
createdAt: true,
createdBy: true,
examType: true,
creator: {
select: { realName: true }
},
_count: {
select: {
nodes: true,
assignments: true // Count usage
}
}
},
orderBy: { createdAt: 'desc' },
skip,
take: Number(pageSize)
}),
prisma.exam.count({ where })
]);
const result = exams.map(exam => ({
id: exam.id,
@@ -34,15 +58,19 @@ export class ExamService {
totalScore: Number(exam.totalScore),
duration: exam.suggestedDuration,
questionCount: exam._count.nodes,
usageCount: exam._count.assignments,
status: exam.status,
createdAt: exam.createdAt.toISOString()
createdAt: exam.createdAt.toISOString(),
creatorName: exam.creator.realName,
isMyExam: exam.createdBy === userId,
examType: exam.examType
}));
return {
items: result,
totalCount: result.length,
pageIndex: 1,
pageSize: result.length
totalCount,
pageIndex: Number(page),
pageSize: Number(pageSize)
};
}
@@ -277,13 +305,25 @@ export class ExamService {
// Create new nodes recursively
const createNodes = async (nodes: any[], parentId: string | null = null) => {
for (const node of nodes) {
// Ensure node.id is a valid UUID if provided, otherwise generate one
// The frontend might send temporary IDs like "node-123456" which are not valid UUIDs
// We should always generate new UUIDs for the database to avoid format errors
// BUT we need to map the old IDs to new IDs to maintain parent-child relationships if we were doing it in parallel,
// but here we do it sequentially and pass the new parentId down.
// HOWEVER, if the frontend sends an ID, it expects that ID to persist?
// No, the frontend re-fetches or updates state.
// Actually, 'node.id' from frontend might be 'node-171...' which is not UUID.
// So we MUST generate a new UUID.
const dbId = uuidv4();
const newNode = await prisma.examNode.create({
data: {
id: node.id || uuidv4(),
id: dbId,
examId: id,
parentNodeId: parentId,
nodeType: node.nodeType,
questionId: node.questionId,
questionId: node.questionId?.startsWith('temp-') ? null : node.questionId, // Handle temp IDs if any
title: node.title,
description: node.description,
score: node.score,
@@ -303,6 +343,18 @@ export class ExamService {
await createNodes(rootNodes);
}
// Recalculate total score
const allNodes = await prisma.examNode.findMany({
where: { examId: id, nodeType: 'Question' },
select: { score: true }
});
const totalScore = allNodes.reduce((sum, n) => sum + Number(n.score), 0);
await prisma.exam.update({
where: { id },
data: { totalScore }
});
return { message: 'Exam structure updated' };
}
}

View File

@@ -0,0 +1,68 @@
import prisma from '../utils/prisma';
import { $Enums } from '@prisma/client';
export class GradingService {
async getSubmissions(userId: string, assignmentId: string) {
const assignment = await prisma.assignment.findUnique({ where: { id: assignmentId, isDeleted: false }, include: { class: true } });
if (!assignment) throw new Error('Assignment not found');
const isMember = await prisma.classMember.findFirst({ where: { classId: assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false } });
if (!isMember) throw new Error('Permission denied');
const submissions = await prisma.studentSubmission.findMany({ where: { assignmentId, isDeleted: false }, include: { student: { select: { id: true, realName: true, studentId: true, avatarUrl: true } } }, orderBy: [{ submissionStatus: 'asc' }, { submitTime: 'desc' }] });
return submissions.map(s => ({ id: s.id, studentName: s.student.realName, studentId: s.student.studentId, avatarUrl: s.student.avatarUrl, status: s.submissionStatus, score: s.totalScore ? Number(s.totalScore) : null, submitTime: s.submitTime?.toISOString() || null }));
}
async getPaperForGrading(userId: string, submissionId: string) {
const submission = await prisma.studentSubmission.findUnique({ where: { id: submissionId, isDeleted: false }, include: { student: { select: { realName: true, studentId: true } }, assignment: { include: { exam: { include: { nodes: { where: { isDeleted: false }, include: { question: { include: { knowledgePoints: { select: { knowledgePoint: { select: { name: true } } } } } } }, orderBy: { sortOrder: 'asc' } } } }, class: true } }, details: { include: { examNode: { include: { question: true } } } } } });
if (!submission) throw new Error('Submission not found');
const isMember = await prisma.classMember.findFirst({ where: { classId: submission.assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false } });
if (!isMember) throw new Error('Permission denied');
const nodes = submission.assignment.exam.nodes.map(node => {
const detail = submission.details.find(d => d.examNodeId === node.id);
return { examNodeId: node.id, questionId: node.questionId, questionContent: node.question?.content, questionType: node.question?.questionType, question: node.question ? { id: node.question.id, content: node.question.content, type: node.question.questionType, difficulty: node.question.difficulty, answer: node.question.answer, parse: node.question.explanation, knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name) } : undefined, score: Number(node.score), studentAnswer: detail?.studentAnswer || null, studentScore: detail?.score ? Number(detail.score) : null, judgement: detail?.judgement || null, teacherComment: detail?.teacherComment || null };
});
return { submissionId: submission.id, studentName: submission.student.realName, studentId: submission.student.studentId, status: submission.submissionStatus, totalScore: submission.totalScore ? Number(submission.totalScore) : null, submitTime: submission.submitTime?.toISOString() || null, nodes };
}
async submitGrade(userId: string, submissionId: string, grades: Array<{ examNodeId: string; score?: number; judgement?: string; teacherComment?: string }>) {
if (!grades || !Array.isArray(grades)) throw new Error('Invalid grades data');
const submission = await prisma.studentSubmission.findUnique({ where: { id: submissionId, isDeleted: false }, include: { assignment: { include: { class: true, exam: { include: { nodes: true } } } } } });
if (!submission) throw new Error('Submission not found');
const isMember = await prisma.classMember.findFirst({ where: { classId: submission.assignment.classId, userId, roleInClass: 'Teacher', isDeleted: false } });
if (!isMember) throw new Error('Permission denied');
if (submission.submissionStatus === 'Pending') throw new Error('Submission has not been submitted');
if (submission.assignment.status === 'Archived') throw new Error('Assignment archived');
// Teacher can only grade after the deadline (Grading Phase)
const now = new Date();
if (now <= submission.assignment.endTime) throw new Error('Cannot grade before deadline');
const allowedSet = new Set(submission.assignment.exam.nodes.filter(n => n.nodeType === 'Question').map(n => n.id));
for (const g of grades) {
if (!allowedSet.has(g.examNodeId)) throw new Error('Invalid exam node');
}
const updatePromises = grades.map(async g => {
const existingDetail = await prisma.submissionDetail.findFirst({ where: { submissionId, examNodeId: g.examNodeId, isDeleted: false } });
if (existingDetail) {
return prisma.submissionDetail.update({ where: { id: existingDetail.id }, data: { score: g.score, judgement: g.judgement ? (g.judgement as $Enums.JudgementResult) : null, teacherComment: g.teacherComment, updatedBy: userId } });
} else {
return prisma.submissionDetail.create({ data: { submissionId, examNodeId: g.examNodeId, score: g.score, judgement: g.judgement ? (g.judgement as $Enums.JudgementResult) : null, teacherComment: g.teacherComment, createdBy: userId, updatedBy: userId } });
}
});
await Promise.all(updatePromises);
const allDetails = await prisma.submissionDetail.findMany({ where: { submissionId, isDeleted: false } });
const totalScore = allDetails.reduce((sum, d) => sum + (d.score ? Number(d.score) : 0), 0);
// Ensure status is updated to 'Graded'
await prisma.studentSubmission.update({
where: { id: submissionId },
data: {
submissionStatus: 'Graded',
totalScore,
updatedBy: userId
}
});
return { message: 'Grading submitted successfully', totalScore };
}
}
export const gradingService = new GradingService();

View File

@@ -0,0 +1,103 @@
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
import { generateInviteCode } from '../utils/helpers';
export class OrgService {
async getSchools() {
return prisma.school.findMany({
where: { isDeleted: false },
select: { id: true, name: true, regionCode: true, address: true },
orderBy: { name: 'asc' }
});
}
async getMyClasses(userId: string, role?: string) {
const memberships = await prisma.classMember.findMany({
where: { userId, isDeleted: false, ...(role && { roleInClass: role as any }) },
include: {
class: {
include: {
grade: { include: { school: true } },
_count: { select: { members: true } }
}
}
}
});
return memberships.map(m => ({
id: m.class.id,
name: m.class.name,
gradeName: m.class.grade.name,
schoolName: m.class.grade.school.name,
inviteCode: m.class.inviteCode,
studentCount: m.class._count.members,
myRole: m.roleInClass
}));
}
async createClass(userId: string, name: string, gradeId: string) {
const grade = await prisma.grade.findUnique({ where: { id: gradeId, isDeleted: false }, include: { school: true } });
if (!grade) throw new Error('Grade not found');
const inviteCode = await generateInviteCode();
const classId = uuidv4();
const newClass = await prisma.class.create({
data: { id: classId, gradeId, name, inviteCode, headTeacherId: userId, createdBy: userId, updatedBy: userId }
});
await prisma.classMember.create({
data: { id: uuidv4(), classId, userId, roleInClass: 'Teacher', createdBy: userId, updatedBy: userId }
});
return { id: newClass.id, name: newClass.name, gradeName: grade.name, schoolName: grade.school.name, inviteCode: newClass.inviteCode, studentCount: 1 };
}
async joinClass(userId: string, inviteCode: string) {
const targetClass = await prisma.class.findUnique({ where: { inviteCode, isDeleted: false }, include: { grade: { include: { school: true } } } });
if (!targetClass) throw new Error('Invalid invite code');
const existingMember = await prisma.classMember.findFirst({ where: { classId: targetClass.id, userId, isDeleted: false } });
if (existingMember) throw new Error('You are already a member of this class');
await prisma.classMember.create({ data: { id: uuidv4(), classId: targetClass.id, userId, roleInClass: 'Student', createdBy: userId, updatedBy: userId } });
// Auto-assign existing assignments to the new student
const assignments = await prisma.assignment.findMany({
where: { classId: targetClass.id, isDeleted: false }
});
if (assignments.length > 0) {
const submissionData = assignments.map(a => ({
id: uuidv4(),
assignmentId: a.id,
studentId: userId,
submissionStatus: 'Pending' as const,
createdBy: userId,
updatedBy: userId
}));
// Use createMany for efficiency
await prisma.studentSubmission.createMany({
data: submissionData
});
}
return { id: targetClass.id, name: targetClass.name, gradeName: targetClass.grade.name, schoolName: targetClass.grade.school.name };
}
async getClassMembers(userId: string, classId: string) {
const targetClass = await prisma.class.findUnique({ where: { id: classId, isDeleted: false } });
if (!targetClass) throw new Error('Class not found');
const isMember = await prisma.classMember.findFirst({ where: { classId, userId, isDeleted: false } });
if (!isMember) throw new Error('You are not a member of this class');
const members = await prisma.classMember.findMany({ where: { classId, isDeleted: false }, include: { user: { select: { id: true, realName: true, studentId: true, avatarUrl: true, gender: true } } }, orderBy: [{ roleInClass: 'asc' }, { createdAt: 'asc' }] });
const assignmentsCount = await prisma.assignment.count({ where: { classId } });
const formattedMembers = await Promise.all(members.map(async m => {
const submissions = await prisma.studentSubmission.findMany({ where: { studentId: m.user.id, assignment: { classId } }, select: { totalScore: true, submissionStatus: true, submitTime: true }, orderBy: { submitTime: 'desc' }, take: 5 });
const recentTrendRaw = submissions.map(s => s.totalScore ? Number(s.totalScore) : 0);
const recentTrend = recentTrendRaw.concat(Array(Math.max(0, 5 - recentTrendRaw.length)).fill(0)).slice(0, 5);
const completedCount = await prisma.studentSubmission.count({ where: { studentId: m.user.id, assignment: { classId }, submissionStatus: { in: ['Submitted', 'Graded'] } } });
const attendanceRate = assignmentsCount > 0 ? Math.round((completedCount / assignmentsCount) * 100) : 0;
const latestScore = submissions[0]?.totalScore ? Number(submissions[0].totalScore) : null;
const status = latestScore !== null ? (latestScore >= 90 ? 'Excellent' : (latestScore < 60 ? 'AtRisk' : 'Active')) : 'Active';
return { id: m.user.id, studentId: m.user.studentId, realName: m.user.realName, avatarUrl: m.user.avatarUrl, gender: m.user.gender, role: m.roleInClass, recentTrend, status, attendanceRate };
}));
return formattedMembers;
}
}
export const orgService = new OrgService();

View File

@@ -0,0 +1,131 @@
import prisma from '../utils/prisma';
import { $Enums } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
export class QuestionService {
async search(userId: string, params: {
subjectId?: string;
questionType?: string;
difficulty?: number;
difficultyMin?: number;
difficultyMax?: number;
keyword?: string;
createdBy?: string; // 'me' or specific userId
sortBy?: 'latest' | 'popular';
page?: number;
pageSize?: number;
}) {
const {
subjectId,
questionType,
difficulty,
difficultyMin,
difficultyMax,
keyword,
createdBy,
sortBy = 'latest',
page = 1,
pageSize = 10
} = params;
const skip = (page - 1) * pageSize;
const where: any = {
isDeleted: false,
...(subjectId && { subjectId }),
...(questionType && { questionType }),
...(keyword && { content: { contains: keyword } })
};
if (difficultyMin || difficultyMax) {
where.difficulty = {};
if (difficultyMin) where.difficulty.gte = difficultyMin;
if (difficultyMax) where.difficulty.lte = difficultyMax;
} else if (typeof difficulty === 'number') {
where.difficulty = difficulty;
}
if (createdBy === 'me') where.createdBy = userId;
else if (createdBy) where.createdBy = createdBy;
let orderBy: any = { createdAt: 'desc' };
if (sortBy === 'popular') orderBy = { usageCount: 'desc' };
const [questions, totalCount] = await Promise.all([
prisma.question.findMany({
where,
select: {
id: true,
content: true,
questionType: true,
difficulty: true,
answer: true,
explanation: true,
createdAt: true,
createdBy: true,
knowledgePoints: { select: { knowledgePoint: { select: { name: true } } } }
},
skip,
take: pageSize,
orderBy
}),
prisma.question.count({ where })
]);
const items = questions.map(q => ({
id: q.id,
content: q.content,
type: q.questionType,
difficulty: q.difficulty,
answer: q.answer,
parse: q.explanation,
knowledgePoints: q.knowledgePoints.map(kp => kp.knowledgePoint.name),
isMyQuestion: q.createdBy === userId
}));
return { items, totalCount, pageIndex: page, pageSize };
}
async create(userId: string, data: { subjectId: string; content: string; questionType: string; difficulty?: number; answer: string; explanation?: string; optionsConfig?: any }) {
const { subjectId, content, questionType, difficulty = 3, answer, explanation, optionsConfig } = data;
if (!subjectId || !content || !questionType || !answer) throw new Error('Missing required fields');
const questionId = uuidv4();
const question = await prisma.question.create({
data: { id: questionId, subjectId, content, questionType: questionType as $Enums.QuestionType, difficulty, answer, explanation, optionsConfig: optionsConfig || null, createdBy: userId, updatedBy: userId }
});
return { id: question.id, message: 'Question created successfully' };
}
async update(userId: string, id: string, data: { content?: string; questionType?: string; difficulty?: number; answer?: string; explanation?: string; optionsConfig?: any }) {
const question = await prisma.question.findUnique({ where: { id } });
if (!question) throw new Error('Question not found');
if (question.createdBy !== userId) throw new Error('Permission denied');
const { questionType: qt, ...rest } = data;
await prisma.question.update({ where: { id }, data: { ...rest, ...(qt && { questionType: qt as $Enums.QuestionType }), optionsConfig: data.optionsConfig || null, updatedBy: userId } });
return { message: 'Question updated successfully' };
}
async softDelete(userId: string, id: string) {
const question = await prisma.question.findUnique({ where: { id } });
if (!question) throw new Error('Question not found');
if (question.createdBy !== userId) throw new Error('Permission denied');
await prisma.question.update({ where: { id }, data: { isDeleted: true } });
return { message: 'Question deleted successfully' };
}
parseText(text: string) {
if (!text) throw new Error('Text is required');
const questions = text.split(/\n\s*\n/).map(block => {
const lines = block.trim().split('\n');
const content = lines[0];
const options = lines.slice(1).filter(l => /^[A-D]\./.test(l));
return {
content,
type: options.length > 0 ? 'SingleChoice' : 'Subjective',
options: options.length > 0 ? options : undefined,
answer: 'A',
parse: '解析暂无'
};
});
return questions;
}
}
export const questionService = new QuestionService();

View File

@@ -0,0 +1,476 @@
import prisma from '../utils/prisma';
import { v4 as uuidv4 } from 'uuid';
import { calculateRank } from '../utils/helpers';
import { isClassMember } from '../utils/helpers';
export class SubmissionService {
async getStudentPaper(userId: string, assignmentId: string) {
const assignment = await prisma.assignment.findUnique({
where: { id: assignmentId, isDeleted: false },
include: {
exam: {
include: {
nodes: {
where: { isDeleted: false },
include: {
question: {
include: {
knowledgePoints: { select: { knowledgePoint: { select: { name: true } } } }
}
}
},
orderBy: { sortOrder: 'asc' }
}
}
}
}
});
if (!assignment) throw new Error('Assignment not found');
if (assignment.status === 'Archived') throw new Error('Assignment archived');
const member = await isClassMember(userId, assignment.classId);
if (!member) throw new Error('Permission denied');
const now = new Date();
if (now < assignment.startTime) throw new Error('Assignment has not started yet');
if (now > assignment.endTime && !assignment.allowLateSubmission) throw new Error('Assignment has ended');
let submission = await prisma.studentSubmission.findFirst({
where: { assignmentId, studentId: userId, isDeleted: false },
include: { details: true }
});
if (!submission) {
submission = await prisma.studentSubmission.create({
data: { id: uuidv4(), assignmentId, studentId: userId, submissionStatus: 'Pending', createdBy: userId, updatedBy: userId },
include: { details: true }
});
}
// If not started, mark as started
if (!submission.startedAt) {
await prisma.studentSubmission.update({
where: { id: submission.id },
data: { startedAt: new Date(), updatedBy: userId }
});
}
const buildTree = (nodes: any[], parentId: string | null = null): any[] => {
return nodes
.filter((node) => node.parentNodeId === parentId)
.map((node) => {
const detail = submission!.details.find((d) => d.examNodeId === node.id);
return {
id: node.id,
nodeType: node.nodeType,
title: node.title,
description: node.description,
questionId: node.questionId,
questionContent: node.question?.content,
questionType: node.question?.questionType,
question: node.question
? {
id: node.question.id,
content: node.question.content,
type: node.question.questionType,
difficulty: node.question.difficulty,
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name),
options: (() => {
const cfg: any = (node as any).question?.optionsConfig;
if (!cfg) return [];
try {
if (Array.isArray(cfg)) return cfg.map((v: any) => String(v));
if (cfg.options && Array.isArray(cfg.options)) return cfg.options.map((v: any) => String(v));
if (typeof cfg === 'object') {
return Object.keys(cfg)
.sort()
.map((k) => String(cfg[k]));
}
return [];
} catch {
return [];
}
})()
}
: undefined,
score: Number(node.score),
sortOrder: node.sortOrder,
studentAnswer: detail?.studentAnswer || null,
children: buildTree(nodes, node.id)
};
});
};
const rootNodes = buildTree(assignment.exam.nodes);
return {
examId: assignment.exam.id,
title: assignment.title,
duration: assignment.exam.suggestedDuration,
totalScore: Number(assignment.exam.totalScore),
startTime: assignment.startTime.toISOString(),
endTime: assignment.endTime.toISOString(),
submissionId: submission.id,
status: submission.submissionStatus,
rootNodes
};
}
async saveProgress(
userId: string,
assignmentId: string,
answers: Array<{ examNodeId: string; studentAnswer: any }>
) {
if (!answers || !Array.isArray(answers)) throw new Error('Invalid answers data');
const submission = await prisma.studentSubmission.findFirst({
where: { assignmentId, studentId: userId, isDeleted: false },
include: { assignment: true }
});
if (!submission) throw new Error('Submission not found');
if (submission.assignment.status === 'Archived') throw new Error('Assignment archived');
const member = await isClassMember(userId, submission.assignment.classId);
if (!member) throw new Error('Permission denied');
const now = new Date();
if (now < submission.assignment.startTime) throw new Error('Assignment has not started yet');
if (now > submission.assignment.endTime && !submission.assignment.allowLateSubmission) throw new Error('Cannot save progress after deadline');
const allowedNodeIds = await prisma.examNode.findMany({ where: { examId: submission.assignment.examId, isDeleted: false }, select: { id: true } });
const allowedSet = new Set(allowedNodeIds.map(n => n.id));
for (const a of answers) {
if (!allowedSet.has(a.examNodeId)) throw new Error('Invalid exam node');
}
// Cannot save if already submitted or graded
if (submission.submissionStatus !== 'Pending') {
throw new Error('Cannot save progress for submitted assignment');
}
const updatePromises = answers.map(async (answer) => {
const { examNodeId, studentAnswer } = answer;
const existingDetail = await prisma.submissionDetail.findFirst({
where: { submissionId: submission!.id, examNodeId, isDeleted: false }
});
if (existingDetail) {
return prisma.submissionDetail.update({
where: { id: existingDetail.id },
data: { studentAnswer, updatedBy: userId }
});
} else {
return prisma.submissionDetail.create({
data: { id: uuidv4(), submissionId: submission!.id, examNodeId, studentAnswer, createdBy: userId, updatedBy: userId }
});
}
});
await Promise.all(updatePromises);
await prisma.studentSubmission.update({
where: { id: submission.id },
data: { updatedBy: userId }
});
return { message: 'Progress saved successfully' };
}
async submitAnswers(
userId: string,
assignmentId: string,
answers: Array<{ examNodeId: string; studentAnswer: any }>,
timeSpent?: number
) {
if (!answers || !Array.isArray(answers)) throw new Error('Invalid answers data');
// Fetch submission with exam nodes and questions for auto-grading
const submission = await prisma.studentSubmission.findFirst({
where: { assignmentId, studentId: userId, isDeleted: false },
include: {
assignment: {
include: {
exam: {
include: {
nodes: {
include: { question: true }
}
}
}
}
}
}
});
if (!submission) throw new Error('Submission not found');
if (submission.assignment.status === 'Archived') throw new Error('Assignment archived');
const member = await isClassMember(userId, submission.assignment.classId);
if (!member) throw new Error('Permission denied');
const now = new Date();
if (now < submission.assignment.startTime) throw new Error('Assignment has not started yet');
if (now > submission.assignment.endTime && !submission.assignment.allowLateSubmission) throw new Error('Cannot submit after deadline');
if (submission.submissionStatus !== 'Pending') throw new Error('Already submitted');
const allowedNodes = submission.assignment.exam.nodes;
const allowedSet = new Set(allowedNodes.map(n => n.id));
for (const a of answers) {
if (!allowedSet.has(a.examNodeId)) throw new Error('Invalid exam node');
}
// Auto-grading logic
let allQuestionsGraded = true;
// Check if there are any questions in the exam. If no questions, it is effectively graded.
const questionNodes = allowedNodes.filter(n => n.nodeType === 'Question');
if (questionNodes.length === 0) allQuestionsGraded = true;
const updatePromises = answers.map(async (answer) => {
const { examNodeId, studentAnswer } = answer;
const node = allowedNodes.find(n => n.id === examNodeId);
let score: number | undefined = undefined;
let judgement: any = undefined; // JudgementResult
// Perform auto-grading if enabled and question exists
if (submission.assignment.autoScoreEnabled && node && node.question) {
const qType = node.question.questionType;
const standardAnswer = node.question.answer || '';
const maxScore = Number(node.score);
const studAnsStr = String(studentAnswer || '').trim();
// Simple auto-grading for objective questions
if (qType === 'SingleChoice' || qType === 'TrueFalse') {
if (studAnsStr === standardAnswer.trim()) {
score = maxScore;
judgement = 'Correct';
} else {
score = 0;
judgement = 'Incorrect';
}
} else if (qType === 'MultipleChoice') {
// Normalize by sorting comma-separated values
// e.g. "A,B" vs "B, A"
const normalize = (s: string) => s.split(/[,]/).map(x => x.trim()).filter(x => x).sort().join(',');
if (normalize(studAnsStr) === normalize(standardAnswer)) {
score = maxScore;
judgement = 'Correct';
} else {
score = 0;
judgement = 'Incorrect';
}
} else {
// Subjective or complex types require manual grading
// If any question cannot be auto-graded, the whole submission is not fully graded
allQuestionsGraded = false;
}
} else if (node && node.nodeType === 'Question') {
// If auto-score disabled or no question data, assume manual
allQuestionsGraded = false;
}
const existingDetail = await prisma.submissionDetail.findFirst({
where: { submissionId: submission!.id, examNodeId, isDeleted: false }
});
const data = {
studentAnswer: String(studentAnswer), // Ensure string
score: score, // specific score for this update
judgement: judgement,
updatedBy: userId
};
if (existingDetail) {
return prisma.submissionDetail.update({
where: { id: existingDetail.id },
data: data
});
} else {
return prisma.submissionDetail.create({
data: {
id: uuidv4(),
submissionId: submission!.id,
examNodeId,
...data,
createdBy: userId
}
});
}
});
await Promise.all(updatePromises);
// Check if we missed any questions (unanswered questions)
// If the student didn't answer a question, it won't be in 'answers'.
// We need to check if all questions in the exam have a corresponding detail with a score.
// However, the loop above only processes submitted answers.
// If 'allQuestionsGraded' is true so far (meaning all SUBMITTED answers were auto-graded),
// we still need to check if there are any questions that were NOT submitted.
// But usually, frontend sends all answers (even empty).
// Let's check the database for completeness to be sure?
// For simplicity/performance, we rely on the flag 'allQuestionsGraded' derived from input.
// BUT, if there are unsubmitted questions, they remain ungraded.
// So strictly, we should count graded details vs total questions.
// Let's calculate total score
const allDetails = await prisma.submissionDetail.findMany({
where: { submissionId: submission.id, isDeleted: false }
});
const calculatedTotalScore = allDetails.reduce((acc, curr) => acc + (Number(curr.score) || 0), 0);
// Determine final status
// If all questions are objective AND auto-score enabled, we can set to Graded.
// But we also need to ensure all questions were covered.
// If the loop set allQuestionsGraded = false, then it's Submitted.
// Also need to check if we have details for all questions?
// If a student leaves a SingleChoice blank, it should be graded as 0.
// If it's not in 'answers', it won't be created/updated.
// So we might have missing details.
// A robust implementation would fill in missing details as incorrect.
// For now, let's just stick to: if we encountered any non-auto-gradable question, status is Submitted.
// Otherwise, Graded.
const finalStatus = allQuestionsGraded ? 'Graded' : 'Submitted';
await prisma.studentSubmission.update({
where: { id: submission.id },
data: {
submissionStatus: finalStatus,
submitTime: new Date(),
timeSpentSeconds: timeSpent || null,
totalScore: calculatedTotalScore,
updatedBy: userId
}
});
return { message: 'Answers submitted successfully', submissionId: submission.id };
}
async getSubmissionResult(userId: string, submissionId: string) {
const submission = await prisma.studentSubmission.findUnique({
where: { id: submissionId, isDeleted: false },
include: {
student: { select: { realName: true } },
assignment: {
include: {
exam: {
include: {
nodes: {
where: { isDeleted: false },
include: { question: { include: { knowledgePoints: { select: { knowledgePoint: { select: { name: true } } } } } } },
orderBy: { sortOrder: 'asc' }
}
}
}
}
},
details: { include: { examNode: { include: { question: true } } } }
}
});
if (!submission) throw new Error('Submission not found');
if (submission.studentId !== userId) throw new Error('Permission denied');
// Strictly allow viewing ONLY if assignment is Archived (Ended)
if (submission.assignment.status !== 'Archived') {
return { submissionId: submission.id, status: submission.submissionStatus, message: 'Results not published yet' };
}
if (submission.submissionStatus !== 'Graded') {
return { submissionId: submission.id, status: submission.submissionStatus, message: 'Your submission has not been graded yet' };
}
const totalScore = Number(submission.totalScore || 0);
const { rank, totalStudents, beatRate } = await calculateRank(submission.assignmentId, totalScore);
const nodes = submission.assignment.exam.nodes.map((node) => {
const detail = submission.details.find((d) => d.examNodeId === node.id);
return {
examNodeId: node.id,
questionId: node.questionId,
questionContent: node.question?.content,
questionType: node.question?.questionType,
question: node.question
? {
id: node.question.id,
content: node.question.content,
type: node.question.questionType,
difficulty: node.question.difficulty,
answer: node.question.answer,
parse: node.question.explanation,
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
}
: undefined,
score: Number(node.score),
studentScore: detail?.score ? Number(detail.score) : null,
studentAnswer: detail?.studentAnswer || null,
autoCheckResult: detail?.judgement === 'Correct',
teacherComment: detail?.teacherComment || null
};
});
return {
submissionId: submission.id,
studentName: submission.student.realName,
totalScore,
rank,
totalStudents,
beatRate,
submitTime: submission.submitTime?.toISOString() || null,
nodes
};
}
async getSubmissionResultByAssignment(userId: string, assignmentId: string) {
const submission = await prisma.studentSubmission.findFirst({
where: { assignmentId, studentId: userId, isDeleted: false },
include: {
student: { select: { realName: true } },
assignment: {
include: {
exam: {
include: {
nodes: {
where: { isDeleted: false },
include: { question: { include: { knowledgePoints: { select: { knowledgePoint: { select: { name: true } } } } } } },
orderBy: { sortOrder: 'asc' }
}
}
}
}
},
details: { include: { examNode: { include: { question: true } } } }
}
});
if (!submission) throw new Error('Submission not found');
if (submission.assignment.status !== 'Archived') {
return { submissionId: submission.id, status: submission.submissionStatus, message: 'Results not published yet' };
}
if (submission.submissionStatus !== 'Graded') {
return { submissionId: submission.id, status: submission.submissionStatus, message: 'Your submission has not been graded yet' };
}
const totalScore = Number(submission.totalScore || 0);
const { rank, totalStudents, beatRate } = await calculateRank(submission.assignmentId, totalScore);
const nodes = submission.assignment.exam.nodes.map((node) => {
const detail = submission.details.find((d) => d.examNodeId === node.id);
return {
examNodeId: node.id,
questionId: node.questionId,
questionContent: node.question?.content,
questionType: node.question?.questionType,
question: node.question
? {
id: node.question.id,
content: node.question.content,
type: node.question.questionType,
difficulty: node.question.difficulty,
answer: node.question.answer,
parse: node.question.explanation,
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
}
: undefined,
score: Number(node.score),
studentScore: detail?.score ? Number(detail.score) : null,
studentAnswer: detail?.studentAnswer || null,
autoCheckResult: detail?.judgement === 'Correct',
teacherComment: detail?.teacherComment || null
};
});
return {
submissionId: submission.id,
studentName: submission.student.realName,
totalScore,
rank,
totalStudents,
beatRate,
submitTime: submission.submitTime?.toISOString() || null,
nodes
};
}
}
export const submissionService = new SubmissionService();