fix(dev): bind frontend to 127.0.0.1:8080 and avoid EACCES\nfix(backend): bind server to 127.0.0.1:8081, add permissive CORS whitelist\nfix(auth): login form UX remove default username, clarify placeholder, add test account autofill\nchore(api): set frontend API_BASE_URL to 127.0.0.1:8081\nrefactor(assignments): lifecycle/state logic and archive endpoint\nfeat(analytics): add exam stats endpoint and client method\nchore(lint): add eslint configs
This commit is contained in:
5
.eslintignore
Normal file
5
.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
backend/dist
|
||||||
|
backend/node_modules
|
||||||
17
.eslintrc.json
Normal file
17
.eslintrc.json
Normal 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/"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
Model.ts
12
Model.ts
@@ -50,14 +50,12 @@ export interface ApplicationUser extends BaseEntity {
|
|||||||
/** 头像URL */
|
/** 头像URL */
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
|
||||||
/** 性别:Male | Female | Unknown */
|
gender: 'Male' | 'Female';
|
||||||
gender: 'Male' | 'Female' | 'Unknown';
|
|
||||||
|
|
||||||
/** 当前所属学校ID(冗余字段,用于快速确定用户主要归属) */
|
/** 当前所属学校ID(冗余字段,用于快速确定用户主要归属) */
|
||||||
currentSchoolId: string;
|
currentSchoolId: string;
|
||||||
|
|
||||||
/** 账号状态:Active | Disabled */
|
accountStatus: 'Active' | 'Suspended' | 'Graduated';
|
||||||
accountStatus: 'Active' | 'Disabled';
|
|
||||||
|
|
||||||
/** 邮箱(可选) */
|
/** 邮箱(可选) */
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -419,8 +417,7 @@ export interface SubmissionDetail extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
export enum Gender {
|
export enum Gender {
|
||||||
Male = 'Male',
|
Male = 'Male',
|
||||||
Female = 'Female',
|
Female = 'Female'
|
||||||
Unknown = 'Unknown'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -428,7 +425,8 @@ export enum Gender {
|
|||||||
*/
|
*/
|
||||||
export enum AccountStatus {
|
export enum AccountStatus {
|
||||||
Active = 'Active',
|
Active = 'Active',
|
||||||
Disabled = 'Disabled'
|
Suspended = 'Suspended',
|
||||||
|
Graduated = 'Graduated'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
56
UI_DTO.ts
56
UI_DTO.ts
@@ -246,8 +246,12 @@ export interface ExamDto {
|
|||||||
totalScore: number;
|
totalScore: number;
|
||||||
duration: number; // 建议时长(分钟)
|
duration: number; // 建议时长(分钟)
|
||||||
questionCount: number; // 总题数
|
questionCount: number; // 总题数
|
||||||
|
usageCount?: number;
|
||||||
status: 'Draft' | 'Published';
|
status: 'Draft' | 'Published';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
creatorName?: string;
|
||||||
|
isMyExam?: boolean;
|
||||||
|
examType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -273,6 +277,9 @@ export interface ExamNodeDto {
|
|||||||
|
|
||||||
// === 递归子节点 ===
|
// === 递归子节点 ===
|
||||||
children?: ExamNodeDto[]; // 子节点(支持无限嵌套)
|
children?: ExamNodeDto[]; // 子节点(支持无限嵌套)
|
||||||
|
|
||||||
|
// === 学生作答(用于恢复进度) ===
|
||||||
|
studentAnswer?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -304,24 +311,67 @@ export interface ExamStatsDto {
|
|||||||
// 6. Assignment / 作业
|
// 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 {
|
export interface AssignmentTeacherViewDto {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
examTitle: string;
|
||||||
|
subjectName?: string;
|
||||||
|
examType?: string;
|
||||||
className: string;
|
className: string;
|
||||||
|
gradeName: string;
|
||||||
submittedCount: number;
|
submittedCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
status: 'Active' | 'Ended' | 'Scheduled';
|
status: string; // 'Active' | 'Closed' | 'Ended'
|
||||||
|
hasPendingGrading?: boolean;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
examTitle: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignmentStudentViewDto {
|
export interface AssignmentStudentViewDto {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
examTitle: string;
|
examTitle: string;
|
||||||
|
subjectName?: string;
|
||||||
|
teacherName?: string;
|
||||||
|
duration?: number;
|
||||||
|
questionCount?: number;
|
||||||
|
totalScore?: number;
|
||||||
|
className?: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
status: 'Pending' | 'Graded' | 'Submitted';
|
status: 'Pending' | 'InProgress' | 'Submitted' | 'Grading' | 'Completed';
|
||||||
score?: number;
|
score?: number;
|
||||||
|
isSubmitted?: boolean; // Helper flag to distinguish between 'Grading' (expired but not submitted) vs (submitted)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
112
backend/package-lock.json
generated
112
backend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -718,6 +719,23 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "2.4.3",
|
"version": "2.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
@@ -792,6 +810,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -850,6 +880,15 @@
|
|||||||
"ms": "2.0.0"
|
"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": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -949,6 +988,21 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -1070,6 +1124,42 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1186,6 +1276,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -1471,6 +1576,7 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "5.22.0"
|
"@prisma/engines": "5.22.0"
|
||||||
},
|
},
|
||||||
@@ -1497,6 +1603,12 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
|
|||||||
@@ -22,20 +22,21 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"express": "^4.21.1",
|
"axios": "^1.13.2",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"bcryptjs": "^2.4.3",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"zod": "^3.23.8",
|
"uuid": "^11.0.3",
|
||||||
"uuid": "^11.0.3"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/node": "^22.10.1",
|
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -328,9 +328,11 @@ model Exam {
|
|||||||
id String @id @default(uuid()) @db.VarChar(36)
|
id String @id @default(uuid()) @db.VarChar(36)
|
||||||
subjectId String @map("subject_id") @db.VarChar(36)
|
subjectId String @map("subject_id") @db.VarChar(36)
|
||||||
title String @db.VarChar(200)
|
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)
|
totalScore Decimal @map("total_score") @default(0) @db.Decimal(5, 1)
|
||||||
suggestedDuration Int @map("suggested_duration")
|
suggestedDuration Int @map("suggested_duration")
|
||||||
status ExamStatus @default(Draft)
|
status ExamStatus @default(Draft)
|
||||||
|
description String? @db.Text
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
createdBy String @map("created_by") @db.VarChar(36)
|
createdBy String @map("created_by") @db.VarChar(36)
|
||||||
@@ -402,6 +404,7 @@ model Assignment {
|
|||||||
endTime DateTime @map("end_time")
|
endTime DateTime @map("end_time")
|
||||||
allowLateSubmission Boolean @map("allow_late_submission") @default(false)
|
allowLateSubmission Boolean @map("allow_late_submission") @default(false)
|
||||||
autoScoreEnabled Boolean @map("auto_score_enabled") @default(true)
|
autoScoreEnabled Boolean @map("auto_score_enabled") @default(true)
|
||||||
|
status AssignmentStatus @default(Active)
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
createdBy String @map("created_by") @db.VarChar(36)
|
createdBy String @map("created_by") @db.VarChar(36)
|
||||||
@@ -425,6 +428,7 @@ model StudentSubmission {
|
|||||||
assignmentId String @map("assignment_id") @db.VarChar(36)
|
assignmentId String @map("assignment_id") @db.VarChar(36)
|
||||||
studentId String @map("student_id") @db.VarChar(36)
|
studentId String @map("student_id") @db.VarChar(36)
|
||||||
submissionStatus SubmissionStatus @map("submission_status") @default(Pending)
|
submissionStatus SubmissionStatus @map("submission_status") @default(Pending)
|
||||||
|
startedAt DateTime? @map("started_at")
|
||||||
submitTime DateTime? @map("submit_time")
|
submitTime DateTime? @map("submit_time")
|
||||||
timeSpentSeconds Int? @map("time_spent_seconds")
|
timeSpentSeconds Int? @map("time_spent_seconds")
|
||||||
totalScore Decimal? @map("total_score") @db.Decimal(5, 1)
|
totalScore Decimal? @map("total_score") @db.Decimal(5, 1)
|
||||||
@@ -478,6 +482,11 @@ enum SubmissionStatus {
|
|||||||
Graded
|
Graded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AssignmentStatus {
|
||||||
|
Active
|
||||||
|
Archived
|
||||||
|
}
|
||||||
|
|
||||||
enum JudgementResult {
|
enum JudgementResult {
|
||||||
Correct
|
Correct
|
||||||
Incorrect
|
Incorrect
|
||||||
|
|||||||
@@ -1,66 +1,14 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { analyticsService } from '../services/analytics.service';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 获取班级表现(平均分趋势)
|
// 获取班级表现(平均分趋势)
|
||||||
export const getClassPerformance = async (req: AuthRequest, res: Response) => {
|
export const getClassPerformance = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const result = await analyticsService.getClassPerformance(req.userId);
|
||||||
|
res.json(result);
|
||||||
// 获取教师管理的班级
|
|
||||||
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)',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get class performance error:', error);
|
console.error('Get class performance error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get class performance' });
|
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) => {
|
export const getStudentGrowth = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const result = await analyticsService.getStudentGrowth(req.userId);
|
||||||
|
res.json(result);
|
||||||
// 获取学生最近的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)',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get student growth error:', error);
|
console.error('Get student growth error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get student growth' });
|
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) => {
|
export const getRadar = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// 模拟数据,因为目前没有明确的能力维度字段
|
const result = await analyticsService.getRadar();
|
||||||
res.json({
|
res.json(result);
|
||||||
indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力','个人表现'],
|
|
||||||
values: [85, 78, 92, 88, 75,99]
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get radar error:', error);
|
console.error('Get radar error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get radar data' });
|
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) => {
|
export const getStudentRadar = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// 模拟数据
|
const result = await analyticsService.getStudentRadar();
|
||||||
res.json({
|
res.json(result);
|
||||||
indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力'],
|
|
||||||
values: [80, 85, 90, 82, 78]
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get student radar error:', error);
|
console.error('Get student radar error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get student radar data' });
|
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) => {
|
export const getScoreDistribution = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const distribution = await analyticsService.getScoreDistribution(req.userId);
|
||||||
|
|
||||||
// 获取教师管理的班级
|
|
||||||
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++;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(distribution);
|
res.json(distribution);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get score distribution error:', 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) => {
|
export const getTeacherStats = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const stats = await analyticsService.getTeacherStats(req.userId);
|
||||||
|
res.json(stats);
|
||||||
// 获取教师管理的班级
|
|
||||||
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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get teacher stats error:', error);
|
console.error('Get teacher stats error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get teacher stats' });
|
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) => {
|
export const getExamStats = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id: examId } = req.params as any;
|
const { id } = req.params as any;
|
||||||
const assignments = await prisma.assignment.findMany({
|
const result = await analyticsService.getExamStats(id);
|
||||||
where: { examId, isDeleted: false },
|
res.json(result);
|
||||||
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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get exam stats error:', error);
|
console.error('Get exam stats error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get exam stats' });
|
res.status(500).json({ error: 'Failed to get exam stats' });
|
||||||
|
|||||||
@@ -1,84 +1,19 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
import prisma from '../utils/prisma';
|
import { assignmentService } from '../services/assignment.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
// GET /api/assignments/teaching
|
// GET /api/assignments/teaching
|
||||||
// 获取我教的班级的作业列表(教师视角)
|
// 获取发布的作业列表(教师视角)
|
||||||
export const getTeachingAssignments = async (req: AuthRequest, res: Response) => {
|
export const getTeachingAssignments = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// 查询我作为教师的所有班级
|
const { classId, examType, subjectId, status } = req.query;
|
||||||
const myClasses = await prisma.classMember.findMany({
|
const result = await assignmentService.getTeachingAssignments(req.userId!, {
|
||||||
where: {
|
classId: classId as string,
|
||||||
userId: req.userId!,
|
examType: examType as string,
|
||||||
roleInClass: 'Teacher',
|
subjectId: subjectId as string,
|
||||||
isDeleted: false
|
status: status as string
|
||||||
},
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get teaching assignments error:', error);
|
console.error('Get teaching assignments error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get teaching assignments' });
|
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) => {
|
export const getStudentAssignments = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// 查询我作为学生的所有提交记录
|
const filters = {
|
||||||
const submissions = await prisma.studentSubmission.findMany({
|
subjectId: req.query.subjectId as string,
|
||||||
where: {
|
examType: req.query.examType as string,
|
||||||
studentId: req.userId!,
|
status: req.query.status as string
|
||||||
isDeleted: false
|
};
|
||||||
},
|
const result = await assignmentService.getStudentAssignments(req.userId!, filters);
|
||||||
include: {
|
res.json(result);
|
||||||
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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get student assignments error:', error);
|
console.error('Get student assignments error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get student assignments' });
|
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) => {
|
export const createAssignment = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled } = req.body;
|
const { examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled } = req.body;
|
||||||
|
|
||||||
if (!examId || !classId || !title || !startTime || !endTime) {
|
if (!examId || !classId || !title || !startTime || !endTime) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
// 验证试卷存在且已发布
|
const result = await assignmentService.createAssignment(req.userId!, { examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled });
|
||||||
const exam = await prisma.exam.findUnique({
|
res.json(result);
|
||||||
where: { id: examId, isDeleted: false }
|
} 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 (!exam) {
|
if (e.message.includes('Exam must be published')) return res.status(400).json({ error: e.message });
|
||||||
return res.status(404).json({ error: 'Exam not found' });
|
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) {
|
} catch (error) {
|
||||||
console.error('Create assignment error:', error);
|
console.error('Create assignment error:', error);
|
||||||
res.status(500).json({ error: 'Failed to create assignment' });
|
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
|
// GET /api/assignments/:id/stats
|
||||||
// 获取作业统计信息
|
// 获取作业统计信息
|
||||||
export const getAssignmentStats = async (req: AuthRequest, res: Response) => {
|
export const getAssignmentStats = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id: assignmentId } = req.params;
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
// 验证作业存在
|
const result = await assignmentService.getAssignmentStats(req.userId!, id);
|
||||||
const assignment = await prisma.assignment.findUnique({
|
res.json(result);
|
||||||
where: { id: assignmentId, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: {
|
if (e.message === 'Assignment not found') return res.status(404).json({ error: e.message });
|
||||||
class: true
|
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
|
||||||
}
|
throw e;
|
||||||
});
|
|
||||||
|
|
||||||
if (!assignment) {
|
|
||||||
return res.status(404).json({ error: 'Assignment not found' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限(教师)
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get assignment stats error:', error);
|
console.error('Get assignment stats error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get assignment stats' });
|
res.status(500).json({ error: 'Failed to get assignment stats' });
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
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) => {
|
export const getMessages = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const messages = await commonService.getMessages(req.userId);
|
||||||
|
|
||||||
const messages = await prisma.message.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { createdAt: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(messages);
|
res.json(messages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get messages error:', 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) => {
|
export const markMessageRead = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.userId;
|
try {
|
||||||
|
const result = await commonService.markMessageRead(req.userId!, id);
|
||||||
const message = await prisma.message.findUnique({ where: { id } });
|
res.json(result);
|
||||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
} catch (e: any) {
|
||||||
if (message.userId !== userId) return res.status(403).json({ error: 'Forbidden' });
|
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 });
|
||||||
await prisma.message.update({
|
throw e;
|
||||||
where: { id },
|
}
|
||||||
data: { isRead: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Mark message read error:', error);
|
console.error('Mark message read error:', error);
|
||||||
res.status(500).json({ error: 'Failed to mark message read' });
|
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) => {
|
export const createMessage = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
const { title, content, type } = req.body;
|
try {
|
||||||
|
const message = await commonService.createMessage(req.userId!, req.body);
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
res.json(message);
|
||||||
if (!title || !content) {
|
} catch (e: any) {
|
||||||
return res.status(400).json({ error: 'Title and content are required' });
|
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) {
|
} catch (error) {
|
||||||
console.error('Create message error:', error);
|
console.error('Create message error:', error);
|
||||||
res.status(500).json({ error: 'Failed to create message' });
|
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) => {
|
export const getSchedule = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
try {
|
||||||
|
const data = await commonService.getSchedule(req.userId);
|
||||||
// 获取用户关联的班级
|
res.json(data);
|
||||||
const user = await prisma.applicationUser.findUnique({
|
} catch (e: any) {
|
||||||
where: { id: userId },
|
if (e.message === 'User not found') return res.status(404).json({ error: e.message });
|
||||||
include: {
|
throw e;
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get schedule error:', error);
|
console.error('Get schedule error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get schedule' });
|
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) => {
|
export const getWeekSchedule = async (req: AuthRequest, res: Response) => {
|
||||||
// 复用 getSchedule 逻辑,因为我们返回了所有日程
|
|
||||||
return getSchedule(req, res);
|
return getSchedule(req, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加日程 (仅教师)
|
// 添加日程 (仅教师)
|
||||||
export const addEvent = async (req: AuthRequest, res: Response) => {
|
export const addEvent = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
try {
|
||||||
const { subject, className, classId, room, dayOfWeek, period, startTime, endTime } = req.body;
|
const result = await commonService.addEvent(req.userId!, req.body);
|
||||||
|
res.status(201).json(result);
|
||||||
let resolvedClassId: string | null = null;
|
} catch (e: any) {
|
||||||
if (classId) {
|
if (e.message === 'Class not found') return res.status(404).json({ error: e.message });
|
||||||
const clsById = await prisma.class.findUnique({ where: { id: classId } });
|
if (e.message === 'classId or className is required') return res.status(400).json({ error: e.message });
|
||||||
if (!clsById) return res.status(404).json({ error: 'Class not found' });
|
throw e;
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查权限 (简化:假设所有教师都可以添加)
|
|
||||||
// 实际应检查是否是该班级的教师
|
|
||||||
|
|
||||||
await prisma.schedule.create({
|
|
||||||
data: {
|
|
||||||
classId: resolvedClassId!,
|
|
||||||
subject,
|
|
||||||
room,
|
|
||||||
dayOfWeek,
|
|
||||||
period,
|
|
||||||
startTime,
|
|
||||||
endTime
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json({ success: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add event error:', error);
|
console.error('Add event error:', error);
|
||||||
res.status(500).json({ error: 'Failed to add event' });
|
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) => {
|
export const deleteEvent = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await prisma.schedule.delete({ where: { id } });
|
const result = await commonService.deleteEvent(id);
|
||||||
res.json({ success: true });
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete event error:', error);
|
console.error('Delete event error:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete event' });
|
res.status(500).json({ error: 'Failed to delete event' });
|
||||||
|
|||||||
17
backend/src/controllers/config.controller.ts
Normal file
17
backend/src/controllers/config.controller.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
import prisma from '../utils/prisma';
|
import { curriculumService } from '../services/curriculum.service';
|
||||||
|
|
||||||
// GET /api/curriculum/subjects
|
// GET /api/curriculum/subjects
|
||||||
// 获取学科列表
|
// 获取学科列表
|
||||||
export const getSubjects = async (req: AuthRequest, res: Response) => {
|
export const getSubjects = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const subjects = await prisma.subject.findMany({
|
const subjects = await curriculumService.getSubjects();
|
||||||
where: { isDeleted: false },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
code: true,
|
|
||||||
icon: true
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(subjects);
|
res.json(subjects);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get subjects error:', 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) => {
|
export const getTextbookTree = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
// 尝试作为 textbook ID 查找
|
const result = await curriculumService.getTextbookTree(id);
|
||||||
let textbook = await prisma.textbook.findUnique({
|
res.json(result);
|
||||||
where: { id, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: {
|
if (e.message === 'Textbook not found') return res.status(404).json({ error: e.message });
|
||||||
units: {
|
throw e;
|
||||||
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' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get textbook tree error:', error);
|
console.error('Get textbook tree error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get textbook tree' });
|
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) => {
|
export const getTextbooksBySubject = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const textbooks = await prisma.textbook.findMany({
|
const textbooks = await curriculumService.getTextbooksBySubject(id);
|
||||||
where: { subjectId: id, isDeleted: false },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
publisher: true,
|
|
||||||
versionYear: true,
|
|
||||||
coverUrl: true
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
res.json(textbooks);
|
res.json(textbooks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get textbooks error:', error);
|
console.error('Get textbooks error:', error);
|
||||||
@@ -147,21 +50,8 @@ export const getTextbooksBySubject = async (req: AuthRequest, res: Response) =>
|
|||||||
// POST /api/curriculum/textbooks
|
// POST /api/curriculum/textbooks
|
||||||
export const createTextbook = async (req: AuthRequest, res: Response) => {
|
export const createTextbook = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const textbook = await curriculumService.createTextbook(req.userId!, req.body);
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(textbook);
|
res.json(textbook);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create textbook error:', 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) => {
|
export const updateTextbook = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, publisher, versionYear, coverUrl } = req.body;
|
const textbook = await curriculumService.updateTextbook(id, req.body);
|
||||||
const textbook = await prisma.textbook.update({
|
|
||||||
where: { id },
|
|
||||||
data: { name, publisher, versionYear, coverUrl }
|
|
||||||
});
|
|
||||||
res.json(textbook);
|
res.json(textbook);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update textbook error:', 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) => {
|
export const deleteTextbook = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await prisma.textbook.update({
|
const result = await curriculumService.deleteTextbook(id);
|
||||||
where: { id },
|
res.json(result);
|
||||||
data: { isDeleted: true }
|
|
||||||
});
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete textbook error:', error);
|
console.error('Delete textbook error:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete textbook' });
|
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
|
// POST /api/curriculum/units
|
||||||
export const createUnit = async (req: AuthRequest, res: Response) => {
|
export const createUnit = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const unit = await curriculumService.createUnit(req.userId!, req.body);
|
||||||
|
|
||||||
const { textbookId, name, sortOrder } = req.body;
|
|
||||||
const unit = await prisma.textbookUnit.create({
|
|
||||||
data: {
|
|
||||||
textbookId,
|
|
||||||
name,
|
|
||||||
sortOrder: sortOrder || 0,
|
|
||||||
createdBy: userId,
|
|
||||||
updatedBy: userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(unit);
|
res.json(unit);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create unit error:', 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) => {
|
export const updateUnit = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, sortOrder } = req.body;
|
const unit = await curriculumService.updateUnit(id, req.body);
|
||||||
const unit = await prisma.textbookUnit.update({
|
|
||||||
where: { id },
|
|
||||||
data: { name, sortOrder }
|
|
||||||
});
|
|
||||||
res.json(unit);
|
res.json(unit);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update unit error:', 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) => {
|
export const deleteUnit = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await prisma.textbookUnit.update({
|
const result = await curriculumService.deleteUnit(id);
|
||||||
where: { id },
|
res.json(result);
|
||||||
data: { isDeleted: true }
|
|
||||||
});
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete unit error:', error);
|
console.error('Delete unit error:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete unit' });
|
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
|
// POST /api/curriculum/lessons
|
||||||
export const createLesson = async (req: AuthRequest, res: Response) => {
|
export const createLesson = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const lesson = await curriculumService.createLesson(req.userId!, req.body);
|
||||||
|
|
||||||
const { unitId, name, sortOrder } = req.body;
|
|
||||||
const lesson = await prisma.textbookLesson.create({
|
|
||||||
data: {
|
|
||||||
unitId,
|
|
||||||
name,
|
|
||||||
sortOrder: sortOrder || 0,
|
|
||||||
createdBy: userId,
|
|
||||||
updatedBy: userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(lesson);
|
res.json(lesson);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create lesson error:', 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) => {
|
export const updateLesson = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, sortOrder } = req.body;
|
const lesson = await curriculumService.updateLesson(id, req.body);
|
||||||
const lesson = await prisma.textbookLesson.update({
|
|
||||||
where: { id },
|
|
||||||
data: { name, sortOrder }
|
|
||||||
});
|
|
||||||
res.json(lesson);
|
res.json(lesson);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update lesson error:', 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) => {
|
export const deleteLesson = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await prisma.textbookLesson.update({
|
const result = await curriculumService.deleteLesson(id);
|
||||||
where: { id },
|
res.json(result);
|
||||||
data: { isDeleted: true }
|
|
||||||
});
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete lesson error:', error);
|
console.error('Delete lesson error:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete lesson' });
|
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
|
// POST /api/curriculum/knowledge-points
|
||||||
export const createKnowledgePoint = async (req: AuthRequest, res: Response) => {
|
export const createKnowledgePoint = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
const point = await curriculumService.createKnowledgePoint(req.userId!, req.body);
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.json(point);
|
res.json(point);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create knowledge point error:', 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) => {
|
export const updateKnowledgePoint = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, difficulty, description } = req.body;
|
const point = await curriculumService.updateKnowledgePoint(id, req.body);
|
||||||
const point = await prisma.knowledgePoint.update({
|
|
||||||
where: { id },
|
|
||||||
data: { name, difficulty, description }
|
|
||||||
});
|
|
||||||
res.json(point);
|
res.json(point);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update knowledge point error:', 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) => {
|
export const deleteKnowledgePoint = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await prisma.knowledgePoint.update({
|
const result = await curriculumService.deleteKnowledgePoint(id);
|
||||||
where: { id },
|
res.json(result);
|
||||||
data: { isDeleted: true }
|
|
||||||
});
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete knowledge point error:', error);
|
console.error('Delete knowledge point error:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete knowledge point' });
|
res.status(500).json({ error: 'Failed to delete knowledge point' });
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { examService } from '../services/exam.service';
|
|||||||
// GET /api/exams
|
// GET /api/exams
|
||||||
export const getExams = async (req: AuthRequest, res: Response) => {
|
export const getExams = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { subjectId, status } = req.query;
|
const { subjectId, status, scope, page, pageSize, examType } = req.query;
|
||||||
const result = await examService.getExams(req.userId!, {
|
const result = await examService.getExams(req.userId!, {
|
||||||
subjectId: subjectId as string,
|
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);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,72 +1,20 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
import prisma from '../utils/prisma';
|
import { gradingService } from '../services/grading.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
// GET /api/grading/:assignmentId/list
|
// GET /api/grading/:assignmentId/list
|
||||||
// 获取作业的所有学生提交列表
|
// 获取作业的所有学生提交列表
|
||||||
export const getSubmissions = async (req: AuthRequest, res: Response) => {
|
export const getSubmissions = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { assignmentId } = req.params;
|
const { assignmentId } = req.params;
|
||||||
|
try {
|
||||||
// 验证作业存在
|
const items = await gradingService.getSubmissions(req.userId!, assignmentId);
|
||||||
const assignment = await prisma.assignment.findUnique({
|
res.json(items);
|
||||||
where: { id: assignmentId, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: { class: true }
|
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;
|
||||||
if (!assignment) {
|
|
||||||
return res.status(404).json({ error: 'Assignment not found' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限(必须是班级教师)
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get submissions error:', error);
|
console.error('Get submissions error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get submissions' });
|
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) => {
|
export const getPaperForGrading = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { submissionId } = req.params;
|
const { submissionId } = req.params;
|
||||||
|
try {
|
||||||
// 获取提交记录
|
const result = await gradingService.getPaperForGrading(req.userId!, submissionId);
|
||||||
const submission = await prisma.studentSubmission.findUnique({
|
res.json(result);
|
||||||
where: { id: submissionId, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: {
|
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
|
||||||
student: {
|
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
|
||||||
select: {
|
throw e;
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get paper for grading error:', error);
|
console.error('Get paper for grading error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get paper' });
|
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) => {
|
export const submitGrade = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { submissionId } = req.params;
|
const { submissionId } = req.params;
|
||||||
const { grades } = req.body; // Array of { examNodeId, score, judgement, teacherComment }
|
const { grades } = req.body;
|
||||||
|
try {
|
||||||
if (!grades || !Array.isArray(grades)) {
|
const result = await gradingService.submitGrade(req.userId!, submissionId, grades);
|
||||||
return res.status(400).json({ error: 'Invalid grades data' });
|
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) {
|
} catch (error) {
|
||||||
console.error('Submit grade error:', error);
|
console.error('Submit grade error:', error);
|
||||||
res.status(500).json({ error: 'Failed to submit grading' });
|
res.status(500).json({ error: 'Failed to submit grading' });
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
import prisma from '../utils/prisma';
|
import { orgService } from '../services/org.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { generateInviteCode } from '../utils/helpers';
|
|
||||||
|
|
||||||
// GET /api/org/schools
|
// GET /api/org/schools
|
||||||
export const getSchools = async (req: AuthRequest, res: Response) => {
|
export const getSchools = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const schools = await prisma.school.findMany({
|
const schools = await orgService.getSchools();
|
||||||
where: { isDeleted: false },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
regionCode: true,
|
|
||||||
address: true
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(schools);
|
res.json(schools);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get schools error:', 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) => {
|
export const getMyClasses = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { role } = req.query; // 可选:筛选角色
|
const { role } = req.query; // 可选:筛选角色
|
||||||
|
const classes = await orgService.getMyClasses(req.userId!, role as string | undefined);
|
||||||
// 通过 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 // 我在这个班级的角色
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(classes);
|
res.json(classes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get my classes error:', error);
|
console.error('Get my classes error:', error);
|
||||||
@@ -84,54 +35,13 @@ export const createClass = async (req: AuthRequest, res: Response) => {
|
|||||||
if (!name || !gradeId) {
|
if (!name || !gradeId) {
|
||||||
return res.status(400).json({ error: 'Missing required fields: name, gradeId' });
|
return res.status(400).json({ error: 'Missing required fields: name, gradeId' });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
// 验证年级是否存在
|
const result = await orgService.createClass(req.userId!, name, gradeId);
|
||||||
const grade = await prisma.grade.findUnique({
|
res.json(result);
|
||||||
where: { id: gradeId, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: { school: true }
|
if (e.message === 'Grade not found') return res.status(404).json({ error: e.message });
|
||||||
});
|
throw e;
|
||||||
|
|
||||||
if (!grade) {
|
|
||||||
return res.status(404).json({ error: 'Grade not found' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一邀请码
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Create class error:', error);
|
console.error('Create class error:', error);
|
||||||
res.status(500).json({ error: 'Failed to create class' });
|
res.status(500).json({ error: 'Failed to create class' });
|
||||||
@@ -147,55 +57,14 @@ export const joinClass = async (req: AuthRequest, res: Response) => {
|
|||||||
if (!inviteCode) {
|
if (!inviteCode) {
|
||||||
return res.status(400).json({ error: 'Missing invite code' });
|
return res.status(400).json({ error: 'Missing invite code' });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
// 查找班级
|
const result = await orgService.joinClass(req.userId!, inviteCode);
|
||||||
const targetClass = await prisma.class.findUnique({
|
res.json({ message: 'Successfully joined the class', class: result });
|
||||||
where: { inviteCode, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: {
|
if (e.message === 'Invalid invite code') return res.status(404).json({ error: e.message });
|
||||||
grade: {
|
if (e.message === 'You are already a member of this class') return res.status(400).json({ error: e.message });
|
||||||
include: { school: true }
|
throw e;
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetClass) {
|
|
||||||
return res.status(404).json({ error: 'Invalid invite code' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已经是班级成员
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Join class error:', error);
|
console.error('Join class error:', error);
|
||||||
res.status(500).json({ error: 'Failed to join class' });
|
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) => {
|
export const getClassMembers = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id: classId } = req.params;
|
const { id: classId } = req.params;
|
||||||
|
try {
|
||||||
// 验证班级存在
|
const formattedMembers = await orgService.getClassMembers(req.userId!, classId);
|
||||||
const targetClass = await prisma.class.findUnique({
|
res.json(formattedMembers);
|
||||||
where: { id: classId, isDeleted: false }
|
} 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 });
|
||||||
if (!targetClass) {
|
throw e;
|
||||||
return res.status(404).json({ error: 'Class not found' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证当前用户是否是班级成员
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get class members error:', error);
|
console.error('Get class members error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get class members' });
|
res.status(500).json({ error: 'Failed to get class members' });
|
||||||
|
|||||||
@@ -1,104 +1,13 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
import prisma from '../utils/prisma';
|
import { questionService } from '../services/question.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
// POST /api/questions/search
|
// POST /api/questions/search
|
||||||
// 简单的题目搜索(按科目、难度筛选)
|
// 简单的题目搜索(按科目、难度筛选)
|
||||||
export const searchQuestions = async (req: AuthRequest, res: Response) => {
|
export const searchQuestions = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const result = await questionService.search(req.userId!, req.body);
|
||||||
subjectId,
|
res.json(result);
|
||||||
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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search questions error:', error);
|
console.error('Search questions error:', error);
|
||||||
res.status(500).json({ error: 'Failed to search questions' });
|
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) => {
|
export const createQuestion = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { subjectId, content, questionType, difficulty = 3, answer, explanation, optionsConfig, knowledgePoints } = req.body;
|
try {
|
||||||
|
const result = await questionService.create(req.userId!, req.body);
|
||||||
if (!subjectId || !content || !questionType || !answer) {
|
res.json(result);
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Create question error:', error);
|
console.error('Create question error:', error);
|
||||||
res.status(500).json({ error: 'Failed to create question' });
|
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) => {
|
export const updateQuestion = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { content, questionType, difficulty, answer, explanation, optionsConfig } = req.body;
|
try {
|
||||||
|
const result = await questionService.update(req.userId!, id, req.body);
|
||||||
const question = await prisma.question.findUnique({ where: { id } });
|
res.json(result);
|
||||||
if (!question) return res.status(404).json({ error: 'Question not found' });
|
} catch (e: any) {
|
||||||
|
if (e.message === 'Question not found') return res.status(404).json({ error: e.message });
|
||||||
// Only creator can update (or admin)
|
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
|
||||||
if (question.createdBy !== req.userId) {
|
throw e;
|
||||||
// For now, let's assume strict ownership.
|
|
||||||
// In real app, check role.
|
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Update question error:', error);
|
console.error('Update question error:', error);
|
||||||
res.status(500).json({ error: 'Failed to update question' });
|
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) => {
|
export const deleteQuestion = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
const question = await prisma.question.findUnique({ where: { id } });
|
const result = await questionService.softDelete(req.userId!, id);
|
||||||
if (!question) return res.status(404).json({ error: 'Question not found' });
|
res.json(result);
|
||||||
|
} catch (e: any) {
|
||||||
if (question.createdBy !== req.userId) {
|
if (e.message === 'Question not found') return res.status(404).json({ error: e.message });
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
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) {
|
} catch (error) {
|
||||||
console.error('Delete question error:', error);
|
console.error('Delete question error:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete question' });
|
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
|
// POST /api/questions/parse-text
|
||||||
export const parseText = async (req: AuthRequest, res: Response) => {
|
export const parseText = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { text } = req.body;
|
try {
|
||||||
if (!text) return res.status(400).json({ error: 'Text is required' });
|
const { text } = req.body;
|
||||||
|
const questions = questionService.parseText(text);
|
||||||
// 简单的模拟解析逻辑
|
res.json(questions);
|
||||||
// 假设每行是一个题目,或者用空行分隔
|
} catch (e: any) {
|
||||||
const questions = text.split(/\n\s*\n/).map((block: string) => {
|
if (e.message === 'Text is required') return res.status(400).json({ error: e.message });
|
||||||
const lines = block.trim().split('\n');
|
throw e;
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Parse text error:', error);
|
console.error('Parse text error:', error);
|
||||||
res.status(500).json({ error: 'Failed to parse text' });
|
res.status(500).json({ error: 'Failed to parse text' });
|
||||||
|
|||||||
@@ -1,229 +1,75 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
import prisma from '../utils/prisma';
|
import { submissionService } from '../services/submission.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { calculateRank } from '../utils/helpers';
|
|
||||||
|
|
||||||
// GET /api/submissions/:assignmentId/paper
|
// GET /api/submissions/:assignmentId/paper
|
||||||
// 学生获取答题卡
|
// 学生获取答题卡
|
||||||
export const getStudentPaper = async (req: AuthRequest, res: Response) => {
|
export const getStudentPaper = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { assignmentId } = req.params;
|
const { assignmentId } = req.params;
|
||||||
|
try {
|
||||||
// 获取作业信息
|
const paper = await submissionService.getStudentPaper(req.userId!, assignmentId);
|
||||||
const assignment = await prisma.assignment.findUnique({
|
res.json(paper);
|
||||||
where: { id: assignmentId, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: {
|
if (e.message === 'Assignment not found') return res.status(404).json({ error: e.message });
|
||||||
exam: {
|
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
|
||||||
include: {
|
if (e.message === 'Assignment archived') return res.status(400).json({ error: e.message });
|
||||||
nodes: {
|
if (e.message === 'Assignment has not started yet') return res.status(400).json({ error: e.message });
|
||||||
where: { isDeleted: false },
|
if (e.message === 'Assignment has ended') return res.status(400).json({ error: e.message });
|
||||||
include: {
|
throw e;
|
||||||
question: {
|
|
||||||
include: {
|
|
||||||
knowledgePoints: {
|
|
||||||
select: {
|
|
||||||
knowledgePoint: {
|
|
||||||
select: { name: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: { sortOrder: 'asc' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!assignment) {
|
|
||||||
return res.status(404).json({ error: 'Assignment not found' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证作业时间
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get student paper error:', error);
|
console.error('Get student paper error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get student paper' });
|
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
|
// POST /api/submissions/:assignmentId/submit
|
||||||
// 学生提交答案
|
// 学生提交答案
|
||||||
export const submitAnswers = async (req: AuthRequest, res: Response) => {
|
export const submitAnswers = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { assignmentId } = req.params;
|
const { assignmentId } = req.params;
|
||||||
const { answers, timeSpent } = req.body; // answers: Array of { examNodeId, studentAnswer }
|
const { answers, timeSpent } = req.body; // answers: Array of { examNodeId, studentAnswer }
|
||||||
|
try {
|
||||||
if (!answers || !Array.isArray(answers)) {
|
const result = await submissionService.submitAnswers(req.userId!, assignmentId, answers, timeSpent);
|
||||||
return res.status(400).json({ error: 'Invalid answers data' });
|
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) {
|
} catch (error) {
|
||||||
console.error('Submit answers error:', error);
|
console.error('Submit answers error:', error);
|
||||||
res.status(500).json({ error: 'Failed to submit answers' });
|
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) => {
|
export const getSubmissionResult = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { submissionId } = req.params;
|
const { submissionId } = req.params;
|
||||||
|
try {
|
||||||
// 获取提交记录
|
const result = await submissionService.getSubmissionResult(req.userId!, submissionId);
|
||||||
const submission = await prisma.studentSubmission.findUnique({
|
res.json(result);
|
||||||
where: { id: submissionId, isDeleted: false },
|
} catch (e: any) {
|
||||||
include: {
|
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
|
||||||
assignment: {
|
if (e.message === 'Permission denied') return res.status(403).json({ error: e.message });
|
||||||
include: {
|
throw e;
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证是本人的提交
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get submission result error:', error);
|
console.error('Get submission result error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get submission result' });
|
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) => {
|
export const getSubmissionResultByAssignment = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { assignmentId } = req.params;
|
const { assignmentId } = req.params;
|
||||||
const submission = await prisma.studentSubmission.findFirst({
|
try {
|
||||||
where: { assignmentId, studentId: req.userId!, isDeleted: false },
|
const result = await submissionService.getSubmissionResultByAssignment(req.userId!, assignmentId);
|
||||||
include: {
|
res.json(result);
|
||||||
assignment: {
|
} catch (e: any) {
|
||||||
include: {
|
if (e.message === 'Submission not found') return res.status(404).json({ error: e.message });
|
||||||
exam: {
|
throw e;
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Get submission result by assignment error:', error);
|
console.error('Get submission result by assignment error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get submission result' });
|
res.status(500).json({ error: 'Failed to get submission result' });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import authRoutes from './routes/auth.routes';
|
|||||||
import examRoutes from './routes/exam.routes';
|
import examRoutes from './routes/exam.routes';
|
||||||
import analyticsRoutes from './routes/analytics.routes';
|
import analyticsRoutes from './routes/analytics.routes';
|
||||||
import commonRoutes from './routes/common.routes';
|
import commonRoutes from './routes/common.routes';
|
||||||
|
import configRoutes from './routes/config.routes';
|
||||||
import orgRouter from './routes/org.routes';
|
import orgRouter from './routes/org.routes';
|
||||||
import curriculumRouter from './routes/curriculum.routes';
|
import curriculumRouter from './routes/curriculum.routes';
|
||||||
import questionRouter from './routes/question.routes';
|
import questionRouter from './routes/question.routes';
|
||||||
@@ -16,11 +17,23 @@ import gradingRouter from './routes/grading.routes';
|
|||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = express();
|
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({
|
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
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -34,6 +47,7 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
// API路由
|
// API路由
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/config', configRoutes);
|
||||||
app.use('/api/org', orgRouter);
|
app.use('/api/org', orgRouter);
|
||||||
app.use('/api/curriculum', curriculumRouter);
|
app.use('/api/curriculum', curriculumRouter);
|
||||||
app.use('/api/questions', questionRouter);
|
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, () => {
|
app.listen(PORT, HOST as any, () => {
|
||||||
console.log(`✅ Server running on http://localhost:${PORT}`);
|
console.log(`✅ Server running on http://${HOST}:${PORT}`);
|
||||||
console.log(`📊 Environment: ${process.env.NODE_ENV}`);
|
console.log(`📊 Environment: ${process.env.NODE_ENV}`);
|
||||||
console.log(`🔗 CORS enabled for: ${process.env.CORS_ORIGIN}`);
|
console.log(`🔗 CORS enabled for: ${process.env.CORS_ORIGIN}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { authenticate } from '../middleware/auth.middleware';
|
|||||||
import {
|
import {
|
||||||
getClassPerformance,
|
getClassPerformance,
|
||||||
getStudentGrowth,
|
getStudentGrowth,
|
||||||
|
getStudentStats,
|
||||||
getRadar,
|
getRadar,
|
||||||
getStudentRadar,
|
getStudentRadar,
|
||||||
getScoreDistribution,
|
getScoreDistribution,
|
||||||
@@ -17,6 +18,7 @@ router.use(authenticate);
|
|||||||
|
|
||||||
router.get('/class/performance', getClassPerformance);
|
router.get('/class/performance', getClassPerformance);
|
||||||
router.get('/student/growth', getStudentGrowth);
|
router.get('/student/growth', getStudentGrowth);
|
||||||
|
router.get('/student/stats', getStudentStats);
|
||||||
router.get('/radar', getRadar);
|
router.get('/radar', getRadar);
|
||||||
router.get('/student/radar', getStudentRadar);
|
router.get('/student/radar', getStudentRadar);
|
||||||
router.get('/distribution', getScoreDistribution);
|
router.get('/distribution', getScoreDistribution);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const router = Router();
|
|||||||
router.get('/teaching', authenticate, assignmentController.getTeachingAssignments);
|
router.get('/teaching', authenticate, assignmentController.getTeachingAssignments);
|
||||||
router.get('/learning', authenticate, assignmentController.getStudentAssignments);
|
router.get('/learning', authenticate, assignmentController.getStudentAssignments);
|
||||||
router.post('/', authenticate, assignmentController.createAssignment);
|
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);
|
router.get('/:id/stats', authenticate, assignmentController.getAssignmentStats);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -14,18 +14,12 @@ const router = Router();
|
|||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// Messages
|
|
||||||
router.get('/messages', getMessages);
|
router.get('/messages', getMessages);
|
||||||
router.post('/messages/:id/read', markMessageRead);
|
router.post('/messages/:id/read', markMessageRead);
|
||||||
router.post('/messages', createMessage);
|
router.post('/messages', createMessage);
|
||||||
|
|
||||||
// Schedule
|
|
||||||
router.get('/schedule/week', getWeekSchedule);
|
|
||||||
router.get('/common/schedule/week', getWeekSchedule);
|
router.get('/common/schedule/week', getWeekSchedule);
|
||||||
router.get('/common/schedule', getSchedule); // For realCommonService compatibility
|
router.get('/common/schedule', getSchedule);
|
||||||
router.post('/schedule', addEvent);
|
|
||||||
router.delete('/schedule/:id', deleteEvent);
|
|
||||||
// Compatibility for frontend realScheduleService which posts to /common/schedule
|
|
||||||
router.post('/common/schedule', addEvent);
|
router.post('/common/schedule', addEvent);
|
||||||
router.delete('/common/schedule/:id', deleteEvent);
|
router.delete('/common/schedule/:id', deleteEvent);
|
||||||
|
|
||||||
|
|||||||
9
backend/src/routes/config.routes.ts
Normal file
9
backend/src/routes/config.routes.ts
Normal 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;
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ const router = Router();
|
|||||||
|
|
||||||
router.get('/:assignmentId/paper', authenticate, submissionController.getStudentPaper);
|
router.get('/:assignmentId/paper', authenticate, submissionController.getStudentPaper);
|
||||||
router.post('/:assignmentId/submit', authenticate, submissionController.submitAnswers);
|
router.post('/:assignmentId/submit', authenticate, submissionController.submitAnswers);
|
||||||
|
router.post('/:assignmentId/save', authenticate, submissionController.saveProgress);
|
||||||
router.get('/:submissionId/result', authenticate, submissionController.getSubmissionResult);
|
router.get('/:submissionId/result', authenticate, submissionController.getSubmissionResult);
|
||||||
router.get('/by-assignment/:assignmentId/result', authenticate, submissionController.getSubmissionResultByAssignment);
|
router.get('/by-assignment/:assignmentId/result', authenticate, submissionController.getSubmissionResultByAssignment);
|
||||||
|
|
||||||
|
|||||||
214
backend/src/services/analytics.service.ts
Normal file
214
backend/src/services/analytics.service.ts
Normal 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();
|
||||||
483
backend/src/services/assignment.service.ts
Normal file
483
backend/src/services/assignment.service.ts
Normal 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();
|
||||||
80
backend/src/services/common.service.ts
Normal file
80
backend/src/services/common.service.ts
Normal 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();
|
||||||
20
backend/src/services/config.service.ts
Normal file
20
backend/src/services/config.service.ts
Normal 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();
|
||||||
121
backend/src/services/curriculum.service.ts
Normal file
121
backend/src/services/curriculum.service.ts
Normal 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();
|
||||||
@@ -2,30 +2,54 @@ import prisma from '../utils/prisma';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export class ExamService {
|
export class ExamService {
|
||||||
async getExams(userId: string, query: { subjectId?: string; status?: string }) {
|
async getExams(userId: string, query: { subjectId?: string; status?: string; scope?: 'mine' | 'public'; page?: number; pageSize?: number; examType?: string }) {
|
||||||
const { subjectId, status } = query;
|
const { subjectId, status, scope = 'mine', page = 1, pageSize = 20, examType } = query;
|
||||||
|
|
||||||
const exams = await prisma.exam.findMany({
|
const where: any = {
|
||||||
where: {
|
isDeleted: false,
|
||||||
createdBy: userId,
|
...(subjectId && { subjectId }),
|
||||||
isDeleted: false,
|
...(status && { status: status as any }),
|
||||||
...(subjectId && { subjectId }),
|
...(examType && { examType })
|
||||||
...(status && { status: status as any })
|
};
|
||||||
},
|
|
||||||
select: {
|
if (scope === 'mine') {
|
||||||
id: true,
|
where.createdBy = userId;
|
||||||
title: true,
|
} else {
|
||||||
subjectId: true,
|
where.status = 'Published';
|
||||||
totalScore: true,
|
// Optionally exclude own exams from public list if desired, or keep them
|
||||||
suggestedDuration: true,
|
}
|
||||||
status: true,
|
|
||||||
createdAt: true,
|
const skip = (page - 1) * pageSize;
|
||||||
_count: {
|
|
||||||
select: { nodes: true }
|
const [exams, totalCount] = await Promise.all([
|
||||||
}
|
prisma.exam.findMany({
|
||||||
},
|
where,
|
||||||
orderBy: { createdAt: 'desc' }
|
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 => ({
|
const result = exams.map(exam => ({
|
||||||
id: exam.id,
|
id: exam.id,
|
||||||
@@ -34,15 +58,19 @@ export class ExamService {
|
|||||||
totalScore: Number(exam.totalScore),
|
totalScore: Number(exam.totalScore),
|
||||||
duration: exam.suggestedDuration,
|
duration: exam.suggestedDuration,
|
||||||
questionCount: exam._count.nodes,
|
questionCount: exam._count.nodes,
|
||||||
|
usageCount: exam._count.assignments,
|
||||||
status: exam.status,
|
status: exam.status,
|
||||||
createdAt: exam.createdAt.toISOString()
|
createdAt: exam.createdAt.toISOString(),
|
||||||
|
creatorName: exam.creator.realName,
|
||||||
|
isMyExam: exam.createdBy === userId,
|
||||||
|
examType: exam.examType
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: result,
|
items: result,
|
||||||
totalCount: result.length,
|
totalCount,
|
||||||
pageIndex: 1,
|
pageIndex: Number(page),
|
||||||
pageSize: result.length
|
pageSize: Number(pageSize)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,13 +305,25 @@ export class ExamService {
|
|||||||
// Create new nodes recursively
|
// Create new nodes recursively
|
||||||
const createNodes = async (nodes: any[], parentId: string | null = null) => {
|
const createNodes = async (nodes: any[], parentId: string | null = null) => {
|
||||||
for (const node of nodes) {
|
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({
|
const newNode = await prisma.examNode.create({
|
||||||
data: {
|
data: {
|
||||||
id: node.id || uuidv4(),
|
id: dbId,
|
||||||
examId: id,
|
examId: id,
|
||||||
parentNodeId: parentId,
|
parentNodeId: parentId,
|
||||||
nodeType: node.nodeType,
|
nodeType: node.nodeType,
|
||||||
questionId: node.questionId,
|
questionId: node.questionId?.startsWith('temp-') ? null : node.questionId, // Handle temp IDs if any
|
||||||
title: node.title,
|
title: node.title,
|
||||||
description: node.description,
|
description: node.description,
|
||||||
score: node.score,
|
score: node.score,
|
||||||
@@ -303,6 +343,18 @@ export class ExamService {
|
|||||||
await createNodes(rootNodes);
|
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' };
|
return { message: 'Exam structure updated' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
backend/src/services/grading.service.ts
Normal file
68
backend/src/services/grading.service.ts
Normal 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();
|
||||||
103
backend/src/services/org.service.ts
Normal file
103
backend/src/services/org.service.ts
Normal 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();
|
||||||
131
backend/src/services/question.service.ts
Normal file
131
backend/src/services/question.service.ts
Normal 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();
|
||||||
476
backend/src/services/submission.service.ts
Normal file
476
backend/src/services/submission.service.ts
Normal 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
4407
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -3,19 +3,19 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -H 127.0.0.1 -p 8080",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"framer-motion": "^11.0.24",
|
||||||
|
"lucide-react": "^0.368.0",
|
||||||
"next": "14.2.0",
|
"next": "14.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"lucide-react": "^0.368.0",
|
|
||||||
"framer-motion": "^11.0.24",
|
|
||||||
"recharts": "^2.12.4",
|
"recharts": "^2.12.4",
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"tailwind-merge": "^2.2.2"
|
"tailwind-merge": "^2.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"@types/react": "^18.2.73",
|
"@types/react": "^18.2.73",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-next": "^14.2.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.4.3"
|
"typescript": "^5.4.3"
|
||||||
|
|||||||
21
src/app/(dashboard)/assignments/[id]/analysis/page.tsx
Normal file
21
src/app/(dashboard)/assignments/[id]/analysis/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,18 +3,16 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
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 { TeacherAssignmentList } from '@/features/assignment/components/TeacherAssignmentList';
|
||||||
import { StudentAssignmentList } from '@/features/assignment/components/StudentAssignmentList';
|
import { StudentAssignmentList } from '@/features/assignment/components/StudentAssignmentList';
|
||||||
import { CreateAssignmentModal } from '@/features/assignment/components/CreateAssignmentModal';
|
import { CreateAssignmentModal } from '@/features/assignment/components/CreateAssignmentModal';
|
||||||
import { AssignmentStats } from '@/features/assignment/components/AssignmentStats';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function AssignmentsPage() {
|
export default function AssignmentsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [analyzingId, setAnalyzingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
@@ -26,45 +24,31 @@ export default function AssignmentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigateToPreview = (id: string) => {
|
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) => {
|
const handleViewResult = (id: string) => {
|
||||||
router.push(`/student-result/${id}`);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isStudent ? (
|
{isStudent ? (
|
||||||
<StudentAssignmentList
|
<StudentAssignmentList
|
||||||
onStartExam={handleNavigateToPreview}
|
onStartExam={(id) => router.push(`/student-exam/${id}`)}
|
||||||
onViewResult={handleViewResult}
|
onViewResult={handleViewResult}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TeacherAssignmentList
|
<TeacherAssignmentList
|
||||||
onNavigateToGrading={handleNavigateToGrading}
|
onNavigateToGrading={handleNavigateToGrading}
|
||||||
onNavigateToPreview={handleNavigateToPreview}
|
onNavigateToPreview={handleNavigateToPreview}
|
||||||
onAnalyze={setAnalyzingId}
|
onAnalyze={handleAnalyze}
|
||||||
setIsCreating={setIsCreating}
|
setIsCreating={setIsCreating}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,48 +1,15 @@
|
|||||||
|
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { successResponse, errorResponse, dbDelay } from '@/lib/server-utils';
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
await dbDelay();
|
const body = await request.json();
|
||||||
|
const res = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||||
try {
|
method: 'POST',
|
||||||
const body = await request.json();
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const { username, password } = body;
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
// Simple mock validation
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!username) {
|
return NextResponse.json(data, { status: res.status });
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,14 @@
|
|||||||
|
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { successResponse, errorResponse, extractToken, dbDelay } from '@/lib/server-utils';
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
await dbDelay();
|
const authHeader = request.headers.get('authorization') || '';
|
||||||
|
const res = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||||
const token = extractToken(request);
|
method: 'GET',
|
||||||
if (!token) {
|
headers: { Authorization: authHeader }
|
||||||
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 data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
|
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { successResponse, errorResponse } from '@/lib/server-utils';
|
|
||||||
import { db } from '@/lib/db';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
const body = await request.json();
|
||||||
const body = await request.json();
|
const res = await fetch(`${API_BASE_URL}/config/db`, {
|
||||||
const { host, port, user, password, database } = body;
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (!host || !user) {
|
body: JSON.stringify(body)
|
||||||
return errorResponse('Missing required fields');
|
});
|
||||||
}
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
|
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { successResponse, dbDelay } from '@/lib/server-utils';
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
await dbDelay();
|
const url = new URL(request.url);
|
||||||
|
const role = url.searchParams.get('role') || '';
|
||||||
const { searchParams } = new URL(request.url);
|
const res = await fetch(`${API_BASE_URL}/org/classes?role=${encodeURIComponent(role)}`, {
|
||||||
const role = searchParams.get('role');
|
method: 'GET'
|
||||||
|
});
|
||||||
let classes = [
|
const data = await res.json().catch(() => ({}));
|
||||||
{ id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' },
|
return NextResponse.json(data, { status: res.status });
|
||||||
{ 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import Link from 'next/link';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, FileQuestion, Users, Settings, LogOut,
|
LayoutDashboard, BookOpen, FileQuestion, Users, Settings, LogOut,
|
||||||
Bell, Search, GraduationCap, ScrollText, ClipboardList, Database,
|
Bell, Search, GraduationCap, ScrollText, ClipboardList,
|
||||||
Menu, X, CalendarDays, Terminal
|
Menu, X, CalendarDays, Terminal
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import { getApiMode, setApiMode } from '@/services/api';
|
import { } from '@/services/api';
|
||||||
|
|
||||||
const NavItem = ({ icon: Icon, label, href, isActive }: any) => (
|
const NavItem = ({ icon: Icon, label, href, isActive }: any) => (
|
||||||
<Link href={href} className="block w-full">
|
<Link href={href} className="block w-full">
|
||||||
@@ -45,7 +45,6 @@ export const Sidebar = () => {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const pathname = usePathname() || '';
|
const pathname = usePathname() || '';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isMock, setIsMock] = useState(getApiMode());
|
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
const renderNavItems = () => {
|
const renderNavItems = () => {
|
||||||
@@ -89,14 +88,6 @@ export const Sidebar = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="pt-6 border-t border-gray-100 space-y-2">
|
<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={Terminal} label="控制台配置" href="/consoleconfig" isActive={pathname === '/consoleconfig'} />
|
||||||
<NavItem icon={Settings} label="系统设置" href="/settings" isActive={pathname === '/settings'} />
|
<NavItem icon={Settings} label="系统设置" href="/settings" isActive={pathname === '/settings'} />
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
iconPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button: React.FC<ButtonProps> = ({
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
@@ -15,6 +16,7 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
loading = false,
|
loading = false,
|
||||||
icon,
|
icon,
|
||||||
|
iconPosition = 'left',
|
||||||
className = '',
|
className = '',
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
@@ -42,8 +44,9 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading && <Loader2 className="animate-spin" size={size === 'sm' ? 12 : 16} />}
|
{loading && <Loader2 className="animate-spin" size={size === 'sm' ? 12 : 16} />}
|
||||||
{!loading && icon}
|
{!loading && icon && iconPosition === 'left' && icon}
|
||||||
{children}
|
{children}
|
||||||
|
{!loading && icon && iconPosition === 'right' && icon}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
335
src/features/assignment/components/AssignmentAnalysis.tsx
Normal file
335
src/features/assignment/components/AssignmentAnalysis.tsx
Normal 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';
|
||||||
@@ -8,12 +8,14 @@ import { examService, orgService, assignmentService } from '@/services/api';
|
|||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
interface CreateAssignmentModalProps {
|
interface CreateAssignmentModalProps {
|
||||||
|
isOpen?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
|
preSelectedExamId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ onClose, onSuccess }) => {
|
export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ onClose, onSuccess, preSelectedExamId }) => {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(preSelectedExamId ? 2 : 1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
@@ -29,9 +31,46 @@ export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ on
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
examService.getMyExams().then(res => setExams(res.items));
|
const init = async () => {
|
||||||
orgService.getClasses().then(setClasses);
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedExam && !config.title) {
|
if (selectedExam && !config.title) {
|
||||||
@@ -44,13 +83,57 @@ export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ on
|
|||||||
try {
|
try {
|
||||||
await assignmentService.publishAssignment({
|
await assignmentService.publishAssignment({
|
||||||
examId: selectedExam?.id,
|
examId: selectedExam?.id,
|
||||||
classIds: selectedClassIds,
|
classId: selectedClassIds[0], // Backend only accepts single classId currently. UI allows multiple but service logic implies one.
|
||||||
...config
|
// 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');
|
showToast('作业发布成功!', 'success');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
155
src/features/assignment/components/EditAssignmentModal.tsx
Normal file
155
src/features/assignment/components/EditAssignmentModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { AssignmentStudentViewDto } from '../../../../UI_DTO';
|
import { AssignmentStudentViewDto, SubjectDto } from '../../../../UI_DTO';
|
||||||
import { assignmentService } from '@/services/api';
|
import { assignmentService, curriculumService } from '@/services/api';
|
||||||
import { Card } from '@/components/ui/Card';
|
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 {
|
interface StudentAssignmentListProps {
|
||||||
onStartExam: (id: string) => void;
|
onStartExam: (id: string) => void;
|
||||||
@@ -13,56 +13,171 @@ interface StudentAssignmentListProps {
|
|||||||
|
|
||||||
export const StudentAssignmentList: React.FC<StudentAssignmentListProps> = ({ onStartExam, onViewResult }) => {
|
export const StudentAssignmentList: React.FC<StudentAssignmentListProps> = ({ onStartExam, onViewResult }) => {
|
||||||
const [assignments, setAssignments] = useState<AssignmentStudentViewDto[]>([]);
|
const [assignments, setAssignments] = useState<AssignmentStudentViewDto[]>([]);
|
||||||
|
const [subjects, setSubjects] = useState<SubjectDto[]>([]);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
subjectId: 'all',
|
||||||
|
examType: 'all',
|
||||||
|
status: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="space-y-6">
|
||||||
{assignments.map((item, idx) => (
|
{/* Filters */}
|
||||||
<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">
|
<Card className="p-4 flex flex-wrap gap-4 items-center" noPadding>
|
||||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0 text-2xl font-bold shadow-sm
|
<div className="flex items-center gap-2 text-gray-500 mr-2">
|
||||||
${item.status === 'Pending' ? 'bg-blue-100 text-blue-600' : (item.status === 'Graded' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500')}
|
<Filter size={18} />
|
||||||
`}>
|
<span className="font-bold text-sm">筛选:</span>
|
||||||
{item.status === 'Pending' ? <Clock size={24}/> : (item.status === 'Graded' ? item.score : <CheckCircle size={24} />)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
<select
|
||||||
<div className="flex items-center gap-3 mb-1">
|
value={filters.subjectId}
|
||||||
<h3 className="text-lg font-bold text-gray-900">{item.title}</h3>
|
onChange={(e) => setFilters({...filters, subjectId: e.target.value})}
|
||||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase
|
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"
|
||||||
${item.status === 'Pending' ? 'bg-blue-50 text-blue-600 border-blue-100' : 'bg-gray-50 text-gray-500 border-gray-200'}
|
>
|
||||||
`}>
|
<option value="all">全部学科</option>
|
||||||
{item.status === 'Pending' ? '待完成' : (item.status === 'Graded' ? '已批改' : '已提交')}
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</span>
|
</select>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div>
|
<select
|
||||||
{item.status === 'Pending' ? (
|
value={filters.examType}
|
||||||
<button
|
onChange={(e) => setFilters({...filters, examType: e.target.value})}
|
||||||
onClick={() => onStartExam(item.id)}
|
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"
|
||||||
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"
|
>
|
||||||
>
|
<option value="all">全部类型</option>
|
||||||
<Play size={16} fill="currentColor" /> 开始答题
|
{examTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</button>
|
</select>
|
||||||
) : (
|
|
||||||
<button
|
<div className="flex bg-gray-100 p-1 rounded-lg">
|
||||||
onClick={() => item.status === 'Graded' && onViewResult(item.id)}
|
{statuses.map(s => (
|
||||||
disabled={item.status !== 'Graded'}
|
<button
|
||||||
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'}`}
|
key={s.value}
|
||||||
>
|
onClick={() => setFilters({...filters, status: s.value})}
|
||||||
<Eye size={16} /> {item.status === 'Graded' ? '查看详情' : '等待批改'}
|
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'}`}
|
||||||
</button>
|
>
|
||||||
)}
|
{s.label}
|
||||||
</div>
|
</button>
|
||||||
</Card>
|
))}
|
||||||
))}
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { AssignmentTeacherViewDto } from '../../../../UI_DTO';
|
import { AssignmentTeacherViewDto, ClassDto, SubjectDto } from '../../../../UI_DTO';
|
||||||
import { assignmentService } from '@/services/api';
|
import { assignmentService, orgService, curriculumService } from '@/services/api';
|
||||||
import { Card } from '@/components/ui/Card';
|
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 {
|
interface TeacherAssignmentListProps {
|
||||||
onNavigateToGrading?: (id: string) => void;
|
onNavigateToGrading?: (id: string) => void;
|
||||||
@@ -20,119 +25,272 @@ export const TeacherAssignmentList: React.FC<TeacherAssignmentListProps> = ({
|
|||||||
setIsCreating
|
setIsCreating
|
||||||
}) => {
|
}) => {
|
||||||
const [assignments, setAssignments] = useState<AssignmentTeacherViewDto[]>([]);
|
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(() => {
|
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) {
|
switch (status) {
|
||||||
case 'Active': return 'bg-blue-50 text-blue-600 border-blue-100';
|
case 'Active': return 'info';
|
||||||
case 'Ended': return 'bg-gray-100 text-gray-600 border-gray-200';
|
case 'Ended': return 'default';
|
||||||
case 'Scheduled': return 'bg-orange-50 text-orange-600 border-orange-100';
|
default: return 'default';
|
||||||
default: return 'bg-gray-50 text-gray-500';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string, hasPending?: boolean) => {
|
||||||
|
if (hasPending) return '待批改';
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Active': return '进行中';
|
case 'Active': return '进行中';
|
||||||
case 'Ended': return '已结束';
|
case 'Ended': return '已结束';
|
||||||
case 'Scheduled': return '计划中';
|
|
||||||
default: return status;
|
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 (
|
return (
|
||||||
<>
|
<div className="space-y-6 pb-10">
|
||||||
<div className="flex justify-between items-center mb-6">
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
<div>
|
<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>
|
<p className="text-gray-500 text-sm">作业与测评管理</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button icon={<Plus size={18} />} onClick={() => setIsCreating(true)}>
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
{/* Filters */}
|
||||||
{assignments.map((item, idx) => {
|
<Card className="p-4 flex flex-wrap gap-4 items-center" noPadding>
|
||||||
const progress = Math.round((item.submittedCount / item.totalCount) * 100);
|
<div className="flex items-center gap-2 text-gray-500 mr-2">
|
||||||
|
<Filter size={18} />
|
||||||
|
<span className="font-bold text-sm">筛选:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="flex bg-gray-100 p-1 rounded-lg">
|
||||||
<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">
|
{tabs.map(tab => (
|
||||||
<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'}`}>
|
<button
|
||||||
{progress}%
|
key={tab.id}
|
||||||
</div>
|
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">
|
<select
|
||||||
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 mb-1">
|
value={filters.classId}
|
||||||
<h3 className="text-lg font-bold text-gray-900 truncate">{item.title}</h3>
|
onChange={(e) => setFilters({...filters, classId: e.target.value})}
|
||||||
<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)}`}>
|
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]"
|
||||||
{getStatusLabel(item.status)}
|
>
|
||||||
|
<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>
|
</span>
|
||||||
</div>
|
{item.subjectName && (
|
||||||
<div className="flex items-center justify-center md:justify-start gap-2 text-sm text-gray-500 mb-2">
|
<span className="text-[10px] bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">
|
||||||
<FileText size={14} />
|
{item.subjectName}
|
||||||
<span>关联试卷: <span className="font-medium text-gray-700">{item.examTitle}</span></span>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-center md:justify-start gap-4 text-sm text-gray-500">
|
<div className="grid grid-cols-3 gap-2 mb-6 mt-auto">
|
||||||
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
|
<div className="bg-gray-50 p-2 rounded-lg text-center">
|
||||||
<Users size={14} className="text-gray-400" />
|
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><CheckCircle size={10}/> 进度</div>
|
||||||
<span className="font-medium text-gray-700">{item.className}</span>
|
<div className="font-bold text-blue-600">{progress}%</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
|
<div className="bg-gray-50 p-2 rounded-lg text-center">
|
||||||
<Calendar size={14} className="text-gray-400" />
|
<div className="text-xs text-gray-400 mb-1 flex items-center justify-center gap-1"><Users size={10}/> 提交</div>
|
||||||
<span>截止: {item.dueDate}</span>
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full md:w-48 flex flex-col gap-2">
|
<div className="flex gap-2 pt-4 border-t border-gray-100">
|
||||||
<div className="flex justify-between text-xs font-bold text-gray-500">
|
<button
|
||||||
<span>提交进度</span>
|
onClick={() => onNavigateToPreview && onNavigateToPreview(item.id)}
|
||||||
<span>{item.submittedCount}/{item.totalCount}</span>
|
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>
|
||||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
</Card>
|
||||||
<div
|
);
|
||||||
className={`h-full rounded-full ${item.status === 'Active' ? 'bg-blue-500' : 'bg-gray-400'}`}
|
})}
|
||||||
style={{ width: `${progress}%` }}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{editingAssignment && (
|
||||||
<button
|
<EditAssignmentModal
|
||||||
onClick={() => onNavigateToPreview && onNavigateToPreview(item.id)}
|
isOpen={true}
|
||||||
title="预览试卷"
|
onClose={() => setEditingAssignment(null)}
|
||||||
className="p-3 rounded-xl hover:bg-purple-50 text-gray-400 hover:text-purple-600 transition-colors"
|
onSuccess={() => {
|
||||||
>
|
// Refresh list
|
||||||
<Eye size={20} />
|
setFilters({ ...filters });
|
||||||
</button>
|
setEditingAssignment(null);
|
||||||
<button
|
}}
|
||||||
onClick={() => onAnalyze && onAnalyze(item.id)}
|
assignment={{
|
||||||
title="数据分析"
|
id: editingAssignment.id,
|
||||||
className="p-3 rounded-xl hover:bg-blue-50 text-gray-400 hover:text-blue-600 transition-colors"
|
title: editingAssignment.title,
|
||||||
>
|
dueDate: editingAssignment.dueDate,
|
||||||
<BarChart3 size={20} />
|
status: editingAssignment.status
|
||||||
</button>
|
}}
|
||||||
<button
|
/>
|
||||||
onClick={() => onNavigateToGrading && onNavigateToGrading(item.id)}
|
)}
|
||||||
title="进入批改"
|
</div>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ interface LoginFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }) => {
|
export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }) => {
|
||||||
const [username, setUsername] = useState('admin');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -50,7 +50,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -95,6 +95,23 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
<div className="mt-8 text-center space-y-2">
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
忘记密码? <button className="text-blue-600 font-bold hover:underline">联系管理员</button>
|
忘记密码? <button className="text-blue-600 font-bold hover:underline">联系管理员</button>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface StudentDashboardProps {
|
|||||||
export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) => {
|
export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) => {
|
||||||
const [performanceData, setPerformanceData] = useState<ChartDataDto | null>(null);
|
const [performanceData, setPerformanceData] = useState<ChartDataDto | null>(null);
|
||||||
const [radarData, setRadarData] = useState<RadarChartDto | 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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -39,7 +40,7 @@ export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) =>
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const [growth, radar] = await Promise.all([
|
const [growth, radar, studentStats] = await Promise.all([
|
||||||
analyticsService.getStudentGrowth().catch(err => {
|
analyticsService.getStudentGrowth().catch(err => {
|
||||||
console.error('Failed to load growth:', err);
|
console.error('Failed to load growth:', err);
|
||||||
return null;
|
return null;
|
||||||
@@ -47,11 +48,16 @@ export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) =>
|
|||||||
analyticsService.getStudentRadar().catch(err => {
|
analyticsService.getStudentRadar().catch(err => {
|
||||||
console.error('Failed to load radar:', err);
|
console.error('Failed to load radar:', err);
|
||||||
return null;
|
return null;
|
||||||
|
}),
|
||||||
|
analyticsService.getStudentStats().catch(err => {
|
||||||
|
console.error('Failed to load student stats:', err);
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setPerformanceData(growth);
|
setPerformanceData(growth);
|
||||||
setRadarData(radar);
|
setRadarData(radar);
|
||||||
|
setStats(studentStats);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load dashboard data:', err);
|
console.error('Failed to load dashboard data:', err);
|
||||||
const errorMessage = getErrorMessage(err);
|
const errorMessage = getErrorMessage(err);
|
||||||
@@ -113,13 +119,13 @@ export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) =>
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||||
<div onClick={() => onNavigate('assignments')} className="cursor-pointer">
|
<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>
|
||||||
<div onClick={() => onNavigate('assignments')} className="cursor-pointer">
|
<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>
|
</div>
|
||||||
<StatCard title="平均成绩" value="88.5" subValue="前 10%" icon={Trophy} color="bg-yellow-500" delay={0.3} />
|
<StatCard title="平均成绩" value={String(stats?.average || 0)} 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={`${stats?.studyDuration || 0}h`} subValue="本周累计" icon={Clock} color="bg-blue-500" delay={0.4} />
|
||||||
|
|
||||||
<div className="md:col-span-3 space-y-6">
|
<div className="md:col-span-3 space-y-6">
|
||||||
<Card delay={0.5}>
|
<Card delay={0.5}>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ExamDetailDto, ExamNodeDto, QuestionSummaryDto, ParsedQuestionDto } from '../../../../UI_DTO';
|
import { ExamDetailDto, ExamNodeDto, QuestionSummaryDto, ParsedQuestionDto, SubjectDto } from '../../../../UI_DTO';
|
||||||
import { examService, questionService } from '@/services/api';
|
import { examService, questionService, curriculumService } from '@/services/api';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
@@ -11,7 +11,7 @@ import { ImportModal } from './ImportModal';
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Save, Plus, Trash2, GripVertical,
|
ArrowLeft, Save, Plus, Trash2, GripVertical,
|
||||||
ChevronDown, ChevronUp, FileInput, Search, Filter,
|
ChevronDown, ChevronUp, FileInput, Search, Filter,
|
||||||
Clock, Hash, Calculator, FolderPlus, FileText
|
Clock, Hash, Calculator, FolderPlus, FileText, BookOpen, Send
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ interface ExamEditorProps {
|
|||||||
|
|
||||||
export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
|
export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
|
||||||
const [exam, setExam] = useState<ExamDetailDto | null>(null);
|
const [exam, setExam] = useState<ExamDetailDto | null>(null);
|
||||||
|
const [subjects, setSubjects] = useState<SubjectDto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||||
@@ -33,52 +34,63 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
|
|||||||
// Init
|
// Init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
if (examId) {
|
try {
|
||||||
const data = await examService.getExamDetail(examId);
|
const [questions, subjectsData] = await Promise.all([
|
||||||
setExam(data);
|
questionService.search({}),
|
||||||
// Auto expand all group nodes
|
curriculumService.getSubjects()
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const questions = await questionService.search({});
|
setQuestionBank(questions.items);
|
||||||
setQuestionBank(questions.items);
|
setSubjects(subjectsData);
|
||||||
setLoading(false);
|
|
||||||
|
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();
|
init();
|
||||||
}, [examId]);
|
}, [examId]);
|
||||||
@@ -106,14 +118,34 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
|
|||||||
}
|
}
|
||||||
}, [exam?.rootNodes]);
|
}, [exam?.rootNodes]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (status: 'Draft' | 'Published' = 'Draft') => {
|
||||||
if (!exam) return;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await examService.saveExam(exam);
|
await examService.saveExam({
|
||||||
showToast('试卷保存成功', 'success');
|
...exam,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
showToast(status === 'Published' ? '试卷已发布' : '草稿已保存', 'success');
|
||||||
if (!examId) onBack();
|
if (!examId) onBack();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
showToast('保存失败', 'error');
|
showToast('保存失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -415,41 +447,62 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" onClick={onBack} icon={<ArrowLeft size={20} />} />
|
<Button variant="ghost" onClick={onBack} icon={<ArrowLeft size={20} />} />
|
||||||
<div className="h-8 w-px bg-gray-200" />
|
<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
|
<input
|
||||||
value={exam.title}
|
value={exam.title}
|
||||||
onChange={e => setExam({ ...exam, title: e.target.value })}
|
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="请输入试卷标题..."
|
placeholder="请输入试卷标题..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex gap-6 text-sm text-gray-500">
|
<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-2 bg-gray-100 px-3 py-1.5 rounded-lg">
|
<div className="flex items-center gap-1">
|
||||||
<Clock size={16} />
|
<Clock size={14} />
|
||||||
<input
|
<span>{exam.duration} 分钟</span>
|
||||||
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>
|
</div>
|
||||||
<div className="flex items-center gap-2 bg-gray-100 px-3 py-1.5 rounded-lg">
|
<div className="w-px h-3 bg-gray-300" />
|
||||||
<Hash size={16} />
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-bold text-gray-900">{exam.questionCount}</span>
|
<Hash size={14} />
|
||||||
<span>题</span>
|
<span>{exam.totalScore} 分</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button variant="secondary" icon={<FolderPlus size={18} />} onClick={() => addGroupNode()}>添加分组</Button>
|
<Button
|
||||||
<Button variant="secondary" icon={<FileInput size={18} />} onClick={() => setShowImport(true)}>智能导入</Button>
|
variant="outline"
|
||||||
<Button onClick={handleSave} loading={saving} icon={<Save size={18} />}>保存试卷</Button>
|
onClick={() => handleSave('Draft')}
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -460,7 +513,7 @@ export const ExamEditor: React.FC<ExamEditorProps> = ({ examId, onBack }) => {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
{exam.rootNodes.length === 0 ? (
|
{exam.rootNodes.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="text-center py-12 text-gray-400">
|
||||||
<p className="mb-4">试卷为空,点击右上角"添加分组"开始构建试卷</p>
|
<p className="mb-4">试卷为空,点击右上角 "添加分组" 开始构建试卷</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -2,125 +2,336 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ExamDto } from '../../../../UI_DTO';
|
import { ExamDto, SubjectDto } from '../../../../UI_DTO';
|
||||||
import { examService } from '@/services/api';
|
import { examService, curriculumService } from '@/services/api';
|
||||||
import { Card } from '@/components/ui/Card';
|
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 { Badge } from '@/components/ui/Badge';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { LoadingState, SkeletonCard } from '@/components/ui/LoadingState';
|
import { LoadingState, SkeletonCard } from '@/components/ui/LoadingState';
|
||||||
import { ErrorState } from '@/components/ui/ErrorState';
|
import { ErrorState } from '@/components/ui/ErrorState';
|
||||||
import { getErrorMessage, getErrorType } from '@/utils/errorUtils';
|
import { getErrorMessage, getErrorType } from '@/utils/errorUtils';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
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 [exams, setExams] = useState<ExamDto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadExams = async () => {
|
curriculumService.getSubjects().then(setSubjects);
|
||||||
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();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getStatusVariant = (status: string) => status === 'Published' ? 'success' : 'warning';
|
const fetchExams = async () => {
|
||||||
const getStatusLabel = (status: string) => status === 'Published' ? '已发布' : '草稿';
|
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) {
|
useEffect(() => {
|
||||||
return (
|
fetchExams();
|
||||||
<div className="space-y-6">
|
}, [scope, filters, page]);
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
// Reset page when filters change
|
||||||
<h2 className="text-2xl font-bold text-gray-900">考试引擎</h2>
|
useEffect(() => {
|
||||||
<p className="text-gray-500 mt-1">创建、管理及发布考试</p>
|
setPage(1);
|
||||||
</div>
|
}, [scope, filters]);
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
const handleDelete = async (id: string) => {
|
||||||
<SkeletonCard />
|
if (!confirm('确定要删除该试卷吗?此操作不可恢复。')) return;
|
||||||
<SkeletonCard />
|
try {
|
||||||
<SkeletonCard />
|
await examService.deleteExam(id);
|
||||||
</div>
|
setExams(prev => prev.filter(e => e.id !== id));
|
||||||
</div>
|
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) {
|
if (error) {
|
||||||
return (
|
return <ErrorState type={getErrorType(error) as any} message={error} onRetry={fetchExams} />;
|
||||||
<ErrorState
|
|
||||||
type={getErrorType(error) as any}
|
|
||||||
message={error}
|
|
||||||
onRetry={() => window.location.reload()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-10">
|
<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">
|
{/* Header & Scope Toggle */}
|
||||||
<div>
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">考试引擎</h2>
|
<div className="flex bg-gray-100 p-1 rounded-xl">
|
||||||
<p className="text-gray-500 mt-1 font-medium">创建、管理及发布考试</p>
|
<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>
|
||||||
|
|
||||||
<div className="flex gap-3 w-full md:w-auto">
|
<div className="flex gap-3 w-full md:w-auto">
|
||||||
<div className="relative flex-1 md:w-64">
|
<Button icon={<Plus size={18} />} onClick={onCreateExam}>
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
新建试卷
|
||||||
<input
|
</Button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
{/* Filters */}
|
||||||
{exams.map((exam, idx) => (
|
<Card className="p-4 flex flex-wrap gap-4 items-center" noPadding>
|
||||||
<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="flex items-center gap-2 text-gray-500 mr-2">
|
||||||
<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">
|
<Filter size={18} />
|
||||||
<FileText size={28} />
|
<span className="font-bold text-sm">筛选:</span>
|
||||||
</div>
|
</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">
|
<select
|
||||||
<Badge variant={getStatusVariant(exam.status) as any}>{getStatusLabel(exam.status)}</Badge>
|
value={filters.subjectId}
|
||||||
<span className="text-xs text-gray-400 font-medium">创建于: {exam.createdAt}</span>
|
onChange={(e) => setFilters({...filters, subjectId: e.target.value})}
|
||||||
</div>
|
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]"
|
||||||
<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">
|
<option value="all">全部学科</option>
|
||||||
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
<Clock size={14} className="text-gray-400" />
|
</select>
|
||||||
<span>{exam.duration} 分钟</span>
|
|
||||||
|
<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>
|
||||||
<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>
|
||||||
<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>
|
|
||||||
|
<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>
|
||||||
<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>
|
{publishExamId && (
|
||||||
))}
|
<CreateAssignmentModal
|
||||||
</div>
|
isOpen={true}
|
||||||
|
onClose={() => setPublishExamId(null)}
|
||||||
|
preSelectedExamId={publishExamId}
|
||||||
|
onSuccess={handlePublishSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { UserProfileDto } from '../../UI_DTO';
|
import { UserProfileDto } from '../../UI_DTO';
|
||||||
import { authService, subscribeApiMode } from '@/services/api';
|
import { authService } from '@/services/api';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface AuthContextType {
|
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(() => {
|
useEffect(() => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,6 @@
|
|||||||
|
|
||||||
import * as realApi from './realApi';
|
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 authService = realApi.realAuthService;
|
||||||
export const orgService = realApi.realOrgService;
|
export const orgService = realApi.realOrgService;
|
||||||
export const curriculumService = realApi.realCurriculumService;
|
export const curriculumService = realApi.realCurriculumService;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TextbookDto,
|
TextbookDto,
|
||||||
QuestionSummaryDto, PagedResult, ParsedQuestionDto,
|
QuestionSummaryDto, PagedResult, ParsedQuestionDto,
|
||||||
ExamDto, ExamDetailDto, ExamStatsDto,
|
ExamDto, ExamDetailDto, ExamStatsDto,
|
||||||
AssignmentTeacherViewDto, AssignmentStudentViewDto,
|
AssignmentTeacherViewDto, AssignmentStudentViewDto, AssignmentAnalysisDto,
|
||||||
StudentSubmissionSummaryDto, GradingPaperDto, StudentExamPaperDto, SubmitExamDto, StudentResultDto,
|
StudentSubmissionSummaryDto, GradingPaperDto, StudentExamPaperDto, SubmitExamDto, StudentResultDto,
|
||||||
ChartDataDto, RadarChartDto, ScoreDistributionDto, ScheduleDto, CreateScheduleDto,
|
ChartDataDto, RadarChartDto, ScoreDistributionDto, ScheduleDto, CreateScheduleDto,
|
||||||
MessageDto, CreateMessageDto
|
MessageDto, CreateMessageDto
|
||||||
@@ -60,17 +60,23 @@ export interface IQuestionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IExamService {
|
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>;
|
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>;
|
getStats(id: string): Promise<ExamStatsDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAssignmentService {
|
export interface IAssignmentService {
|
||||||
getTeachingAssignments(): Promise<PagedResult<AssignmentTeacherViewDto>>;
|
getTeachingAssignments(filters?: { classId?: string; examType?: string; subjectId?: string; status?: string }): Promise<PagedResult<AssignmentTeacherViewDto>>;
|
||||||
getStudentAssignments(): Promise<PagedResult<AssignmentStudentViewDto>>;
|
getStudentAssignments(filters?: { subjectId?: string; examType?: string; status?: string }): Promise<PagedResult<AssignmentStudentViewDto>>;
|
||||||
publishAssignment(data: any): Promise<void>;
|
publishAssignment(data: any): Promise<void>;
|
||||||
|
updateAssignment(id: string, data: any): Promise<void>;
|
||||||
|
archiveAssignment(id: string): Promise<void>;
|
||||||
getAssignmentStats(id: string): Promise<ExamStatsDto>;
|
getAssignmentStats(id: string): Promise<ExamStatsDto>;
|
||||||
|
getAssignmentAnalysis(id: string): Promise<AssignmentAnalysisDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAnalyticsService {
|
export interface IAnalyticsService {
|
||||||
@@ -80,6 +86,7 @@ export interface IAnalyticsService {
|
|||||||
getStudentRadar(): Promise<RadarChartDto>;
|
getStudentRadar(): Promise<RadarChartDto>;
|
||||||
getScoreDistribution(): Promise<ScoreDistributionDto[]>;
|
getScoreDistribution(): Promise<ScoreDistributionDto[]>;
|
||||||
getTeacherStats(): Promise<{ activeStudents: number; averageScore: number; pendingGrading: number; passRate: number }>;
|
getTeacherStats(): Promise<{ activeStudents: number; averageScore: number; pendingGrading: number; passRate: number }>;
|
||||||
|
getStudentStats(): Promise<{ completed: number; todo: number; average: number; studyDuration: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionFilterDto {
|
export interface QuestionFilterDto {
|
||||||
@@ -112,8 +119,10 @@ export interface IGradingService {
|
|||||||
|
|
||||||
export interface ISubmissionService {
|
export interface ISubmissionService {
|
||||||
getStudentPaper(assignmentId: string): Promise<StudentExamPaperDto>;
|
getStudentPaper(assignmentId: string): Promise<StudentExamPaperDto>;
|
||||||
submitExam(data: SubmitExamDto): Promise<void>;
|
submitAnswers(assignmentId: string, answers: Array<{ examNodeId: string; studentAnswer: any }>, timeSpent?: number): Promise<{ message: string; submissionId: string }>;
|
||||||
getSubmissionResult(assignmentId: string): Promise<StudentResultDto>;
|
saveProgress(assignmentId: string, answers: Array<{ examNodeId: string; studentAnswer: any }>): Promise<void>;
|
||||||
|
getSubmissionResult(submissionId: string): Promise<StudentResultDto>;
|
||||||
|
getSubmissionResultByAssignment(assignmentId: string): Promise<StudentResultDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommonService {
|
export interface ICommonService {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '../../UI_DTO';
|
} 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 秒超时
|
const DEFAULT_TIMEOUT = 30000; // 30 秒超时
|
||||||
|
|
||||||
// Helper to handle requests with timeout
|
// Helper to handle requests with timeout
|
||||||
@@ -180,8 +180,28 @@ export const realQuestionService: IQuestionService = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const realExamService: IExamService = {
|
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}`),
|
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) => {
|
saveExam: async (exam: ExamDetailDto) => {
|
||||||
// Determine if create or update
|
// Determine if create or update
|
||||||
if (exam.id) {
|
if (exam.id) {
|
||||||
@@ -219,13 +239,34 @@ export const realExamService: IExamService = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const realAssignmentService: IAssignmentService = {
|
export const realAssignmentService: IAssignmentService = {
|
||||||
getTeachingAssignments: () => request('/assignments/teaching'),
|
getTeachingAssignments: (filters) => {
|
||||||
getStudentAssignments: () => request('/assignments/learning'),
|
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', {
|
publishAssignment: (data) => request('/assignments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data)
|
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 = {
|
export const realSubmissionService: ISubmissionService = {
|
||||||
getStudentPaper: (id) => request(`/submissions/${id}/paper`),
|
getStudentPaper: (id) => request(`/submissions/${id}/paper`),
|
||||||
submitExam: (data) => {
|
submitAnswers: (assignmentId, answers, timeSpent) => request(`/submissions/${assignmentId}/submit`, {
|
||||||
const answersArray = Object.entries(data.answers || {}).map(([examNodeId, studentAnswer]) => ({ examNodeId, studentAnswer: (typeof studentAnswer === 'object' ? JSON.stringify(studentAnswer) : studentAnswer) }));
|
method: 'POST',
|
||||||
return request(`/submissions/${data.assignmentId}/submit`, {
|
body: JSON.stringify({ answers, timeSpent })
|
||||||
method: 'POST',
|
}),
|
||||||
body: JSON.stringify({ answers: answersArray, timeSpent: data.timeSpent })
|
saveProgress: (assignmentId, answers) => request(`/submissions/${assignmentId}/save`, {
|
||||||
});
|
method: 'POST',
|
||||||
},
|
body: JSON.stringify({ answers })
|
||||||
getSubmissionResult: (assignmentId: string) => request(`/submissions/by-assignment/${assignmentId}/result`),
|
}),
|
||||||
|
getSubmissionResult: (submissionId) => request(`/submissions/${submissionId}/result`),
|
||||||
|
getSubmissionResultByAssignment: (assignmentId) => request(`/submissions/by-assignment/${assignmentId}/result`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const realCommonService: ICommonService = {
|
export const realCommonService: ICommonService = {
|
||||||
@@ -279,5 +322,6 @@ export const realAnalyticsService: IAnalyticsService = {
|
|||||||
getRadar: () => request('/analytics/radar'),
|
getRadar: () => request('/analytics/radar'),
|
||||||
getStudentRadar: () => request('/analytics/student/radar'),
|
getStudentRadar: () => request('/analytics/student/radar'),
|
||||||
getScoreDistribution: () => request('/analytics/distribution'),
|
getScoreDistribution: () => request('/analytics/distribution'),
|
||||||
getTeacherStats: () => request('/analytics/teacher-stats')
|
getTeacherStats: () => request('/analytics/teacher-stats'),
|
||||||
|
getStudentStats: () => request('/analytics/student/stats')
|
||||||
};
|
};
|
||||||
|
|||||||
345
src/types.ts
345
src/types.ts
@@ -1,344 +1 @@
|
|||||||
|
export * from '../UI_DTO';
|
||||||
// 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';
|
|
||||||
|
|||||||
@@ -1,58 +1,48 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { ExamList } from '@/features/exam/components/ExamList';
|
import { ExamList } from '@/features/exam/components/ExamList';
|
||||||
import { ExamEditor } from '@/features/exam/components/ExamEditor';
|
import { ExamEditor } from '@/features/exam/components/ExamEditor';
|
||||||
import { ExamStats } from '@/features/exam/components/ExamStats';
|
import { ExamStats } from '@/features/exam/components/ExamStats';
|
||||||
|
|
||||||
export const ExamEngine: React.FC = () => {
|
export const ExamEngine = () => {
|
||||||
const [view, setView] = useState<'list' | 'editor' | 'stats'>('list');
|
const [viewState, setViewState] = useState<'list' | 'editor' | 'stats'>('list');
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
const [selectedExamId, setSelectedExamId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
return (
|
const handleEditExam = (id: string) => {
|
||||||
<div className="max-w-[1600px] mx-auto h-full">
|
setSelectedExamId(id);
|
||||||
<AnimatePresence mode='wait'>
|
setViewState('editor');
|
||||||
{view === 'list' && (
|
};
|
||||||
<motion.div
|
|
||||||
key="list"
|
const handleCreateExam = () => {
|
||||||
initial={{ opacity: 0, x: -20 }}
|
setSelectedExamId(undefined);
|
||||||
animate={{ opacity: 1, x: 0 }}
|
setViewState('editor');
|
||||||
exit={{ opacity: 0, x: -20 }}
|
};
|
||||||
>
|
|
||||||
<ExamList
|
const handleStats = (id: string) => {
|
||||||
onCreate={() => { setSelectedId(undefined); setView('editor'); }}
|
setSelectedExamId(id);
|
||||||
onEdit={(id) => { setSelectedId(id); setView('editor'); }}
|
setViewState('stats');
|
||||||
onStats={(id) => { setSelectedId(id); setView('stats'); }}
|
};
|
||||||
/>
|
|
||||||
</motion.div>
|
const handleBack = () => {
|
||||||
)}
|
setViewState('list');
|
||||||
{view === 'editor' && (
|
setSelectedExamId(undefined);
|
||||||
<motion.div
|
};
|
||||||
key="editor"
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
if (viewState === 'editor') {
|
||||||
animate={{ opacity: 1, x: 0 }}
|
return <ExamEditor examId={selectedExamId} onBack={handleBack} />;
|
||||||
exit={{ opacity: 0, x: 20 }}
|
}
|
||||||
className="h-[calc(100vh-120px)]"
|
|
||||||
>
|
if (viewState === 'stats' && selectedExamId) {
|
||||||
<ExamEditor
|
return <ExamStats examId={selectedExamId} onBack={handleBack} />;
|
||||||
examId={selectedId}
|
}
|
||||||
onBack={() => setView('list')}
|
|
||||||
/>
|
return (
|
||||||
</motion.div>
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
)}
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-1">
|
||||||
{view === 'stats' && selectedId && (
|
<ExamList
|
||||||
<motion.div
|
onEditExam={handleEditExam}
|
||||||
key="stats"
|
onCreateExam={handleCreateExam}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
/>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 20 }}
|
|
||||||
>
|
|
||||||
<ExamStats
|
|
||||||
examId={selectedId}
|
|
||||||
onBack={() => setView('list')}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { RunnerQuestionCard } from '@/features/exam/components/runner/RunnerQues
|
|||||||
import { ExamHeader } from '@/features/exam/components/runner/ExamHeader';
|
import { ExamHeader } from '@/features/exam/components/runner/ExamHeader';
|
||||||
import { AnswerSheet } from '@/features/exam/components/runner/AnswerSheet';
|
import { AnswerSheet } from '@/features/exam/components/runner/AnswerSheet';
|
||||||
import { SubmitConfirmModal } from '@/features/exam/components/runner/SubmitConfirmModal';
|
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';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
interface StudentExamRunnerProps {
|
interface StudentExamRunnerProps {
|
||||||
@@ -26,17 +26,24 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
|
|||||||
const [showSubmitModal, setShowSubmitModal] = useState(false);
|
const [showSubmitModal, setShowSubmitModal] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const [lastSavedTime, setLastSavedTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
submissionService.getStudentPaper(assignmentId).then(data => {
|
submissionService.getStudentPaper(assignmentId).then(data => {
|
||||||
setPaper(data);
|
setPaper(data);
|
||||||
setTimeLeft(data.duration * 60);
|
// Initialize answers if user had some saved progress (TODO: Backend should return saved answers)
|
||||||
// Recursively collect all question nodes
|
// 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 flattenNodes = (nodes: typeof data.rootNodes): any[] => {
|
||||||
const questions: any[] = [];
|
const questions: any[] = [];
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
if (node.nodeType === 'Question') {
|
if (node.nodeType === 'Question') {
|
||||||
questions.push({ ...node, id: node.id });
|
questions.push({ ...node, id: node.id });
|
||||||
|
if (node.studentAnswer) {
|
||||||
|
initialAnswers[node.id] = node.studentAnswer;
|
||||||
|
}
|
||||||
} else if (node.children) {
|
} else if (node.children) {
|
||||||
questions.push(...flattenNodes(node.children));
|
questions.push(...flattenNodes(node.children));
|
||||||
}
|
}
|
||||||
@@ -45,9 +52,33 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
|
|||||||
};
|
};
|
||||||
const allQuestions = flattenNodes(data.rootNodes);
|
const allQuestions = flattenNodes(data.rootNodes);
|
||||||
setFlatQuestions(allQuestions);
|
setFlatQuestions(allQuestions);
|
||||||
|
setAnswers(initialAnswers);
|
||||||
|
setTimeLeft(data.duration * 60);
|
||||||
});
|
});
|
||||||
}, [assignmentId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!paper) return;
|
if (!paper) return;
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@@ -84,12 +115,12 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
|
|||||||
setShowSubmitModal(false);
|
setShowSubmitModal(false);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const submitData: SubmitExamDto = {
|
const answerArray = Object.entries(answers).map(([key, value]) => ({
|
||||||
assignmentId,
|
examNodeId: key,
|
||||||
answers,
|
studentAnswer: value
|
||||||
timeSpent: paper ? paper.duration * 60 - timeLeft : 0
|
}));
|
||||||
};
|
const timeSpent = paper ? paper.duration * 60 - timeLeft : 0;
|
||||||
await submissionService.submitExam(submitData);
|
await submissionService.submitAnswers(assignmentId, answerArray, timeSpent);
|
||||||
showToast('试卷提交成功!正在跳转...', 'success');
|
showToast('试卷提交成功!正在跳转...', 'success');
|
||||||
setTimeout(onExit, 2000);
|
setTimeout(onExit, 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -134,6 +165,13 @@ export const StudentExamRunner: React.FC<StudentExamRunnerProps> = ({ assignment
|
|||||||
onSubmit={handleSubmitClick}
|
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 flex overflow-hidden relative">
|
||||||
<div className="flex-1 max-w-4xl mx-auto w-full p-6 flex flex-col">
|
<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>
|
<Card className="flex-1 p-8 flex flex-col relative overflow-hidden" noPadding>
|
||||||
|
|||||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user