Compare commits

1 Commits
main ... dev

63 changed files with 8478 additions and 3694 deletions

5
.eslintignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
dist
backend/dist
backend/node_modules

17
.eslintrc.json Normal file
View File

@@ -0,0 +1,17 @@
{
"root": true,
"extends": ["next/core-web-vitals"],
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "module",
"project": ["./tsconfig.json"],
"tsconfigRootDir": "."
},
"ignorePatterns": [
"node_modules/",
".next/",
"backend/dist/",
"backend/node_modules/",
"dist/"
]
}

View File

@@ -50,14 +50,12 @@ export interface ApplicationUser extends BaseEntity {
/** 头像URL */
avatarUrl: string;
/** 性别Male | Female | Unknown */
gender: 'Male' | 'Female' | 'Unknown';
gender: 'Male' | 'Female';
/** 当前所属学校ID冗余字段用于快速确定用户主要归属 */
currentSchoolId: string;
/** 账号状态Active | Disabled */
accountStatus: 'Active' | 'Disabled';
accountStatus: 'Active' | 'Suspended' | 'Graduated';
/** 邮箱(可选) */
email?: string;
@@ -419,8 +417,7 @@ export interface SubmissionDetail extends BaseEntity {
*/
export enum Gender {
Male = 'Male',
Female = 'Female',
Unknown = 'Unknown'
Female = 'Female'
}
/**
@@ -428,7 +425,8 @@ export enum Gender {
*/
export enum AccountStatus {
Active = 'Active',
Disabled = 'Disabled'
Suspended = 'Suspended',
Graduated = 'Graduated'
}
/**

View File

@@ -246,8 +246,12 @@ export interface ExamDto {
totalScore: number;
duration: number; // 建议时长(分钟)
questionCount: number; // 总题数
usageCount?: number;
status: 'Draft' | 'Published';
createdAt: string;
creatorName?: string;
isMyExam?: boolean;
examType?: string;
}
/**
@@ -273,6 +277,9 @@ export interface ExamNodeDto {
// === 递归子节点 ===
children?: ExamNodeDto[]; // 子节点(支持无限嵌套)
// === 学生作答(用于恢复进度) ===
studentAnswer?: any;
}
/**
@@ -304,24 +311,67 @@ export interface ExamStatsDto {
// 6. Assignment / 作业
// ============================================================
export interface AssignmentAnalysisDto {
overview: {
title: string;
examTitle: string;
totalStudents: number;
submittedCount: number;
averageScore: number;
maxScore: number;
minScore: number;
};
questions: {
id: string;
title: string;
questionId: string | null;
score: number;
totalAnswers: number;
errorCount: number;
errorRate: number;
correctAnswer: string;
knowledgePoints: string[];
wrongSubmissions: {
studentName: string;
studentAnswer: string;
}[];
}[];
knowledgePoints: {
name: string;
errorRate: number;
}[];
}
export interface AssignmentTeacherViewDto {
id: string;
title: string;
examTitle: string;
subjectName?: string;
examType?: string;
className: string;
gradeName: string;
submittedCount: number;
totalCount: number;
status: 'Active' | 'Ended' | 'Scheduled';
status: string; // 'Active' | 'Closed' | 'Ended'
hasPendingGrading?: boolean;
dueDate: string;
examTitle: string;
createdAt: string;
}
export interface AssignmentStudentViewDto {
id: string;
title: string;
examTitle: string;
subjectName?: string;
teacherName?: string;
duration?: number;
questionCount?: number;
totalScore?: number;
className?: string;
endTime: string;
status: 'Pending' | 'Graded' | 'Submitted';
status: 'Pending' | 'InProgress' | 'Submitted' | 'Grading' | 'Completed';
score?: number;
isSubmitted?: boolean; // Helper flag to distinguish between 'Grading' (expired but not submitted) vs (submitted)
}
// ============================================================

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,20 +22,21 @@
"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",

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,6 +404,7 @@ 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)
@@ -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();

4407
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,19 @@
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -H 127.0.0.1 -p 8080",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.0",
"framer-motion": "^11.0.24",
"lucide-react": "^0.368.0",
"next": "14.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.368.0",
"framer-motion": "^11.0.24",
"recharts": "^2.12.4",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.2"
},
"devDependencies": {
@@ -23,6 +23,8 @@
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.22",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.3"

View File

@@ -0,0 +1,21 @@
"use client";
import React from 'react';
import { AssignmentAnalysis } from '@/features/assignment/components/AssignmentAnalysis';
import { useRouter, useSearchParams } from 'next/navigation';
export default function AssignmentAnalysisPage({ params }: { params: { id: string } }) {
const router = useRouter();
const searchParams = useSearchParams();
const tab = searchParams.get('tab') as 'overview' | 'details' | null;
return (
<div className="max-w-7xl mx-auto">
<AssignmentAnalysis
assignmentId={params.id}
onBack={() => router.push('/assignments')}
initialTab={tab || 'details'}
/>
</div>
);
}

View File

@@ -3,18 +3,16 @@
import React, { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { TeacherAssignmentList } from '@/features/assignment/components/TeacherAssignmentList';
import { StudentAssignmentList } from '@/features/assignment/components/StudentAssignmentList';
import { CreateAssignmentModal } from '@/features/assignment/components/CreateAssignmentModal';
import { AssignmentStats } from '@/features/assignment/components/AssignmentStats';
import { useRouter } from 'next/navigation';
export default function AssignmentsPage() {
const { user } = useAuth();
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const [analyzingId, setAnalyzingId] = useState<string | null>(null);
if (!user) return null;
@@ -26,45 +24,31 @@ export default function AssignmentsPage() {
};
const handleNavigateToPreview = (id: string) => {
router.push(`/student-exam/${id}`);
// Navigate to the new Analysis/Preview page
router.push(`/assignments/${id}/analysis?tab=details`);
};
const handleAnalyze = (id: string) => {
router.push(`/assignments/${id}/analysis?tab=overview`);
};
const handleViewResult = (id: string) => {
router.push(`/student-result/${id}`);
};
if (analyzingId && !isStudent) {
return (
// Fix: cast props to any to avoid framer-motion type errors
<motion.div
{...({
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 }
} as any)}
className="h-[calc(100vh-100px)]"
>
<AssignmentStats
assignmentId={analyzingId}
onBack={() => setAnalyzingId(null)}
/>
</motion.div>
);
}
return (
<>
<div className="space-y-6">
{isStudent ? (
<StudentAssignmentList
onStartExam={handleNavigateToPreview}
onStartExam={(id) => router.push(`/student-exam/${id}`)}
onViewResult={handleViewResult}
/>
) : (
<TeacherAssignmentList
onNavigateToGrading={handleNavigateToGrading}
onNavigateToPreview={handleNavigateToPreview}
onAnalyze={setAnalyzingId}
onAnalyze={handleAnalyze}
setIsCreating={setIsCreating}
/>
)}

View File

@@ -1,48 +1,15 @@
import { NextRequest } from 'next/server';
import { successResponse, errorResponse, dbDelay } from '@/lib/server-utils';
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
export async function POST(request: NextRequest) {
await dbDelay();
try {
const body = await request.json();
const { username, password } = body;
// Simple mock validation
if (!username) {
return errorResponse('Username is required');
}
let role = 'Teacher';
let name = '李明';
let id = 'u-tea-1';
if (username === 'student' || username.startsWith('s')) {
role = 'Student';
name = '王小明';
id = 'u-stu-1';
} else if (username === 'admin') {
role = 'Admin';
name = '系统管理员';
id = 'u-adm-1';
}
const token = `mock-jwt-token-${id}-${Date.now()}`;
return successResponse({
token,
user: {
id,
realName: name,
studentId: username,
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${name}`,
gender: 'Male',
schoolId: 's-1',
role
}
});
} catch (e) {
return errorResponse('Invalid request body');
}
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@@ -1,28 +1,14 @@
import { NextRequest } from 'next/server';
import { successResponse, errorResponse, extractToken, dbDelay } from '@/lib/server-utils';
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
export async function GET(request: NextRequest) {
await dbDelay();
const token = extractToken(request);
if (!token) {
return errorResponse('Unauthorized', 401);
}
// In a real app, verify JWT here.
// For mock, we return a default user or parse the mock token if it contained info.
return successResponse({
id: "u-1",
realName: "李明 (Real API)",
studentId: "T2024001",
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alex",
gender: "Male",
schoolId: "s-1",
role: "Teacher",
email: 'liming@school.edu',
phone: '13800138000',
bio: '来自真实 API 的数据'
const authHeader = request.headers.get('authorization') || '';
const res = await fetch(`${API_BASE_URL}/auth/me`, {
method: 'GET',
headers: { Authorization: authHeader }
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@@ -1,27 +1,15 @@
import { NextRequest } from 'next/server';
import { successResponse, errorResponse } from '@/lib/server-utils';
import { db } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { host, port, user, password, database } = body;
if (!host || !user) {
return errorResponse('Missing required fields');
}
await db.testConnection({
host,
port: Number(port),
user,
password,
database
});
return successResponse({ message: 'Connection successful' });
} catch (e: any) {
return errorResponse(e.message || 'Connection failed', 500);
}
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/config/db`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@@ -1,23 +1,14 @@
import { NextRequest } from 'next/server';
import { successResponse, dbDelay } from '@/lib/server-utils';
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
export async function GET(request: NextRequest) {
await dbDelay();
const { searchParams } = new URL(request.url);
const role = searchParams.get('role');
let classes = [
{ id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' },
{ id: 'c-2', name: '高一 (12) 班', gradeName: '高一年级', teacherName: '张伟', studentCount: 28, inviteCode: 'M2L4Q' },
{ id: 'c-3', name: 'AP 微积分先修班', gradeName: '高三年级', teacherName: '李明', studentCount: 15, inviteCode: 'Z9J1W' },
{ id: 'c-4', name: '物理奥赛集训队', gradeName: '高二年级', teacherName: '王博士', studentCount: 20, inviteCode: 'H4R8T' },
];
if (role === 'Student') {
classes = classes.slice(0, 1);
}
return successResponse(classes);
const url = new URL(request.url);
const role = url.searchParams.get('role') || '';
const res = await fetch(`${API_BASE_URL}/org/classes?role=${encodeURIComponent(role)}`, {
method: 'GET'
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@@ -7,11 +7,11 @@ import Link from 'next/link';
import { motion, AnimatePresence } from 'framer-motion';
import {
LayoutDashboard, BookOpen, FileQuestion, Users, Settings, LogOut,
Bell, Search, GraduationCap, ScrollText, ClipboardList, Database,
Bell, Search, GraduationCap, ScrollText, ClipboardList,
Menu, X, CalendarDays, Terminal
} from 'lucide-react';
import { useAuth } from '@/lib/auth-context';
import { getApiMode, setApiMode } from '@/services/api';
import { } from '@/services/api';
const NavItem = ({ icon: Icon, label, href, isActive }: any) => (
<Link href={href} className="block w-full">
@@ -45,7 +45,6 @@ export const Sidebar = () => {
const { user, logout } = useAuth();
const pathname = usePathname() || '';
const router = useRouter();
const [isMock, setIsMock] = useState(getApiMode());
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const renderNavItems = () => {
@@ -89,14 +88,6 @@ export const Sidebar = () => {
</nav>
<div className="pt-6 border-t border-gray-100 space-y-2">
<button
onClick={() => { setApiMode(!isMock); setIsMock(!isMock); }}
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors text-xs font-bold uppercase tracking-wider border ${isMock ? 'bg-amber-50 text-amber-600 border-amber-200' : 'bg-green-50 text-green-600 border-green-200'}`}
>
<Database size={14} />
{isMock ? 'Mock Data' : 'Real API'}
</button>
<NavItem icon={Terminal} label="控制台配置" href="/consoleconfig" isActive={pathname === '/consoleconfig'} />
<NavItem icon={Settings} label="系统设置" href="/settings" isActive={pathname === '/settings'} />

View File

@@ -7,6 +7,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
icon?: React.ReactNode;
iconPosition?: 'left' | 'right';
}
export const Button: React.FC<ButtonProps> = ({
@@ -15,6 +16,7 @@ export const Button: React.FC<ButtonProps> = ({
size = 'md',
loading = false,
icon,
iconPosition = 'left',
className = '',
disabled,
...props
@@ -42,8 +44,9 @@ export const Button: React.FC<ButtonProps> = ({
{...props}
>
{loading && <Loader2 className="animate-spin" size={size === 'sm' ? 12 : 16} />}
{!loading && icon}
{!loading && icon && iconPosition === 'left' && icon}
{children}
{!loading && icon && iconPosition === 'right' && icon}
</button>
);
};

View File

@@ -0,0 +1,335 @@
"use client";
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
PieChart, Pie, Cell, Legend
} from 'recharts';
import {
ChevronDown, ChevronUp, Users, AlertCircle, CheckCircle, HelpCircle,
BarChart2, BookOpen, Target, ArrowLeft
} from 'lucide-react';
import { AssignmentAnalysisDto } from '../../../../UI_DTO';
import { assignmentService } from '@/services/api';
import { Card } from '@/components/ui/Card';
import { Loader2 } from 'lucide-react';
interface AssignmentAnalysisProps {
assignmentId: string;
onBack?: () => void;
initialTab?: 'overview' | 'details';
}
export const AssignmentAnalysis: React.FC<AssignmentAnalysisProps> = ({ assignmentId, onBack, initialTab = 'details' }) => {
const [data, setData] = useState<AssignmentAnalysisDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'details'>(initialTab);
const [expandedQuestionId, setExpandedQuestionId] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const result = await assignmentService.getAssignmentAnalysis(assignmentId);
setData(result);
} catch (err: any) {
setError(err.message || 'Failed to load analysis data');
} finally {
setLoading(false);
}
};
fetchData();
}, [assignmentId]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="animate-spin text-blue-600" size={32} />
<span className="ml-3 text-gray-500">...</span>
</div>
);
}
if (error || !data) {
return (
<div className="p-8 text-center">
<AlertCircle className="mx-auto text-red-500 mb-4" size={48} />
<h3 className="text-lg font-bold text-gray-900"></h3>
<p className="text-gray-500 mt-2">{error}</p>
<button onClick={onBack} className="mt-4 text-blue-600 hover:underline"></button>
</div>
);
}
const toggleQuestion = (id: string) => {
setExpandedQuestionId(prev => prev === id ? null : id);
};
// Prepare chart data
const questionErrorData = data.questions.map(q => ({
name: `Q${q.score ? '' : ''}${q.title.substring(0, 5)}...`,
errorCount: q.errorCount,
errorRate: Math.round(q.errorRate * 100),
fullTitle: q.title
}));
const kpErrorData = data.knowledgePoints.map(kp => ({
name: kp.name,
errorRate: Math.round(kp.errorRate * 100)
}));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div>
<div className="flex items-center gap-2 mb-1">
<button onClick={onBack} className="p-1 hover:bg-gray-100 rounded-full transition-colors">
<ArrowLeft size={20} className="text-gray-500" />
</button>
<h2 className="text-2xl font-bold text-gray-900">{data.overview.title}</h2>
</div>
<p className="text-gray-500 ml-8">: {data.overview.examTitle}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setActiveTab('details')}
className={`px-4 py-2 rounded-lg font-bold text-sm flex items-center gap-2 transition-all ${activeTab === 'details' ? 'bg-blue-50 text-blue-600' : 'text-gray-500 hover:bg-gray-50'}`}
>
<FileText size={16} />
</button>
<button
onClick={() => setActiveTab('overview')}
className={`px-4 py-2 rounded-lg font-bold text-sm flex items-center gap-2 transition-all ${activeTab === 'overview' ? 'bg-blue-50 text-blue-600' : 'text-gray-500 hover:bg-gray-50'}`}
>
<BarChart2 size={16} />
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="参考人数"
value={`${data.overview.submittedCount}/${data.overview.totalStudents}`}
icon={Users}
color="blue"
/>
<StatCard
label="平均分"
value={data.overview.averageScore.toFixed(1)}
icon={Target}
color="green"
/>
<StatCard
label="最高分"
value={data.overview.maxScore}
icon={ChevronUp}
color="orange"
/>
<StatCard
label="最低分"
value={data.overview.minScore}
icon={ChevronDown}
color="red"
/>
</div>
{/* Content Area */}
<AnimatePresence mode="wait">
{activeTab === 'overview' ? (
<motion.div
key="overview"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
>
{/* Question Error Rate Chart */}
<Card className="p-6">
<h3 className="text-lg font-bold text-gray-900 mb-6 flex items-center gap-2">
<AlertCircle className="text-red-500" size={20} />
</h3>
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={questionErrorData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
<XAxis dataKey="name" tick={{fontSize: 12}} />
<YAxis allowDecimals={false} />
<RechartsTooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
formatter={(value: number, name: string) => [name === 'errorRate' ? `${value}%` : value, name === 'errorRate' ? '错误率' : '错误人数']}
/>
<Bar dataKey="errorCount" name="错误人数" fill="#EF4444" radius={[4, 4, 0, 0]} barSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
{/* Knowledge Point Analysis */}
<Card className="p-6">
<h3 className="text-lg font-bold text-gray-900 mb-6 flex items-center gap-2">
<BookOpen className="text-purple-500" size={20} />
</h3>
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart layout="vertical" data={kpErrorData} margin={{ top: 5, right: 30, left: 40, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#E5E7EB" />
<XAxis type="number" unit="%" />
<YAxis dataKey="name" type="category" width={100} tick={{fontSize: 12}} />
<RechartsTooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
formatter={(value: number) => [`${value}%`, '错误率']}
/>
<Bar dataKey="errorRate" fill="#8B5CF6" radius={[0, 4, 4, 0]} barSize={20} />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
</motion.div>
) : (
<motion.div
key="details"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-4"
>
{data.questions.map((q, idx) => (
<Card key={q.id} className="overflow-hidden transition-all duration-300 border-transparent hover:border-blue-200">
{/* Question Header */}
<div
onClick={() => toggleQuestion(q.id)}
className="p-4 cursor-pointer flex items-start gap-4 hover:bg-gray-50/50 transition-colors"
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 font-bold text-lg ${
q.errorRate > 0.6 ? 'bg-red-100 text-red-600' : (q.errorRate > 0.3 ? 'bg-orange-100 text-orange-600' : 'bg-green-100 text-green-600')
}`}>
{idx + 1}
</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<div className="font-medium text-gray-900 mb-1" dangerouslySetInnerHTML={{ __html: q.title }} />
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500">: {q.score}</span>
<span className={`font-bold ${q.errorRate > 0.5 ? 'text-red-500' : 'text-gray-500'}`}>
: {Math.round(q.errorRate * 100)}%
</span>
{expandedQuestionId === q.id ? <ChevronUp size={20} className="text-gray-400" /> : <ChevronDown size={20} className="text-gray-400" />}
</div>
</div>
{/* Tags */}
<div className="flex gap-2 mt-2">
{q.knowledgePoints.map(kp => (
<span key={kp} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{kp}
</span>
))}
</div>
</div>
</div>
{/* Expanded Details */}
<AnimatePresence>
{expandedQuestionId === q.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-gray-100 bg-gray-50/30"
>
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column: Stats & Answer */}
<div className="space-y-6">
{/* Stats */}
<div className="flex gap-4 text-sm">
<div className="bg-white px-4 py-2 rounded-lg border border-gray-200 shadow-sm">
<span className="text-gray-500 block text-xs"></span>
<span className="font-bold text-gray-900 text-lg">{q.totalAnswers}</span>
</div>
<div className="bg-white px-4 py-2 rounded-lg border border-red-100 shadow-sm">
<span className="text-red-500 block text-xs"></span>
<span className="font-bold text-red-600 text-lg">{q.errorCount}</span>
</div>
</div>
{/* Reference Answer */}
<div className="bg-green-50 p-4 rounded-xl border border-green-100">
<h4 className="text-sm font-bold text-green-800 mb-2 flex items-center gap-2">
<CheckCircle size={16} />
</h4>
<div className="text-green-900 font-medium">{q.correctAnswer || '无参考答案'}</div>
</div>
</div>
{/* Right Column: Student Errors */}
<div className="bg-white p-4 rounded-xl border border-gray-200 shadow-sm">
<h4 className="text-sm font-bold text-gray-700 mb-3 flex items-center gap-2">
<AlertCircle size={16} className="text-red-500" />
({q.wrongSubmissions.length})
</h4>
{q.wrongSubmissions.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
<CheckCircle size={32} className="mx-auto mb-2 text-green-400 opacity-50" />
</div>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto pr-2 custom-scrollbar">
{q.wrongSubmissions.map((sub, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded-lg text-sm hover:bg-red-50 transition-colors group">
<div className="font-medium text-gray-700">{sub.studentName}</div>
<div className="text-gray-500 group-hover:text-red-600 truncate max-w-[150px]" title={sub.studentAnswer}>
{sub.studentAnswer || '未作答'}
</div>
</div>
))}
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
// Helper Component for Stat Cards
const StatCard = ({ label, value, icon: Icon, color }: any) => {
const colorStyles = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
orange: 'bg-orange-50 text-orange-600',
red: 'bg-red-50 text-red-600',
};
return (
<div className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${(colorStyles as any)[color] || 'bg-gray-50 text-gray-600'}`}>
<Icon size={24} />
</div>
<div>
<p className="text-xs text-gray-500 font-medium">{label}</p>
<p className="text-xl font-bold text-gray-900">{value}</p>
</div>
</div>
);
};
import { FileText } from 'lucide-react';

View File

@@ -8,12 +8,14 @@ import { examService, orgService, assignmentService } from '@/services/api';
import { useToast } from '@/components/ui/Toast';
interface CreateAssignmentModalProps {
isOpen?: boolean;
onClose: () => void;
onSuccess: () => void;
preSelectedExamId?: string;
}
export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ onClose, onSuccess }) => {
const [step, setStep] = useState(1);
export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ onClose, onSuccess, preSelectedExamId }) => {
const [step, setStep] = useState(preSelectedExamId ? 2 : 1);
const [loading, setLoading] = useState(false);
const { showToast } = useToast();
@@ -29,9 +31,46 @@ export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ on
});
useEffect(() => {
examService.getMyExams().then(res => setExams(res.items));
orgService.getClasses().then(setClasses);
}, []);
const init = async () => {
try {
const [examsRes, classesData] = await Promise.all([
examService.getExams({ scope: 'mine' }), // Fetch mine or all public? Maybe just mine for now or both
orgService.getClasses()
]);
setExams(examsRes.items);
setClasses(classesData);
if (preSelectedExamId) {
// Try to find in fetched exams, or fetch detail if not found (e.g. public exam)
const found = examsRes.items.find(e => e.id === preSelectedExamId);
if (found) {
setSelectedExam(found);
} else {
// Fetch detail if not in list
try {
const detail = await examService.getExamDetail(preSelectedExamId);
// Map detail to DTO roughly
setSelectedExam({
id: detail.id,
title: detail.title,
subjectId: detail.subjectId,
totalScore: detail.totalScore,
duration: detail.duration,
questionCount: detail.questionCount,
status: detail.status as any,
createdAt: detail.createdAt
});
} catch (e) {
console.error('Failed to load pre-selected exam', e);
}
}
}
} catch (e) {
console.error(e);
}
};
init();
}, [preSelectedExamId]);
useEffect(() => {
if (selectedExam && !config.title) {
@@ -44,13 +83,57 @@ export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ on
try {
await assignmentService.publishAssignment({
examId: selectedExam?.id,
classIds: selectedClassIds,
...config
classId: selectedClassIds[0], // Backend only accepts single classId currently. UI allows multiple but service logic implies one.
// If we want multiple, we need to loop here or update backend.
// Assuming for now user selects one or we just take the first one if API is singular.
// Looking at UI, it's a list of classes, so user might pick multiple.
// But realApi.ts passes 'classIds' to publishAssignment, wait...
// Let's check realApi.ts again.
// It sends `classIds: selectedClassIds`.
// But backend CreateAssignmentDto likely expects `classId` (singular) based on previous error checks?
// Actually, backend controller destructures `classId` from body.
// So if we send `classIds`, backend sees `classId` as undefined!
// We must iterate or update backend.
// For a quick fix to make it work: let's assume we loop over selected classes and call create for each, OR update backend to handle array.
// Updating backend is better but 'classId' is singular in DB assignment table usually.
// Let's loop here to be safe and support multiple classes.
// Wait, the backend controller: const { classId ... } = req.body.
// So we must send `classId`.
title: config.title,
startTime: config.startDate,
endTime: config.dueDate,
allowLateSubmission: true,
autoScoreEnabled: true
});
// If multiple classes selected, we need to handle that.
// For now, let's change the logic to loop if there are multiple classes.
for (const clsId of selectedClassIds) {
await assignmentService.publishAssignment({
examId: selectedExam?.id,
classId: clsId,
title: config.title,
startTime: config.startDate,
endTime: config.dueDate,
allowLateSubmission: true,
autoScoreEnabled: true
});
}
showToast('作业发布成功!', 'success');
onSuccess();
} catch (e) {
showToast('发布失败,请重试', 'error');
console.error(e);
// Check for specific error messages
const msg = (e as any).message || '发布失败';
if (msg.includes('Exam must be published')) {
showToast('发布失败:试卷必须先发布才能布置作业', 'error');
} else {
showToast(`发布失败: ${msg}`, 'error');
}
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,155 @@
"use client";
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { X, Calendar, CheckCircle2, Clock, FileText } from 'lucide-react';
import { assignmentService } from '@/services/api';
import { useToast } from '@/components/ui/Toast';
interface EditAssignmentModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
assignment: {
id: string;
title: string;
dueDate: string;
status: string;
};
}
export const EditAssignmentModal: React.FC<EditAssignmentModalProps> = ({ isOpen, onClose, onSuccess, assignment }) => {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
dueDate: '',
dueTime: '23:59'
});
const { showToast } = useToast();
useEffect(() => {
if (isOpen && assignment) {
const date = new Date(assignment.dueDate);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
setFormData({
title: assignment.title,
dueDate: `${year}-${month}-${day}`,
dueTime: `${hours}:${minutes}`
});
}
}, [isOpen, assignment]);
const handleSubmit = async () => {
if (!formData.title || !formData.dueDate || !formData.dueTime) {
showToast('请填写完整信息', 'error');
return;
}
try {
setLoading(true);
const endDateTime = new Date(`${formData.dueDate}T${formData.dueTime}`);
await assignmentService.updateAssignment(assignment.id, {
title: formData.title,
endTime: endDateTime.toISOString()
});
showToast('作业信息更新成功', 'success');
onSuccess();
onClose();
} catch (error: any) {
console.error(error);
showToast(error.message || '更新失败', 'error');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden"
>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900"></h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors p-1 hover:bg-gray-100 rounded-full">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 space-y-6">
<div>
<label className="block text-sm font-bold text-gray-700 mb-2"></label>
<div className="relative">
<FileText className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all"
placeholder="请输入作业标题"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2"></label>
<div className="grid grid-cols-2 gap-4">
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="date"
value={formData.dueDate}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setFormData({...formData, dueDate: e.target.value})}
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all"
/>
</div>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="time"
value={formData.dueTime}
onChange={(e) => setFormData({...formData, dueTime: e.target.value})}
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all"
/>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
*
</p>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-100 flex justify-end gap-3 bg-gray-50/50">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"
>
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-6 py-2 rounded-xl text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 shadow-lg shadow-blue-500/30 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? '保存中...' : '保存修改'}
</button>
</div>
</motion.div>
</div>
);
};

