chore: initial import to Nexus_Edu
This commit is contained in:
15
backend/.env.example
Normal file
15
backend/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# 环境变量配置
|
||||
|
||||
# 数据库连接
|
||||
DATABASE_URL="mysql://root:wx1998WX@mysql.eazygame.cn:13002/edunexus"
|
||||
|
||||
# JWT密钥
|
||||
JWT_SECRET="your-secret-key-change-in-production"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
NODE_ENV="development"
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGIN="http://localhost:3000"
|
||||
6
backend/.gitignore
vendored
Normal file
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
prisma/.env
|
||||
137
backend/README.md
Normal file
137
backend/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# EduNexus Backend API
|
||||
|
||||
后端API服务,基于Express + Prisma + MySQL。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
复制`.env.example`为`.env`并配置数据库连接:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑`.env`:
|
||||
```env
|
||||
DATABASE_URL="mysql://用户名:密码@localhost:3306/edunexus"
|
||||
```
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 生成Prisma客户端
|
||||
npm run prisma:generate
|
||||
|
||||
# 同步数据库schema
|
||||
npm run prisma:push
|
||||
```
|
||||
|
||||
### 4. (可选) 导入示例数据
|
||||
|
||||
在MySQL中执行:
|
||||
```bash
|
||||
mysql -u root -p edunexus < ../database/seed.sql
|
||||
```
|
||||
|
||||
### 5. 启动服务器
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 生产模式
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
服务器将在 http://localhost:3001 启动
|
||||
|
||||
## API端点
|
||||
|
||||
### Auth `/api/auth`
|
||||
- `POST /register` - 注册
|
||||
- `POST /login` - 登录
|
||||
- `GET /profile` - 获取个人信息 🔒
|
||||
- `PUT /profile` - 更新个人信息 🔒
|
||||
- `POST /change-password` - 修改密码 🔒
|
||||
|
||||
### Exams `/api/exams`
|
||||
- `GET /` - 获取试卷列表 🔒
|
||||
- `GET /:id` - 获取试卷详情 🔒
|
||||
- `POST /` - 创建试卷 🔒
|
||||
- `PUT /:id` - 更新试卷 🔒
|
||||
- `DELETE /:id` - 删除试卷 🔒
|
||||
- `POST /:id/nodes` - 添加节点 🔒
|
||||
- `PUT /:id/nodes/:nodeId` - 更新节点 🔒
|
||||
- `DELETE /:id/nodes/:nodeId` - 删除节点 🔒
|
||||
|
||||
### 其他模块
|
||||
- `/api/org` - 组织架构 (TODO)
|
||||
- `/api/curriculum` - 教材知识 (TODO)
|
||||
- `/api/questions` - 题库 (TODO)
|
||||
- `/api/assignments` - 作业 (TODO)
|
||||
- `/api/submissions` - 提交 (TODO)
|
||||
- `/api/grading` - 批阅 (TODO)
|
||||
|
||||
> 🔒 = 需要JWT认证
|
||||
|
||||
## 认证
|
||||
|
||||
API使用JWT Bearer Token认证。
|
||||
|
||||
1. 调用`/api/auth/login`获取token
|
||||
2. 在请求头中添加:`Authorization: Bearer <token>`
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── controllers/ # 控制器
|
||||
│ ├── routes/ # 路由
|
||||
│ ├── middleware/ # 中间件
|
||||
│ ├── utils/ # 工具函数
|
||||
│ └── index.ts # 入口文件
|
||||
├── prisma/
|
||||
│ └── schema.prisma # Prisma模型定义
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
使用Prisma ORM管理MySQL数据库:
|
||||
|
||||
```bash
|
||||
# 查看数据库
|
||||
npm run prisma:studio
|
||||
|
||||
# 创建迁移
|
||||
npm run prisma:migrate
|
||||
|
||||
# 重置数据库
|
||||
npm run prisma:push
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
- 所有API返回JSON格式
|
||||
- 使用UUID作为主键
|
||||
- 支持软删除 (isDeleted字段)
|
||||
- 所有表包含审计字段
|
||||
- ExamNode支持无限层级嵌套
|
||||
|
||||
## 待完成
|
||||
|
||||
- [ ] 完善其他6个模块的API实现
|
||||
- [ ] 添加数据验证中间件
|
||||
- [ ] 实现文件上传功能
|
||||
- [ ] 添加单元测试
|
||||
- [ ] 添加API文档(Swagger)
|
||||
1841
backend/package-lock.json
generated
Normal file
1841
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
backend/package.json
Normal file
44
backend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "edunexus-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "EduNexus Pro Backend API Server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"education",
|
||||
"api",
|
||||
"mysql"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"express": "^4.21.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"zod": "^3.23.8",
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
526
backend/prisma/schema.prisma
Normal file
526
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,526 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["fullTextIndex"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 基础审计字段模型
|
||||
// =============================================
|
||||
|
||||
model ApplicationUser {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
realName String @map("real_name") @db.VarChar(50)
|
||||
studentId String? @map("student_id") @db.VarChar(20)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(500)
|
||||
gender Gender @default(Male)
|
||||
currentSchoolId String? @map("current_school_id") @db.VarChar(36)
|
||||
accountStatus AccountStatus @map("account_status") @default(Active)
|
||||
email String? @db.VarChar(100)
|
||||
phone String? @db.VarChar(20)
|
||||
bio String? @db.Text
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
// Relations
|
||||
classMemberships ClassMember[]
|
||||
createdQuestions Question[] @relation("CreatedQuestions")
|
||||
createdExams Exam[] @relation("CreatedExams")
|
||||
submissions StudentSubmission[]
|
||||
messages Message[]
|
||||
|
||||
@@index([studentId])
|
||||
@@index([email])
|
||||
@@index([phone])
|
||||
@@index([currentSchoolId])
|
||||
@@map("application_users")
|
||||
}
|
||||
|
||||
enum Gender {
|
||||
Male
|
||||
Female
|
||||
}
|
||||
|
||||
enum AccountStatus {
|
||||
Active
|
||||
Suspended
|
||||
Graduated
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 组织架构模块
|
||||
// =============================================
|
||||
|
||||
model School {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
name String @db.VarChar(100)
|
||||
regionCode String @map("region_code") @db.VarChar(20)
|
||||
address String? @db.VarChar(200)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
grades Grade[]
|
||||
|
||||
@@index([regionCode])
|
||||
@@map("schools")
|
||||
}
|
||||
|
||||
model Grade {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
schoolId String @map("school_id") @db.VarChar(36)
|
||||
name String @db.VarChar(50)
|
||||
sortOrder Int @map("sort_order")
|
||||
enrollmentYear Int @map("enrollment_year")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
classes Class[]
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([enrollmentYear])
|
||||
@@map("grades")
|
||||
}
|
||||
|
||||
model Class {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
gradeId String @map("grade_id") @db.VarChar(36)
|
||||
name String @db.VarChar(50)
|
||||
inviteCode String @unique @map("invite_code") @db.VarChar(10)
|
||||
headTeacherId String? @map("head_teacher_id") @db.VarChar(36)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
grade Grade @relation(fields: [gradeId], references: [id])
|
||||
members ClassMember[]
|
||||
assignments Assignment[]
|
||||
schedules Schedule[]
|
||||
|
||||
@@index([gradeId])
|
||||
@@map("classes")
|
||||
}
|
||||
|
||||
model ClassMember {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
classId String @map("class_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
roleInClass ClassRole @map("role_in_class") @default(Student)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
user ApplicationUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([classId, userId])
|
||||
@@index([userId])
|
||||
@@map("class_members")
|
||||
}
|
||||
|
||||
enum ClassRole {
|
||||
Student
|
||||
Monitor
|
||||
Committee
|
||||
Teacher
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 教材与知识图谱模块
|
||||
// =============================================
|
||||
|
||||
model Subject {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
name String @db.VarChar(50)
|
||||
code String @unique @db.VarChar(20)
|
||||
icon String? @db.VarChar(50)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
textbooks Textbook[]
|
||||
questions Question[]
|
||||
exams Exam[]
|
||||
|
||||
@@map("subjects")
|
||||
}
|
||||
|
||||
model Textbook {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
subjectId String @map("subject_id") @db.VarChar(36)
|
||||
name String @db.VarChar(100)
|
||||
publisher String @db.VarChar(100)
|
||||
versionYear String @map("version_year") @db.VarChar(20)
|
||||
coverUrl String? @map("cover_url") @db.VarChar(500)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
units TextbookUnit[]
|
||||
|
||||
@@index([subjectId])
|
||||
@@map("textbooks")
|
||||
}
|
||||
|
||||
model TextbookUnit {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
textbookId String @map("textbook_id") @db.VarChar(36)
|
||||
name String @db.VarChar(100)
|
||||
sortOrder Int @map("sort_order")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
textbook Textbook @relation(fields: [textbookId], references: [id], onDelete: Cascade)
|
||||
lessons TextbookLesson[]
|
||||
|
||||
@@index([textbookId])
|
||||
@@index([sortOrder])
|
||||
@@map("textbook_units")
|
||||
}
|
||||
|
||||
model TextbookLesson {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
unitId String @map("unit_id") @db.VarChar(36)
|
||||
name String @db.VarChar(100)
|
||||
sortOrder Int @map("sort_order")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
unit TextbookUnit @relation(fields: [unitId], references: [id], onDelete: Cascade)
|
||||
knowledgePoints KnowledgePoint[]
|
||||
|
||||
@@index([unitId])
|
||||
@@index([sortOrder])
|
||||
@@map("textbook_lessons")
|
||||
}
|
||||
|
||||
model KnowledgePoint {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
lessonId String @map("lesson_id") @db.VarChar(36)
|
||||
parentKnowledgePointId String? @map("parent_knowledge_point_id") @db.VarChar(36)
|
||||
name String @db.VarChar(200)
|
||||
difficulty Int
|
||||
description String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
lesson TextbookLesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
parentKnowledgePoint KnowledgePoint? @relation("KnowledgePointHierarchy", fields: [parentKnowledgePointId], references: [id], onDelete: Cascade)
|
||||
childKnowledgePoints KnowledgePoint[] @relation("KnowledgePointHierarchy")
|
||||
questionAssociations QuestionKnowledge[]
|
||||
|
||||
@@index([lessonId])
|
||||
@@index([parentKnowledgePointId])
|
||||
@@index([difficulty])
|
||||
@@map("knowledge_points")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 题库资源模块
|
||||
// =============================================
|
||||
|
||||
model Question {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
subjectId String @map("subject_id") @db.VarChar(36)
|
||||
content String @db.Text
|
||||
optionsConfig Json? @map("options_config")
|
||||
questionType QuestionType @map("question_type")
|
||||
answer String @db.Text
|
||||
explanation String? @db.Text
|
||||
difficulty Int @default(3)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
creator ApplicationUser @relation("CreatedQuestions", fields: [createdBy], references: [id])
|
||||
knowledgePoints QuestionKnowledge[]
|
||||
examNodes ExamNode[]
|
||||
|
||||
@@index([subjectId])
|
||||
@@index([questionType])
|
||||
@@index([difficulty])
|
||||
@@fulltext([content])
|
||||
@@map("questions")
|
||||
}
|
||||
|
||||
model QuestionKnowledge {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
questionId String @map("question_id") @db.VarChar(36)
|
||||
knowledgePointId String @map("knowledge_point_id") @db.VarChar(36)
|
||||
weight Int @default(100)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
||||
knowledgePoint KnowledgePoint @relation(fields: [knowledgePointId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([questionId, knowledgePointId])
|
||||
@@index([knowledgePointId])
|
||||
@@map("question_knowledge")
|
||||
}
|
||||
|
||||
enum QuestionType {
|
||||
SingleChoice
|
||||
MultipleChoice
|
||||
TrueFalse
|
||||
FillBlank
|
||||
Subjective
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 试卷工程模块
|
||||
// =============================================
|
||||
|
||||
model Exam {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
subjectId String @map("subject_id") @db.VarChar(36)
|
||||
title String @db.VarChar(200)
|
||||
totalScore Decimal @map("total_score") @default(0) @db.Decimal(5, 1)
|
||||
suggestedDuration Int @map("suggested_duration")
|
||||
status ExamStatus @default(Draft)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
creator ApplicationUser @relation("CreatedExams", fields: [createdBy], references: [id])
|
||||
nodes ExamNode[]
|
||||
assignments Assignment[]
|
||||
|
||||
@@index([subjectId])
|
||||
@@index([status])
|
||||
@@index([createdBy])
|
||||
@@map("exams")
|
||||
}
|
||||
|
||||
model ExamNode {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
examId String @map("exam_id") @db.VarChar(36)
|
||||
parentNodeId String? @map("parent_node_id") @db.VarChar(36)
|
||||
nodeType NodeType @map("node_type")
|
||||
questionId String? @map("question_id") @db.VarChar(36)
|
||||
title String? @db.VarChar(200)
|
||||
description String? @db.Text
|
||||
score Decimal @db.Decimal(5, 1)
|
||||
sortOrder Int @map("sort_order")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
exam Exam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
parentNode ExamNode? @relation("ExamNodeHierarchy", fields: [parentNodeId], references: [id], onDelete: Cascade)
|
||||
childNodes ExamNode[] @relation("ExamNodeHierarchy")
|
||||
question Question? @relation(fields: [questionId], references: [id])
|
||||
submissionDetails SubmissionDetail[]
|
||||
|
||||
@@index([examId])
|
||||
@@index([parentNodeId])
|
||||
@@index([sortOrder])
|
||||
@@index([questionId])
|
||||
@@map("exam_nodes")
|
||||
}
|
||||
|
||||
enum ExamStatus {
|
||||
Draft
|
||||
Published
|
||||
}
|
||||
|
||||
enum NodeType {
|
||||
Group
|
||||
Question
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 教学执行模块
|
||||
// =============================================
|
||||
|
||||
model Assignment {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
examId String @map("exam_id") @db.VarChar(36)
|
||||
classId String @map("class_id") @db.VarChar(36)
|
||||
title String @db.VarChar(200)
|
||||
startTime DateTime @map("start_time")
|
||||
endTime DateTime @map("end_time")
|
||||
allowLateSubmission Boolean @map("allow_late_submission") @default(false)
|
||||
autoScoreEnabled Boolean @map("auto_score_enabled") @default(true)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
exam Exam @relation(fields: [examId], references: [id])
|
||||
class Class @relation(fields: [classId], references: [id])
|
||||
submissions StudentSubmission[]
|
||||
|
||||
@@index([examId])
|
||||
@@index([classId])
|
||||
@@index([startTime])
|
||||
@@index([endTime])
|
||||
@@map("assignments")
|
||||
}
|
||||
|
||||
model StudentSubmission {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
assignmentId String @map("assignment_id") @db.VarChar(36)
|
||||
studentId String @map("student_id") @db.VarChar(36)
|
||||
submissionStatus SubmissionStatus @map("submission_status") @default(Pending)
|
||||
submitTime DateTime? @map("submit_time")
|
||||
timeSpentSeconds Int? @map("time_spent_seconds")
|
||||
totalScore Decimal? @map("total_score") @db.Decimal(5, 1)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
student ApplicationUser @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
details SubmissionDetail[]
|
||||
|
||||
@@unique([assignmentId, studentId])
|
||||
@@index([studentId])
|
||||
@@index([submitTime])
|
||||
@@index([submissionStatus])
|
||||
@@map("student_submissions")
|
||||
}
|
||||
|
||||
model SubmissionDetail {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
submissionId String @map("submission_id") @db.VarChar(36)
|
||||
examNodeId String @map("exam_node_id") @db.VarChar(36)
|
||||
studentAnswer String? @map("student_answer") @db.Text
|
||||
gradingData Json? @map("grading_data")
|
||||
score Decimal? @db.Decimal(5, 1)
|
||||
judgement JudgementResult?
|
||||
teacherComment String? @map("teacher_comment") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String @map("updated_by") @db.VarChar(36)
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
|
||||
submission StudentSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
||||
examNode ExamNode @relation(fields: [examNodeId], references: [id])
|
||||
|
||||
@@unique([submissionId, examNodeId])
|
||||
@@index([examNodeId])
|
||||
@@index([judgement])
|
||||
@@map("submission_details")
|
||||
}
|
||||
|
||||
enum SubmissionStatus {
|
||||
Pending
|
||||
Submitted
|
||||
Grading
|
||||
Graded
|
||||
}
|
||||
|
||||
enum JudgementResult {
|
||||
Correct
|
||||
Incorrect
|
||||
Partial
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 辅助功能模块
|
||||
// =============================================
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
title String @db.VarChar(200)
|
||||
content String @db.Text
|
||||
type String @db.VarChar(20) // Announcement, Notification, Alert
|
||||
senderName String @map("sender_name") @db.VarChar(50)
|
||||
isRead Boolean @default(false) @map("is_read")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user ApplicationUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("messages")
|
||||
}
|
||||
|
||||
model Schedule {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
classId String @map("class_id") @db.VarChar(36)
|
||||
subject String @db.VarChar(50)
|
||||
room String? @db.VarChar(50)
|
||||
dayOfWeek Int @map("day_of_week") // 1-7
|
||||
period Int // 1-8
|
||||
startTime String @map("start_time") @db.VarChar(10) // HH:mm
|
||||
endTime String @map("end_time") @db.VarChar(10) // HH:mm
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([classId])
|
||||
@@map("schedules")
|
||||
}
|
||||
570
backend/prisma/seed.ts
Normal file
570
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 开始创建种子数据...\n');
|
||||
|
||||
// 0. 清空数据 (按依赖顺序反向删除)
|
||||
console.log('🧹 清空现有数据...');
|
||||
await prisma.submissionDetail.deleteMany();
|
||||
await prisma.studentSubmission.deleteMany();
|
||||
await prisma.assignment.deleteMany();
|
||||
await prisma.examNode.deleteMany();
|
||||
await prisma.exam.deleteMany();
|
||||
await prisma.questionKnowledge.deleteMany();
|
||||
await prisma.question.deleteMany();
|
||||
await prisma.knowledgePoint.deleteMany();
|
||||
await prisma.textbookLesson.deleteMany();
|
||||
await prisma.textbookUnit.deleteMany();
|
||||
await prisma.textbook.deleteMany();
|
||||
await prisma.subject.deleteMany();
|
||||
await prisma.classMember.deleteMany();
|
||||
await prisma.class.deleteMany();
|
||||
await prisma.grade.deleteMany();
|
||||
await prisma.school.deleteMany();
|
||||
await prisma.applicationUser.deleteMany();
|
||||
console.log(' ✅ 数据已清空');
|
||||
|
||||
// 1. 创建学校
|
||||
console.log('📚 创建学校...');
|
||||
const school = await prisma.school.create({
|
||||
data: {
|
||||
id: 'school-demo-001',
|
||||
name: '北京示范高中',
|
||||
regionCode: '110101',
|
||||
address: '北京市东城区示范路100号',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
});
|
||||
console.log(` ✅ 创建学校: ${school.name}`);
|
||||
|
||||
// 2. 创建年级
|
||||
console.log('\n🎓 创建年级...');
|
||||
const grades = await Promise.all([
|
||||
prisma.grade.create({
|
||||
data: {
|
||||
id: 'grade-1',
|
||||
schoolId: school.id,
|
||||
name: '高一年级',
|
||||
sortOrder: 1,
|
||||
enrollmentYear: 2024,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
}),
|
||||
prisma.grade.create({
|
||||
data: {
|
||||
id: 'grade-2',
|
||||
schoolId: school.id,
|
||||
name: '高二年级',
|
||||
sortOrder: 2,
|
||||
enrollmentYear: 2023,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
})
|
||||
]);
|
||||
console.log(` ✅ 创建 ${grades.length} 个年级`);
|
||||
|
||||
// 3. 创建科目
|
||||
console.log('\n📖 创建科目...');
|
||||
const subjects = await Promise.all([
|
||||
prisma.subject.create({
|
||||
data: {
|
||||
id: 'subject-math',
|
||||
name: '数学',
|
||||
code: 'MATH',
|
||||
icon: '📐',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
}),
|
||||
prisma.subject.create({
|
||||
data: {
|
||||
id: 'subject-physics',
|
||||
name: '物理',
|
||||
code: 'PHYS',
|
||||
icon: '⚡',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
}),
|
||||
prisma.subject.create({
|
||||
data: {
|
||||
id: 'subject-english',
|
||||
name: '英语',
|
||||
code: 'ENG',
|
||||
icon: '🔤',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
})
|
||||
]);
|
||||
console.log(` ✅ 创建 ${subjects.length} 个科目`);
|
||||
|
||||
// 4. 创建教师账号
|
||||
console.log('\n👨🏫 创建教师账号...');
|
||||
const teacherPassword = await bcrypt.hash('123456', 10);
|
||||
const teachers = await Promise.all([
|
||||
prisma.applicationUser.create({
|
||||
data: {
|
||||
id: 'teacher-001',
|
||||
realName: '李明',
|
||||
studentId: 'T2024001',
|
||||
email: 'liming@school.edu',
|
||||
phone: '13800138001',
|
||||
gender: 'Male',
|
||||
currentSchoolId: school.id,
|
||||
accountStatus: 'Active',
|
||||
passwordHash: teacherPassword,
|
||||
bio: '数学教师,教龄10年',
|
||||
avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=teacher1',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
}),
|
||||
prisma.applicationUser.create({
|
||||
data: {
|
||||
id: 'teacher-002',
|
||||
realName: '张伟',
|
||||
studentId: 'T2024002',
|
||||
email: 'zhangwei@school.edu',
|
||||
phone: '13800138002',
|
||||
gender: 'Male',
|
||||
currentSchoolId: school.id,
|
||||
accountStatus: 'Active',
|
||||
passwordHash: teacherPassword,
|
||||
bio: '物理教师,教龄8年',
|
||||
avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=teacher2',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
})
|
||||
]);
|
||||
console.log(` ✅ 创建 ${teachers.length} 个教师账号 (密码: 123456)`);
|
||||
|
||||
// 5. 创建学生账号
|
||||
console.log('\n👨🎓 创建学生账号...');
|
||||
const studentPassword = await bcrypt.hash('123456', 10);
|
||||
const students = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const student = await prisma.applicationUser.create({
|
||||
data: {
|
||||
id: `student-${String(i).padStart(3, '0')}`,
|
||||
realName: `学生${i}号`,
|
||||
studentId: `S2024${String(i).padStart(3, '0')}`,
|
||||
email: `student${i}@school.edu`,
|
||||
phone: `1380013${String(8000 + i)}`,
|
||||
gender: i % 2 === 0 ? 'Female' : 'Male',
|
||||
currentSchoolId: school.id,
|
||||
accountStatus: 'Active',
|
||||
passwordHash: studentPassword,
|
||||
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=student${i}`,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
});
|
||||
students.push(student);
|
||||
}
|
||||
console.log(` ✅ 创建 ${students.length} 个学生账号 (密码: 123456)`);
|
||||
|
||||
// 6. 创建班级
|
||||
console.log('\n🏫 创建班级...');
|
||||
const class1 = await prisma.class.create({
|
||||
data: {
|
||||
id: 'class-001',
|
||||
gradeId: grades[0].id,
|
||||
name: '高一(1)班',
|
||||
inviteCode: 'ABC123',
|
||||
headTeacherId: teachers[0].id,
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
console.log(` ✅ 创建班级: ${class1.name} (邀请码: ${class1.inviteCode})`);
|
||||
|
||||
// 7. 添加班级成员
|
||||
console.log('\n👥 添加班级成员...');
|
||||
// 添加教师
|
||||
await prisma.classMember.create({
|
||||
data: {
|
||||
id: 'cm-teacher-001',
|
||||
classId: class1.id,
|
||||
userId: teachers[0].id,
|
||||
roleInClass: 'Teacher',
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
// 添加学生
|
||||
for (let i = 0; i < students.length; i++) {
|
||||
await prisma.classMember.create({
|
||||
data: {
|
||||
id: `cm-student-${String(i + 1).padStart(3, '0')}`,
|
||||
classId: class1.id,
|
||||
userId: students[i].id,
|
||||
roleInClass: i === 0 ? 'Monitor' : 'Student',
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log(` ✅ 添加 1 个教师和 ${students.length} 个学生到班级`);
|
||||
|
||||
// 8. 创建教材
|
||||
console.log('\n📚 创建教材...');
|
||||
const textbook = await prisma.textbook.create({
|
||||
data: {
|
||||
id: 'textbook-math-1',
|
||||
subjectId: subjects[0].id,
|
||||
name: '普通高中教科书·数学A版(必修第一册)',
|
||||
publisher: '人民教育出版社',
|
||||
versionYear: '2024',
|
||||
coverUrl: 'https://placehold.co/300x400/007AFF/ffffff?text=Math',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
});
|
||||
console.log(` ✅ 创建教材: ${textbook.name}`);
|
||||
|
||||
// 9. 创建单元和课节
|
||||
console.log('\n📑 创建单元和课节...');
|
||||
const unit1 = await prisma.textbookUnit.create({
|
||||
data: {
|
||||
id: 'unit-001',
|
||||
textbookId: textbook.id,
|
||||
name: '第一章 集合与常用逻辑用语',
|
||||
sortOrder: 1,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
});
|
||||
|
||||
const lessons = await Promise.all([
|
||||
prisma.textbookLesson.create({
|
||||
data: {
|
||||
id: 'lesson-001',
|
||||
unitId: unit1.id,
|
||||
name: '1.1 集合的概念',
|
||||
sortOrder: 1,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
}),
|
||||
prisma.textbookLesson.create({
|
||||
data: {
|
||||
id: 'lesson-002',
|
||||
unitId: unit1.id,
|
||||
name: '1.2 集合间的基本关系',
|
||||
sortOrder: 2,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
})
|
||||
]);
|
||||
console.log(` ✅ 创建 1 个单元和 ${lessons.length} 个课节`);
|
||||
|
||||
// 10. 创建知识点
|
||||
console.log('\n🎯 创建知识点...');
|
||||
const knowledgePoints = await Promise.all([
|
||||
prisma.knowledgePoint.create({
|
||||
data: {
|
||||
id: 'kp-001',
|
||||
lessonId: lessons[0].id,
|
||||
name: '集合的含义',
|
||||
difficulty: 1,
|
||||
description: '理解集合的基本概念',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
}),
|
||||
prisma.knowledgePoint.create({
|
||||
data: {
|
||||
id: 'kp-002',
|
||||
lessonId: lessons[1].id,
|
||||
name: '子集的概念',
|
||||
difficulty: 2,
|
||||
description: '掌握子集的定义和性质',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system'
|
||||
}
|
||||
})
|
||||
]);
|
||||
console.log(` ✅ 创建 ${knowledgePoints.length} 个知识点`);
|
||||
|
||||
// 11. 创建题目
|
||||
console.log('\n📝 创建题目...');
|
||||
const questions = await Promise.all([
|
||||
prisma.question.create({
|
||||
data: {
|
||||
id: 'question-001',
|
||||
subjectId: subjects[0].id,
|
||||
content: '<p>已知集合 A = {1, 2, 3}, B = {2, 3, 4}, 则 A ∩ B = ( )</p>',
|
||||
questionType: 'SingleChoice',
|
||||
difficulty: 2,
|
||||
answer: 'B',
|
||||
explanation: '集合 A 与 B 的公共元素为 2 和 3',
|
||||
optionsConfig: {
|
||||
options: [
|
||||
{ label: 'A', content: '{1}' },
|
||||
{ label: 'B', content: '{2, 3}' },
|
||||
{ label: 'C', content: '{1, 2, 3, 4}' },
|
||||
{ label: 'D', content: '∅' }
|
||||
]
|
||||
},
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
}),
|
||||
prisma.question.create({
|
||||
data: {
|
||||
id: 'question-002',
|
||||
subjectId: subjects[0].id,
|
||||
content: '<p>若集合 A ⊆ B,则下列说法正确的是 ( )</p>',
|
||||
questionType: 'SingleChoice',
|
||||
difficulty: 2,
|
||||
answer: 'C',
|
||||
explanation: '子集定义:A的所有元素都在B中',
|
||||
optionsConfig: {
|
||||
options: [
|
||||
{ label: 'A', content: 'A ∪ B = A' },
|
||||
{ label: 'B', content: 'A ∩ B = B' },
|
||||
{ label: 'C', content: 'A ∩ B = A' },
|
||||
{ label: 'D', content: 'A ∪ B = ∅' }
|
||||
]
|
||||
},
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
}),
|
||||
prisma.question.create({
|
||||
data: {
|
||||
id: 'question-003',
|
||||
subjectId: subjects[0].id,
|
||||
content: '<p>函数 f(x) = x² - 2x + 1 的最小值是 ______</p>',
|
||||
questionType: 'FillBlank',
|
||||
difficulty: 3,
|
||||
answer: '0',
|
||||
explanation: '配方法:f(x) = (x-1)², 最小值为 0',
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
})
|
||||
]);
|
||||
console.log(` ✅ 创建 ${questions.length} 个题目`);
|
||||
|
||||
// 12. 创建试卷
|
||||
console.log('\n📋 创建试卷...');
|
||||
const exam = await prisma.exam.create({
|
||||
data: {
|
||||
id: 'exam-001',
|
||||
subjectId: subjects[0].id,
|
||||
title: '高一数学第一单元测试',
|
||||
totalScore: 100,
|
||||
suggestedDuration: 90,
|
||||
status: 'Published',
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
console.log(` ✅ 创建试卷: ${exam.title}`);
|
||||
|
||||
// 13. 创建试卷节点
|
||||
console.log('\n🌳 创建试卷结构...');
|
||||
const groupNode = await prisma.examNode.create({
|
||||
data: {
|
||||
id: 'node-group-001',
|
||||
examId: exam.id,
|
||||
nodeType: 'Group',
|
||||
title: '一、选择题',
|
||||
description: '本大题共 2 小题,每小题 5 分,共 10 分',
|
||||
score: 10,
|
||||
sortOrder: 1,
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
prisma.examNode.create({
|
||||
data: {
|
||||
id: 'node-q-001',
|
||||
examId: exam.id,
|
||||
parentNodeId: groupNode.id,
|
||||
nodeType: 'Question',
|
||||
questionId: questions[0].id,
|
||||
score: 5,
|
||||
sortOrder: 1,
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
}),
|
||||
prisma.examNode.create({
|
||||
data: {
|
||||
id: 'node-q-002',
|
||||
examId: exam.id,
|
||||
parentNodeId: groupNode.id,
|
||||
nodeType: 'Question',
|
||||
questionId: questions[1].id,
|
||||
score: 5,
|
||||
sortOrder: 2,
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
}),
|
||||
prisma.examNode.create({
|
||||
data: {
|
||||
id: 'node-q-003',
|
||||
examId: exam.id,
|
||||
nodeType: 'Question',
|
||||
questionId: questions[2].id,
|
||||
title: '二、填空题',
|
||||
score: 10,
|
||||
sortOrder: 2,
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
})
|
||||
]);
|
||||
console.log(` ✅ 创建试卷结构(1个分组,3道题目)`);
|
||||
|
||||
// 14. 创建作业
|
||||
console.log('\n📮 创建作业...');
|
||||
const assignment = await prisma.assignment.create({
|
||||
data: {
|
||||
id: 'assignment-001',
|
||||
examId: exam.id,
|
||||
classId: class1.id,
|
||||
title: '第一单元课后练习',
|
||||
startTime: new Date('2025-11-26T00:00:00Z'),
|
||||
endTime: new Date('2025-12-31T23:59:59Z'),
|
||||
allowLateSubmission: false,
|
||||
autoScoreEnabled: true,
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
console.log(` ✅ 创建作业: ${assignment.title}`);
|
||||
|
||||
// 15. 为所有学生创建提交记录并模拟答题/批改
|
||||
console.log('\n📬 创建学生提交记录并模拟答题...');
|
||||
for (let i = 0; i < students.length; i++) {
|
||||
const status = i < 5 ? 'Graded' : (i < 8 ? 'Submitted' : 'Pending');
|
||||
const score = status === 'Graded' ? Math.floor(Math.random() * 20) + 80 : null; // 80-100分
|
||||
|
||||
const submission = await prisma.studentSubmission.create({
|
||||
data: {
|
||||
id: `submission-${String(i + 1).padStart(3, '0')}`,
|
||||
assignmentId: assignment.id,
|
||||
studentId: students[i].id,
|
||||
submissionStatus: status,
|
||||
submitTime: status !== 'Pending' ? new Date() : null,
|
||||
totalScore: score,
|
||||
timeSpentSeconds: status !== 'Pending' ? 3600 : null,
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
|
||||
// 如果已提交或已批改,创建答题详情
|
||||
if (status !== 'Pending') {
|
||||
// 题目1:单选题 (正确答案 B)
|
||||
await prisma.submissionDetail.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
submissionId: submission.id,
|
||||
examNodeId: 'node-q-001',
|
||||
studentAnswer: i % 3 === 0 ? 'A' : 'B', // 部分答错
|
||||
score: status === 'Graded' ? (i % 3 === 0 ? 0 : 5) : null,
|
||||
judgement: status === 'Graded' ? (i % 3 === 0 ? 'Incorrect' : 'Correct') : null,
|
||||
createdBy: students[i].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
|
||||
// 题目2:单选题 (正确答案 C)
|
||||
await prisma.submissionDetail.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
submissionId: submission.id,
|
||||
examNodeId: 'node-q-002',
|
||||
studentAnswer: 'C', // 全部答对
|
||||
score: status === 'Graded' ? 5 : null,
|
||||
judgement: status === 'Graded' ? 'Correct' : null,
|
||||
createdBy: students[i].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
|
||||
// 题目3:填空题 (正确答案 0)
|
||||
await prisma.submissionDetail.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
submissionId: submission.id,
|
||||
examNodeId: 'node-q-003',
|
||||
studentAnswer: '0',
|
||||
score: status === 'Graded' ? 10 : null,
|
||||
judgement: status === 'Graded' ? 'Correct' : null,
|
||||
teacherComment: status === 'Graded' ? '做得好!' : null,
|
||||
createdBy: students[i].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` ✅ 为 ${students.length} 个学生创建提交记录 (5个已批改, 3个已提交, 2个未提交)`);
|
||||
|
||||
// 创建更多试卷以测试列表
|
||||
console.log('\n📄 创建更多试卷数据...');
|
||||
for (let i = 2; i <= 15; i++) {
|
||||
await prisma.exam.create({
|
||||
data: {
|
||||
id: `exam-${String(i).padStart(3, '0')}`,
|
||||
subjectId: subjects[i % 3].id,
|
||||
title: `模拟试卷 ${i}`,
|
||||
totalScore: 100,
|
||||
suggestedDuration: 90,
|
||||
status: i % 2 === 0 ? 'Published' : 'Draft',
|
||||
createdAt: new Date(Date.now() - i * 86400000), // 过去的时间
|
||||
createdBy: teachers[0].id,
|
||||
updatedBy: teachers[0].id
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log(` ✅ 创建额外 14 份试卷`);
|
||||
|
||||
console.log('\n✨ 种子数据创建完成!\n');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log('📊 数据统计:');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(` 学校: 1 所`);
|
||||
console.log(` 年级: ${grades.length} 个`);
|
||||
console.log(` 科目: ${subjects.length} 个`);
|
||||
console.log(` 教师: ${teachers.length} 个`);
|
||||
console.log(` 学生: ${students.length} 个`);
|
||||
console.log(` 班级: 1 个`);
|
||||
console.log(` 教材: 1 本`);
|
||||
console.log(` 题目: ${questions.length} 道`);
|
||||
console.log(` 试卷: 1 份`);
|
||||
console.log(` 作业: 1 个`);
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
console.log('🔑 测试账号:');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' 教师账号: liming@school.edu / 123456');
|
||||
console.log(' 学生账号: student1@school.edu / 123456');
|
||||
console.log(' 班级邀请码: ABC123');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ 种子数据创建失败:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
378
backend/src/controllers/analytics.controller.ts
Normal file
378
backend/src/controllers/analytics.controller.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { Response } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 获取班级表现(平均分趋势)
|
||||
export const getClassPerformance = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
// 获取教师管理的班级
|
||||
const classes = await prisma.class.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ headTeacherId: userId },
|
||||
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
|
||||
],
|
||||
isDeleted: false
|
||||
},
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
|
||||
const classIds = classes.map(c => c.id);
|
||||
|
||||
// 获取最近的5次作业/考试
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
classId: { in: classIds },
|
||||
isDeleted: false
|
||||
},
|
||||
orderBy: { endTime: 'desc' },
|
||||
take: 5,
|
||||
include: {
|
||||
submissions: {
|
||||
where: { submissionStatus: 'Graded' },
|
||||
select: { totalScore: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按时间正序排列
|
||||
assignments.reverse();
|
||||
|
||||
const labels = assignments.map(a => a.title);
|
||||
const data = assignments.map(a => {
|
||||
const scores = a.submissions.map(s => Number(s.totalScore));
|
||||
const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0;
|
||||
return Number(avg.toFixed(1));
|
||||
});
|
||||
|
||||
res.json({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '班级平均分',
|
||||
data,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.5)',
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get class performance error:', error);
|
||||
res.status(500).json({ error: 'Failed to get class performance' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取学生成长(个人成绩趋势)
|
||||
export const getStudentGrowth = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
// 获取学生最近的5次已批改提交
|
||||
const submissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
studentId: userId,
|
||||
submissionStatus: 'Graded',
|
||||
isDeleted: false
|
||||
},
|
||||
orderBy: { submitTime: 'desc' },
|
||||
take: 5,
|
||||
include: { assignment: true }
|
||||
});
|
||||
|
||||
submissions.reverse();
|
||||
|
||||
const labels = submissions.map(s => s.assignment.title);
|
||||
const data = submissions.map(s => Number(s.totalScore));
|
||||
|
||||
res.json({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '我的成绩',
|
||||
data,
|
||||
borderColor: 'rgb(53, 162, 235)',
|
||||
backgroundColor: 'rgba(53, 162, 235, 0.5)',
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get student growth error:', error);
|
||||
res.status(500).json({ error: 'Failed to get student growth' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取班级能力雷达图
|
||||
export const getRadar = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 模拟数据,因为目前没有明确的能力维度字段
|
||||
res.json({
|
||||
indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力','个人表现'],
|
||||
values: [85, 78, 92, 88, 75,99]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get radar error:', error);
|
||||
res.status(500).json({ error: 'Failed to get radar data' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取学生能力雷达图
|
||||
export const getStudentRadar = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 模拟数据
|
||||
res.json({
|
||||
indicators: ['知识掌握', '应用能力', '分析能力', '逻辑思维', '创新能力'],
|
||||
values: [80, 85, 90, 82, 78]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get student radar error:', error);
|
||||
res.status(500).json({ error: 'Failed to get student radar data' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取成绩分布
|
||||
export const getScoreDistribution = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
// 获取教师管理的班级
|
||||
const classes = await prisma.class.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ headTeacherId: userId },
|
||||
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
|
||||
],
|
||||
isDeleted: false
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
const classIds = classes.map(c => c.id);
|
||||
|
||||
if (classIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// 获取这些班级的作业
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { classId: { in: classIds }, isDeleted: false },
|
||||
select: { id: true }
|
||||
});
|
||||
const assignmentIds = assignments.map(a => a.id);
|
||||
|
||||
// 获取所有已批改作业的分数
|
||||
const submissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
assignmentId: { in: assignmentIds },
|
||||
submissionStatus: 'Graded',
|
||||
isDeleted: false
|
||||
},
|
||||
select: { totalScore: true }
|
||||
});
|
||||
|
||||
const scores = submissions.map(s => Number(s.totalScore));
|
||||
const distribution = [
|
||||
{ range: '0-60', count: 0 },
|
||||
{ range: '60-70', count: 0 },
|
||||
{ range: '70-80', count: 0 },
|
||||
{ range: '80-90', count: 0 },
|
||||
{ range: '90-100', count: 0 }
|
||||
];
|
||||
|
||||
scores.forEach(score => {
|
||||
if (score < 60) distribution[0].count++;
|
||||
else if (score < 70) distribution[1].count++;
|
||||
else if (score < 80) distribution[2].count++;
|
||||
else if (score < 90) distribution[3].count++;
|
||||
else distribution[4].count++;
|
||||
});
|
||||
|
||||
res.json(distribution);
|
||||
} catch (error) {
|
||||
console.error('Get score distribution error:', error);
|
||||
res.status(500).json({ error: 'Failed to get score distribution' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取教师统计数据(活跃学生、平均分、待批改、及格率)
|
||||
export const getTeacherStats = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
// 获取教师管理的班级
|
||||
const classes = await prisma.class.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ headTeacherId: userId },
|
||||
{ members: { some: { userId: userId, roleInClass: 'Teacher' } } }
|
||||
],
|
||||
isDeleted: false
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
const classIds = classes.map(c => c.id);
|
||||
|
||||
if (classIds.length === 0) {
|
||||
return res.json({
|
||||
activeStudents: 0,
|
||||
averageScore: 0,
|
||||
pendingGrading: 0,
|
||||
passRate: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 活跃学生数:这些班级中的学生总数
|
||||
const activeStudents = await prisma.classMember.count({
|
||||
where: {
|
||||
classId: { in: classIds },
|
||||
roleInClass: 'Student',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 获取这些班级的作业
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
classId: { in: classIds },
|
||||
isDeleted: false
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
const assignmentIds = assignments.map(a => a.id);
|
||||
|
||||
// 3. 待批改数
|
||||
const pendingGrading = await prisma.studentSubmission.count({
|
||||
where: {
|
||||
assignmentId: { in: assignmentIds },
|
||||
submissionStatus: 'Submitted',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 已批改的提交(用于计算平均分和及格率)
|
||||
const gradedSubmissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
assignmentId: { in: assignmentIds },
|
||||
submissionStatus: 'Graded',
|
||||
isDeleted: false
|
||||
},
|
||||
select: { totalScore: true }
|
||||
});
|
||||
|
||||
let averageScore = 0;
|
||||
let passRate = 0;
|
||||
|
||||
if (gradedSubmissions.length > 0) {
|
||||
const scores = gradedSubmissions.map(s => Number(s.totalScore));
|
||||
const sum = scores.reduce((a, b) => a + b, 0);
|
||||
averageScore = Number((sum / scores.length).toFixed(1));
|
||||
|
||||
const passedCount = scores.filter(score => score >= 60).length;
|
||||
passRate = Number(((passedCount / scores.length) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
res.json({
|
||||
activeStudents,
|
||||
averageScore,
|
||||
pendingGrading,
|
||||
passRate
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get teacher stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to get teacher stats' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getExamStats = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id: examId } = req.params as any;
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { examId, isDeleted: false },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
const assignmentIds = assignments.map(a => a.id);
|
||||
|
||||
const gradedSubmissions = await prisma.studentSubmission.findMany({
|
||||
where: { assignmentId: { in: assignmentIds }, submissionStatus: 'Graded', isDeleted: false },
|
||||
select: { id: true, totalScore: true }
|
||||
});
|
||||
|
||||
const scores = gradedSubmissions.map(s => Number(s.totalScore));
|
||||
const averageScore = scores.length > 0 ? Number((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)) : 0;
|
||||
const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
|
||||
const minScore = scores.length > 0 ? Math.min(...scores) : 0;
|
||||
const passRate = scores.length > 0 ? Number(((scores.filter(s => s >= 60).length / scores.length) * 100).toFixed(1)) : 0;
|
||||
|
||||
const distribution = [
|
||||
{ range: '0-60', count: 0 },
|
||||
{ range: '60-70', count: 0 },
|
||||
{ range: '70-80', count: 0 },
|
||||
{ range: '80-90', count: 0 },
|
||||
{ range: '90-100', count: 0 }
|
||||
];
|
||||
scores.forEach(score => {
|
||||
if (score < 60) distribution[0].count++;
|
||||
else if (score < 70) distribution[1].count++;
|
||||
else if (score < 80) distribution[2].count++;
|
||||
else if (score < 90) distribution[3].count++;
|
||||
else distribution[4].count++;
|
||||
});
|
||||
|
||||
const examNodes = await prisma.examNode.findMany({
|
||||
where: { examId, isDeleted: false },
|
||||
select: {
|
||||
id: true,
|
||||
questionId: true,
|
||||
question: { select: { content: true, difficulty: true, questionType: true } }
|
||||
}
|
||||
});
|
||||
const nodeIds = examNodes.map(n => n.id);
|
||||
const submissionIds = gradedSubmissions.map(s => s.id);
|
||||
|
||||
const details = await prisma.submissionDetail.findMany({
|
||||
where: { examNodeId: { in: nodeIds }, submissionId: { in: submissionIds }, isDeleted: false },
|
||||
select: { examNodeId: true, judgement: true }
|
||||
});
|
||||
|
||||
const statsMap = new Map<string, { total: number; wrong: number }>();
|
||||
for (const d of details) {
|
||||
const s = statsMap.get(d.examNodeId) || { total: 0, wrong: 0 };
|
||||
s.total += 1;
|
||||
if (d.judgement === 'Incorrect') s.wrong += 1;
|
||||
statsMap.set(d.examNodeId, s);
|
||||
}
|
||||
|
||||
const wrongQuestions = examNodes.map(n => {
|
||||
const s = statsMap.get(n.id) || { total: 0, wrong: 0 };
|
||||
const errorRate = s.total > 0 ? Math.round((s.wrong / s.total) * 100) : 0;
|
||||
return {
|
||||
id: n.questionId || n.id,
|
||||
content: n.question?.content || '',
|
||||
errorRate,
|
||||
difficulty: n.question?.difficulty || 0,
|
||||
type: n.question?.questionType || 'Unknown'
|
||||
};
|
||||
}).sort((a, b) => b.errorRate - a.errorRate).slice(0, 20);
|
||||
|
||||
res.json({
|
||||
averageScore,
|
||||
passRate,
|
||||
maxScore,
|
||||
minScore,
|
||||
scoreDistribution: distribution,
|
||||
wrongQuestions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get exam stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to get exam stats' });
|
||||
}
|
||||
};
|
||||
311
backend/src/controllers/assignment.controller.ts
Normal file
311
backend/src/controllers/assignment.controller.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/prisma';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// GET /api/assignments/teaching
|
||||
// 获取我教的班级的作业列表(教师视角)
|
||||
export const getTeachingAssignments = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 查询我作为教师的所有班级
|
||||
const myClasses = await prisma.classMember.findMany({
|
||||
where: {
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Teacher',
|
||||
isDeleted: false
|
||||
},
|
||||
select: { classId: true }
|
||||
});
|
||||
|
||||
const classIds = myClasses.map(m => m.classId);
|
||||
|
||||
if (classIds.length === 0) {
|
||||
return res.json({ items: [], totalCount: 0, pageIndex: 1, pageSize: 10 });
|
||||
}
|
||||
|
||||
// 查询这些班级的作业
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
classId: { in: classIds },
|
||||
isDeleted: false
|
||||
},
|
||||
include: {
|
||||
exam: {
|
||||
select: {
|
||||
title: true,
|
||||
totalScore: true
|
||||
}
|
||||
},
|
||||
class: {
|
||||
include: {
|
||||
grade: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: { submissions: true }
|
||||
},
|
||||
submissions: {
|
||||
where: {
|
||||
submissionStatus: { in: ['Submitted', 'Graded'] }
|
||||
},
|
||||
select: { id: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
// 格式化返回数据
|
||||
const items = assignments.map(assignment => {
|
||||
const totalCount = assignment._count.submissions;
|
||||
const submittedCount = assignment.submissions.length;
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
examTitle: assignment.exam.title,
|
||||
className: assignment.class.name,
|
||||
gradeName: assignment.class.grade.name,
|
||||
submittedCount,
|
||||
totalCount,
|
||||
status: new Date() > assignment.endTime ? 'Closed' : 'Active',
|
||||
dueDate: assignment.endTime.toISOString(),
|
||||
createdAt: assignment.createdAt.toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
items,
|
||||
totalCount: items.length,
|
||||
pageIndex: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get teaching assignments error:', error);
|
||||
res.status(500).json({ error: 'Failed to get teaching assignments' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/assignments/learning
|
||||
// 获取我的作业列表(学生视角)
|
||||
export const getStudentAssignments = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 查询我作为学生的所有提交记录
|
||||
const submissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
studentId: req.userId!,
|
||||
isDeleted: false
|
||||
},
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
exam: {
|
||||
select: {
|
||||
title: true,
|
||||
totalScore: true
|
||||
}
|
||||
},
|
||||
class: {
|
||||
include: {
|
||||
grade: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
// 格式化返回数据
|
||||
const items = submissions.map(submission => ({
|
||||
id: submission.assignment.id,
|
||||
title: submission.assignment.title,
|
||||
examTitle: submission.assignment.exam.title,
|
||||
className: submission.assignment.class.name,
|
||||
startTime: submission.assignment.startTime.toISOString(),
|
||||
endTime: submission.assignment.endTime.toISOString(),
|
||||
status: submission.submissionStatus,
|
||||
score: submission.totalScore ? Number(submission.totalScore) : null,
|
||||
submitTime: submission.submitTime?.toISOString() || null
|
||||
}));
|
||||
|
||||
res.json({
|
||||
items,
|
||||
totalCount: items.length,
|
||||
pageIndex: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get student assignments error:', error);
|
||||
res.status(500).json({ error: 'Failed to get student assignments' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/assignments
|
||||
// 发布作业到班级
|
||||
export const createAssignment = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled } = req.body;
|
||||
|
||||
if (!examId || !classId || !title || !startTime || !endTime) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// 验证试卷存在且已发布
|
||||
const exam = await prisma.exam.findUnique({
|
||||
where: { id: examId, isDeleted: false }
|
||||
});
|
||||
|
||||
if (!exam) {
|
||||
return res.status(404).json({ error: 'Exam not found' });
|
||||
}
|
||||
|
||||
if (exam.status !== 'Published') {
|
||||
return res.status(400).json({ error: 'Exam must be published before creating assignment' });
|
||||
}
|
||||
|
||||
// 验证我是该班级的教师
|
||||
const membership = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
classId,
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Teacher',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return res.status(403).json({ error: 'You are not a teacher of this class' });
|
||||
}
|
||||
|
||||
// 获取班级所有学生
|
||||
const students = await prisma.classMember.findMany({
|
||||
where: {
|
||||
classId,
|
||||
roleInClass: 'Student',
|
||||
isDeleted: false
|
||||
},
|
||||
select: { userId: true }
|
||||
});
|
||||
|
||||
// 创建作业
|
||||
const assignmentId = uuidv4();
|
||||
const assignment = await prisma.assignment.create({
|
||||
data: {
|
||||
id: assignmentId,
|
||||
examId,
|
||||
classId,
|
||||
title,
|
||||
startTime: new Date(startTime),
|
||||
endTime: new Date(endTime),
|
||||
allowLateSubmission: allowLateSubmission ?? false,
|
||||
autoScoreEnabled: autoScoreEnabled ?? true,
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
// 为所有学生创建提交记录
|
||||
const submissionPromises = students.map(student =>
|
||||
prisma.studentSubmission.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
assignmentId,
|
||||
studentId: student.userId,
|
||||
submissionStatus: 'Pending',
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(submissionPromises);
|
||||
|
||||
res.json({
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
message: `Assignment created successfully for ${students.length} students`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create assignment error:', error);
|
||||
res.status(500).json({ error: 'Failed to create assignment' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/assignments/:id/stats
|
||||
// 获取作业统计信息
|
||||
export const getAssignmentStats = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id: assignmentId } = req.params;
|
||||
|
||||
// 验证作业存在
|
||||
const assignment = await prisma.assignment.findUnique({
|
||||
where: { id: assignmentId, isDeleted: false },
|
||||
include: {
|
||||
class: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'Assignment not found' });
|
||||
}
|
||||
|
||||
// 验证权限(教师)
|
||||
const isMember = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
classId: assignment.classId,
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Teacher',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!isMember) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
// 统计提交情况
|
||||
const submissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
assignmentId,
|
||||
isDeleted: false
|
||||
},
|
||||
select: {
|
||||
submissionStatus: true,
|
||||
totalScore: true
|
||||
}
|
||||
});
|
||||
|
||||
const totalCount = submissions.length;
|
||||
const submittedCount = submissions.filter(s =>
|
||||
s.submissionStatus === 'Submitted' || s.submissionStatus === 'Graded'
|
||||
).length;
|
||||
const gradedCount = submissions.filter(s => s.submissionStatus === 'Graded').length;
|
||||
|
||||
// 计算平均分(只统计已批改的)
|
||||
const gradedScores = submissions
|
||||
.filter(s => s.submissionStatus === 'Graded' && s.totalScore !== null)
|
||||
.map(s => Number(s.totalScore));
|
||||
|
||||
const averageScore = gradedScores.length > 0
|
||||
? gradedScores.reduce((sum, score) => sum + score, 0) / gradedScores.length
|
||||
: 0;
|
||||
|
||||
const maxScore = gradedScores.length > 0 ? Math.max(...gradedScores) : 0;
|
||||
const minScore = gradedScores.length > 0 ? Math.min(...gradedScores) : 0;
|
||||
|
||||
res.json({
|
||||
totalStudents: totalCount,
|
||||
submittedCount,
|
||||
gradedCount,
|
||||
pendingCount: totalCount - submittedCount,
|
||||
averageScore: Math.round(averageScore * 10) / 10,
|
||||
maxScore,
|
||||
minScore,
|
||||
passRate: 0, // TODO: 需要定义及格线
|
||||
scoreDistribution: [] // TODO: 可以实现分数段分布
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get assignment stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to get assignment stats' });
|
||||
}
|
||||
};
|
||||
89
backend/src/controllers/auth.controller.ts
Normal file
89
backend/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { authService } from '../services/auth.service';
|
||||
|
||||
// POST /api/auth/register
|
||||
export const register = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { realName, email, phone, password, gender } = req.body;
|
||||
|
||||
if (!realName || !password || (!email && !phone)) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const result = await authService.register({ realName, email, phone, password, gender });
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Register error:', error);
|
||||
if (error.message === 'User already exists') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to register' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/auth/login
|
||||
export const login = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { email, phone, password } = req.body;
|
||||
|
||||
if (!password || (!email && !phone)) {
|
||||
return res.status(400).json({ error: 'Missing credentials' });
|
||||
}
|
||||
|
||||
const result = await authService.login({ email, phone, password });
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
if (error.message === 'Invalid credentials') {
|
||||
return res.status(401).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to login' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/auth/me
|
||||
export const me = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await authService.getMe(req.userId!);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Get me error:', error);
|
||||
if (error.message === 'User not found') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to get user info' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/auth/profile
|
||||
export const updateProfile = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { realName, gender, avatarUrl } = req.body;
|
||||
const result = await authService.updateProfile(req.userId!, { realName, gender, avatarUrl });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Update profile error:', error);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/auth/change-password
|
||||
export const changePassword = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
|
||||
if (!oldPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const result = await authService.changePassword(req.userId!, oldPassword, newPassword);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Change password error:', error);
|
||||
if (error.message === 'Invalid old password') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
};
|
||||
176
backend/src/controllers/common.controller.ts
Normal file
176
backend/src/controllers/common.controller.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/prisma';
|
||||
|
||||
// 获取消息列表
|
||||
export const getMessages = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(messages);
|
||||
} catch (error) {
|
||||
console.error('Get messages error:', error);
|
||||
res.status(500).json({ error: 'Failed to get messages' });
|
||||
}
|
||||
};
|
||||
|
||||
// 标记消息为已读
|
||||
export const markMessageRead = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.userId;
|
||||
|
||||
const message = await prisma.message.findUnique({ where: { id } });
|
||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||
if (message.userId !== userId) return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
await prisma.message.update({
|
||||
where: { id },
|
||||
data: { isRead: true }
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Mark message read error:', error);
|
||||
res.status(500).json({ error: 'Failed to mark message read' });
|
||||
}
|
||||
};
|
||||
|
||||
// 创建消息
|
||||
export const createMessage = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const { title, content, type } = req.body;
|
||||
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
if (!title || !content) {
|
||||
return res.status(400).json({ error: 'Title and content are required' });
|
||||
}
|
||||
|
||||
const message = await prisma.message.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
content,
|
||||
type: type || 'System',
|
||||
senderName: 'Me',
|
||||
isRead: false
|
||||
}
|
||||
});
|
||||
|
||||
res.json(message);
|
||||
} catch (error) {
|
||||
console.error('Create message error:', error);
|
||||
res.status(500).json({ error: 'Failed to create message' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取日程
|
||||
export const getSchedule = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
// 获取用户关联的班级
|
||||
const user = await prisma.applicationUser.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
classMemberships: {
|
||||
include: { class: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const classIds = user.classMemberships.map(cm => cm.classId);
|
||||
|
||||
// 获取这些班级的日程
|
||||
const schedules = await prisma.schedule.findMany({
|
||||
where: { classId: { in: classIds } },
|
||||
include: { class: true }
|
||||
});
|
||||
|
||||
const scheduleDtos = schedules.map(s => ({
|
||||
id: s.id,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
className: s.class.name,
|
||||
subject: s.subject,
|
||||
room: s.room || '',
|
||||
isToday: s.dayOfWeek === new Date().getDay(),
|
||||
dayOfWeek: s.dayOfWeek,
|
||||
period: s.period
|
||||
}));
|
||||
|
||||
res.json(scheduleDtos);
|
||||
} catch (error) {
|
||||
console.error('Get schedule error:', error);
|
||||
res.status(500).json({ error: 'Failed to get schedule' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取周日程
|
||||
export const getWeekSchedule = async (req: AuthRequest, res: Response) => {
|
||||
// 复用 getSchedule 逻辑,因为我们返回了所有日程
|
||||
return getSchedule(req, res);
|
||||
};
|
||||
|
||||
// 添加日程 (仅教师)
|
||||
export const addEvent = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const { subject, className, classId, room, dayOfWeek, period, startTime, endTime } = req.body;
|
||||
|
||||
let resolvedClassId: string | null = null;
|
||||
if (classId) {
|
||||
const clsById = await prisma.class.findUnique({ where: { id: classId } });
|
||||
if (!clsById) return res.status(404).json({ error: 'Class not found' });
|
||||
resolvedClassId = clsById.id;
|
||||
} else if (className) {
|
||||
const clsByName = await prisma.class.findFirst({ where: { name: className } });
|
||||
if (!clsByName) return res.status(404).json({ error: 'Class not found' });
|
||||
resolvedClassId = clsByName.id;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'classId or className is required' });
|
||||
}
|
||||
|
||||
// 检查权限 (简化:假设所有教师都可以添加)
|
||||
// 实际应检查是否是该班级的教师
|
||||
|
||||
await prisma.schedule.create({
|
||||
data: {
|
||||
classId: resolvedClassId!,
|
||||
subject,
|
||||
room,
|
||||
dayOfWeek,
|
||||
period,
|
||||
startTime,
|
||||
endTime
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Add event error:', error);
|
||||
res.status(500).json({ error: 'Failed to add event' });
|
||||
}
|
||||
};
|
||||
|
||||
// 删除日程
|
||||
export const deleteEvent = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.schedule.delete({ where: { id } });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete event error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
}
|
||||
};
|
||||
370
backend/src/controllers/curriculum.controller.ts
Normal file
370
backend/src/controllers/curriculum.controller.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/prisma';
|
||||
|
||||
// GET /api/curriculum/subjects
|
||||
// 获取学科列表
|
||||
export const getSubjects = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const subjects = await prisma.subject.findMany({
|
||||
where: { isDeleted: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
icon: true
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
res.json(subjects);
|
||||
} catch (error) {
|
||||
console.error('Get subjects error:', error);
|
||||
res.status(500).json({ error: 'Failed to get subjects' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/curriculum/textbooks/:id/tree
|
||||
// 获取教材的知识树结构
|
||||
// 支持传入 textbook ID 或 subject ID
|
||||
export const getTextbookTree = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 尝试作为 textbook ID 查找
|
||||
let textbook = await prisma.textbook.findUnique({
|
||||
where: { id, isDeleted: false },
|
||||
include: {
|
||||
units: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
knowledgePoints: {
|
||||
where: { isDeleted: false },
|
||||
orderBy: { difficulty: 'asc' }
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果找不到,尝试作为 subject ID 查找第一个教材
|
||||
if (!textbook) {
|
||||
textbook = await prisma.textbook.findFirst({
|
||||
where: { subjectId: id, isDeleted: false },
|
||||
include: {
|
||||
units: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
knowledgePoints: {
|
||||
where: { isDeleted: false },
|
||||
orderBy: { difficulty: 'asc' }
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!textbook) {
|
||||
return res.status(404).json({ error: 'Textbook not found' });
|
||||
}
|
||||
|
||||
// 格式化返回数据
|
||||
const units = textbook.units.map(unit => ({
|
||||
id: unit.id,
|
||||
textbookId: unit.textbookId,
|
||||
name: unit.name,
|
||||
sortOrder: unit.sortOrder,
|
||||
lessons: unit.lessons.map(lesson => ({
|
||||
id: lesson.id,
|
||||
unitId: lesson.unitId,
|
||||
name: lesson.name,
|
||||
sortOrder: lesson.sortOrder,
|
||||
knowledgePoints: lesson.knowledgePoints.map(kp => ({
|
||||
id: kp.id,
|
||||
lessonId: kp.lessonId,
|
||||
name: kp.name,
|
||||
difficulty: kp.difficulty,
|
||||
description: kp.description
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
res.json({
|
||||
textbook: {
|
||||
id: textbook.id,
|
||||
name: textbook.name,
|
||||
publisher: textbook.publisher,
|
||||
versionYear: textbook.versionYear,
|
||||
coverUrl: textbook.coverUrl
|
||||
},
|
||||
units
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get textbook tree error:', error);
|
||||
res.status(500).json({ error: 'Failed to get textbook tree' });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Textbook CRUD ====================
|
||||
|
||||
// GET /api/curriculum/subjects/:id/textbooks
|
||||
export const getTextbooksBySubject = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const textbooks = await prisma.textbook.findMany({
|
||||
where: { subjectId: id, isDeleted: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
publisher: true,
|
||||
versionYear: true,
|
||||
coverUrl: true
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
res.json(textbooks);
|
||||
} catch (error) {
|
||||
console.error('Get textbooks error:', error);
|
||||
res.status(500).json({ error: 'Failed to get textbooks' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/curriculum/textbooks
|
||||
export const createTextbook = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const { subjectId, name, publisher, versionYear, coverUrl } = req.body;
|
||||
const textbook = await prisma.textbook.create({
|
||||
data: {
|
||||
subjectId,
|
||||
name,
|
||||
publisher,
|
||||
versionYear,
|
||||
coverUrl: coverUrl || '',
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
res.json(textbook);
|
||||
} catch (error) {
|
||||
console.error('Create textbook error:', error);
|
||||
res.status(500).json({ error: 'Failed to create textbook' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/curriculum/textbooks/:id
|
||||
export const updateTextbook = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, publisher, versionYear, coverUrl } = req.body;
|
||||
const textbook = await prisma.textbook.update({
|
||||
where: { id },
|
||||
data: { name, publisher, versionYear, coverUrl }
|
||||
});
|
||||
res.json(textbook);
|
||||
} catch (error) {
|
||||
console.error('Update textbook error:', error);
|
||||
res.status(500).json({ error: 'Failed to update textbook' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/curriculum/textbooks/:id
|
||||
export const deleteTextbook = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.textbook.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true }
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete textbook error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete textbook' });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Unit CRUD ====================
|
||||
|
||||
// POST /api/curriculum/units
|
||||
export const createUnit = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const { textbookId, name, sortOrder } = req.body;
|
||||
const unit = await prisma.textbookUnit.create({
|
||||
data: {
|
||||
textbookId,
|
||||
name,
|
||||
sortOrder: sortOrder || 0,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
res.json(unit);
|
||||
} catch (error) {
|
||||
console.error('Create unit error:', error);
|
||||
res.status(500).json({ error: 'Failed to create unit' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/curriculum/units/:id
|
||||
export const updateUnit = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, sortOrder } = req.body;
|
||||
const unit = await prisma.textbookUnit.update({
|
||||
where: { id },
|
||||
data: { name, sortOrder }
|
||||
});
|
||||
res.json(unit);
|
||||
} catch (error) {
|
||||
console.error('Update unit error:', error);
|
||||
res.status(500).json({ error: 'Failed to update unit' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/curriculum/units/:id
|
||||
export const deleteUnit = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.textbookUnit.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true }
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete unit error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete unit' });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Lesson CRUD ====================
|
||||
|
||||
// POST /api/curriculum/lessons
|
||||
export const createLesson = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const { unitId, name, sortOrder } = req.body;
|
||||
const lesson = await prisma.textbookLesson.create({
|
||||
data: {
|
||||
unitId,
|
||||
name,
|
||||
sortOrder: sortOrder || 0,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
res.json(lesson);
|
||||
} catch (error) {
|
||||
console.error('Create lesson error:', error);
|
||||
res.status(500).json({ error: 'Failed to create lesson' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/curriculum/lessons/:id
|
||||
export const updateLesson = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, sortOrder } = req.body;
|
||||
const lesson = await prisma.textbookLesson.update({
|
||||
where: { id },
|
||||
data: { name, sortOrder }
|
||||
});
|
||||
res.json(lesson);
|
||||
} catch (error) {
|
||||
console.error('Update lesson error:', error);
|
||||
res.status(500).json({ error: 'Failed to update lesson' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/curriculum/lessons/:id
|
||||
export const deleteLesson = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.textbookLesson.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true }
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete lesson error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete lesson' });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Knowledge Point CRUD ====================
|
||||
|
||||
// POST /api/curriculum/knowledge-points
|
||||
export const createKnowledgePoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const { lessonId, name, difficulty, description } = req.body;
|
||||
const point = await prisma.knowledgePoint.create({
|
||||
data: {
|
||||
lessonId,
|
||||
name,
|
||||
difficulty: difficulty || 1,
|
||||
description: description || '',
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
res.json(point);
|
||||
} catch (error) {
|
||||
console.error('Create knowledge point error:', error);
|
||||
res.status(500).json({ error: 'Failed to create knowledge point' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/curriculum/knowledge-points/:id
|
||||
export const updateKnowledgePoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, difficulty, description } = req.body;
|
||||
const point = await prisma.knowledgePoint.update({
|
||||
where: { id },
|
||||
data: { name, difficulty, description }
|
||||
});
|
||||
res.json(point);
|
||||
} catch (error) {
|
||||
console.error('Update knowledge point error:', error);
|
||||
res.status(500).json({ error: 'Failed to update knowledge point' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/curriculum/knowledge-points/:id
|
||||
export const deleteKnowledgePoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.knowledgePoint.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true }
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete knowledge point error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete knowledge point' });
|
||||
}
|
||||
};
|
||||
139
backend/src/controllers/exam.controller.ts
Normal file
139
backend/src/controllers/exam.controller.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { examService } from '../services/exam.service';
|
||||
|
||||
// GET /api/exams
|
||||
export const getExams = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { subjectId, status } = req.query;
|
||||
const result = await examService.getExams(req.userId!, {
|
||||
subjectId: subjectId as string,
|
||||
status: status as string
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Get exams error:', error);
|
||||
res.status(500).json({ error: 'Failed to get exams' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/exams/:id
|
||||
export const getExamDetail = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await examService.getExamDetail(id);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Get exam detail error:', error);
|
||||
if (error.message === 'Exam not found') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to get exam detail' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/exams
|
||||
export const createExam = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { subjectId, title, suggestedDuration } = req.body;
|
||||
|
||||
if (!subjectId || !title) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const result = await examService.createExam(req.userId!, { subjectId, title, suggestedDuration });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Create exam error:', error);
|
||||
res.status(500).json({ error: 'Failed to create exam' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/exams/:id
|
||||
export const updateExam = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { title, suggestedDuration, status } = req.body;
|
||||
|
||||
const result = await examService.updateExam(req.userId!, id, { title, suggestedDuration, status });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Update exam error:', error);
|
||||
res.status(500).json({ error: 'Failed to update exam' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/exams/:id
|
||||
export const deleteExam = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await examService.deleteExam(req.userId!, id);
|
||||
res.json({ message: 'Exam deleted' });
|
||||
} catch (error) {
|
||||
console.error('Delete exam error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete exam' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/exams/:id/nodes
|
||||
export const addNode = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id: examId } = req.params;
|
||||
const { parentNodeId, nodeType, questionId, title, description, score, sortOrder } = req.body;
|
||||
|
||||
const result = await examService.addNode(req.userId!, examId, {
|
||||
parentNodeId, nodeType, questionId, title, description, score, sortOrder
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Add node error:', error);
|
||||
res.status(500).json({ error: 'Failed to add node' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/exams/:id/nodes/:nodeId
|
||||
export const updateNode = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { nodeId } = req.params;
|
||||
const { title, description, score, sortOrder } = req.body;
|
||||
|
||||
const result = await examService.updateNode(req.userId!, nodeId, {
|
||||
title, description, score, sortOrder
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Update node error:', error);
|
||||
res.status(500).json({ error: 'Failed to update node' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/exams/:id/nodes/:nodeId
|
||||
export const deleteNode = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { nodeId } = req.params;
|
||||
await examService.deleteNode(req.userId!, nodeId);
|
||||
res.json({ message: 'Node deleted' });
|
||||
} catch (error) {
|
||||
console.error('Delete node error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete node' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/exams/:id/structure
|
||||
export const updateExamStructure = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rootNodes, title, suggestedDuration } = req.body;
|
||||
|
||||
const result = await examService.updateExamStructure(req.userId!, id, {
|
||||
rootNodes, title, suggestedDuration
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Update exam structure error:', error);
|
||||
if (error.message && error.message.includes('cannot be structurally modified')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to update exam structure' });
|
||||
}
|
||||
};
|
||||
307
backend/src/controllers/grading.controller.ts
Normal file
307
backend/src/controllers/grading.controller.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/prisma';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// GET /api/grading/:assignmentId/list
|
||||
// 获取作业的所有学生提交列表
|
||||
export const getSubmissions = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { assignmentId } = req.params;
|
||||
|
||||
// 验证作业存在
|
||||
const assignment = await prisma.assignment.findUnique({
|
||||
where: { id: assignmentId, isDeleted: false },
|
||||
include: { class: true }
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'Assignment not found' });
|
||||
}
|
||||
|
||||
// 验证权限(必须是班级教师)
|
||||
const isMember = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
classId: assignment.classId,
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Teacher',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!isMember) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
// 获取所有提交
|
||||
const submissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
assignmentId,
|
||||
isDeleted: false
|
||||
},
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
id: true,
|
||||
realName: true,
|
||||
studentId: true,
|
||||
avatarUrl: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ submissionStatus: 'asc' }, // 待批改的在前
|
||||
{ submitTime: 'desc' }
|
||||
]
|
||||
});
|
||||
|
||||
// 格式化返回数据
|
||||
const items = submissions.map(submission => ({
|
||||
id: submission.id,
|
||||
studentName: submission.student.realName,
|
||||
studentId: submission.student.studentId,
|
||||
avatarUrl: submission.student.avatarUrl,
|
||||
status: submission.submissionStatus,
|
||||
score: submission.totalScore ? Number(submission.totalScore) : null,
|
||||
submitTime: submission.submitTime?.toISOString() || null
|
||||
}));
|
||||
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
console.error('Get submissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to get submissions' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/grading/submissions/:submissionId
|
||||
// 获取某个学生的答卷详情(教师批改用)
|
||||
export const getPaperForGrading = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { submissionId } = req.params;
|
||||
|
||||
// 获取提交记录
|
||||
const submission = await prisma.studentSubmission.findUnique({
|
||||
where: { id: submissionId, isDeleted: false },
|
||||
include: {
|
||||
student: {
|
||||
select: {
|
||||
realName: true,
|
||||
studentId: true
|
||||
}
|
||||
},
|
||||
assignment: {
|
||||
include: {
|
||||
exam: {
|
||||
include: {
|
||||
nodes: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
question: {
|
||||
include: {
|
||||
knowledgePoints: {
|
||||
select: {
|
||||
knowledgePoint: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
}
|
||||
},
|
||||
class: true
|
||||
}
|
||||
},
|
||||
details: {
|
||||
include: {
|
||||
examNode: {
|
||||
include: {
|
||||
question: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!submission) {
|
||||
return res.status(404).json({ error: 'Submission not found' });
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const isMember = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
classId: submission.assignment.classId,
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Teacher',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!isMember) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
// 构建答题详情(包含学生答案和批改信息)
|
||||
const nodes = submission.assignment.exam.nodes.map(node => {
|
||||
const detail = submission.details.find(d => d.examNodeId === node.id);
|
||||
|
||||
return {
|
||||
examNodeId: node.id,
|
||||
questionId: node.questionId,
|
||||
questionContent: node.question?.content,
|
||||
questionType: node.question?.questionType,
|
||||
// 构造完整的 question 对象以供前端使用
|
||||
question: node.question ? {
|
||||
id: node.question.id,
|
||||
content: node.question.content,
|
||||
type: node.question.questionType,
|
||||
difficulty: node.question.difficulty,
|
||||
answer: node.question.answer,
|
||||
parse: node.question.explanation,
|
||||
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
|
||||
} : undefined,
|
||||
score: Number(node.score),
|
||||
studentAnswer: detail?.studentAnswer || null,
|
||||
studentScore: detail?.score ? Number(detail.score) : null,
|
||||
judgement: detail?.judgement || null,
|
||||
teacherComment: detail?.teacherComment || null
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
submissionId: submission.id,
|
||||
studentName: submission.student.realName,
|
||||
studentId: submission.student.studentId,
|
||||
status: submission.submissionStatus,
|
||||
totalScore: submission.totalScore ? Number(submission.totalScore) : null,
|
||||
submitTime: submission.submitTime?.toISOString() || null,
|
||||
nodes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get paper for grading error:', error);
|
||||
res.status(500).json({ error: 'Failed to get paper' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/grading/submissions/:submissionId
|
||||
// 提交批改结果
|
||||
export const submitGrade = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { submissionId } = req.params;
|
||||
const { grades } = req.body; // Array of { examNodeId, score, judgement, teacherComment }
|
||||
|
||||
if (!grades || !Array.isArray(grades)) {
|
||||
return res.status(400).json({ error: 'Invalid grades data' });
|
||||
}
|
||||
|
||||
// 获取提交记录
|
||||
const submission = await prisma.studentSubmission.findUnique({
|
||||
where: { id: submissionId, isDeleted: false },
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
class: true,
|
||||
exam: {
|
||||
include: {
|
||||
nodes: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!submission) {
|
||||
return res.status(404).json({ error: 'Submission not found' });
|
||||
}
|
||||
|
||||
// 验证权限
|
||||
const isMember = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
classId: submission.assignment.classId,
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Teacher',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!isMember) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
// 更新或创建批改详情
|
||||
const updatePromises = grades.map(async (grade: any) => {
|
||||
const { examNodeId, score, judgement, teacherComment } = grade;
|
||||
|
||||
// 查找或创建 SubmissionDetail
|
||||
const existingDetail = await prisma.submissionDetail.findFirst({
|
||||
where: {
|
||||
submissionId,
|
||||
examNodeId,
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (existingDetail) {
|
||||
return prisma.submissionDetail.update({
|
||||
where: { id: existingDetail.id },
|
||||
data: {
|
||||
score,
|
||||
judgement,
|
||||
teacherComment,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return prisma.submissionDetail.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
submissionId,
|
||||
examNodeId,
|
||||
score,
|
||||
judgement,
|
||||
teacherComment,
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// 重新计算总分
|
||||
const allDetails = await prisma.submissionDetail.findMany({
|
||||
where: {
|
||||
submissionId,
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
const totalScore = allDetails.reduce((sum, detail) => {
|
||||
return sum + (detail.score ? Number(detail.score) : 0);
|
||||
}, 0);
|
||||
|
||||
// 更新提交状态
|
||||
await prisma.studentSubmission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
submissionStatus: 'Graded',
|
||||
totalScore,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Grading submitted successfully',
|
||||
totalScore
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Submit grade error:', error);
|
||||
res.status(500).json({ error: 'Failed to submit grading' });
|
||||
}
|
||||
};
|
||||
307
backend/src/controllers/org.controller.ts
Normal file
307
backend/src/controllers/org.controller.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/prisma';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { generateInviteCode } from '../utils/helpers';
|
||||
|
||||
// GET /api/org/schools
|
||||
export const getSchools = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const schools = await prisma.school.findMany({
|
||||
where: { isDeleted: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
regionCode: true,
|
||||
address: true
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
res.json(schools);
|
||||
} catch (error) {
|
||||
console.error('Get schools error:', error);
|
||||
res.status(500).json({ error: 'Failed to get schools' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/org/classes
|
||||
// 获取我的班级列表(教师或学生视角)
|
||||
export const getMyClasses = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { role } = req.query; // 可选:筛选角色
|
||||
|
||||
// 通过 ClassMember 关联查询
|
||||
const memberships = await prisma.classMember.findMany({
|
||||
where: {
|
||||
userId: req.userId!,
|
||||
isDeleted: false,
|
||||
...(role && { roleInClass: role as any })
|
||||
},
|
||||
include: {
|
||||
class: {
|
||||
include: {
|
||||
grade: {
|
||||
include: {
|
||||
school: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: { members: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 格式化返回数据
|
||||
const classes = memberships.map(membership => {
|
||||
const cls = membership.class;
|
||||
return {
|
||||
id: cls.id,
|
||||
name: cls.name,
|
||||
gradeName: cls.grade.name,
|
||||
schoolName: cls.grade.school.name,
|
||||
inviteCode: cls.inviteCode,
|
||||
studentCount: cls._count.members,
|
||||
myRole: membership.roleInClass // 我在这个班级的角色
|
||||
};
|
||||
});
|
||||
|
||||
res.json(classes);
|
||||
} catch (error) {
|
||||
console.error('Get my classes error:', error);
|
||||
res.status(500).json({ error: 'Failed to get classes' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/org/classes
|
||||
// 创建新班级
|
||||
export const createClass = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, gradeId } = req.body;
|
||||
|
||||
if (!name || !gradeId) {
|
||||
return res.status(400).json({ error: 'Missing required fields: name, gradeId' });
|
||||
}
|
||||
|
||||
// 验证年级是否存在
|
||||
const grade = await prisma.grade.findUnique({
|
||||
where: { id: gradeId, isDeleted: false },
|
||||
include: { school: true }
|
||||
});
|
||||
|
||||
if (!grade) {
|
||||
return res.status(404).json({ error: 'Grade not found' });
|
||||
}
|
||||
|
||||
// 生成唯一邀请码
|
||||
const inviteCode = await generateInviteCode();
|
||||
|
||||
// 创建班级
|
||||
const classId = uuidv4();
|
||||
const newClass = await prisma.class.create({
|
||||
data: {
|
||||
id: classId,
|
||||
gradeId,
|
||||
name,
|
||||
inviteCode,
|
||||
headTeacherId: req.userId,
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
// 自动将创建者添加为班级教师
|
||||
await prisma.classMember.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
classId,
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Teacher',
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: newClass.id,
|
||||
name: newClass.name,
|
||||
gradeName: grade.name,
|
||||
schoolName: grade.school.name,
|
||||
inviteCode: newClass.inviteCode,
|
||||
studentCount: 1 // 当前只有创建者一个成员
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create class error:', error);
|
||||
res.status(500).json({ error: 'Failed to create class' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/org/classes/join
|
||||
// 学生通过邀请码加入班级
|
||||
export const joinClass = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { inviteCode } = req.body;
|
||||
|
||||
if (!inviteCode) {
|
||||
return res.status(400).json({ error: 'Missing invite code' });
|
||||
}
|
||||
|
||||
// 查找班级
|
||||
const targetClass = await prisma.class.findUnique({
|
||||
where: { inviteCode, isDeleted: false },
|
||||
include: {
|
||||
grade: {
|
||||
include: { school: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetClass) {
|
||||
return res.status(404).json({ error: 'Invalid invite code' });
|
||||
}
|
||||
|
||||
// 检查是否已经是班级成员
|
||||
const existingMember = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
classId: targetClass.id,
|
||||
userId: req.userId!,
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
return res.status(400).json({ error: 'You are already a member of this class' });
|
||||
}
|
||||
|
||||
// 添加为班级学生
|
||||
await prisma.classMember.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
classId: targetClass.id,
|
||||
userId: req.userId!,
|
||||
roleInClass: 'Student',
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Successfully joined the class',
|
||||
class: {
|
||||
id: targetClass.id,
|
||||
name: targetClass.name,
|
||||
gradeName: targetClass.grade.name,
|
||||
schoolName: targetClass.grade.school.name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Join class error:', error);
|
||||
res.status(500).json({ error: 'Failed to join class' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/org/classes/:id/members
|
||||
// 获取班级成员列表
|
||||
export const getClassMembers = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id: classId } = req.params;
|
||||
|
||||
// 验证班级存在
|
||||
const targetClass = await prisma.class.findUnique({
|
||||
where: { id: classId, isDeleted: false }
|
||||
});
|
||||
|
||||
if (!targetClass) {
|
||||
return res.status(404).json({ error: 'Class not found' });
|
||||
}
|
||||
|
||||
// 验证当前用户是否是班级成员
|
||||
const isMember = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
classId,
|
||||
userId: req.userId!,
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!isMember) {
|
||||
return res.status(403).json({ error: 'You are not a member of this class' });
|
||||
}
|
||||
|
||||
const members = await prisma.classMember.findMany({
|
||||
where: {
|
||||
classId,
|
||||
isDeleted: false
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
realName: true,
|
||||
studentId: true,
|
||||
avatarUrl: true,
|
||||
gender: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ roleInClass: 'asc' }, // 教师在前
|
||||
{ createdAt: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
const assignmentsCount = await prisma.assignment.count({
|
||||
where: { classId }
|
||||
});
|
||||
|
||||
const formattedMembers = await Promise.all(members.map(async member => {
|
||||
const submissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
studentId: member.user.id,
|
||||
assignment: { classId }
|
||||
},
|
||||
select: {
|
||||
totalScore: true,
|
||||
submissionStatus: true,
|
||||
submitTime: true
|
||||
},
|
||||
orderBy: { submitTime: 'desc' },
|
||||
take: 5
|
||||
});
|
||||
|
||||
const recentTrendRaw = submissions.map(s => s.totalScore ? Number(s.totalScore) : 0);
|
||||
const recentTrend = recentTrendRaw.concat(Array(Math.max(0, 5 - recentTrendRaw.length)).fill(0)).slice(0,5);
|
||||
|
||||
const completedCount = await prisma.studentSubmission.count({
|
||||
where: {
|
||||
studentId: member.user.id,
|
||||
assignment: { classId },
|
||||
submissionStatus: { in: ['Submitted', 'Graded'] }
|
||||
}
|
||||
});
|
||||
const attendanceRate = assignmentsCount > 0 ? Math.round((completedCount / assignmentsCount) * 100) : 0;
|
||||
|
||||
const latestScore = submissions[0]?.totalScore ? Number(submissions[0].totalScore) : null;
|
||||
const status = latestScore !== null ? (latestScore >= 90 ? 'Excellent' : (latestScore < 60 ? 'AtRisk' : 'Active')) : 'Active';
|
||||
|
||||
return {
|
||||
id: member.user.id,
|
||||
studentId: member.user.studentId,
|
||||
realName: member.user.realName,
|
||||
avatarUrl: member.user.avatarUrl,
|
||||
gender: member.user.gender,
|
||||
role: member.roleInClass,
|
||||
recentTrend,
|
||||
status,
|
||||
attendanceRate
|
||||
};
|
||||
}));
|
||||
|
||||
res.json(formattedMembers);
|
||||
} catch (error) {
|
||||
console.error('Get class members error:', error);
|
||||
res.status(500).json({ error: 'Failed to get class members' });
|
||||
}
|
||||
};
|
||||
235
backend/src/controllers/question.controller.ts
Normal file
235
backend/src/controllers/question.controller.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/prisma';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// POST /api/questions/search
|
||||
// 简单的题目搜索(按科目、难度筛选)
|
||||
export const searchQuestions = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
subjectId,
|
||||
questionType,
|
||||
difficulty, // exact match (legacy)
|
||||
difficultyMin,
|
||||
difficultyMax,
|
||||
keyword,
|
||||
createdBy, // 'me' or specific userId
|
||||
sortBy = 'latest', // 'latest' | 'popular'
|
||||
page = 1,
|
||||
pageSize = 10
|
||||
} = req.body;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {
|
||||
isDeleted: false,
|
||||
...(subjectId && { subjectId }),
|
||||
...(questionType && { questionType }),
|
||||
...(keyword && { content: { contains: keyword } }),
|
||||
};
|
||||
|
||||
// Difficulty range
|
||||
if (difficultyMin || difficultyMax) {
|
||||
where.difficulty = {};
|
||||
if (difficultyMin) where.difficulty.gte = difficultyMin;
|
||||
if (difficultyMax) where.difficulty.lte = difficultyMax;
|
||||
} else if (difficulty) {
|
||||
where.difficulty = difficulty;
|
||||
}
|
||||
|
||||
// CreatedBy filter
|
||||
if (createdBy === 'me') {
|
||||
where.createdBy = req.userId;
|
||||
} else if (createdBy) {
|
||||
where.createdBy = createdBy;
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let orderBy: any = { createdAt: 'desc' };
|
||||
if (sortBy === 'popular') {
|
||||
orderBy = { usageCount: 'desc' }; // Assuming usageCount exists, otherwise fallback to createdAt
|
||||
}
|
||||
|
||||
// 查询题目
|
||||
const [questions, totalCount] = await Promise.all([
|
||||
prisma.question.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
questionType: true,
|
||||
difficulty: true,
|
||||
answer: true,
|
||||
explanation: true,
|
||||
createdAt: true,
|
||||
createdBy: true,
|
||||
knowledgePoints: {
|
||||
select: {
|
||||
knowledgePoint: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy
|
||||
}),
|
||||
prisma.question.count({ where })
|
||||
]);
|
||||
|
||||
// 映射到前端 DTO
|
||||
const items = questions.map(q => ({
|
||||
id: q.id,
|
||||
content: q.content,
|
||||
type: q.questionType,
|
||||
difficulty: q.difficulty,
|
||||
answer: q.answer,
|
||||
parse: q.explanation,
|
||||
knowledgePoints: q.knowledgePoints.map(kp => kp.knowledgePoint.name),
|
||||
isMyQuestion: q.createdBy === req.userId
|
||||
}));
|
||||
|
||||
res.json({
|
||||
items,
|
||||
totalCount,
|
||||
pageIndex: page,
|
||||
pageSize
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search questions error:', error);
|
||||
res.status(500).json({ error: 'Failed to search questions' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/questions
|
||||
// 创建题目
|
||||
export const createQuestion = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { subjectId, content, questionType, difficulty = 3, answer, explanation, optionsConfig, knowledgePoints } = req.body;
|
||||
|
||||
if (!subjectId || !content || !questionType || !answer) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const questionId = uuidv4();
|
||||
|
||||
// Handle knowledge points connection if provided
|
||||
// This is a simplified version, ideally we should resolve KP IDs first
|
||||
|
||||
const question = await prisma.question.create({
|
||||
data: {
|
||||
id: questionId,
|
||||
subjectId,
|
||||
content,
|
||||
questionType,
|
||||
difficulty,
|
||||
answer,
|
||||
explanation,
|
||||
optionsConfig: optionsConfig || null,
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: question.id,
|
||||
message: 'Question created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create question error:', error);
|
||||
res.status(500).json({ error: 'Failed to create question' });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/questions/:id
|
||||
export const updateQuestion = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { content, questionType, difficulty, answer, explanation, optionsConfig } = req.body;
|
||||
|
||||
const question = await prisma.question.findUnique({ where: { id } });
|
||||
if (!question) return res.status(404).json({ error: 'Question not found' });
|
||||
|
||||
// Only creator can update (or admin)
|
||||
if (question.createdBy !== req.userId) {
|
||||
// For now, let's assume strict ownership.
|
||||
// In real app, check role.
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
await prisma.question.update({
|
||||
where: { id },
|
||||
data: {
|
||||
content,
|
||||
questionType,
|
||||
difficulty,
|
||||
answer,
|
||||
explanation,
|
||||
optionsConfig: optionsConfig || null,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Question updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Update question error:', error);
|
||||
res.status(500).json({ error: 'Failed to update question' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/questions/:id
|
||||
export const deleteQuestion = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const question = await prisma.question.findUnique({ where: { id } });
|
||||
if (!question) return res.status(404).json({ error: 'Question not found' });
|
||||
|
||||
if (question.createdBy !== req.userId) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
await prisma.question.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true }
|
||||
});
|
||||
|
||||
res.json({ message: 'Question deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete question error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete question' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/questions/parse-text
|
||||
export const parseText = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
if (!text) return res.status(400).json({ error: 'Text is required' });
|
||||
|
||||
// 简单的模拟解析逻辑
|
||||
// 假设每行是一个题目,或者用空行分隔
|
||||
const questions = text.split(/\n\s*\n/).map((block: string) => {
|
||||
const lines = block.trim().split('\n');
|
||||
const content = lines[0];
|
||||
const options = lines.slice(1).filter((l: string) => /^[A-D]\./.test(l));
|
||||
|
||||
return {
|
||||
content: content,
|
||||
type: options.length > 0 ? 'SingleChoice' : 'Subjective',
|
||||
options: options.length > 0 ? options : undefined,
|
||||
answer: 'A', // 默认答案
|
||||
parse: '解析暂无'
|
||||
};
|
||||
});
|
||||
|
||||
res.json(questions);
|
||||
} catch (error) {
|
||||
console.error('Parse text error:', error);
|
||||
res.status(500).json({ error: 'Failed to parse text' });
|
||||
}
|
||||
};
|
||||
439
backend/src/controllers/submission.controller.ts
Normal file
439
backend/src/controllers/submission.controller.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import prisma from '../utils/prisma';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { calculateRank } from '../utils/helpers';
|
||||
|
||||
// GET /api/submissions/:assignmentId/paper
|
||||
// 学生获取答题卡
|
||||
export const getStudentPaper = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { assignmentId } = req.params;
|
||||
|
||||
// 获取作业信息
|
||||
const assignment = await prisma.assignment.findUnique({
|
||||
where: { id: assignmentId, isDeleted: false },
|
||||
include: {
|
||||
exam: {
|
||||
include: {
|
||||
nodes: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
question: {
|
||||
include: {
|
||||
knowledgePoints: {
|
||||
select: {
|
||||
knowledgePoint: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: 'Assignment not found' });
|
||||
}
|
||||
|
||||
// 验证作业时间
|
||||
const now = new Date();
|
||||
if (now < assignment.startTime) {
|
||||
return res.status(400).json({ error: 'Assignment has not started yet' });
|
||||
}
|
||||
|
||||
if (now > assignment.endTime && !assignment.allowLateSubmission) {
|
||||
return res.status(400).json({ error: 'Assignment has ended' });
|
||||
}
|
||||
|
||||
// 查找或创建学生提交记录
|
||||
let submission = await prisma.studentSubmission.findFirst({
|
||||
where: {
|
||||
assignmentId,
|
||||
studentId: req.userId!,
|
||||
isDeleted: false
|
||||
},
|
||||
include: {
|
||||
details: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!submission) {
|
||||
// 创建新的提交记录
|
||||
submission = await prisma.studentSubmission.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
assignmentId,
|
||||
studentId: req.userId!,
|
||||
submissionStatus: 'Pending',
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
},
|
||||
include: {
|
||||
details: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 构建试卷结构(树形)
|
||||
const buildTree = (nodes: any[], parentId: string | null = null): any[] => {
|
||||
return nodes
|
||||
.filter(node => node.parentNodeId === parentId)
|
||||
.map(node => {
|
||||
const detail = submission!.details.find(d => d.examNodeId === node.id);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
nodeType: node.nodeType,
|
||||
title: node.title,
|
||||
description: node.description,
|
||||
questionId: node.questionId,
|
||||
questionContent: node.question?.content,
|
||||
questionType: node.question?.questionType,
|
||||
// 构造完整的 question 对象以供前端使用
|
||||
question: node.question ? {
|
||||
id: node.question.id,
|
||||
content: node.question.content,
|
||||
type: node.question.questionType,
|
||||
difficulty: node.question.difficulty,
|
||||
answer: node.question.answer,
|
||||
parse: node.question.explanation,
|
||||
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name),
|
||||
options: (() => {
|
||||
const cfg: any = (node as any).question?.optionsConfig;
|
||||
if (!cfg) return [];
|
||||
try {
|
||||
if (Array.isArray(cfg)) return cfg.map((v: any) => String(v));
|
||||
if (cfg.options && Array.isArray(cfg.options)) return cfg.options.map((v: any) => String(v));
|
||||
if (typeof cfg === 'object') {
|
||||
return Object.keys(cfg).sort().map(k => String(cfg[k]));
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
} : undefined,
|
||||
score: Number(node.score),
|
||||
sortOrder: node.sortOrder,
|
||||
studentAnswer: detail?.studentAnswer || null,
|
||||
children: buildTree(nodes, node.id)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const rootNodes = buildTree(assignment.exam.nodes);
|
||||
|
||||
res.json({
|
||||
examId: assignment.exam.id,
|
||||
title: assignment.title,
|
||||
duration: assignment.exam.suggestedDuration,
|
||||
totalScore: Number(assignment.exam.totalScore),
|
||||
startTime: assignment.startTime.toISOString(),
|
||||
endTime: assignment.endTime.toISOString(),
|
||||
submissionId: submission.id,
|
||||
status: submission.submissionStatus,
|
||||
rootNodes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get student paper error:', error);
|
||||
res.status(500).json({ error: 'Failed to get student paper' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/submissions/:assignmentId/submit
|
||||
// 学生提交答案
|
||||
export const submitAnswers = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { assignmentId } = req.params;
|
||||
const { answers, timeSpent } = req.body; // answers: Array of { examNodeId, studentAnswer }
|
||||
|
||||
if (!answers || !Array.isArray(answers)) {
|
||||
return res.status(400).json({ error: 'Invalid answers data' });
|
||||
}
|
||||
|
||||
// 获取提交记录
|
||||
const submission = await prisma.studentSubmission.findFirst({
|
||||
where: {
|
||||
assignmentId,
|
||||
studentId: req.userId!,
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!submission) {
|
||||
return res.status(404).json({ error: 'Submission not found' });
|
||||
}
|
||||
|
||||
// 批量创建/更新答题详情
|
||||
const updatePromises = answers.map(async (answer: any) => {
|
||||
const { examNodeId, studentAnswer } = answer;
|
||||
|
||||
const existingDetail = await prisma.submissionDetail.findFirst({
|
||||
where: {
|
||||
submissionId: submission.id,
|
||||
examNodeId,
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (existingDetail) {
|
||||
return prisma.submissionDetail.update({
|
||||
where: { id: existingDetail.id },
|
||||
data: {
|
||||
studentAnswer,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return prisma.submissionDetail.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
submissionId: submission.id,
|
||||
examNodeId,
|
||||
studentAnswer,
|
||||
createdBy: req.userId!,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// 更新提交状态
|
||||
await prisma.studentSubmission.update({
|
||||
where: { id: submission.id },
|
||||
data: {
|
||||
submissionStatus: 'Submitted',
|
||||
submitTime: new Date(),
|
||||
timeSpentSeconds: timeSpent || null,
|
||||
updatedBy: req.userId!
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: 如果开启自动批改,这里可以实现自动评分逻辑
|
||||
|
||||
res.json({
|
||||
message: 'Answers submitted successfully',
|
||||
submissionId: submission.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Submit answers error:', error);
|
||||
res.status(500).json({ error: 'Failed to submit answers' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/submissions/:submissionId/result
|
||||
// 查看批改结果
|
||||
export const getSubmissionResult = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { submissionId } = req.params;
|
||||
|
||||
// 获取提交记录
|
||||
const submission = await prisma.studentSubmission.findUnique({
|
||||
where: { id: submissionId, isDeleted: false },
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
exam: {
|
||||
include: {
|
||||
nodes: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
question: {
|
||||
include: {
|
||||
knowledgePoints: {
|
||||
select: {
|
||||
knowledgePoint: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
details: {
|
||||
include: {
|
||||
examNode: {
|
||||
include: {
|
||||
question: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!submission) {
|
||||
return res.status(404).json({ error: 'Submission not found' });
|
||||
}
|
||||
|
||||
// 验证是本人的提交
|
||||
if (submission.studentId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
// 如果还没有批改,返回未批改状态
|
||||
if (submission.submissionStatus !== 'Graded') {
|
||||
return res.json({
|
||||
submissionId: submission.id,
|
||||
status: submission.submissionStatus,
|
||||
message: 'Your submission has not been graded yet'
|
||||
});
|
||||
}
|
||||
|
||||
// 计算排名
|
||||
const totalScore = Number(submission.totalScore || 0);
|
||||
const { rank, totalStudents, beatRate } = await calculateRank(
|
||||
submission.assignmentId,
|
||||
totalScore
|
||||
);
|
||||
|
||||
// 构建答题详情
|
||||
const nodes = submission.assignment.exam.nodes.map(node => {
|
||||
const detail = submission.details.find(d => d.examNodeId === node.id);
|
||||
|
||||
return {
|
||||
examNodeId: node.id,
|
||||
questionId: node.questionId,
|
||||
questionContent: node.question?.content,
|
||||
questionType: node.question?.questionType,
|
||||
// 构造完整的 question 对象以供前端使用
|
||||
question: node.question ? {
|
||||
id: node.question.id,
|
||||
content: node.question.content,
|
||||
type: node.question.questionType,
|
||||
difficulty: node.question.difficulty,
|
||||
answer: node.question.answer,
|
||||
parse: node.question.explanation,
|
||||
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
|
||||
} : undefined,
|
||||
score: Number(node.score),
|
||||
studentScore: detail?.score ? Number(detail.score) : null,
|
||||
studentAnswer: detail?.studentAnswer || null,
|
||||
autoCheckResult: detail?.judgement === 'Correct',
|
||||
teacherComment: detail?.teacherComment || null
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
submissionId: submission.id,
|
||||
studentName: 'Me', // 学生看自己的结果
|
||||
totalScore,
|
||||
rank,
|
||||
totalStudents,
|
||||
beatRate,
|
||||
submitTime: submission.submitTime?.toISOString() || null,
|
||||
nodes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get submission result error:', error);
|
||||
res.status(500).json({ error: 'Failed to get submission result' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getSubmissionResultByAssignment = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { assignmentId } = req.params;
|
||||
const submission = await prisma.studentSubmission.findFirst({
|
||||
where: { assignmentId, studentId: req.userId!, isDeleted: false },
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
exam: {
|
||||
include: {
|
||||
nodes: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
question: {
|
||||
include: {
|
||||
knowledgePoints: {
|
||||
select: { knowledgePoint: { select: { name: true } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
details: {
|
||||
include: {
|
||||
examNode: { include: { question: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!submission) {
|
||||
return res.status(404).json({ error: 'Submission not found' });
|
||||
}
|
||||
|
||||
if (submission.submissionStatus !== 'Graded') {
|
||||
return res.json({
|
||||
submissionId: submission.id,
|
||||
status: submission.submissionStatus,
|
||||
message: 'Your submission has not been graded yet'
|
||||
});
|
||||
}
|
||||
|
||||
const totalScore = Number(submission.totalScore || 0);
|
||||
const { rank, totalStudents, beatRate } = await calculateRank(
|
||||
submission.assignmentId,
|
||||
totalScore
|
||||
);
|
||||
|
||||
const nodes = submission.assignment.exam.nodes.map(node => {
|
||||
const detail = submission.details.find(d => d.examNodeId === node.id);
|
||||
return {
|
||||
examNodeId: node.id,
|
||||
questionId: node.questionId,
|
||||
questionContent: node.question?.content,
|
||||
questionType: node.question?.questionType,
|
||||
question: node.question ? {
|
||||
id: node.question.id,
|
||||
content: node.question.content,
|
||||
type: node.question.questionType,
|
||||
difficulty: node.question.difficulty,
|
||||
answer: node.question.answer,
|
||||
parse: node.question.explanation,
|
||||
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
|
||||
} : undefined,
|
||||
score: Number(node.score),
|
||||
studentScore: detail?.score ? Number(detail.score) : null,
|
||||
studentAnswer: detail?.studentAnswer || null,
|
||||
autoCheckResult: detail?.judgement === 'Correct',
|
||||
teacherComment: detail?.teacherComment || null
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
submissionId: submission.id,
|
||||
studentName: 'Me',
|
||||
totalScore,
|
||||
rank,
|
||||
totalStudents,
|
||||
beatRate,
|
||||
submitTime: submission.submitTime?.toISOString() || null,
|
||||
nodes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get submission result by assignment error:', error);
|
||||
res.status(500).json({ error: 'Failed to get submission result' });
|
||||
}
|
||||
};
|
||||
73
backend/src/index.ts
Normal file
73
backend/src/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './routes/auth.routes';
|
||||
import examRoutes from './routes/exam.routes';
|
||||
import analyticsRoutes from './routes/analytics.routes';
|
||||
import commonRoutes from './routes/common.routes';
|
||||
import orgRouter from './routes/org.routes';
|
||||
import curriculumRouter from './routes/curriculum.routes';
|
||||
import questionRouter from './routes/question.routes';
|
||||
import assignmentRouter from './routes/assignment.routes';
|
||||
import submissionRouter from './routes/submission.routes';
|
||||
import gradingRouter from './routes/grading.routes';
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// 中间件
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 日志中间件
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// API路由
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/org', orgRouter);
|
||||
app.use('/api/curriculum', curriculumRouter);
|
||||
app.use('/api/questions', questionRouter);
|
||||
app.use('/api/exams', examRoutes);
|
||||
app.use('/api/assignments', assignmentRouter);
|
||||
app.use('/api/submissions', submissionRouter);
|
||||
app.use('/api/grading', gradingRouter);
|
||||
app.use('/api/analytics', analyticsRoutes);
|
||||
app.use('/api', commonRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 404处理
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal server error',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`✅ Server running on http://localhost:${PORT}`);
|
||||
console.log(`📊 Environment: ${process.env.NODE_ENV}`);
|
||||
console.log(`🔗 CORS enabled for: ${process.env.CORS_ORIGIN}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
51
backend/src/middleware/auth.middleware.ts
Normal file
51
backend/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: string;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export const authenticate = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET as string;
|
||||
if (!secret) {
|
||||
return res.status(500).json({ error: 'Auth secret not configured' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, secret) as { userId: string; role?: string };
|
||||
req.userId = decoded.userId;
|
||||
req.userRole = decoded.role;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
export const optionalAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
const secret = process.env.JWT_SECRET as string;
|
||||
if (secret) {
|
||||
const decoded = jwt.verify(token, secret) as { userId: string };
|
||||
req.userId = decoded.userId;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next();
|
||||
}
|
||||
};
|
||||
26
backend/src/routes/analytics.routes.ts
Normal file
26
backend/src/routes/analytics.routes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import {
|
||||
getClassPerformance,
|
||||
getStudentGrowth,
|
||||
getRadar,
|
||||
getStudentRadar,
|
||||
getScoreDistribution,
|
||||
getTeacherStats,
|
||||
getExamStats
|
||||
} from '../controllers/analytics.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 所有分析接口都需要认证
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/class/performance', getClassPerformance);
|
||||
router.get('/student/growth', getStudentGrowth);
|
||||
router.get('/radar', getRadar);
|
||||
router.get('/student/radar', getStudentRadar);
|
||||
router.get('/distribution', getScoreDistribution);
|
||||
router.get('/teacher-stats', getTeacherStats);
|
||||
router.get('/exam/:id/stats', getExamStats);
|
||||
|
||||
export default router;
|
||||
12
backend/src/routes/assignment.routes.ts
Normal file
12
backend/src/routes/assignment.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import * as assignmentController from '../controllers/assignment.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/teaching', authenticate, assignmentController.getTeachingAssignments);
|
||||
router.get('/learning', authenticate, assignmentController.getStudentAssignments);
|
||||
router.post('/', authenticate, assignmentController.createAssignment);
|
||||
router.get('/:id/stats', authenticate, assignmentController.getAssignmentStats);
|
||||
|
||||
export default router;
|
||||
16
backend/src/routes/auth.routes.ts
Normal file
16
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import * as authController from '../controllers/auth.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Public routes
|
||||
router.post('/register', authController.register);
|
||||
router.post('/login', authController.login);
|
||||
|
||||
// Protected routes
|
||||
router.get('/me', authenticate, authController.me);
|
||||
router.put('/profile', authenticate, authController.updateProfile);
|
||||
router.post('/change-password', authenticate, authController.changePassword);
|
||||
|
||||
export default router;
|
||||
32
backend/src/routes/common.routes.ts
Normal file
32
backend/src/routes/common.routes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import {
|
||||
getMessages,
|
||||
markMessageRead,
|
||||
createMessage,
|
||||
getSchedule,
|
||||
getWeekSchedule,
|
||||
addEvent,
|
||||
deleteEvent
|
||||
} from '../controllers/common.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// Messages
|
||||
router.get('/messages', getMessages);
|
||||
router.post('/messages/:id/read', markMessageRead);
|
||||
router.post('/messages', createMessage);
|
||||
|
||||
// Schedule
|
||||
router.get('/schedule/week', getWeekSchedule);
|
||||
router.get('/common/schedule/week', getWeekSchedule);
|
||||
router.get('/common/schedule', getSchedule); // For realCommonService compatibility
|
||||
router.post('/schedule', addEvent);
|
||||
router.delete('/schedule/:id', deleteEvent);
|
||||
// Compatibility for frontend realScheduleService which posts to /common/schedule
|
||||
router.post('/common/schedule', addEvent);
|
||||
router.delete('/common/schedule/:id', deleteEvent);
|
||||
|
||||
export default router;
|
||||
32
backend/src/routes/curriculum.routes.ts
Normal file
32
backend/src/routes/curriculum.routes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import * as curriculumController from '../controllers/curriculum.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Basic queries
|
||||
router.get('/subjects', curriculumController.getSubjects);
|
||||
router.get('/textbooks/:id/tree', curriculumController.getTextbookTree);
|
||||
router.get('/subjects/:id/textbooks', curriculumController.getTextbooksBySubject);
|
||||
|
||||
// Textbook CRUD
|
||||
router.post('/textbooks', authenticate, curriculumController.createTextbook);
|
||||
router.put('/textbooks/:id', authenticate, curriculumController.updateTextbook);
|
||||
router.delete('/textbooks/:id', authenticate, curriculumController.deleteTextbook);
|
||||
|
||||
// Unit CRUD
|
||||
router.post('/units', authenticate, curriculumController.createUnit);
|
||||
router.put('/units/:id', authenticate, curriculumController.updateUnit);
|
||||
router.delete('/units/:id', authenticate, curriculumController.deleteUnit);
|
||||
|
||||
// Lesson CRUD
|
||||
router.post('/lessons', authenticate, curriculumController.createLesson);
|
||||
router.put('/lessons/:id', authenticate, curriculumController.updateLesson);
|
||||
router.delete('/lessons/:id', authenticate, curriculumController.deleteLesson);
|
||||
|
||||
// Knowledge Point CRUD
|
||||
router.post('/knowledge-points', authenticate, curriculumController.createKnowledgePoint);
|
||||
router.put('/knowledge-points/:id', authenticate, curriculumController.updateKnowledgePoint);
|
||||
router.delete('/knowledge-points/:id', authenticate, curriculumController.deleteKnowledgePoint);
|
||||
|
||||
export default router;
|
||||
34
backend/src/routes/exam.routes.ts
Normal file
34
backend/src/routes/exam.routes.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Router } from 'express';
|
||||
import * as examController from '../controllers/exam.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/exams - 获取试卷列表
|
||||
router.get('/', authenticate, examController.getExams);
|
||||
|
||||
// GET /api/exams/:id - 获取试卷详情
|
||||
router.get('/:id', authenticate, examController.getExamDetail);
|
||||
|
||||
// POST /api/exams - 创建试卷
|
||||
router.post('/', authenticate, examController.createExam);
|
||||
|
||||
// PUT /api/exams/:id - 更新试卷
|
||||
router.put('/:id', authenticate, examController.updateExam);
|
||||
|
||||
// DELETE /api/exams/:id - 删除试卷
|
||||
router.delete('/:id', authenticate, examController.deleteExam);
|
||||
|
||||
// PUT /api/exams/:id/structure - 更新试卷结构
|
||||
router.put('/:id/structure', authenticate, examController.updateExamStructure);
|
||||
|
||||
// POST /api/exams/:id/nodes - 添加节点
|
||||
router.post('/:id/nodes', authenticate, examController.addNode);
|
||||
|
||||
// PUT /api/exams/:id/nodes/:nodeId - 更新节点
|
||||
router.put('/:id/nodes/:nodeId', authenticate, examController.updateNode);
|
||||
|
||||
// DELETE /api/exams/:id/nodes/:nodeId - 删除节点
|
||||
router.delete('/:id/nodes/:nodeId', authenticate, examController.deleteNode);
|
||||
|
||||
export default router;
|
||||
11
backend/src/routes/grading.routes.ts
Normal file
11
backend/src/routes/grading.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import * as gradingController from '../controllers/grading.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/:assignmentId/list', authenticate, gradingController.getSubmissions);
|
||||
router.get('/submissions/:submissionId', authenticate, gradingController.getPaperForGrading);
|
||||
router.post('/submissions/:submissionId', authenticate, gradingController.submitGrade);
|
||||
|
||||
export default router;
|
||||
13
backend/src/routes/org.routes.ts
Normal file
13
backend/src/routes/org.routes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import * as orgController from '../controllers/org.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/schools', authenticate, orgController.getSchools);
|
||||
router.get('/classes', authenticate, orgController.getMyClasses);
|
||||
router.post('/classes', authenticate, orgController.createClass);
|
||||
router.post('/classes/join', authenticate, orgController.joinClass);
|
||||
router.get('/classes/:id/members', authenticate, orgController.getClassMembers);
|
||||
|
||||
export default router;
|
||||
11
backend/src/routes/question.routes.ts
Normal file
11
backend/src/routes/question.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import * as questionController from '../controllers/question.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/search', authenticate, questionController.searchQuestions);
|
||||
router.post('/parse-text', authenticate, questionController.parseText);
|
||||
router.post('/', authenticate, questionController.createQuestion);
|
||||
|
||||
export default router;
|
||||
12
backend/src/routes/submission.routes.ts
Normal file
12
backend/src/routes/submission.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import * as submissionController from '../controllers/submission.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/:assignmentId/paper', authenticate, submissionController.getStudentPaper);
|
||||
router.post('/:assignmentId/submit', authenticate, submissionController.submitAnswers);
|
||||
router.get('/:submissionId/result', authenticate, submissionController.getSubmissionResult);
|
||||
router.get('/by-assignment/:assignmentId/result', authenticate, submissionController.getSubmissionResultByAssignment);
|
||||
|
||||
export default router;
|
||||
204
backend/src/services/auth.service.ts
Normal file
204
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import prisma from '../utils/prisma';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class AuthService {
|
||||
async register(data: { realName: string; email?: string; phone?: string; password: string; gender?: string }) {
|
||||
const { realName, email, phone, password, gender = 'Male' } = data;
|
||||
|
||||
// Check if user exists
|
||||
const existing = await prisma.applicationUser.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
email ? { email } : {},
|
||||
phone ? { phone } : {}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const userId = uuidv4();
|
||||
const user = await prisma.applicationUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
realName,
|
||||
email,
|
||||
phone,
|
||||
gender: gender as any, // Cast to any to avoid Enum type mismatch for now
|
||||
passwordHash,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
// Generate Token
|
||||
const token = this.generateToken(user.id);
|
||||
|
||||
const role = await this.deriveRole(user.id);
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
realName: user.realName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
gender: user.gender,
|
||||
avatarUrl: user.avatarUrl,
|
||||
studentId: user.studentId || '',
|
||||
schoolId: user.currentSchoolId || '',
|
||||
role
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async login(credentials: { email?: string; phone?: string; password: string }) {
|
||||
const { email, phone, password } = credentials;
|
||||
|
||||
const user = await prisma.applicationUser.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
email ? { email } : {},
|
||||
phone ? { phone } : {}
|
||||
],
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const token = this.generateToken(user.id);
|
||||
|
||||
const role = await this.deriveRole(user.id);
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
realName: user.realName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
gender: user.gender,
|
||||
avatarUrl: user.avatarUrl,
|
||||
studentId: user.studentId || '',
|
||||
schoolId: user.currentSchoolId || '',
|
||||
role
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getMe(userId: string) {
|
||||
const user = await prisma.applicationUser.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const role = await this.deriveRole(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
realName: user.realName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
gender: user.gender,
|
||||
avatarUrl: user.avatarUrl,
|
||||
studentId: user.studentId || '',
|
||||
schoolId: user.currentSchoolId || '',
|
||||
role
|
||||
};
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, data: { realName?: string; gender?: string; avatarUrl?: string }) {
|
||||
const user = await prisma.applicationUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...data,
|
||||
...(data.gender && { gender: data.gender as any }),
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
const role = await this.deriveRole(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
realName: user.realName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
gender: user.gender,
|
||||
avatarUrl: user.avatarUrl,
|
||||
studentId: user.studentId || '',
|
||||
schoolId: user.currentSchoolId || '',
|
||||
role
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string) {
|
||||
const user = await prisma.applicationUser.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid old password');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.applicationUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Password updated successfully' };
|
||||
}
|
||||
|
||||
private generateToken(userId: string): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('Auth secret not configured');
|
||||
}
|
||||
const options: SignOptions = {
|
||||
expiresIn: (process.env.JWT_EXPIRES_IN as any) || '7d'
|
||||
};
|
||||
return jwt.sign({ userId }, secret, options);
|
||||
}
|
||||
|
||||
private async deriveRole(userId: string): Promise<'Teacher' | 'Student' | 'Admin'> {
|
||||
const isHeadTeacher = await prisma.class.findFirst({
|
||||
where: { headTeacherId: userId, isDeleted: false },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
const isClassTeacher = await prisma.classMember.findFirst({
|
||||
where: { userId, roleInClass: 'Teacher', isDeleted: false },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (isHeadTeacher || isClassTeacher) return 'Teacher';
|
||||
return 'Student';
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
310
backend/src/services/exam.service.ts
Normal file
310
backend/src/services/exam.service.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import prisma from '../utils/prisma';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class ExamService {
|
||||
async getExams(userId: string, query: { subjectId?: string; status?: string }) {
|
||||
const { subjectId, status } = query;
|
||||
|
||||
const exams = await prisma.exam.findMany({
|
||||
where: {
|
||||
createdBy: userId,
|
||||
isDeleted: false,
|
||||
...(subjectId && { subjectId }),
|
||||
...(status && { status: status as any })
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
subjectId: true,
|
||||
totalScore: true,
|
||||
suggestedDuration: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { nodes: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
const result = exams.map(exam => ({
|
||||
id: exam.id,
|
||||
subjectId: exam.subjectId,
|
||||
title: exam.title,
|
||||
totalScore: Number(exam.totalScore),
|
||||
duration: exam.suggestedDuration,
|
||||
questionCount: exam._count.nodes,
|
||||
status: exam.status,
|
||||
createdAt: exam.createdAt.toISOString()
|
||||
}));
|
||||
|
||||
return {
|
||||
items: result,
|
||||
totalCount: result.length,
|
||||
pageIndex: 1,
|
||||
pageSize: result.length
|
||||
};
|
||||
}
|
||||
|
||||
async getExamDetail(id: string) {
|
||||
const exam = await prisma.exam.findUnique({
|
||||
where: { id, isDeleted: false },
|
||||
include: {
|
||||
nodes: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
question: {
|
||||
include: {
|
||||
knowledgePoints: {
|
||||
select: {
|
||||
knowledgePoint: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!exam) {
|
||||
throw new Error('Exam not found');
|
||||
}
|
||||
|
||||
const buildTree = (nodes: any[], parentId: string | null = null): any[] => {
|
||||
return nodes
|
||||
.filter(node => node.parentNodeId === parentId)
|
||||
.map(node => ({
|
||||
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,
|
||||
answer: node.question.answer,
|
||||
parse: node.question.explanation,
|
||||
knowledgePoints: node.question.knowledgePoints.map((kp: any) => kp.knowledgePoint.name)
|
||||
} : undefined,
|
||||
score: Number(node.score),
|
||||
sortOrder: node.sortOrder,
|
||||
children: buildTree(nodes, node.id)
|
||||
}));
|
||||
};
|
||||
|
||||
const rootNodes = buildTree(exam.nodes);
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
subjectId: exam.subjectId,
|
||||
title: exam.title,
|
||||
totalScore: Number(exam.totalScore),
|
||||
duration: exam.suggestedDuration,
|
||||
questionCount: exam.nodes.length,
|
||||
status: exam.status,
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
rootNodes
|
||||
};
|
||||
}
|
||||
|
||||
async createExam(userId: string, data: { subjectId: string; title: string; suggestedDuration?: number }) {
|
||||
const { subjectId, title, suggestedDuration = 90 } = data;
|
||||
|
||||
const examId = uuidv4();
|
||||
const exam = await prisma.exam.create({
|
||||
data: {
|
||||
id: examId,
|
||||
subjectId,
|
||||
title,
|
||||
suggestedDuration,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
subjectId: exam.subjectId,
|
||||
title: exam.title,
|
||||
totalScore: Number(exam.totalScore),
|
||||
duration: exam.suggestedDuration,
|
||||
questionCount: 0,
|
||||
status: exam.status,
|
||||
createdAt: exam.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async updateExam(userId: string, id: string, data: { title?: string; suggestedDuration?: number; status?: any }) {
|
||||
const { title, suggestedDuration, status } = data;
|
||||
|
||||
const exam = await prisma.exam.update({
|
||||
where: { id, createdBy: userId },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
...(suggestedDuration && { suggestedDuration }),
|
||||
...(status && { status }),
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Exam updated', id: exam.id };
|
||||
}
|
||||
|
||||
async deleteExam(userId: string, id: string) {
|
||||
await prisma.exam.update({
|
||||
where: { id, createdBy: userId },
|
||||
data: {
|
||||
isDeleted: true,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Exam deleted' };
|
||||
}
|
||||
|
||||
async addNode(userId: string, examId: string, data: {
|
||||
parentNodeId?: string | null;
|
||||
nodeType: any;
|
||||
questionId?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
score?: number;
|
||||
sortOrder: number;
|
||||
}) {
|
||||
const { parentNodeId, nodeType, questionId, title, description, score, sortOrder } = data;
|
||||
|
||||
const nodeId = uuidv4();
|
||||
const node = await prisma.examNode.create({
|
||||
data: {
|
||||
id: nodeId,
|
||||
examId,
|
||||
parentNodeId,
|
||||
nodeType,
|
||||
questionId,
|
||||
title,
|
||||
description,
|
||||
score: score || 0,
|
||||
sortOrder,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
return { id: node.id, message: 'Node added' };
|
||||
}
|
||||
|
||||
async updateNode(userId: string, nodeId: string, data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
score?: number;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
const { title, description, score, sortOrder } = data;
|
||||
|
||||
await prisma.examNode.update({
|
||||
where: { id: nodeId },
|
||||
data: {
|
||||
...(title !== undefined && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
...(score !== undefined && { score }),
|
||||
...(sortOrder !== undefined && { sortOrder }),
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Node updated' };
|
||||
}
|
||||
|
||||
async deleteNode(userId: string, nodeId: string) {
|
||||
await prisma.examNode.update({
|
||||
where: { id: nodeId },
|
||||
data: {
|
||||
isDeleted: true,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Node deleted' };
|
||||
}
|
||||
|
||||
async updateExamStructure(userId: string, id: string, data: {
|
||||
rootNodes: any[];
|
||||
title?: string;
|
||||
suggestedDuration?: number;
|
||||
}) {
|
||||
const { rootNodes, title, suggestedDuration } = data;
|
||||
|
||||
// 1. Update Basic Info
|
||||
await prisma.exam.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
...(suggestedDuration && { suggestedDuration }),
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Check Submissions
|
||||
const submissionCount = await prisma.studentSubmission.count({
|
||||
where: {
|
||||
assignment: {
|
||||
examId: id
|
||||
},
|
||||
submissionStatus: {
|
||||
in: ['Submitted', 'Grading', 'Graded']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (submissionCount > 0) {
|
||||
throw new Error('This exam already has student submissions and cannot be structurally modified.');
|
||||
}
|
||||
|
||||
// 3. Rebuild Structure
|
||||
// Delete old nodes
|
||||
await prisma.examNode.deleteMany({
|
||||
where: { examId: id }
|
||||
});
|
||||
|
||||
// Create new nodes recursively
|
||||
const createNodes = async (nodes: any[], parentId: string | null = null) => {
|
||||
for (const node of nodes) {
|
||||
const newNode = await prisma.examNode.create({
|
||||
data: {
|
||||
id: node.id || uuidv4(),
|
||||
examId: id,
|
||||
parentNodeId: parentId,
|
||||
nodeType: node.nodeType,
|
||||
questionId: node.questionId,
|
||||
title: node.title,
|
||||
description: node.description,
|
||||
score: node.score,
|
||||
sortOrder: node.sortOrder,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
await createNodes(node.children, newNode.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (rootNodes && rootNodes.length > 0) {
|
||||
await createNodes(rootNodes);
|
||||
}
|
||||
|
||||
return { message: 'Exam structure updated' };
|
||||
}
|
||||
}
|
||||
|
||||
export const examService = new ExamService();
|
||||
143
backend/src/utils/helpers.ts
Normal file
143
backend/src/utils/helpers.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import prisma from './prisma';
|
||||
|
||||
/**
|
||||
* 生成唯一的6位邀请码
|
||||
* 格式:大写字母和数字组合,排除易混淆字符(0, O, I, 1)
|
||||
*/
|
||||
export async function generateInviteCode(): Promise<string> {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 排除 0, O, I, 1
|
||||
const codeLength = 6;
|
||||
|
||||
// 最多尝试10次,避免无限循环
|
||||
for (let attempts = 0; attempts < 10; attempts++) {
|
||||
let code = '';
|
||||
for (let i = 0; i < codeLength; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * chars.length);
|
||||
code += chars[randomIndex];
|
||||
}
|
||||
|
||||
// 检查唯一性
|
||||
const existing = await prisma.class.findUnique({
|
||||
where: { inviteCode: code }
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果10次都失败,抛出错误
|
||||
throw new Error('Failed to generate unique invite code');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建树形结构的通用函数
|
||||
* @param items 包含 id 和 parentId 的对象数组
|
||||
* @param parentId 父节点ID,默认为 null(顶层节点)
|
||||
* @param idKey ID字段名,默认为 'id'
|
||||
* @param parentKey 父ID字段名,默认为 'parentId'
|
||||
* @returns 树形结构数组
|
||||
*/
|
||||
export function buildTree<T extends Record<string, any>>(
|
||||
items: T[],
|
||||
parentId: string | null = null,
|
||||
idKey: string = 'id',
|
||||
parentKey: string = 'parentId'
|
||||
): (T & { children?: T[] })[] {
|
||||
return items
|
||||
.filter(item => item[parentKey] === parentId)
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: buildTree(items, item[idKey], idKey, parentKey)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算学生在作业中的排名
|
||||
* @param assignmentId 作业ID
|
||||
* @param studentScore 学生分数
|
||||
* @returns 排名信息 { rank, totalStudents, beatRate }
|
||||
*/
|
||||
export async function calculateRank(
|
||||
assignmentId: string,
|
||||
studentScore: number
|
||||
): Promise<{ rank: number; totalStudents: number; beatRate: number }> {
|
||||
// 获取所有已批改的提交
|
||||
const submissions = await prisma.studentSubmission.findMany({
|
||||
where: {
|
||||
assignmentId,
|
||||
submissionStatus: 'Graded',
|
||||
totalScore: { not: null }
|
||||
},
|
||||
select: {
|
||||
totalScore: true
|
||||
},
|
||||
orderBy: {
|
||||
totalScore: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
const totalStudents = submissions.length;
|
||||
|
||||
if (totalStudents === 0) {
|
||||
return { rank: 1, totalStudents: 0, beatRate: 0 };
|
||||
}
|
||||
|
||||
// 计算排名(分数相同则排名相同)
|
||||
let rank = 1;
|
||||
for (const submission of submissions) {
|
||||
const score = Number(submission.totalScore);
|
||||
if (score > studentScore) {
|
||||
rank++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算击败百分比
|
||||
const beatCount = totalStudents - rank;
|
||||
const beatRate = totalStudents > 1
|
||||
? Math.round((beatCount / (totalStudents - 1)) * 100)
|
||||
: 0;
|
||||
|
||||
return { rank, totalStudents, beatRate };
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为 ISO 字符串或指定格式
|
||||
*/
|
||||
export function formatDate(date: Date | null | undefined): string | null {
|
||||
if (!date) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户是否是班级的教师
|
||||
*/
|
||||
export async function isClassTeacher(userId: string, classId: string): Promise<boolean> {
|
||||
const membership = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
classId,
|
||||
roleInClass: 'Teacher',
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户是否是班级成员(教师或学生)
|
||||
*/
|
||||
export async function isClassMember(userId: string, classId: string): Promise<boolean> {
|
||||
const membership = await prisma.classMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
classId,
|
||||
isDeleted: false
|
||||
}
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
}
|
||||
17
backend/src/utils/prisma.ts
Normal file
17
backend/src/utils/prisma.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
// 测试数据库连接
|
||||
prisma.$connect()
|
||||
.then(() => console.log('✅ Database connected'))
|
||||
.catch((err) => console.error('❌ Database connection failed:', err));
|
||||
|
||||
// 优雅关闭
|
||||
process.on('beforeExit', async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
export default prisma;
|
||||
28
backend/tsconfig.json
Normal file
28
backend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user