View File

@@ -1,10 +1,10 @@
"use client";
import React, { useState, useEffect } from 'react';
import { AssignmentStudentViewDto } from '../../../../UI_DTO';
import { assignmentService } from '@/services/api';
import { AssignmentStudentViewDto, SubjectDto } from '../../../../UI_DTO';
import { assignmentService, curriculumService } from '@/services/api';
import { Card } from '@/components/ui/Card';
import { Clock, CheckCircle, Calendar, Play, Eye } from 'lucide-react';
import { Clock, CheckCircle, Calendar, Play, Eye, BookOpen, User, FileText, AlertCircle, Filter } from 'lucide-react';
interface StudentAssignmentListProps {
onStartExam: (id: string) => void;
@@ -13,56 +13,171 @@ interface StudentAssignmentListProps {
export const StudentAssignmentList: React.FC<StudentAssignmentListProps> = ({ onStartExam, onViewResult }) => {
const [assignments, setAssignments] = useState<AssignmentStudentViewDto[]>([]);
const [subjects, setSubjects] = useState<SubjectDto[]>([]);
const [filters, setFilters] = useState({
subjectId: 'all',
examType: 'all',
status: 'all'
});
useEffect(() => {
assignmentService.getStudentAssignments().then(res => setAssignments(res.items));
curriculumService.getSubjects().then(setSubjects);
}, []);
useEffect(() => {
const loadAssignments = async () => {
const res = await assignmentService.getStudentAssignments(filters);
setAssignments(res.items);
};
loadAssignments();
}, [filters]);
const examTypes = ['Midterm', 'Final', 'Unit', 'Weekly', 'Uncategorized'];
const statuses = [
{ value: 'all', label: '全部状态' },
{ value: 'Pending', label: '待完成' },
{ value: 'Completed', label: '已完成' }
];
return (
<div className="grid grid-cols-1 gap-4">
{assignments.map((item, idx) => (
<Card key={item.id} delay={idx * 0.1} className="flex flex-col md:flex-row items-center gap-6 group border-transparent hover:border-blue-200 transition-all">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0 text-2xl font-bold shadow-sm
${item.status === 'Pending' ? 'bg-blue-100 text-blue-600' : (item.status === 'Graded' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500')}
`}>
{item.status === 'Pending' ? <Clock size={24}/> : (item.status === 'Graded' ? item.score : <CheckCircle size={24} />)}
</div>
<div className="space-y-6">
{/* Filters */}
<Card className="p-4 flex flex-wrap gap-4 items-center" noPadding>
<div className="flex items-center gap-2 text-gray-500 mr-2">
<Filter size={18} />
<span className="font-bold text-sm">:</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-bold text-gray-900">{item.title}</h3>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase
${item.status === 'Pending' ? 'bg-blue-50 text-blue-600 border-blue-100' : 'bg-gray-50 text-gray-500 border-gray-200'}
`}>
{item.status === 'Pending' ? '待完成' : (item.status === 'Graded' ? '已批改' : '已提交')}
</span>
</div>
<p className="text-sm text-gray-500 mb-2">: {item.examTitle}</p>
<div className="text-xs text-gray-400 font-medium flex items-center gap-1">
<Calendar size={12}/> : {item.endTime}
</div>
</div>
<select
value={filters.subjectId}
onChange={(e) => setFilters({...filters, subjectId: e.target.value})}
className="bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none"
>
<option value="all"></option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<div>
{item.status === 'Pending' ? (
<button
onClick={() => onStartExam(item.id)}
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 hover:scale-105 transition-all flex items-center gap-2"
>
<Play size={16} fill="currentColor" />
</button>
) : (
<button
onClick={() => item.status === 'Graded' && onViewResult(item.id)}
disabled={item.status !== 'Graded'}
className={`px-6 py-2.5 rounded-xl font-bold transition-colors flex items-center gap-2 ${item.status === 'Graded' ? 'bg-gray-100 text-gray-900 hover:bg-gray-200' : 'bg-gray-50 text-gray-400 cursor-not-allowed'}`}
>
<Eye size={16} /> {item.status === 'Graded' ? '查看详情' : '等待批改'}
</button>
)}
</div>
</Card>
))}
<select
value={filters.examType}
onChange={(e) => setFilters({...filters, examType: e.target.value})}
className="bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none"
>
<option value="all"></option>
{examTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex bg-gray-100 p-1 rounded-lg">
{statuses.map(s => (
<button
key={s.value}
onClick={() => setFilters({...filters, status: s.value})}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${filters.status === s.value ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700'}`}
>
{s.label}
</button>
))}
</div>
</Card>
{/* List */}
<div className="grid grid-cols-1 gap-4">
{assignments.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<FileText size={48} className="mx-auto mb-4 opacity-20" />
<p></p>
</div>
) : (
assignments.map((item, idx) => (
<Card key={item.id} delay={idx * 0.1} className="flex flex-col md:flex-row items-center gap-6 group border-transparent hover:border-blue-200 transition-all">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0 text-2xl font-bold shadow-sm
${(item.status === 'Pending' || item.status === 'InProgress') ? 'bg-blue-100 text-blue-600' : (item.status === 'Completed' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500')}
`}>
{(() => {
if (item.status === 'Completed') {
return item.score !== null && item.score !== undefined ? item.score : <span className="text-base">0</span>;
}
if (item.status === 'Grading') {
// 如果是 Grading检查是否真正提交了
return item.isSubmitted ? <CheckCircle size={24} /> : <AlertCircle size={24} className="text-orange-400" />;
}
if (item.status === 'Submitted') return <CheckCircle size={24} />;
return <Clock size={24}/>;
})()}
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4 w-full">
<div>
{/* Status Tag */}
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-gray-900">{item.title}</h3>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase
${(item.status === 'Pending' || item.status === 'InProgress') ? 'bg-blue-50 text-blue-600 border-blue-100' :
(item.status === 'Completed' ? 'bg-green-50 text-green-600 border-green-200' : 'bg-gray-50 text-gray-500 border-gray-200')}
`}>
{(() => {
if (item.status === 'Pending') return '待完成';
if (item.status === 'InProgress') return '进行中';
if (item.status === 'Submitted') return '已提交';
if (item.status === 'Grading') return '批改中';
if (item.status === 'Completed') return '已完成';
return item.status;
})()}
</span>
</div>
<p className="text-sm text-gray-500 mb-2 flex items-center gap-2">
<FileText size={14} />
: {item.examTitle}
</p>
<div className="text-xs text-gray-400 font-medium flex items-center gap-3">
<span className="flex items-center gap-1"><Calendar size={12}/> : {new Date(item.endTime).toLocaleDateString()}</span>
{item.duration && <span className="flex items-center gap-1"><Clock size={12}/> : {item.duration}</span>}
</div>
</div>
<div className="flex flex-col justify-center gap-1 text-sm text-gray-500 border-l border-gray-100 pl-4">
{item.subjectName && (
<div className="flex items-center gap-2">
<BookOpen size={14} className="text-gray-400"/>
<span>: {item.subjectName}</span>
</div>
)}
{item.teacherName && (
<div className="flex items-center gap-2">
<User size={14} className="text-gray-400"/>
<span>: {item.teacherName}</span>
</div>
)}
{item.questionCount !== undefined && (
<div className="flex items-center gap-2">
<AlertCircle size={14} className="text-gray-400"/>
<span>题目: {item.questionCount} / {item.totalScore}</span>
</div>
)}
</div>
</div>
<div className="flex-shrink-0 mt-4 md:mt-0">
{(item.status === 'Pending' || item.status === 'InProgress') ? (
<button
onClick={() => onStartExam(item.id)}
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 hover:scale-105 transition-all flex items-center gap-2"
>
<Play size={16} fill="currentColor" /> {item.status === 'InProgress' ? '继续答题' : '开始答题'}
</button>
) : (
<button
onClick={() => item.status === 'Completed' && onViewResult(item.id)}
disabled={item.status !== 'Completed'}
className={`px-6 py-2.5 rounded-xl font-bold transition-colors flex items-center gap-2 ${item.status === 'Completed' ? 'bg-green-600 text-white shadow-lg shadow-green-500/30 hover:bg-green-700 hover:scale-105' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}`}
>
<Eye size={16} /> {item.status === 'Completed' ? '查看详情' : (item.status === 'Grading' ? '等待批改' : '等待截止')}
</button>
)}
</div>
</Card>
))
)}
</div>
</div>
)
}

View File

@@ -1,10 +1,15 @@
"use client";
import React, { useState, useEffect } from 'react';
import { AssignmentTeacherViewDto } from '../../../../UI_DTO';
import { assignmentService } from '@/services/api';
import { AssignmentTeacherViewDto, ClassDto, SubjectDto } from '../../../../UI_DTO';
import { assignmentService, orgService, curriculumService } from '@/services/api';
import { Card } from '@/components/ui/Card';
import { Plus, FileText, Users, Calendar, Eye, BarChart3, ChevronRight } from 'lucide-react';
import { Plus, FileText, Users, Calendar, Eye, BarChart3, ChevronRight, Filter, BookOpen, CheckCircle, Clock, PenTool, Settings, Archive } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { SkeletonCard } from '@/components/ui/LoadingState';
import { EditAssignmentModal } from './EditAssignmentModal';
import { useToast } from '@/components/ui/Toast';
interface TeacherAssignmentListProps {
onNavigateToGrading?: (id: string) => void;
@@ -20,119 +25,272 @@ export const TeacherAssignmentList: React.FC<TeacherAssignmentListProps> = ({
setIsCreating
}) => {
const [assignments, setAssignments] = useState<AssignmentTeacherViewDto[]>([]);
const [classes, setClasses] = useState<ClassDto[]>([]);
const [subjects, setSubjects] = useState<SubjectDto[]>([]);
const [filters, setFilters] = useState({
status: 'Active', // Default to 'In Progress'
classId: 'all',
examType: 'all',
subjectId: 'all'
});
const [loading, setLoading] = useState(true);
const [editingAssignment, setEditingAssignment] = useState<AssignmentTeacherViewDto | null>(null);
const { showToast } = useToast();
useEffect(() => {
assignmentService.getTeachingAssignments().then(res => setAssignments(res.items));
// Load initial data for filters
orgService.getClasses().then(setClasses);
curriculumService.getSubjects().then(setSubjects);
}, []);
const getStatusStyle = (status: string) => {
useEffect(() => {
const loadAssignments = async () => {
setLoading(true);
const res = await assignmentService.getTeachingAssignments(filters);
setAssignments(res.items);
setLoading(false);
};
loadAssignments();
}, [filters]);
const getStatusVariant = (status: string, hasPending?: boolean) => {
if (hasPending) return 'warning'; // Needs Grading
switch (status) {
case 'Active': return 'bg-blue-50 text-blue-600 border-blue-100';
case 'Ended': return 'bg-gray-100 text-gray-600 border-gray-200';
case 'Scheduled': return 'bg-orange-50 text-orange-600 border-orange-100';
default: return 'bg-gray-50 text-gray-500';
case 'Active': return 'info';
case 'Ended': return 'default';
default: return 'default';
}
};
const getStatusLabel = (status: string) => {
const getStatusLabel = (status: string, hasPending?: boolean) => {
if (hasPending) return '待批改';
switch (status) {
case 'Active': return '进行中';
case 'Ended': return '已结束';
case 'Scheduled': return '计划中';
default: return status;
}
}
const examTypes = ['Midterm', 'Final', 'Unit', 'Weekly', 'Uncategorized'];
const tabs = [
{ id: 'Active', label: '进行中' },
{ id: 'ToGrade', label: '待批改' },
{ id: 'Graded', label: '已结束' }
];
const handleArchive = async (id: string) => {
if (!confirm('确定要归档并结束此作业吗?归档后学生将看到最终成绩。')) return;
try {
await assignmentService.archiveAssignment(id);
showToast('作业已归档', 'success');
// Refresh list
setFilters({ ...filters });
} catch (err: any) {
showToast(err.message || '归档失败', 'error');
}
};
return (
<>
<div className="flex justify-between items-center mb-6">
<div className="space-y-6 pb-10">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-xl font-bold text-gray-900"></h2>
<h2 className="text-xl font-bold text-gray-900"></h2>
<p className="text-gray-500 text-sm"></p>
</div>
<button
onClick={() => setIsCreating(true)}
className="flex items-center gap-2 bg-blue-600 text-white px-5 py-2.5 rounded-full text-sm font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 transition-all hover:-translate-y-0.5"
>
<Plus size={18} />
<Button icon={<Plus size={18} />} onClick={() => setIsCreating(true)}>
</button>
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{assignments.map((item, idx) => {
const progress = Math.round((item.submittedCount / item.totalCount) * 100);
{/* Filters */}
<Card className="p-4 flex flex-wrap gap-4 items-center" noPadding>
<div className="flex items-center gap-2 text-gray-500 mr-2">
<Filter size={18} />
<span className="font-bold text-sm">:</span>
</div>
return (
<Card key={item.id} delay={idx * 0.1} className="flex flex-col md:flex-row items-center gap-6 group border-transparent hover:border-blue-200 transition-all">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0 text-xl font-bold ${item.status === 'Active' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'}`}>
{progress}%
</div>
<div className="flex bg-gray-100 p-1 rounded-lg">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setFilters({ ...filters, status: tab.id })}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${filters.status === tab.id ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700'}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1 min-w-0 w-full text-center md:text-left">
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 mb-1">
<h3 className="text-lg font-bold text-gray-900 truncate">{item.title}</h3>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase tracking-wider w-fit mx-auto md:mx-0 ${getStatusStyle(item.status)}`}>
{getStatusLabel(item.status)}
<select
value={filters.classId}
onChange={(e) => setFilters({...filters, classId: e.target.value})}
className="bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none min-w-[120px]"
>
<option value="all"></option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select
value={filters.subjectId}
onChange={(e) => setFilters({...filters, subjectId: e.target.value})}
className="bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none min-w-[120px]"
>
<option value="all"></option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<select
value={filters.examType}
onChange={(e) => setFilters({...filters, examType: e.target.value})}
className="bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none min-w-[120px]"
>
<option value="all"></option>
{examTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</Card>
{/* List */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonCard key={i} />)}
</div>
) : assignments.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-3xl border-2 border-dashed border-gray-200">
<FileText className="mx-auto text-gray-300 mb-4" size={48} />
<h3 className="text-lg font-bold text-gray-900 mb-2"></h3>
<p className="text-gray-500 mb-6"></p>
<Button variant="outline" onClick={() => setIsCreating(true)}></Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{assignments.map((item, idx) => {
const progress = item.totalCount > 0 ? Math.round((item.submittedCount / item.totalCount) * 100) : 0;
return (
<Card key={item.id} delay={idx * 0.05} className="group hover:border-blue-200 transition-all flex flex-col h-full">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${item.status === 'Active' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'}`}>
<FileText size={20} />
</div>
<div>
<Badge variant={getStatusVariant(item.status, item.hasPendingGrading) as any}>
{getStatusLabel(item.status, item.hasPendingGrading)}
</Badge>
<div className="text-[10px] text-gray-400 mt-1 flex items-center gap-1">
<Calendar size={10} />
{new Date(item.createdAt).toLocaleDateString()}
</div>
</div>
</div>
{item.status === 'Active' && (
<button
onClick={(e) => { e.stopPropagation(); setEditingAssignment(item); }}
className="p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-600 rounded-lg transition-all"
title="编辑作业设置"
>
<Settings size={16} />
</button>
)}
{item.status === 'Grading' && (
<button
onClick={(e) => { e.stopPropagation(); handleArchive(item.id); }}
className="p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 rounded-lg transition-all"
title="归档/结束作业"
>
<Archive size={16} />
</button>
)}
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-1" title={item.title}>
{item.title}
</h3>
<div className="flex flex-wrap gap-2 mb-3">
<span className="text-[10px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full flex items-center gap-1">
<Users size={10} /> {item.className}
</span>
</div>
<div className="flex items-center justify-center md:justify-start gap-2 text-sm text-gray-500 mb-2">
<FileText size={14} />
<span>: <span className="font-medium text-gray-700">{item.examTitle}</span></span>
{item.subjectName && (
<span className="text-[10px] bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">
{item.subjectName}
</span>
)}
{item.examType && (
<span className="text-[10px] bg-purple-50 text-purple-600 px-2 py-0.5 rounded-full">
{item.examType}
</span>
)}
</div>
<div className="flex items-center justify-center md:justify-start gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
<Users size={14} className="text-gray-400" />
<span className="font-medium text-gray-700">{item.className}</span>
<div className="grid grid-cols-3 gap-2 mb-6 mt-auto">
<div className="bg-gray-50 p-2 rounded-lg text-center">
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><CheckCircle size={10}/> </div>
<div className="font-bold text-blue-600">{progress}%</div>
</div>
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
<Calendar size={14} className="text-gray-400" />
<span>: {item.dueDate}</span>
<div className="bg-gray-50 p-2 rounded-lg text-center">
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><Users size={10}/> </div>
<div className="font-bold text-gray-900">{item.submittedCount}/{item.totalCount}</div>
</div>
<div className="bg-gray-50 p-2 rounded-lg text-center">
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><Clock size={10}/> </div>
<div className="font-bold text-gray-900 text-xs leading-5">
{new Date(item.dueDate).toLocaleDateString(undefined, {month:'numeric', day:'numeric'})}
</div>
</div>
</div>
</div>
<div className="w-full md:w-48 flex flex-col gap-2">
<div className="flex justify-between text-xs font-bold text-gray-500">
<span></span>
<span>{item.submittedCount}/{item.totalCount}</span>
<div className="flex gap-2 pt-4 border-t border-gray-100">
<button
onClick={() => onNavigateToPreview && onNavigateToPreview(item.id)}
className="flex-1 py-2 rounded-lg bg-gray-50 text-gray-600 text-sm font-bold hover:bg-gray-100 hover:text-gray-900 transition-colors flex items-center justify-center gap-2"
title="预览试卷"
>
<Eye size={14} />
</button>
<button
onClick={() => onAnalyze && onAnalyze(item.id)}
className="flex-1 py-2 rounded-lg bg-blue-50 text-blue-600 text-sm font-bold hover:bg-blue-100 hover:text-blue-700 transition-colors flex items-center justify-center gap-2"
title="数据分析"
>
<BarChart3 size={14} />
</button>
<button
onClick={() => item.status !== 'Active' && onNavigateToGrading && onNavigateToGrading(item.id)}
className={`flex-1 py-2 rounded-lg text-sm font-bold transition-colors flex items-center justify-center gap-2 ${item.status === 'Active' ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-purple-50 text-purple-600 hover:bg-purple-100 hover:text-purple-700'}`}
title={item.status === 'Active' ? '未截止不可批改' : '进入批改'}
disabled={item.status === 'Active'}
>
<PenTool size={14} />
</button>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${item.status === 'Active' ? 'bg-blue-500' : 'bg-gray-400'}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
</Card>
);
})}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => onNavigateToPreview && onNavigateToPreview(item.id)}
title="预览试卷"
className="p-3 rounded-xl hover:bg-purple-50 text-gray-400 hover:text-purple-600 transition-colors"
>
<Eye size={20} />
</button>
<button
onClick={() => onAnalyze && onAnalyze(item.id)}
title="数据分析"
className="p-3 rounded-xl hover:bg-blue-50 text-gray-400 hover:text-blue-600 transition-colors"
>
<BarChart3 size={20} />
</button>
<button
onClick={() => onNavigateToGrading && onNavigateToGrading(item.id)}
title="进入批改"
className="p-3 rounded-xl hover:bg-gray-100 text-gray-400 hover:text-gray-900 transition-colors"
>
<ChevronRight size={20} />
</button>
</div>
</Card>
);
})}
</div>
</>
{editingAssignment && (
<EditAssignmentModal
isOpen={true}
onClose={() => setEditingAssignment(null)}
onSuccess={() => {
// Refresh list
setFilters({ ...filters });
setEditingAssignment(null);
}}
assignment={{
id: editingAssignment.id,
title: editingAssignment.title,
dueDate: editingAssignment.dueDate,
status: editingAssignment.status
}}
/>
)}
</div>
);
}

View File

@@ -10,7 +10,7 @@ interface LoginFormProps {
}
export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }) => {
const [username, setUsername] = useState('admin');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -50,7 +50,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3.5 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium placeholder:text-gray-400"
placeholder="请输入学号或工号"
placeholder="请输入邮箱或手机号"
/>
</div>
@@ -95,6 +95,23 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }
</button>
</form>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => { setUsername('liming@school.edu'); setPassword('123456'); }}
className="px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
>
</button>
<button
type="button"
onClick={() => { setUsername('student1@school.edu'); setPassword('123456'); }}
className="px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
>
</button>
</div>
<div className="mt-8 text-center space-y-2">
<p className="text-xs text-gray-400">
? <button className="text-blue-600 font-bold hover:underline"></button>

View File

@@ -28,6 +28,7 @@ interface StudentDashboardProps {
export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) => {
const [performanceData, setPerformanceData] = useState<ChartDataDto | null>(null);
const [radarData, setRadarData] = useState<RadarChartDto | null>(null);
const [stats, setStats] = useState<{ completed: number; todo: number; average: number; studyDuration: number } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -39,7 +40,7 @@ export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) =>
setLoading(true);
setError(null);
const [growth, radar] = await Promise.all([
const [growth, radar, studentStats] = await Promise.all([
analyticsService.getStudentGrowth().catch(err => {
console.error('Failed to load growth:', err);
return null;
@@ -47,11 +48,16 @@ export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) =>
analyticsService.getStudentRadar().catch(err => {
console.error('Failed to load radar:', err);
return null;
}),
analyticsService.getStudentStats().catch(err => {
console.error('Failed to load student stats:', err);
return null;
})
]);
setPerformanceData(growth);
setRadarData(radar);
setStats(studentStats);
} catch (err) {
console.error('Failed to load dashboard data:', err);
const errorMessage = getErrorMessage(err);
@@ -113,13 +119,13 @@ export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) =>
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div onClick={() => onNavigate('assignments')} className="cursor-pointer">
<StatCard title="已完成作业" value="12" subValue="+2 本周" icon={CheckCircle} color="bg-green-500" delay={0.1} />
<StatCard title="已完成作业" value={String(stats?.completed || 0)} subValue="+2 本周" icon={CheckCircle} color="bg-green-500" delay={0.1} />
</div>
<div onClick={() => onNavigate('assignments')} className="cursor-pointer">
<StatCard title="待办任务" value="3" subValue="即将截止" icon={ListTodo} color="bg-orange-500" delay={0.2} />
<StatCard title="待办任务" value={String(stats?.todo || 0)} subValue="即将截止" icon={ListTodo} color="bg-orange-500" delay={0.2} />
</div>
<StatCard title="平均成绩" value="88.5" subValue="前 10%" icon={Trophy} color="bg-yellow-500" delay={0.3} />
<StatCard title="学习时长" value="42h" subValue="本周累计" icon={Clock} color="bg-blue-500" delay={0.4} />
<StatCard title="平均成绩" value={String(stats?.average || 0)} subValue="前 10%" icon={Trophy} color="bg-yellow-500" delay={0.3} />
<StatCard title="学习时长" value={`${stats?.studyDuration || 0}h`} subValue="本周累计" icon={Clock} color="bg-blue-500" delay={0.4} />
<div className="md:col-span-3 space-y-6">
<Card delay={0.5}>

View File

@@ -2,8 +2,8 @@
"use client";
import React, { useState, useEffect } from 'react';
import { ExamDetailDto, ExamNodeDto, QuestionSummaryDto, ParsedQuestionDto } from '../../../../UI_DTO';
import { examService, questionService } from '@/services/api';
import { ExamDetailDto, ExamNodeDto, QuestionSummaryDto, ParsedQuestionDto, SubjectDto } from '../../../../UI_DTO';
import { examService, questionService, curriculumService } from '@/services/api';
import { useToast } from '@/components/ui/Toast';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
@@ -11,7 +11,7 @@ import { ImportModal } from './ImportModal';
import {
ArrowLeft, Save, Plus, Trash2, GripVertical,
ChevronDown, ChevronUp, FileInput, Search, Filter,
Clock, Hash, Calculator, FolderPlus, FileText
Clock, Hash, Calculator, FolderPlus, FileText, BookOpen, Send
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
@@ -22,6 +22,7 @@ interface ExamEditorProps {
export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
const [exam, setExam] = useState<ExamDetailDto | null>(null);
const [subjects, setSubjects] = useState<SubjectDto[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
@@ -33,52 +34,63 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
// Init
useEffect(() => {
const init = async () => {
if (examId) {
const data = await examService.getExamDetail(examId);
setExam(data);
// Auto expand all group nodes
const allGroupIds = new Set<string>();
const collectGroupIds = (nodes: ExamNodeDto[]) => {
nodes.forEach(node => {
if (node.nodeType === 'Group') {
allGroupIds.add(node.id);
if (node.children) collectGroupIds(node.children);
}
});
};
collectGroupIds(data.rootNodes);
setExpandedNodes(allGroupIds);
} else {
// Create new template
const newExam: ExamDetailDto = {
id: '',
subjectId: 'sub-1',
title: '未命名试卷',
totalScore: 0,
duration: 120,
questionCount: 0,
status: 'Draft',
createdAt: new Date().toISOString().split('T')[0],
rootNodes: [
{
id: 'node-1',
nodeType: 'Group',
title: '第一部分:选择题',
description: '请选出正确答案',
score: 0,
sortOrder: 1,
children: []
}
]
};
setExam(newExam);
setExpandedNodes(new Set(['node-1']));
setSelectedNodeId('node-1');
}
try {
const [questions, subjectsData] = await Promise.all([
questionService.search({}),
curriculumService.getSubjects()
]);
const questions = await questionService.search({});
setQuestionBank(questions.items);
setLoading(false);
setQuestionBank(questions.items);
setSubjects(subjectsData);
if (examId) {
const data = await examService.getExamDetail(examId);
setExam(data);
// Auto expand all group nodes
const allGroupIds = new Set<string>();
const collectGroupIds = (nodes: ExamNodeDto[]) => {
nodes.forEach(node => {
if (node.nodeType === 'Group') {
allGroupIds.add(node.id);
if (node.children) collectGroupIds(node.children);
}
});
};
collectGroupIds(data.rootNodes);
setExpandedNodes(allGroupIds);
} else {
// Create new template
const newExam: ExamDetailDto = {
id: '',
subjectId: subjectsData.length > 0 ? subjectsData[0].id : '',
title: '未命名试卷',
totalScore: 0,
duration: 120,
questionCount: 0,
status: 'Draft',
createdAt: new Date().toISOString().split('T')[0],
rootNodes: [
{
id: 'node-1',
nodeType: 'Group',
title: '第一部分:选择题',
description: '请选出正确答案',
score: 0,
sortOrder: 1,
children: []
}
]
};
setExam(newExam);
setExpandedNodes(new Set(['node-1']));
setSelectedNodeId('node-1');
}
} catch (err) {
console.error(err);
showToast('Failed to load initial data', 'error');
} finally {
setLoading(false);
}
};
init();
}, [examId]);
@@ -106,14 +118,34 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
}
}, [exam?.rootNodes]);
const handleSave = async () => {
const handleSave = async (status: 'Draft' | 'Published' = 'Draft') => {
if (!exam) return;
if (!exam.subjectId) {
showToast('请选择所属学科', 'error');
return;
}
if (!exam.title.trim()) {
showToast('请输入试卷标题', 'error');
return;
}
if (status === 'Published' && exam.questionCount === 0) {
showToast('无法发布空试卷,请先添加题目', 'error');
return;
}
setSaving(true);
try {
await examService.saveExam(exam);
showToast('试卷保存成功', 'success');
await examService.saveExam({
...exam,
status
});
showToast(status === 'Published' ? '试卷已发布' : '草稿已保存', 'success');
if (!examId) onBack();
} catch (e) {
console.error(e);
showToast('保存失败', 'error');
} finally {
setSaving(false);
@@ -415,41 +447,62 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={onBack} icon={<ArrowLeft size={20} />} />
<div className="h-8 w-px bg-gray-200" />
{/* Subject Selector */}
<div className="flex items-center gap-2 bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-200">
<BookOpen size={16} className="text-gray-500" />
<select
value={exam.subjectId}
onChange={e => setExam({ ...exam, subjectId: e.target.value })}
className="bg-transparent outline-none text-sm font-bold text-gray-700 min-w-[100px]"
disabled={!!examId} // Disable if editing existing exam (optional, but usually safer)
>
<option value="" disabled></option>
{subjects.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<input
value={exam.title}
onChange={e => setExam({ ...exam, title: e.target.value })}
className="text-xl font-bold text-gray-900 bg-transparent outline-none focus:bg-gray-50 rounded px-2 transition-colors placeholder:text-gray-400 w-96"
className="text-xl font-bold text-gray-900 bg-transparent outline-none focus:bg-gray-50 rounded px-2 transition-colors placeholder:text-gray-400 w-80"
placeholder="请输入试卷标题..."
/>
</div>
<div className="flex items-center gap-6">
<div className="flex gap-6 text-sm text-gray-500">
<div className="flex items-center gap-2 bg-gray-100 px-3 py-1.5 rounded-lg">
<Clock size={16} />
<input
type="number"
value={exam.duration}
onChange={e => setExam({ ...exam, duration: Number(e.target.value) })}
className="w-12 bg-transparent outline-none text-center font-bold text-gray-900"
/>
<span></span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-4 mr-4 text-sm text-gray-500 bg-gray-50 px-3 py-1.5 rounded-lg">
<div className="flex items-center gap-1">
<Clock size={14} />
<span>{exam.duration} </span>
</div>
<div className="flex items-center gap-2 bg-gray-100 px-3 py-1.5 rounded-lg">
<Hash size={16} />
<span className="font-bold text-gray-900">{exam.questionCount}</span>
<span></span>
</div>
<div className="flex items-center gap-2 bg-gray-100 px-3 py-1.5 rounded-lg">
<Calculator size={16} />
<span></span>
<span className="font-bold text-blue-600">{exam.totalScore}</span>
<div className="w-px h-3 bg-gray-300" />
<div className="flex items-center gap-1">
<Hash size={14} />
<span>{exam.totalScore} </span>
</div>
</div>
<div className="flex gap-3">
<Button variant="secondary" icon={<FolderPlus size={18} />} onClick={() => addGroupNode()}></Button>
<Button variant="secondary" icon={<FileInput size={18} />} onClick={() => setShowImport(true)}></Button>
<Button onClick={handleSave} loading={saving} icon={<Save size={18} />}></Button>
</div>
<Button
variant="outline"
onClick={() => handleSave('Draft')}
loading={saving}
disabled={saving}
icon={<Save size={16} />}
>
稿
</Button>
<Button
variant="primary"
onClick={() => handleSave('Published')}
loading={saving}
disabled={saving}
icon={<Send size={16} />}
>
</Button>
</div>
</div>
@@ -460,7 +513,7 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
<Card className="p-6">
{exam.rootNodes.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="mb-4">"添加分组"</p>
<p className="mb-4">&nbsp;&quot;&quot;&nbsp;</p>
</div>
) : (
<div className="space-y-2">

View File

@@ -2,125 +2,336 @@
"use client";
import React, { useEffect, useState } from 'react';
import { ExamDto } from '../../../../UI_DTO';
import { examService } from '@/services/api';
import { ExamDto, SubjectDto } from '../../../../UI_DTO';
import { examService, curriculumService } from '@/services/api';
import { Card } from '@/components/ui/Card';
import { Search, Plus, FileText, Clock, BarChart3, PenTool } from 'lucide-react';
import { Search, Plus, FileText, Clock, BarChart3, PenTool, Trash2, Send, User, Globe, Filter, ChevronLeft, ChevronRight, Users, Calendar } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { LoadingState, SkeletonCard } from '@/components/ui/LoadingState';
import { ErrorState } from '@/components/ui/ErrorState';
import { getErrorMessage, getErrorType } from '@/utils/errorUtils';
import { useToast } from '@/components/ui/Toast';
import { CreateAssignmentModal } from '@/features/assignment/components/CreateAssignmentModal';
export const ExamList = ({ onEdit, onCreate, onStats }: { onEdit: (id: string) => void, onCreate: () => void, onStats: (id: string) => void }) => {
interface ExamListProps {
onEditExam: (id: string) => void;
onCreateExam: () => void;
}
export const ExamList: React.FC<ExamListProps> = ({ onEditExam, onCreateExam }) => {
const [exams, setExams] = useState<ExamDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scope, setScope] = useState<'mine' | 'public'>('mine');
const [publishExamId, setPublishExamId] = useState<string | null>(null);
const [subjects, setSubjects] = useState<SubjectDto[]>([]);
const [filters, setFilters] = useState({
subjectId: 'all',
examType: 'all',
status: 'all'
});
const [page, setPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const pageSize = 20;
const { showToast } = useToast();
useEffect(() => {
const loadExams = async () => {
try {
setLoading(true);
setError(null);
const res = await examService.getMyExams();
setExams(res.items);
} catch (err) {
console.error('Failed to load exams:', err);
const errorMessage = getErrorMessage(err);
setError(errorMessage);
showToast(errorMessage, 'error');
} finally {
setLoading(false);
}
};
loadExams();
curriculumService.getSubjects().then(setSubjects);
}, []);
const getStatusVariant = (status: string) => status === 'Published' ? 'success' : 'warning';
const getStatusLabel = (status: string) => status === 'Published' ? '已发布' : '草稿';
const fetchExams = async () => {
try {
setLoading(true);
setError(null);
const res = await examService.getExams({
scope,
page,
pageSize,
subjectId: filters.subjectId !== 'all' ? filters.subjectId : undefined,
examType: filters.examType !== 'all' ? filters.examType : undefined,
status: filters.status !== 'all' ? filters.status : undefined
});
setExams(res.items);
setTotalCount(res.totalCount);
} catch (err) {
console.error(err);
const msg = getErrorMessage(err);
setError(msg);
showToast(msg, 'error');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900"></h2>
<p className="text-gray-500 mt-1"></p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
</div>
);
}
useEffect(() => {
fetchExams();
}, [scope, filters, page]);
// Reset page when filters change
useEffect(() => {
setPage(1);
}, [scope, filters]);
const handleDelete = async (id: string) => {
if (!confirm('确定要删除该试卷吗?此操作不可恢复。')) return;
try {
await examService.deleteExam(id);
setExams(prev => prev.filter(e => e.id !== id));
setTotalCount(prev => prev - 1);
showToast('删除成功', 'success');
} catch (e) {
console.error('Failed to delete exam:', e);
const msg = getErrorMessage(e);
showToast(`删除失败: ${msg}`, 'error');
}
};
const handlePublishSuccess = () => {
showToast('作业发布成功', 'success');
setPublishExamId(null);
};
const examTypes = ['Midterm', 'Final', 'Unit', 'Weekly', 'Uncategorized'];
const statuses = [
{ value: 'all', label: '全部状态' },
{ value: 'Published', label: '已发布' },
{ value: 'Draft', label: '草稿' }
];
const totalPages = Math.ceil(totalCount / pageSize);
if (error) {
return (
<ErrorState
type={getErrorType(error) as any}
message={error}
onRetry={() => window.location.reload()}
/>
);
return <ErrorState type={getErrorType(error) as any} message={error} onRetry={fetchExams} />;
}
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col md:flex-row justify-between items-end md:items-center mb-8 gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900"></h2>
<p className="text-gray-500 mt-1 font-medium"></p>
{/* Header & Scope Toggle */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex bg-gray-100 p-1 rounded-xl">
<button
onClick={() => setScope('mine')}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${scope === 'mine' ? 'bg-white shadow text-blue-600' : 'text-gray-500 hover:text-gray-700'}`}
>
<User size={16} />
</button>
<button
onClick={() => setScope('public')}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${scope === 'public' ? 'bg-white shadow text-blue-600' : 'text-gray-500 hover:text-gray-700'}`}
>
<Globe size={16} />
</button>
</div>
<div className="flex gap-3 w-full md:w-auto">
<div className="relative flex-1 md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder="搜索试卷..."
className="w-full pl-10 pr-4 py-3 rounded-xl border-none bg-white shadow-sm focus:ring-2 focus:ring-blue-500/20 outline-none text-sm font-medium"
/>
</div>
<Button icon={<Plus size={18} />} onClick={onCreate}></Button>
<Button icon={<Plus size={18} />} onClick={onCreateExam}>
</Button>
</div>
</div>
<div className="grid gap-4">
{exams.map((exam, idx) => (
<Card key={exam.id} delay={idx * 0.1} className="flex flex-col md:flex-row items-center gap-6 hover:border-blue-300 transition-all group">
<div className="w-16 h-16 rounded-2xl bg-blue-50 text-blue-600 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-600 group-hover:text-white transition-colors duration-300 shadow-sm">
<FileText size={28} />
</div>
<div className="flex-1 min-w-0 w-full text-center md:text-left">
<div className="flex items-center justify-center md:justify-start gap-3 mb-2">
<Badge variant={getStatusVariant(exam.status) as any}>{getStatusLabel(exam.status)}</Badge>
<span className="text-xs text-gray-400 font-medium">: {exam.createdAt}</span>
</div>
<h3 className="text-lg font-bold text-gray-900 truncate mb-1">{exam.title}</h3>
<div className="flex items-center justify-center md:justify-start gap-4 text-sm text-gray-500 mt-2">
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
<Clock size={14} className="text-gray-400" />
<span>{exam.duration} </span>
{/* Filters */}
<Card className="p-4 flex flex-wrap gap-4 items-center" noPadding>
<div className="flex items-center gap-2 text-gray-500 mr-2">
<Filter size={18} />
<span className="font-bold text-sm">:</span>
</div>
<select
value={filters.subjectId}
onChange={(e) => setFilters({...filters, subjectId: e.target.value})}
className="bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none min-w-[120px]"
>
<option value="all"></option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<select
value={filters.examType}
onChange={(e) => setFilters({...filters, examType: e.target.value})}
className="bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none min-w-[120px]"
>
<option value="all"></option>
{examTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex bg-gray-100 p-1 rounded-lg">
{statuses.map(s => (
<button
key={s.value}
onClick={() => setFilters({...filters, status: s.value})}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${filters.status === s.value ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500 hover:text-gray-700'}`}
>
{s.label}
</button>
))}
</div>
</Card>
{/* List */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
</div>
) : exams.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-3xl border-2 border-dashed border-gray-200">
<FileText className="mx-auto text-gray-300 mb-4" size={48} />
<h3 className="text-lg font-bold text-gray-900 mb-2"></h3>
<p className="text-gray-500 mb-6"></p>
<Button variant="outline" onClick={onCreateExam}></Button>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{exams.map((exam, idx) => (
<Card key={exam.id} delay={idx * 0.05} className="group hover:border-blue-200 transition-all flex flex-col h-full">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${exam.status === 'Published' ? 'bg-green-100 text-green-600' : 'bg-amber-100 text-amber-600'}`}>
<FileText size={20} />
</div>
<div>
<Badge variant={exam.status === 'Published' ? 'success' : 'warning'}>
{exam.status === 'Published' ? '已发布' : '草稿'}
</Badge>
<div className="text-[10px] text-gray-400 mt-1 flex items-center gap-1">
<Calendar size={10} />
{new Date(exam.createdAt).toLocaleDateString()}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
<span className="font-bold text-gray-700">{exam.questionCount}</span>
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-1" title={exam.title}>
{exam.title}
</h3>
<div className="flex flex-wrap gap-2 mb-3">
{scope === 'public' && exam.creatorName && (
<span className="text-[10px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full flex items-center gap-1">
<User size={10} /> {exam.creatorName}
</span>
)}
{/* Subject Name logic would require mapping subjectId to name, simpler to just show type/duration for now or fetch subject map */}
</div>
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
: <span className="font-bold text-gray-700">{exam.totalScore}</span>
<div className="grid grid-cols-3 gap-2 mb-6 mt-auto">
<div className="bg-gray-50 p-2 rounded-lg text-center">
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><Clock size={10}/> </div>
<div className="font-bold text-gray-900">{exam.duration}m</div>
</div>
<div className="bg-gray-50 p-2 rounded-lg text-center">
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><BarChart3 size={10}/> </div>
<div className="font-bold text-blue-600">{exam.totalScore}</div>
</div>
<div className="bg-gray-50 p-2 rounded-lg text-center">
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><Users size={10}/> 使</div>
<div className="font-bold text-gray-900">{exam.usageCount || 0}</div>
</div>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-gray-100">
<button
onClick={() => onEditExam(exam.id)}
className="flex-1 py-2 rounded-lg bg-gray-50 text-gray-600 text-sm font-bold hover:bg-gray-100 hover:text-gray-900 transition-colors flex items-center justify-center gap-2"
>
<PenTool size={14} />
{scope === 'mine' ? (exam.status === 'Draft' ? '编辑' : '查看') : '查看'}
</button>
{exam.status === 'Draft' ? (
<button
onClick={async () => {
if (!confirm('确定要发布该试卷吗?发布后将对所有人可见。')) return;
try {
// We need to get the detail first to save it with new status,
// or backend could support a patch status endpoint.
// Currently assuming we need to fetch and save full object or update partial.
// Let's try to use saveExam with minimal fields or full object if needed.
// Actually, updateExam endpoint supports partial update?
// Let's fetch detail first to be safe and ensure we have all data to save back.
const detail = await examService.getExamDetail(exam.id);
await examService.saveExam({ ...detail, status: 'Published' });
setExams(prev => prev.map(e => e.id === exam.id ? { ...e, status: 'Published' } : e));
showToast('试卷发布成功', 'success');
} catch (e) {
console.error(e);
showToast('发布失败', 'error');
}
}}
className="flex-1 py-2 rounded-lg bg-green-50 text-green-600 text-sm font-bold hover:bg-green-100 hover:text-green-700 transition-colors flex items-center justify-center gap-2"
title="发布试卷"
>
<Send size={14} />
</button>
) : (
<button
onClick={() => setPublishExamId(exam.id)}
className="flex-1 py-2 rounded-lg bg-blue-50 text-blue-600 text-sm font-bold hover:bg-blue-100 hover:text-blue-700 transition-colors flex items-center justify-center gap-2"
title="布置作业"
>
<Send size={14} />
</button>
)}
{scope === 'mine' && (
<button
onClick={() => handleDelete(exam.id)}
className="p-2 rounded-lg bg-red-50 text-red-500 hover:bg-red-100 hover:text-red-600 transition-colors"
title="删除试卷"
>
<Trash2 size={16} />
</button>
)}
</div>
</Card>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<Button
variant="ghost"
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
icon={<ChevronLeft size={20} />}
>
</Button>
<span className="text-sm font-bold text-gray-500">
{page} / {totalPages}
</span>
<Button
variant="ghost"
disabled={page === totalPages}
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
icon={<ChevronRight size={20} />}
iconPosition="right"
>
</Button>
</div>
<div className="flex items-center gap-3 w-full md:w-auto justify-center md:justify-end border-t md:border-t-0 pt-4 md:pt-0 border-gray-100">
<Button variant="outline" size="sm" icon={<BarChart3 size={16} />} onClick={() => onStats(exam.id)}></Button>
<Button variant="outline" size="sm" icon={<PenTool size={16} />} onClick={() => onEdit(exam.id)}></Button>
</div>
</Card>
))}
</div>
)}
</>
)}
{publishExamId && (
<CreateAssignmentModal
isOpen={true}
onClose={() => setPublishExamId(null)}
preSelectedExamId={publishExamId}
onSuccess={handlePublishSuccess}
/>
)}
</div>
);
};

View File

@@ -2,7 +2,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { UserProfileDto } from '../../UI_DTO';
import { authService, subscribeApiMode } from '@/services/api';
import { authService } from '@/services/api';
import { useRouter, usePathname } from 'next/navigation';
interface AuthContextType {
@@ -35,14 +35,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
// Re-check auth when API mode changes (Strategy Pattern hook)
useEffect(() => {
return subscribeApiMode(() => {
setLoading(true);
checkAuth();
});
}, []);
useEffect(() => {
checkAuth();
}, []);

View File

@@ -1,35 +0,0 @@
// This file would typically use mysql2/promise
// import mysql from 'mysql2/promise';
// Mock DB Configuration storage (In-memory for demo, use env vars in prod)
let dbConfig = {
host: 'localhost',
port: 3306,
user: 'root',
password: '',
database: 'edunexus'
};
// Mock Connection Pool
export const db = {
query: async (sql: string, params?: any[]) => {
// In a real app:
// const connection = await mysql.createConnection(dbConfig);
// const [rows] = await connection.execute(sql, params);
// return rows;
console.log(`[MockDB] Executing SQL: ${sql}`, params);
return [];
},
testConnection: async (config: typeof dbConfig) => {
// Simulate connection attempt
await new Promise(resolve => setTimeout(resolve, 1000));
if (config.host === 'error') throw new Error('Connection timed out');
// Update active config
dbConfig = config;
return true;
},
getConfig: () => dbConfig
};

View File

@@ -1,24 +0,0 @@
import { NextResponse } from 'next/server';
// Simulate database latency
export const dbDelay = () => new Promise(resolve => setTimeout(resolve, 500));
// Standardize JSON success response
export function successResponse(data: any, status = 200) {
return NextResponse.json(data, { status });
}
// Standardize JSON error response
export function errorResponse(message: string, status = 400) {
return NextResponse.json({ success: false, message }, { status });
}
// Helper to extract token from header
export function extractToken(request: Request): string | null {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.split(' ')[1];
}

View File

@@ -1,19 +1,6 @@
import * as realApi from './realApi';
// API Mode Management (Deprecated: Mock mode is permanently disabled)
export const getApiMode = () => false;
export const setApiMode = (isMock: boolean) => {
console.warn("[API] Mock mode is disabled. Always using Real API.");
};
export const subscribeApiMode = (listener: () => void) => {
// No-op as mode never changes
return () => {};
};
// Export Real Services Directly
export const authService = realApi.realAuthService;
export const orgService = realApi.realOrgService;
export const curriculumService = realApi.realCurriculumService;

View File

@@ -6,7 +6,7 @@ import {
TextbookDto,
QuestionSummaryDto, PagedResult, ParsedQuestionDto,
ExamDto, ExamDetailDto, ExamStatsDto,
AssignmentTeacherViewDto, AssignmentStudentViewDto,
AssignmentTeacherViewDto, AssignmentStudentViewDto, AssignmentAnalysisDto,
StudentSubmissionSummaryDto, GradingPaperDto, StudentExamPaperDto, SubmitExamDto, StudentResultDto,
ChartDataDto, RadarChartDto, ScoreDistributionDto, ScheduleDto, CreateScheduleDto,
MessageDto, CreateMessageDto
@@ -60,17 +60,23 @@ export interface IQuestionService {
}
export interface IExamService {
getMyExams(): Promise<PagedResult<ExamDto>>;
getExams(filter?: { subjectId?: string; status?: string; scope?: 'mine' | 'public'; page?: number; pageSize?: number; examType?: string }): Promise<PagedResult<ExamDto>>;
getExamDetail(id: string): Promise<ExamDetailDto>;
saveExam(exam: ExamDetailDto): Promise<void>;
createExam(data: any): Promise<ExamDetailDto>;
updateExam(id: string, data: any): Promise<any>;
deleteExam(id: string): Promise<any>;
saveExam(data: ExamDetailDto): Promise<any>;
getStats(id: string): Promise<ExamStatsDto>;
}
export interface IAssignmentService {
getTeachingAssignments(): Promise<PagedResult<AssignmentTeacherViewDto>>;
getStudentAssignments(): Promise<PagedResult<AssignmentStudentViewDto>>;
getTeachingAssignments(filters?: { classId?: string; examType?: string; subjectId?: string; status?: string }): Promise<PagedResult<AssignmentTeacherViewDto>>;
getStudentAssignments(filters?: { subjectId?: string; examType?: string; status?: string }): Promise<PagedResult<AssignmentStudentViewDto>>;
publishAssignment(data: any): Promise<void>;
updateAssignment(id: string, data: any): Promise<void>;
archiveAssignment(id: string): Promise<void>;
getAssignmentStats(id: string): Promise<ExamStatsDto>;
getAssignmentAnalysis(id: string): Promise<AssignmentAnalysisDto>;
}
export interface IAnalyticsService {
@@ -80,6 +86,7 @@ export interface IAnalyticsService {
getStudentRadar(): Promise<RadarChartDto>;
getScoreDistribution(): Promise<ScoreDistributionDto[]>;
getTeacherStats(): Promise<{ activeStudents: number; averageScore: number; pendingGrading: number; passRate: number }>;
getStudentStats(): Promise<{ completed: number; todo: number; average: number; studyDuration: number }>;
}
export interface QuestionFilterDto {
@@ -112,8 +119,10 @@ export interface IGradingService {
export interface ISubmissionService {
getStudentPaper(assignmentId: string): Promise<StudentExamPaperDto>;
submitExam(data: SubmitExamDto): Promise<void>;
getSubmissionResult(assignmentId: string): Promise<StudentResultDto>;
submitAnswers(assignmentId: string, answers: Array<{ examNodeId: string; studentAnswer: any }>, timeSpent?: number): Promise<{ message: string; submissionId: string }>;
saveProgress(assignmentId: string, answers: Array<{ examNodeId: string; studentAnswer: any }>): Promise<void>;
getSubmissionResult(submissionId: string): Promise<StudentResultDto>;
getSubmissionResultByAssignment(assignmentId: string): Promise<StudentResultDto>;
}
export interface ICommonService {

View File

@@ -1,696 +0,0 @@
import {
IAuthService, IOrgService, ICurriculumService, IQuestionService,
IExamService, IAssignmentService, IAnalyticsService, IGradingService,
ISubmissionService, ICommonService, IMessageService, IScheduleService
} from './interfaces';
import {
LoginResultDto, UserProfileDto, RegisterDto, UpdateProfileDto, ChangePasswordDto,
ClassDto, CreateClassDto, ClassMemberDto, SubjectDto, CurriculumTreeDto,
UnitNodeDto,
TextbookDto,
QuestionSummaryDto, ParsedQuestionDto, PagedResult, ExamDto, ExamDetailDto, ExamStatsDto,
ExamNodeDto, AssignmentTeacherViewDto, AssignmentStudentViewDto,
StudentSubmissionSummaryDto, GradingPaperDto, StudentExamPaperDto, SubmitExamDto, StudentResultDto,
ChartDataDto, RadarChartDto, ScoreDistributionDto, ScheduleDto, CreateScheduleDto,
MessageDto, CreateMessageDto
} from '../../UI_DTO';
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// --- Stateful Mock Data ---
let MOCK_CLASSES: ClassDto[] = [
{ id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' },
{ id: 'c-2', name: '高一 (12) 班', gradeName: '高一年级', teacherName: '张伟', studentCount: 28, inviteCode: 'M2L4Q' },
{ id: 'c-3', name: 'AP 微积分先修班', gradeName: '高三年级', teacherName: '李明', studentCount: 15, inviteCode: 'Z9J1W' },
{ id: 'c-4', name: '物理奥赛集训队', gradeName: '高二年级', teacherName: '王博士', studentCount: 20, inviteCode: 'H4R8T' },
];
let MOCK_STUDENT_CLASSES: ClassDto[] = [
{ id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' }
];
let MOCK_MESSAGES: MessageDto[] = [
{
id: 'msg-1',
title: '关于下周校运会的安排通知',
content: '各位同学、老师:\n\n下周一11月6日将举行第20届秋季运动会请各班做好入场式准备。周一至周二停课两天周三正常上课。\n\n教务处',
type: 'Announcement',
senderName: '教务处',
senderAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=AO',
createdAt: '2小时前',
isRead: false
},
{
id: 'msg-2',
title: '数学期中考试成绩已发布',
content: '高一年级数学期中考试阅卷工作已结束,请各位同学前往“考试结果”查看详情。',
type: 'Notification',
senderName: '李明',
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex',
createdAt: '昨天 14:00',
isRead: true
},
{
id: 'msg-3',
title: '系统维护通知',
content: '系统将于本周日凌晨 02:00 - 04:00 进行例行维护,届时将无法访问,请留意。',
type: 'Alert',
senderName: '系统管理员',
createdAt: '2023-10-28',
isRead: true
}
];
let MOCK_SCHEDULE: ScheduleDto[] = [
{ id: 'sch-1', dayOfWeek: 1, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
{ id: 'sch-2', dayOfWeek: 1, period: 2, startTime: '09:00', endTime: '09:45', className: '高一 (12) 班', subject: '数学', room: 'A303', isToday: false },
{ id: 'sch-3', dayOfWeek: 2, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
{ id: 'sch-4', dayOfWeek: 2, period: 3, startTime: '10:00', endTime: '10:45', className: 'AP 微积分', subject: '微积分', room: 'B102', isToday: false },
{ id: 'sch-5', dayOfWeek: 3, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
{ id: 'sch-6', dayOfWeek: 3, period: 2, startTime: '09:00', endTime: '09:45', className: '高一 (12) 班', subject: '数学', room: 'A303', isToday: false },
{ id: 'sch-7', dayOfWeek: 4, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
{ id: 'sch-8', dayOfWeek: 5, period: 4, startTime: '11:00', endTime: '11:45', className: '奥赛集训', subject: '物理', room: 'Lab 1', isToday: false },
];
export const mockAuthService: IAuthService = {
login: async (username: string): Promise<LoginResultDto> => {
await delay(800);
let role: 'Teacher' | 'Student' | 'Admin' = 'Teacher';
let name = '李明';
let id = 'u-tea-1';
if (username === 'student' || username === '123456' && username.startsWith('s')) {
role = 'Student';
name = '王小明';
id = 'u-stu-1';
} else if (username === 'admin') {
role = 'Admin';
name = '系统管理员';
id = 'u-adm-1';
}
return {
token: "mock-jwt-token-12345",
user: {
id: id,
realName: name,
studentId: username,
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${name}`,
gender: 'Male',
schoolId: 's-1',
role: role,
email: 'liming@school.edu',
phone: '13800138000',
bio: '热爱教育,专注数学教学创新。'
}
};
},
register: async (data: RegisterDto): Promise<LoginResultDto> => {
await delay(1200);
return {
token: "mock-jwt-token-new-user",
user: {
id: `u-${Date.now()}`,
realName: data.realName,
studentId: data.studentId,
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${data.realName}`,
gender: 'Male',
schoolId: 's-1',
role: data.role,
email: '',
phone: '',
bio: '新注册用户'
}
};
},
me: async (): Promise<UserProfileDto> => {
await delay(500);
return {
id: "u-1",
realName: "李明",
studentId: "T2024001",
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alex",
gender: "Male",
schoolId: "s-1",
role: "Teacher",
email: 'liming@school.edu',
phone: '13800138000',
bio: '热爱教育,专注数学教学创新。'
};
},
updateProfile: async (data: UpdateProfileDto): Promise<UserProfileDto> => {
await delay(1000);
return {
id: "u-1",
realName: data.realName || "李明",
studentId: "T2024001",
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alex",
gender: "Male",
schoolId: "s-1",
role: "Teacher",
email: data.email || 'liming@school.edu',
phone: data.phone || '13800138000',
bio: data.bio || '热爱教育,专注数学教学创新。'
};
},
changePassword: async (data: ChangePasswordDto): Promise<void> => {
await delay(1200);
if (data.oldPassword !== '123456') {
throw new Error('旧密码错误');
}
}
};
export const mockOrgService: IOrgService = {
getClasses: async (role?: string): Promise<ClassDto[]> => {
await delay(600);
if (role === 'Student') {
return [...MOCK_STUDENT_CLASSES];
}
return [...MOCK_CLASSES];
},
getClassMembers: async (classId: string): Promise<ClassMemberDto[]> => {
await delay(600);
return Array.from({ length: 32 }).map((_, i) => ({
id: `stu-${i}`,
studentId: `2024${1000 + i}`,
realName: i % 2 === 0 ? `${i + 1}` : `${i + 1}`,
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${classId}-stu-${i}`,
gender: Math.random() > 0.5 ? 'Male' : 'Female',
role: i === 0 ? 'Monitor' : (i < 5 ? 'Committee' : 'Student'),
recentTrend: [
Math.floor(Math.random() * 20) + 80,
Math.floor(Math.random() * 20) + 80,
Math.floor(Math.random() * 20) + 80,
Math.floor(Math.random() * 20) + 80,
Math.floor(Math.random() * 20) + 80,
],
status: i > 28 ? 'AtRisk' : (i < 5 ? 'Excellent' : 'Active'),
attendanceRate: i > 30 ? 85 : 98
}));
},
joinClass: async (inviteCode: string): Promise<void> => {
await delay(1500);
const targetClass = MOCK_CLASSES.find(c => c.inviteCode === inviteCode);
if (!targetClass) throw new Error('无效的邀请码');
const alreadyJoined = MOCK_STUDENT_CLASSES.find(c => c.id === targetClass.id);
if (alreadyJoined) throw new Error('你已经加入了该班级');
MOCK_STUDENT_CLASSES.push(targetClass);
},
createClass: async (data: CreateClassDto): Promise<ClassDto> => {
await delay(1000);
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 5; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
const newClass: ClassDto = {
id: `c-new-${Date.now()}`,
name: data.name,
gradeName: data.gradeName,
teacherName: '李明',
studentCount: 0,
inviteCode: code
};
MOCK_CLASSES.push(newClass);
return newClass;
}
};
export const mockCurriculumService: ICurriculumService = {
getSubjects: async (): Promise<SubjectDto[]> => {
await delay(400);
return [
{ id: 'sub-1', name: '数学', code: 'MATH', icon: '📐' },
{ id: 'sub-2', name: '物理', code: 'PHYS', icon: '⚡' },
{ id: 'sub-3', name: '英语', code: 'ENG', icon: '🔤' },
{ id: 'sub-4', name: '化学', code: 'CHEM', icon: '🧪' },
{ id: 'sub-5', name: '历史', code: 'HIST', icon: '🏛️' },
];
},
getTree: async (id: string): Promise<CurriculumTreeDto> => {
await delay(600);
return {
textbook: { id: 'tb-1', name: '七年级数学上册', publisher: '人教版', versionYear: '2023', coverUrl: '' },
units: []
};
},
getTextbooksBySubject: async (subjectId: string): Promise<TextbookDto[]> => {
await delay(400);
return [
{ id: 'tb-1', name: '七年级数学上册', publisher: '人教版', versionYear: '2023', coverUrl: '' },
{ id: 'tb-2', name: '七年级数学下册', publisher: '人教版', versionYear: '2023', coverUrl: '' }
];
},
// Stubs for CRUD
createTextbook: async () => { },
updateTextbook: async () => { },
deleteTextbook: async () => { },
createUnit: async () => { },
updateUnit: async () => { },
deleteUnit: async () => { },
createLesson: async () => { },
updateLesson: async () => { },
deleteLesson: async () => { },
createKnowledgePoint: async () => { },
updateKnowledgePoint: async () => { },
deleteKnowledgePoint: async () => { }
};
export const mockQuestionService: IQuestionService = {
search: async (filter: any): Promise<PagedResult<QuestionSummaryDto & { answer?: string, parse?: string }>> => {
await delay(600);
const mockQuestions = [
{
id: 'q-math-1',
type: '单选题',
difficulty: 2,
knowledgePoints: ['集合', '交集运算'],
content: `<p>已知集合 <span class="font-serif italic">A</span> = {1, 2, 3}, <span class="font-serif italic">B</span> = {2, 3, 4}, 则 <span class="font-serif italic">A</span> ∩ <span class="font-serif italic">B</span> = ( )</p>
<div class="grid grid-cols-4 gap-4 mt-2 text-sm">
<div>A. {1}</div>
<div>B. {2, 3}</div>
<div>C. {1, 2, 3, 4}</div>
<div>D. ∅</div>
</div>`,
answer: 'B',
parse: '集合 A 与 B 的公共元素为 2 和 3故 A ∩ B = {2, 3}。'
},
{
id: 'q-math-2',
type: '填空题',
difficulty: 3,
knowledgePoints: ['函数', '导数'],
content: `<p>函数 <span class="font-serif italic">f(x)</span> = <span class="font-serif">x</span>ln<span class="font-serif">x</span> 在点 <span class="font-serif italic">x</span> = 1 处的切线方程为 ______.</p>`,
answer: 'x - y - 1 = 0',
parse: 'f\'(x) = lnx + 1, f\'(1) = 1. 又 f(1)=0, 故切线方程为 y-0 = 1*(x-1), 即 x-y-1=0.'
}
];
const items = [...mockQuestions, ...mockQuestions];
return {
totalCount: items.length,
pageIndex: 1,
pageSize: 10,
items: items.map((q, i) => ({ ...q, id: `${q.id}-${i}` }))
};
},
parseText: async (rawText: string): Promise<ParsedQuestionDto[]> => {
await delay(1200);
const parsedQuestions: ParsedQuestionDto[] = [];
const questionBlocks = rawText.split(/\n(?=\d+\.)/g).filter(b => b.trim().length > 0);
questionBlocks.forEach(block => {
const stemMatch = block.match(/^\d+\.([\s\S]*?)(?=(?:A\.|Answer:|答案:|解析:|$))/);
let content = stemMatch ? stemMatch[1].trim() : block;
const optionsMatch = block.match(/([A-D])\.\s*([^\n]+)/g);
let type = '填空题';
let optionsHTML = '';
if (optionsMatch && optionsMatch.length >= 4) {
type = '单选题';
optionsHTML = `<div class="grid grid-cols-4 gap-2 mt-2 text-sm">
${optionsMatch.map(opt => `<div>${opt.trim()}</div>`).join('')}
</div>`;
}
const answerMatch = block.match(/(?:Answer|答案)[:]\s*([^\n]+)/);
const answer = answerMatch ? answerMatch[1].trim() : '';
const parseMatch = block.match(/(?:Parse|解析|Analysis)[:]\s*([\s\S]+)/);
const parse = parseMatch ? parseMatch[1].trim() : '暂无解析';
content = `<p>${content}</p>${optionsHTML}`;
parsedQuestions.push({ content, type, answer, parse });
});
return parsedQuestions;
}
,
create: async (data: any): Promise<any> => {
await delay(300);
return { id: `q-${Date.now()}` };
},
update: async (id: string, data: any): Promise<any> => {
await delay(300);
return { id };
},
delete: async (id: string): Promise<any> => {
await delay(300);
return { id };
}
};
export const mockExamService: IExamService = {
getMyExams: async (): Promise<PagedResult<ExamDto>> => {
await delay(700);
return {
totalCount: 5,
pageIndex: 1,
pageSize: 10,
items: [
{ id: 'e-1', subjectId: 'sub-1', title: '2024-2025学年第一学期期中数学考试', totalScore: 100, duration: 120, questionCount: 22, status: 'Published', createdAt: '2024-10-15' },
{ id: 'e-2', subjectId: 'sub-1', title: '第一单元随堂测试:集合与函数', totalScore: 25, duration: 30, questionCount: 8, status: 'Draft', createdAt: '2024-10-20' },
]
};
},
getExamDetail: async (id: string): Promise<ExamDetailDto> => {
await delay(1000);
return {
id,
subjectId: 'sub-1',
title: '2024-2025学年第一学期期中数学考试',
totalScore: 100,
duration: 120,
questionCount: 10,
status: 'Draft',
createdAt: '2024-10-15',
rootNodes: [
{
id: 'node-1',
nodeType: 'Group',
title: '第一部分:选择题',
description: '本大题共 8 小题,每小题 5 分,共 40 分。',
score: 40,
sortOrder: 1,
children: [
{
id: 'node-1-1',
nodeType: 'Question',
questionId: 'q-1',
questionContent: '已知集合 A={1,2}, B={2,3}, 则 A∩B=?',
questionType: '单选题',
score: 5,
sortOrder: 1
},
{
id: 'node-1-2',
nodeType: 'Question',
questionId: 'q-2',
questionContent: '函数 f(x) = x² + 2x - 3 的零点是?',
questionType: '单选题',
score: 5,
sortOrder: 2
}
]
},
{
id: 'node-2',
nodeType: 'Group',
title: '第二部分:解答题',
description: '需要写出完整解题过程',
score: 60,
sortOrder: 2,
children: [
{
id: 'node-2-1',
nodeType: 'Group',
title: '(一) 计算题',
score: 30,
sortOrder: 1,
children: [
{
id: 'node-2-1-1',
nodeType: 'Question',
questionId: 'q-3',
questionContent: '计算:(1) 2x + 3 = 7',
questionType: '计算题',
score: 10,
sortOrder: 1
},
{
id: 'node-2-1-2',
nodeType: 'Question',
questionId: 'q-4',
questionContent: '计算:(2) 解方程组 ...',
questionType: '计算题',
score: 10,
sortOrder: 2
}
]
},
{
id: 'node-2-2',
nodeType: 'Question',
questionId: 'q-5',
questionContent: '证明:等腰三角形两底角相等',
questionType: '证明题',
score: 30,
sortOrder: 2
}
]
}
]
};
},
saveExam: async (exam: ExamDetailDto): Promise<void> => {
await delay(800);
console.log('Saved exam:', exam);
},
getStats: async (id: string): Promise<ExamStatsDto> => {
await delay(800);
return {
averageScore: 78.5,
passRate: 92.4,
maxScore: 100,
minScore: 42,
scoreDistribution: [{ range: '0-60', count: 2 }, { range: '90-100', count: 8 }],
wrongQuestions: [
{ id: 'q-1', content: '已知集合 A={1,2}, B={2,3}, 则 A∩B=?', errorRate: 45, difficulty: 2, type: '单选题' },
]
};
}
};
export const mockAssignmentService: IAssignmentService = {
getTeachingAssignments: async (): Promise<PagedResult<AssignmentTeacherViewDto>> => {
await delay(500);
return {
totalCount: 4,
pageIndex: 1,
pageSize: 10,
items: [
{ id: 'a-1', title: '期中考试模拟卷', examTitle: '2024-2025学年第一学期期中数学考试', className: '高一 (10) 班', submittedCount: 30, totalCount: 32, status: 'Active', dueDate: '2023-11-01' },
]
};
},
getStudentAssignments: async (): Promise<PagedResult<AssignmentStudentViewDto>> => {
await delay(500);
return {
totalCount: 3,
pageIndex: 1,
pageSize: 10,
items: [
{ id: 'a-1', title: '期中考试模拟卷', examTitle: '2024-2025学年第一学期期中数学考试', endTime: '2023-11-01', status: 'Pending' },
]
}
},
publishAssignment: async (data: any): Promise<void> => {
await delay(1000);
console.log('Published assignment:', data);
},
getAssignmentStats: async (id: string): Promise<ExamStatsDto> => {
await delay(800);
return {
averageScore: 82.5,
passRate: 95.0,
maxScore: 100,
minScore: 58,
scoreDistribution: [],
wrongQuestions: []
};
}
};
export const mockAnalyticsService: IAnalyticsService = {
getClassPerformance: async (): Promise<ChartDataDto> => {
await delay(700);
return {
labels: ['周一', '周二', '周三'],
datasets: [
{ label: '平均分', data: [78, 82, 80], borderColor: '#007AFF', backgroundColor: 'rgba(0, 122, 255, 0.1)' }
]
};
},
getStudentGrowth: async (): Promise<ChartDataDto> => {
await delay(700);
return {
labels: ['第一次', '第二次'],
datasets: [
{ label: '我的成绩', data: [82, 85], borderColor: '#34C759', backgroundColor: 'rgba(52, 199, 89, 0.1)', fill: true },
{ label: '班级平均', data: [75, 78], borderColor: '#8E8E93', backgroundColor: 'transparent', fill: false }
]
};
},
getRadar: async (): Promise<RadarChartDto> => {
await delay(700);
return { indicators: ['代数', '几何'], values: [85, 70] };
},
getStudentRadar: async (): Promise<RadarChartDto> => {
await delay(700);
return { indicators: ['代数', '几何'], values: [90, 60] };
},
getScoreDistribution: async (): Promise<ScoreDistributionDto[]> => {
await delay(600);
return [{ range: '0-60', count: 2 }, { range: '90-100', count: 8 }];
},
getTeacherStats: async () => {
await delay(600);
return {
activeStudents: 1240,
averageScore: 84.5,
pendingGrading: 38,
passRate: 96
};
}
};
export const mockGradingService: IGradingService = {
getSubmissions: async (assignmentId: string): Promise<StudentSubmissionSummaryDto[]> => {
await delay(600);
return Array.from({ length: 15 }).map((_, i) => ({
id: `sub-${i}`,
studentName: `学生 ${i + 1}`,
studentId: `20240${i < 10 ? '0' + i : i}`,
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
status: i < 5 ? 'Graded' : 'Submitted',
score: i < 5 ? 85 + (i % 10) : undefined,
submitTime: '2024-10-24 14:30'
}));
},
getPaper: async (submissionId: string): Promise<GradingPaperDto> => {
await delay(800);
return {
submissionId,
studentName: '王小明',
nodes: [
{
examNodeId: 'node-1',
questionId: 'q1',
questionContent: '已知集合 A={x|x²-2x-3<0}, B={x|y=ln(2-x)}, 求 A∩B.',
questionType: '计算题',
score: 10,
studentAnswer: 'https://placehold.co/600x300/png?text=Student+Handwriting+Here',
studentScore: undefined
}
]
};
}
,
submitGrade: async (submissionId: string, grades: any[]): Promise<{ message: string; totalScore: number }> => {
await delay(600);
const totalScore = grades.reduce((sum, g) => sum + (g.score || 0), 0);
return { message: 'ok', totalScore };
}
};
export const mockSubmissionService: ISubmissionService = {
getStudentPaper: async (assignmentId: string): Promise<StudentExamPaperDto> => {
await delay(1200);
return {
examId: 'e-101',
title: '2024-2025学年第一学期期中数学考试',
duration: 90,
totalScore: 100,
rootNodes: [
{
id: 'node-1',
nodeType: 'Group',
title: '一、选择题',
score: 40,
sortOrder: 1,
children: [
{
id: 'node-1-1',
nodeType: 'Question',
questionId: 'q-1',
questionContent: '已知集合 A={1,2,3}, B={2,3,4}, 则 A∩B=( )',
questionType: '单选题',
score: 5,
sortOrder: 1
}
]
}
]
};
},
submitExam: async (data: SubmitExamDto): Promise<void> => {
await delay(1500);
console.log('Submitted:', data);
},
getSubmissionResult: async (assignmentId: string): Promise<StudentResultDto> => {
await delay(800);
return {
submissionId: 'sub-my-1',
studentName: '我',
totalScore: 88,
rank: 5,
beatRate: 85,
nodes: [
{
examNodeId: 'node-1',
questionId: 'q-1',
questionContent: '已知集合 A={1,2,3}, B={2,3,4}, 则 A∩B=( )',
questionType: '单选题',
score: 5,
studentScore: 5,
studentAnswer: '{2,3}',
autoCheckResult: true
}
]
}
}
}
export const mockCommonService: ICommonService = {
getSchedule: async (): Promise<ScheduleDto[]> => {
await delay(300);
const today = new Date().getDay() || 7;
return MOCK_SCHEDULE.filter(s => s.dayOfWeek === today).map(s => ({ ...s, isToday: true }));
}
}
export const mockMessageService: IMessageService = {
getMessages: async (): Promise<MessageDto[]> => {
await delay(500);
return [...MOCK_MESSAGES];
},
markAsRead: async (id: string): Promise<void> => {
await delay(200);
const msg = MOCK_MESSAGES.find(m => m.id === id);
if (msg) msg.isRead = true;
},
createMessage: async (data: CreateMessageDto): Promise<void> => {
await delay(800);
MOCK_MESSAGES.unshift({
id: `msg-${Date.now()}`,
title: data.title,
content: data.content,
type: data.type as any,
senderName: '我',
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex',
createdAt: '刚刚',
isRead: true
});
}
};
export const mockScheduleService: IScheduleService = {
getWeekSchedule: async (): Promise<ScheduleDto[]> => {
await delay(600);
return [...MOCK_SCHEDULE];
},
addEvent: async (data: CreateScheduleDto): Promise<void> => {
await delay(800);
MOCK_SCHEDULE.push({
id: `sch-${Date.now()}`,
...data,
isToday: false
});
},
deleteEvent: async (id: string): Promise<void> => {
await delay(500);
MOCK_SCHEDULE = MOCK_SCHEDULE.filter(s => s.id !== id);
}
};

View File

@@ -10,7 +10,7 @@ import {
} from '../../UI_DTO';
const API_BASE_URL = 'http://localhost:3001/api'; // 直接连接到后端服务器
const API_BASE_URL = 'http://127.0.0.1:8081/api';
const DEFAULT_TIMEOUT = 30000; // 30 秒超时
// Helper to handle requests with timeout
@@ -180,8 +180,28 @@ export const realQuestionService: IQuestionService = {
};
export const realExamService: IExamService = {
getMyExams: () => request('/exams'),
getExams: (filter) => {
const query = new URLSearchParams();
if (filter?.subjectId) query.append('subjectId', filter.subjectId);
if (filter?.status) query.append('status', filter.status);
if (filter?.scope) query.append('scope', filter.scope);
if (filter?.page) query.append('page', String(filter.page));
if (filter?.pageSize) query.append('pageSize', String(filter.pageSize));
if (filter?.examType) query.append('examType', filter.examType);
return request(`/exams?${query.toString()}`);
},
getExamDetail: (id) => request(`/exams/${id}`),
createExam: (data) => request('/exams', {
method: 'POST',
body: JSON.stringify(data)
}),
updateExam: (id, data) => request(`/exams/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
}),
deleteExam: (id) => request(`/exams/${id}`, {
method: 'DELETE'
}),
saveExam: async (exam: ExamDetailDto) => {
// Determine if create or update
if (exam.id) {
@@ -219,13 +239,34 @@ export const realExamService: IExamService = {
};
export const realAssignmentService: IAssignmentService = {
getTeachingAssignments: () => request('/assignments/teaching'),
getStudentAssignments: () => request('/assignments/learning'),
getTeachingAssignments: (filters) => {
const query = new URLSearchParams();
if (filters?.classId) query.append('classId', filters.classId);
if (filters?.examType) query.append('examType', filters.examType);
if (filters?.subjectId) query.append('subjectId', filters.subjectId);
if (filters?.status) query.append('status', filters.status);
return request(`/assignments/teaching?${query.toString()}`);
},
getStudentAssignments: (filters) => {
const query = new URLSearchParams();
if (filters?.subjectId) query.append('subjectId', filters.subjectId);
if (filters?.examType) query.append('examType', filters.examType);
if (filters?.status) query.append('status', filters.status);
return request(`/assignments/learning?${query.toString()}`);
},
publishAssignment: (data) => request('/assignments', {
method: 'POST',
body: JSON.stringify(data)
}),
getAssignmentStats: (id) => request(`/assignments/${id}/stats`)
updateAssignment: (id, data) => request(`/assignments/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
}),
archiveAssignment: (id) => request(`/assignments/${id}/archive`, {
method: 'POST'
}),
getAssignmentStats: (id) => request(`/assignments/${id}/stats`),
getAssignmentAnalysis: (id) => request(`/assignments/${id}/analysis`)
};
@@ -241,14 +282,16 @@ export const realGradingService: IGradingService = {
export const realSubmissionService: ISubmissionService = {
getStudentPaper: (id) => request(`/submissions/${id}/paper`),
submitExam: (data) => {
const answersArray = Object.entries(data.answers || {}).map(([examNodeId, studentAnswer]) => ({ examNodeId, studentAnswer: (typeof studentAnswer === 'object' ? JSON.stringify(studentAnswer) : studentAnswer) }));
return request(`/submissions/${data.assignmentId}/submit`, {
method: 'POST',
body: JSON.stringify({ answers: answersArray, timeSpent: data.timeSpent })
});
},
getSubmissionResult: (assignmentId: string) => request(`/submissions/by-assignment/${assignmentId}/result`),
submitAnswers: (assignmentId, answers, timeSpent) => request(`/submissions/${assignmentId}/submit`, {
method: 'POST',
body: JSON.stringify({ answers, timeSpent })
}),
saveProgress: (assignmentId, answers) => request(`/submissions/${assignmentId}/save`, {
method: 'POST',
body: JSON.stringify({ answers })
}),
getSubmissionResult: (submissionId) => request(`/submissions/${submissionId}/result`),
getSubmissionResultByAssignment: (assignmentId) => request(`/submissions/by-assignment/${assignmentId}/result`),
};
export const realCommonService: ICommonService = {
@@ -279,5 +322,6 @@ export const realAnalyticsService: IAnalyticsService = {
getRadar: () => request('/analytics/radar'),
getStudentRadar: () => request('/analytics/student/radar'),
getScoreDistribution: () => request('/analytics/distribution'),
getTeacherStats: () => request('/analytics/teacher-stats')
getTeacherStats: () => request('/analytics/teacher-stats'),
getStudentStats: () => request('/analytics/student/stats')
};

View File

@@ -1,344 +1 @@
// 0. Common
export interface ResultDto {
success: boolean;
message: string;
data?: any;
}
export interface PagedResult<T> {
items: T[];
totalCount: number;
pageIndex: number;
pageSize: number;
}
// 1. Auth & User
export interface UserProfileDto {
id: string;
realName: string;
studentId: string;
avatarUrl: string;
gender: string;
schoolId: string;
role: 'Admin' | 'Teacher' | 'Student';
email?: string;
phone?: string;
bio?: string;
}
export interface RegisterDto {
realName: string;
studentId: string; // 学号/工号
password: string;
role: 'Teacher' | 'Student';
}
export interface UpdateProfileDto {
realName?: string;
email?: string;
phone?: string;
bio?: string;
}
export interface ChangePasswordDto {
oldPassword: string;
newPassword: string;
}
export interface LoginResultDto {
token: string;
user: UserProfileDto;
}
// 2. Org
export interface SchoolDto {
id: string;
name: string;
regionCode: string;
address: string;
}
export interface ClassDto {
id: string;
name: string;
inviteCode: string;
gradeName: string;
teacherName: string;
studentCount: number;
}
export interface CreateClassDto {
name: string;
gradeName: string;
}
export interface ClassMemberDto {
id: string;
studentId: string;
realName: string;
avatarUrl: string;
gender: 'Male' | 'Female';
role: 'Student' | 'Monitor' | 'Committee'; // 班长/委员等
recentTrend: number[]; // Last 5 scores/performances
status: 'Active' | 'AtRisk' | 'Excellent';
attendanceRate: number;
}
export interface SchoolStructureDto {
school: SchoolDto;
grades: GradeNodeDto[];
}
export interface GradeNodeDto {
id: string;
name: string;
classes: ClassDto[];
}
// 3. Curriculum
export interface SubjectDto {
id: string;
name: string;
code: string;
icon?: string;
}
export interface TextbookDto {
id: string;
name: string;
publisher: string;
versionYear: string;
coverUrl: string;
}
export interface CurriculumTreeDto {
textbook: TextbookDto;
children: UnitNodeDto[];
}
export interface UnitNodeDto {
id: string;
name: string;
type: 'unit' | 'lesson' | 'point';
children?: UnitNodeDto[];
difficulty?: number;
}
// 4. Question
export interface QuestionSummaryDto {
id: string;
content: string; // HTML
type: string;
difficulty: number;
knowledgePoints: string[];
}
export interface ParsedQuestionDto {
content: string;
type: string;
options?: string[];
answer?: string;
parse?: string;
}
export interface QuestionFilterDto {
subjectId?: string;
type?: number;
difficulty?: number;
keyword?: string;
}
// 5. Exam
export interface ExamDto {
id: string;
title: string;
totalScore: number;
duration: number;
questionCount: number;
status: 'Draft' | 'Published';
createdAt: string;
}
export interface ExamQuestionNodeDto {
id: string; // node id (unique in exam structure)
questionId: string; // ref to QuestionSummaryDto
content: string;
type: string;
score: number;
}
export interface ExamSectionDto {
id: string;
title: string;
description?: string;
questions: ExamQuestionNodeDto[];
}
export interface ExamDetailDto extends ExamDto {
sections: ExamSectionDto[];
}
export interface WrongQuestionAnalysisDto {
id: string;
content: string;
errorRate: number; // 0-100
difficulty: number;
type: string;
}
export interface ExamStatsDto {
averageScore: number;
passRate: number;
maxScore: number;
minScore: number;
scoreDistribution: { range: string; count: number }[];
wrongQuestions: WrongQuestionAnalysisDto[];
}
// 6. Assignment
export interface AssignmentTeacherViewDto {
id: string;
title: string;
className: string;
submittedCount: number;
totalCount: number;
status: 'Active' | 'Ended' | 'Scheduled';
dueDate: string;
examTitle: string;
}
export interface AssignmentStudentViewDto {
id: string;
title: string;
examTitle: string;
endTime: string;
status: 'Pending' | 'Graded' | 'Submitted';
score?: number;
}
// 7. Submission / Student Exam
export interface StudentExamPaperDto {
examId: string;
title: string;
duration: number; // minutes
totalScore: number;
sections: {
id: string;
title: string;
questions: {
id: string; // questionId
content: string;
type: string; // '单选题' | '多选题' | '填空题' | '简答题'
score: number;
options?: string[]; // JSON strings or simple array if processed
}[];
}[];
}
export interface SubmitExamDto {
assignmentId: string;
answers: Record<string, any>;
timeSpent?: number;
}
// 8. Grading & Results
export interface StudentSubmissionSummaryDto {
id: string; // submissionId
studentName: string;
studentId: string;
avatarUrl: string;
status: 'Submitted' | 'Graded' | 'Late';
score?: number;
submitTime: string;
}
export interface GradingPaperDto {
submissionId: string;
studentName: string;
nodes: GradingNodeDto[];
}
export interface GradingNodeDto {
questionId: string;
questionContent: string;
questionType: string;
score: number; // max score
studentScore?: number; // current score
studentAnswer?: string; // Text or Image URL
teacherAnnotation?: string; // JSON for canvas
autoCheckResult?: boolean;
}
export interface StudentResultDto extends GradingPaperDto {
totalScore: number;
rank: number;
beatRate: number;
}
// 9. Analytics
export interface ChartDataDto {
labels: string[];
datasets: {
label: string;
data: number[];
borderColor?: string;
backgroundColor?: string;
fill?: boolean;
}[];
}
export interface RadarChartDto {
indicators: string[];
values: number[];
}
export interface ScoreDistributionDto {
range: string; // e.g. "90-100"
count: number;
}
// 10. Common / Dashboard
export interface ScheduleDto {
id: string;
startTime: string;
endTime: string;
className: string;
subject: string;
room: string;
isToday: boolean;
dayOfWeek?: number; // 1 = Monday, 7 = Sunday
period?: number; // 1-8
}
export interface CreateScheduleDto {
subject: string;
className: string;
room: string;
dayOfWeek: number;
period: number;
startTime: string;
endTime: string;
}
// 11. Messages
export interface MessageDto {
id: string;
title: string;
content: string;
type: 'Announcement' | 'Notification' | 'Alert';
senderName: string;
senderAvatar?: string;
createdAt: string;
isRead: boolean;
}
export interface CreateMessageDto {
title: string;
content: string;
type: 'Announcement' | 'Notification';
targetClassIds?: string[]; // Optional: if empty, broadcast to all managed classes
}
// UI Types
export type ViewState = 'login' | 'dashboard' | 'curriculum' | 'questions' | 'classes' | 'exams' | 'assignments' | 'settings' | 'grading' | 'student-exam' | 'student-result' | 'messages' | 'schedule';
export * from '../UI_DTO';

View File

@@ -1,58 +1,48 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ExamList } from '@/features/exam/components/ExamList';
import { ExamEditor } from '@/features/exam/components/ExamEditor';
import { ExamStats } from '@/features/exam/components/ExamStats';
export const ExamEngine: React.FC = () => {
const [view, setView] = useState<'list' | 'editor' | 'stats'>('list');
const [selectedId, setSelectedId] = useState<string | undefined>();
export const ExamEngine = () => {
const [viewState, setViewState] = useState<'list' | 'editor' | 'stats'>('list');
const [selectedExamId, setSelectedExamId] = useState<string | undefined>(undefined);
return (
<div className="max-w-[1600px] mx-auto h-full">
<AnimatePresence mode='wait'>
{view === 'list' && (
<motion.div
key="list"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<ExamList
onCreate={() => { setSelectedId(undefined); setView('editor'); }}
onEdit={(id) => { setSelectedId(id); setView('editor'); }}
onStats={(id) => { setSelectedId(id); setView('stats'); }}
/>
</motion.div>
)}
{view === 'editor' && (
<motion.div
key="editor"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="h-[calc(100vh-120px)]"
>
<ExamEditor
examId={selectedId}
onBack={() => setView('list')}
/>
</motion.div>
)}
{view === 'stats' && selectedId && (
<motion.div
key="stats"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<ExamStats
examId={selectedId}
onBack={() => setView('list')}
/>
</motion.div>
)}
</AnimatePresence>
const handleEditExam = (id: string) => {
setSelectedExamId(id);
setViewState('editor');
};
const handleCreateExam = () => {
setSelectedExamId(undefined);
setViewState('editor');
};
const handleStats = (id: string) => {
setSelectedExamId(id);
setViewState('stats');
};
const handleBack = () => {
setViewState('list');
setSelectedExamId(undefined);
};
if (viewState === 'editor') {
return <ExamEditor examId={selectedExamId} onBack={handleBack} />;
}
if (viewState === 'stats' && selectedExamId) {
return <ExamStats examId={selectedExamId} onBack={handleBack} />;
}
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto custom-scrollbar p-1">
<ExamList
onEditExam={handleEditExam}
onCreateExam={handleCreateExam}
/>
</div>
);
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { RunnerQuestionCard } from '@/features/exam/components/runner/RunnerQues
import { ExamHeader } from '@/features/exam/components/runner/ExamHeader';
import { AnswerSheet } from '@/features/exam/components/runner/AnswerSheet';
import { SubmitConfirmModal } from '@/features/exam/components/runner/SubmitConfirmModal';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { ChevronLeft, ChevronRight, Loader2, CheckCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface StudentExamRunnerProps {
@@ -26,17 +26,24 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
const [showSubmitModal, setShowSubmitModal] = useState(false);
const [submitting, setSubmitting] = useState(false);
const { showToast } = useToast();
const [lastSavedTime, setLastSavedTime] = useState<Date | null>(null);
useEffect(() => {
submissionService.getStudentPaper(assignmentId).then(data => {
setPaper(data);
setTimeLeft(data.duration * 60);
// Recursively collect all question nodes
// Initialize answers if user had some saved progress (TODO: Backend should return saved answers)
// For now assuming backend logic will populate initial answers later if we implement resume functionality.
// Actually, getStudentPaper does return studentAnswer in rootNodes!
// We need to extract them.
const initialAnswers: Record<string, any> = {};
const flattenNodes = (nodes: typeof data.rootNodes): any[] => {
const questions: any[] = [];
nodes.forEach(node => {
if (node.nodeType === 'Question') {
questions.push({ ...node, id: node.id });
if (node.studentAnswer) {
initialAnswers[node.id] = node.studentAnswer;
}
} else if (node.children) {
questions.push(...flattenNodes(node.children));
}
@@ -45,9 +52,33 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
};
const allQuestions = flattenNodes(data.rootNodes);
setFlatQuestions(allQuestions);
setAnswers(initialAnswers);
setTimeLeft(data.duration * 60);
});
}, [assignmentId]);
// Auto-save logic
useEffect(() => {
const saveInterval = setInterval(async () => {
if (Object.keys(answers).length > 0 && !submitting) {
// Transform answers for API
const answersArray = Object.entries(answers).map(([examNodeId, studentAnswer]) => ({
examNodeId,
studentAnswer: (typeof studentAnswer === 'object' ? JSON.stringify(studentAnswer) : studentAnswer)
}));
try {
await submissionService.saveProgress(assignmentId, answersArray);
setLastSavedTime(new Date());
} catch (err) {
console.error('Auto-save failed', err);
}
}
}, 30000); // Save every 30 seconds
return () => clearInterval(saveInterval);
}, [assignmentId, answers, submitting]);
useEffect(() => {
if (!paper) return;
const timer = setInterval(() => {
@@ -84,12 +115,12 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
setShowSubmitModal(false);
setSubmitting(true);
try {
const submitData: SubmitExamDto = {
assignmentId,
answers,
timeSpent: paper ? paper.duration * 60 - timeLeft : 0
};
await submissionService.submitExam(submitData);
const answerArray = Object.entries(answers).map(([key, value]) => ({
examNodeId: key,
studentAnswer: value
}));
const timeSpent = paper ? paper.duration * 60 - timeLeft : 0;
await submissionService.submitAnswers(assignmentId, answerArray, timeSpent);
showToast('试卷提交成功!正在跳转...', 'success');
setTimeout(onExit, 2000);
} catch (e) {
@@ -134,6 +165,13 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
onSubmit={handleSubmitClick}
/>
{lastSavedTime && (
<div className="absolute top-20 right-6 z-10 text-xs text-gray-400 flex items-center gap-1 bg-white/80 backdrop-blur px-2 py-1 rounded-md shadow-sm border border-gray-100">
<CheckCircle size={12} className="text-green-500" />
{lastSavedTime.toLocaleTimeString()}
</div>
)}
<div className="flex-1 flex overflow-hidden relative">
<div className="flex-1 max-w-4xl mx-auto w-full p-6 flex flex-col">
<Card className="flex-1 p-8 flex flex-col relative overflow-hidden" noPadding>

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long