chore: initial import to Nexus_Edu
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.next/
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
480
API_BCK
Normal file
480
API_BCK
Normal file
@@ -0,0 +1,480 @@
|
||||
# EduNexus 前端组件API接口文档
|
||||
|
||||
## 按组件类型分类的API接口
|
||||
|
||||
### 1. 认证相关组件 (Auth Components)
|
||||
|
||||
#### LoginForm组件
|
||||
- **前端服务**: `authService`
|
||||
- **API调用**: `login(username: string)`
|
||||
- **后端文件**: `auth.controller.ts`
|
||||
- **接口**: `POST /api/auth/login`
|
||||
- **参数**: username, password
|
||||
- **说明**: 用户登录
|
||||
|
||||
#### RegisterForm组件
|
||||
- **前端服务**: `authService`
|
||||
- **API调用**: `register(data: RegisterDto)`
|
||||
- **后端文件**: `auth.controller.ts`
|
||||
- **接口**: `POST /api/auth/register`
|
||||
- **参数**: realName, email, phone, password, gender
|
||||
- **说明**: 用户注册
|
||||
|
||||
#### UserProfile组件
|
||||
- **前端服务**: `authService`
|
||||
- **API调用**:
|
||||
- `me()`
|
||||
- `updateProfile(data: UpdateProfileDto)`
|
||||
- `changePassword(data: ChangePasswordDto)`
|
||||
- **后端文件**: `auth.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/auth/me`
|
||||
- `PUT /api/auth/profile`
|
||||
- `POST /api/auth/change-password`
|
||||
- **说明**: 获取用户信息、更新用户资料、修改密码
|
||||
|
||||
### 2. 仪表板组件 (Dashboard Components)
|
||||
|
||||
#### TeacherDashboard组件
|
||||
- **前端服务**:
|
||||
- `analyticsService`
|
||||
- `commonService`
|
||||
- **API调用**:
|
||||
- `getClassPerformance()`
|
||||
- `getRadar()`
|
||||
- `getScoreDistribution()`
|
||||
- `getSchedule()`
|
||||
- `getTeacherStats()`
|
||||
- **后端文件**:
|
||||
- `analytics.controller.ts`
|
||||
- `common.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/analytics/class-performance`
|
||||
- `GET /api/analytics/radar`
|
||||
- `GET /api/analytics/score-distribution`
|
||||
- `GET /api/schedule`
|
||||
- `GET /api/analytics/teacher-stats`
|
||||
- **说明**: 获取教师统计数据、班级表现、知识图谱、成绩分布、课表
|
||||
|
||||
#### StudentDashboard组件
|
||||
- **前端服务**:
|
||||
- `analyticsService`
|
||||
- `commonService`
|
||||
- **API调用**:
|
||||
- `getStudentGrowth()`
|
||||
- `getStudentRadar()`
|
||||
- `getSchedule()`
|
||||
- **后端文件**:
|
||||
- `analytics.controller.ts`
|
||||
- `common.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/analytics/student-growth`
|
||||
- `GET /api/analytics/student-radar`
|
||||
- `GET /api/schedule`
|
||||
- **说明**: 获取学生成长数据、学生能力雷达图、课表
|
||||
|
||||
### 3. 作业管理组件 (Assignment Components)
|
||||
|
||||
#### TeacherAssignmentList组件
|
||||
- **前端服务**: `assignmentService`
|
||||
- **API调用**: `getTeachingAssignments()`
|
||||
- **后端文件**: `assignment.controller.ts`
|
||||
- **接口**: `GET /api/assignments/teaching`
|
||||
- **参数**: 无
|
||||
- **说明**: 获取教师发布的作业列表
|
||||
|
||||
#### StudentAssignmentList组件
|
||||
- **前端服务**: `assignmentService`
|
||||
- **API调用**: `getStudentAssignments()`
|
||||
- **后端文件**: `assignment.controller.ts`
|
||||
- **接口**: `GET /api/assignments/learning`
|
||||
- **参数**: 无
|
||||
- **说明**: 获取学生需要完成的作业列表
|
||||
|
||||
#### CreateAssignmentModal组件
|
||||
- **前端服务**: `assignmentService`
|
||||
- **API调用**: `publishAssignment(data: any)`
|
||||
- **后端文件**: `assignment.controller.ts`
|
||||
- **接口**: `POST /api/assignments`
|
||||
- **参数**: examId, classId, title, startTime, endTime, allowLateSubmission, autoScoreEnabled
|
||||
- **说明**: 创建并发布作业
|
||||
|
||||
#### AssignmentStats组件
|
||||
- **前端服务**: `assignmentService`
|
||||
- **API调用**: `getAssignmentStats(id: string)`
|
||||
- **后端文件**: `assignment.controller.ts`
|
||||
- **接口**: `GET /api/assignments/:id/stats`
|
||||
- **参数**: assignmentId
|
||||
- **说明**: 获取作业统计数据
|
||||
|
||||
### 4. 考试管理组件 (Exam Components)
|
||||
|
||||
#### ExamEditor组件
|
||||
- **前端服务**:
|
||||
- `examService`
|
||||
- `questionService`
|
||||
- **API调用**:
|
||||
- `getExamDetail(id: string)`
|
||||
- `saveExam(exam: ExamDetailDto)`
|
||||
- `search(filter: any)`
|
||||
- **后端文件**:
|
||||
- `exam.controller.ts`
|
||||
- `question.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/exams/:id`
|
||||
- `PUT /api/exams/:id/structure`
|
||||
- `POST /api/questions/search`
|
||||
- **说明**: 获取考试详情、保存考试结构、搜索题目
|
||||
|
||||
#### ExamList组件
|
||||
- **前端服务**: `examService`
|
||||
- **API调用**: `getMyExams()`
|
||||
- **后端文件**: `exam.controller.ts`
|
||||
- **接口**: `GET /api/exams`
|
||||
- **参数**: subjectId, status
|
||||
- **说明**: 获取我的考试列表
|
||||
|
||||
#### ExamStats组件
|
||||
- **前端服务**: `examService`
|
||||
- **API调用**: `getStats(id: string)`
|
||||
- **后端文件**: `exam.controller.ts`
|
||||
- **接口**: `GET /api/exams/:id/stats`
|
||||
- **参数**: examId
|
||||
- **说明**: 获取考试统计数据
|
||||
|
||||
### 5. 批改管理组件 (Grading Components)
|
||||
|
||||
#### GradingBoard组件
|
||||
- **前端服务**: `gradingService`
|
||||
- **API调用**:
|
||||
- `getSubmissions(assignmentId: string)`
|
||||
- `getPaper(submissionId: string)`
|
||||
- `submitGrade(data: any)`
|
||||
- **后端文件**: `grading.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/grading/:assignmentId/list`
|
||||
- `GET /api/grading/submissions/:submissionId`
|
||||
- `POST /api/grading/submissions/:submissionId`
|
||||
- **说明**: 获取作业提交列表、获取答卷详情、提交批改结果
|
||||
|
||||
#### SubmissionSidebar组件
|
||||
- **前端服务**: `gradingService`
|
||||
- **API调用**: `getSubmissions(assignmentId: string)`
|
||||
- **后端文件**: `grading.controller.ts`
|
||||
- **接口**: `GET /api/grading/:assignmentId/list`
|
||||
- **参数**: assignmentId
|
||||
- **说明**: 获取作业提交列表
|
||||
|
||||
### 6. 学生考试组件 (Student Exam Components)
|
||||
|
||||
#### StudentExamRunner组件
|
||||
- **前端服务**: `submissionService`
|
||||
- **API调用**:
|
||||
- `getStudentPaper(assignmentId: string)`
|
||||
- `submitExam(data: SubmitExamDto)`
|
||||
- **后端文件**: `submission.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/submissions/:assignmentId/paper`
|
||||
- `POST /api/submissions/:assignmentId/submit`
|
||||
- **说明**: 获取学生答题卡、提交答案
|
||||
|
||||
#### StudentResult组件
|
||||
- **前端服务**: `submissionService`
|
||||
- **API调用**: `getSubmissionResult(assignmentId: string)`
|
||||
- **后端文件**: `submission.controller.ts`
|
||||
- **接口**: `GET /api/submissions/:submissionId/result`
|
||||
- **参数**: submissionId
|
||||
- **说明**: 查看考试结果
|
||||
|
||||
### 7. 题库管理组件 (Question Components)
|
||||
|
||||
#### QuestionCard组件
|
||||
- **前端服务**: `questionService`
|
||||
- **API调用**: `search(filter: any)`
|
||||
- **后端文件**: `question.controller.ts`
|
||||
- **接口**: `POST /api/questions/search`
|
||||
- **参数**: subjectId, questionType, difficulty, keyword, createdBy, sortBy, page, pageSize
|
||||
- **说明**: 搜索题目
|
||||
|
||||
#### QuestionModal组件
|
||||
- **前端服务**: `questionService`
|
||||
- **API调用**:
|
||||
- `create(data: any)`
|
||||
- `update(id: string, data: any)`
|
||||
- `delete(id: string)`
|
||||
- **后端文件**: `question.controller.ts`
|
||||
- **接口**:
|
||||
- `POST /api/questions`
|
||||
- `PUT /api/questions/:id`
|
||||
- `DELETE /api/questions/:id`
|
||||
- **说明**: 创建、更新、删除题目
|
||||
|
||||
#### SubjectSelector组件
|
||||
- **前端服务**: `curriculumService`
|
||||
- **API调用**: `getSubjects()`
|
||||
- **后端文件**: `curriculum.controller.ts`
|
||||
- **接口**: `GET /api/curriculum/subjects`
|
||||
- **参数**: 无
|
||||
- **说明**: 获取学科列表
|
||||
|
||||
### 8. 课程管理组件 (Curriculum Components)
|
||||
|
||||
#### KnowledgeGraph组件
|
||||
- **前端服务**: `curriculumService`
|
||||
- **API调用**: `getTree(id: string)`
|
||||
- **后端文件**: `curriculum.controller.ts`
|
||||
- **接口**: `GET /api/curriculum/textbooks/:id/tree`
|
||||
- **参数**: id (textbookId或subjectId)
|
||||
- **说明**: 获取教材知识树结构
|
||||
|
||||
#### TextbookModal组件
|
||||
- **前端服务**: `curriculumService`
|
||||
- **API调用**:
|
||||
- `getTextbooksBySubject(subjectId: string)`
|
||||
- `createTextbook(data: any)`
|
||||
- `updateTextbook(id: string, data: any)`
|
||||
- `deleteTextbook(id: string)`
|
||||
- **后端文件**: `curriculum.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/curriculum/subjects/:id/textbooks`
|
||||
- `POST /api/curriculum/textbooks`
|
||||
- `PUT /api/curriculum/textbooks/:id`
|
||||
- `DELETE /api/curriculum/textbooks/:id`
|
||||
- **说明**: 获取教材列表、创建、更新、删除教材
|
||||
|
||||
#### TreeNode组件
|
||||
- **前端服务**: `curriculumService`
|
||||
- **API调用**: `getTree(id: string)`
|
||||
- **后端文件**: `curriculum.controller.ts`
|
||||
- **接口**: `GET /api/curriculum/textbooks/:id/tree`
|
||||
- **参数**: id
|
||||
- **说明**: 获取课程树结构
|
||||
|
||||
### 9. 班级管理组件 (Class Components)
|
||||
|
||||
#### ClassCard组件
|
||||
- **前端服务**: `orgService`
|
||||
- **API调用**: `getClasses(role?: string)`
|
||||
- **后端文件**: `org.controller.ts`
|
||||
- **接口**: `GET /api/org/classes`
|
||||
- **参数**: role
|
||||
- **说明**: 获取我的班级列表
|
||||
|
||||
#### CreateClassModal组件
|
||||
- **前端服务**: `orgService`
|
||||
- **API调用**: `createClass(data: CreateClassDto)`
|
||||
- **后端文件**: `org.controller.ts`
|
||||
- **接口**: `POST /api/org/classes`
|
||||
- **参数**: name, gradeId
|
||||
- **说明**: 创建新班级
|
||||
|
||||
#### JoinClassModal组件
|
||||
- **前端服务**: `orgService`
|
||||
- **API调用**: `joinClass(inviteCode: string)`
|
||||
- **后端文件**: `org.controller.ts`
|
||||
- **接口**: `POST /api/org/classes/join`
|
||||
- **参数**: inviteCode
|
||||
- **说明**: 通过邀请码加入班级
|
||||
|
||||
#### ClassAnalysis组件
|
||||
- **前端服务**: `orgService`
|
||||
- **API调用**: `getClassMembers(classId: string)`
|
||||
- **后端文件**: `org.controller.ts`
|
||||
- **接口**: `GET /api/org/classes/:id/members`
|
||||
- **参数**: classId
|
||||
- **说明**: 获取班级成员列表
|
||||
|
||||
### 10. 消息组件 (Message Components)
|
||||
|
||||
#### CreateMessageModal组件
|
||||
- **前端服务**: `messageService`
|
||||
- **API调用**:
|
||||
- `getMessages()`
|
||||
- `createMessage(data: CreateMessageDto)`
|
||||
- **后端文件**: `common.controller.ts`
|
||||
- **接口**:
|
||||
- `GET /api/messages`
|
||||
- `POST /api/messages`
|
||||
- **说明**: 获取消息列表、创建消息
|
||||
|
||||
#### NotificationList组件
|
||||
- **前端服务**: `messageService`
|
||||
- **API调用**: `getMessages()`
|
||||
- **后端文件**: `common.controller.ts`
|
||||
- **接口**: `GET /api/messages`
|
||||
- **参数**: 无
|
||||
- **说明**: 获取消息列表
|
||||
|
||||
### 11. 日程组件 (Schedule Components)
|
||||
|
||||
#### Timetable组件
|
||||
- **前端服务**: `scheduleService`
|
||||
- **API调用**: `getWeekSchedule()`
|
||||
- **后端文件**: `common.controller.ts`
|
||||
- **接口**: `GET /api/schedule`
|
||||
- **参数**: 无
|
||||
- **说明**: 获取周日程
|
||||
|
||||
#### EventModal组件
|
||||
- **前端服务**: `scheduleService`
|
||||
- **API调用**:
|
||||
- `addEvent(data: CreateScheduleDto)`
|
||||
- `deleteEvent(id: string)`
|
||||
- **后端文件**: `common.controller.ts`
|
||||
- **接口**:
|
||||
- `POST /api/schedule`
|
||||
- `DELETE /api/schedule/:id`
|
||||
- **说明**: 添加日程、删除日程
|
||||
|
||||
### 12. 设置组件 (Settings Components)
|
||||
|
||||
#### ProfileSettings组件
|
||||
- **前端服务**: `authService`
|
||||
- **API调用**: `updateProfile(data: UpdateProfileDto)`
|
||||
- **后端文件**: `auth.controller.ts`
|
||||
- **接口**: `PUT /api/auth/profile`
|
||||
- **参数**: realName, avatarUrl, bio, gender
|
||||
- **说明**: 更新用户资料
|
||||
|
||||
#### SecuritySettings组件
|
||||
- **前端服务**: `authService`
|
||||
- **API调用**: `changePassword(data: ChangePasswordDto)`
|
||||
- **后端文件**: `auth.controller.ts`
|
||||
- **接口**: `POST /api/auth/change-password`
|
||||
- **参数**: oldPassword, newPassword
|
||||
- **说明**: 修改密码
|
||||
|
||||
## 未使用但可能需要的API
|
||||
|
||||
### 1. 用户管理
|
||||
- **前端服务**: `authService`
|
||||
- **API调用**: `updateProfile(data: UpdateProfileDto)`
|
||||
- **后端文件**: `auth.controller.ts`
|
||||
- **接口**: `PUT /api/auth/profile`
|
||||
- **参数**: realName, avatarUrl, bio, gender
|
||||
- **说明**: 更新用户资料
|
||||
- **使用状态**: 已在ProfileSettings组件中使用
|
||||
|
||||
### 2. 密码管理
|
||||
- **前端服务**: `authService`
|
||||
- **API调用**: `changePassword(data: ChangePasswordDto)`
|
||||
- **后端文件**: `auth.controller.ts`
|
||||
- **接口**: `POST /api/auth/change-password`
|
||||
- **参数**: oldPassword, newPassword
|
||||
- **说明**: 修改密码
|
||||
- **使用状态**: 已在SecuritySettings组件中使用
|
||||
|
||||
### 3. 知识点管理
|
||||
- **前端服务**: `curriculumService`
|
||||
- **API调用**: `createKnowledgePoint(data: any)`
|
||||
- **后端文件**: `curriculum.controller.ts`
|
||||
- **接口**: `POST /api/curriculum/knowledge-points`
|
||||
- **参数**: lessonId, name, difficulty, description
|
||||
- **说明**: 创建知识点
|
||||
- **使用状态**: 未在前端组件中直接使用
|
||||
|
||||
### 4. 课时管理
|
||||
- **前端服务**: `curriculumService`
|
||||
- **API调用**: `createLesson(data: any)`
|
||||
- **后端文件**: `curriculum.controller.ts`
|
||||
- **接口**: `POST /api/curriculum/lessons`
|
||||
- **参数**: unitId, name, sortOrder
|
||||
- **说明**: 创建课时
|
||||
- **使用状态**: 未在前端组件中直接使用
|
||||
|
||||
### 5. 单元管理
|
||||
- **前端服务**: `curriculumService`
|
||||
- **API调用**: `createUnit(data: any)`
|
||||
- **后端文件**: `curriculum.controller.ts`
|
||||
- **接口**: `POST /api/curriculum/units`
|
||||
- **参数**: textbookId, name, sortOrder
|
||||
- **说明**: 创建单元
|
||||
- **使用状态**: 未在前端组件中直接使用
|
||||
|
||||
### 6. 消息管理
|
||||
- **前端服务**: `messageService`
|
||||
- **API调用**: `markAsRead(id: string)`
|
||||
- **后端文件**: `common.controller.ts`
|
||||
- **接口**: `PUT /api/messages/:id/read`
|
||||
- **参数**: id
|
||||
- **说明**: 标记消息为已读
|
||||
- **使用状态**: 未在前端组件中直接使用
|
||||
|
||||
### 7. 统计分析
|
||||
- **前端服务**: `analyticsService`
|
||||
- **API调用**: `getClassPerformance()`
|
||||
- **后端文件**: `analytics.controller.ts`
|
||||
- **接口**: `GET /api/analytics/class-performance`
|
||||
- **参数**: 无
|
||||
- **说明**: 获取班级表现
|
||||
- **使用状态**: 已在TeacherDashboard组件中使用
|
||||
|
||||
### 8. 学生成长
|
||||
- **前端服务**: `analyticsService`
|
||||
- **API调用**: `getStudentGrowth()`
|
||||
- **后端文件**: `analytics.controller.ts`
|
||||
- **接口**: `GET /api/analytics/student-growth`
|
||||
- **参数**: 无
|
||||
- **说明**: 获取学生成长数据
|
||||
- **使用状态**: 已在StudentDashboard组件中使用
|
||||
|
||||
## API参数类型说明
|
||||
|
||||
### 基础数据类型
|
||||
- **string**: 文本类型
|
||||
- **number**: 数字类型
|
||||
- **boolean**: 布尔类型
|
||||
- **Date**: 日期类型
|
||||
- **Array**: 数组类型
|
||||
- **Object**: 对象类型
|
||||
|
||||
### 特殊类型
|
||||
- **UserRole**: 'Student' | 'Teacher' | 'Admin'
|
||||
- **QuestionType**: 'SingleChoice' | 'MultipleChoice' | 'TrueFalse' | 'Subjective' | 'FillBlank'
|
||||
- **SubmissionStatus**: 'Pending' | 'Submitted' | 'Graded' | 'Late'
|
||||
- **ExamStatus**: 'Draft' | 'Published' | 'Archived'
|
||||
- **Difficulty**: 1-5 (数字,1为最简单,5为最难)
|
||||
|
||||
### 枚举类型
|
||||
- **NodeType**: 'Group' | 'Question'
|
||||
- **SortOrder**: 数字,表示排序顺序
|
||||
- **Message Type**: 'System' | 'Teacher' | 'Student'
|
||||
|
||||
## 数据格式说明
|
||||
|
||||
### 分页数据格式
|
||||
```typescript
|
||||
{
|
||||
items: T[],
|
||||
totalCount: number,
|
||||
pageIndex: number,
|
||||
pageSize: number
|
||||
}
|
||||
```
|
||||
|
||||
### 树形数据格式
|
||||
```typescript
|
||||
{
|
||||
id: string,
|
||||
name: string,
|
||||
children?: TreeNode[]
|
||||
}
|
||||
```
|
||||
|
||||
### 统计数据格式
|
||||
```typescript
|
||||
{
|
||||
total: number,
|
||||
passed: number,
|
||||
average: number,
|
||||
distribution: Array<{ range: string, count: number }>
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **认证**: 大部分API都需要用户认证,需要在请求头中包含JWT token
|
||||
2. **权限**: 某些API需要特定权限(如教师权限)
|
||||
3. **分页**: 列表类API通常支持分页,需要传入page和pageSize参数
|
||||
4. **错误处理**: 前端需要处理各种可能的错误情况,如网络错误、权限不足、数据不存在等
|
||||
5. **实时更新**: 某些操作(如提交作业)可能需要实时更新界面状态
|
||||
583
API_BCK.md
Normal file
583
API_BCK.md
Normal file
@@ -0,0 +1,583 @@
|
||||
# API接口文档
|
||||
|
||||
## 考试相关API
|
||||
|
||||
### 1. 考试列表
|
||||
**前端组件**: `ExamList` (src/features/exam/components/ExamList.tsx)
|
||||
**API调用**: `examService.getMyExams()`
|
||||
**后端文件**: `exam.controller.ts` -> `getExams`
|
||||
**后端路由**: `exam.routes.ts` -> `GET /api/exams`
|
||||
**参数**: 无
|
||||
**返回数据**:
|
||||
- items: 考试列表
|
||||
- totalCount: 总数
|
||||
- pageIndex: 页码
|
||||
- pageSize: 页大小
|
||||
|
||||
### 2. 获取考试详情
|
||||
**前端组件**: `ExamEditor` (src/features/exam/components/ExamEditor.tsx)
|
||||
**API调用**: `examService.getExamDetail(examId)`
|
||||
**后端文件**: `exam.controller.ts` -> `getExamDetail`
|
||||
**后端路由**: `exam.routes.ts` -> `GET /api/exams/:id`
|
||||
**参数**: examId (路径参数)
|
||||
**返回数据**: 考试详细信息,包括树形结构
|
||||
|
||||
### 3. 保存考试
|
||||
**前端组件**: `ExamEditor` (src/features/exam/components/ExamEditor.tsx)
|
||||
**API调用**: `examService.saveExam(exam)`
|
||||
**后端文件**: `exam.controller.ts` -> `updateExamStructure` / `createExam`
|
||||
**后端路由**: `exam.routes.ts` -> `POST /api/exams` / `PUT /api/exams/:id/structure`
|
||||
**参数**:
|
||||
- subjectId: 科目ID
|
||||
- title: 标题
|
||||
- suggestedDuration: 建议时长
|
||||
- rootNodes: 树形结构
|
||||
- id: 考试ID(更新时需要)
|
||||
|
||||
### 4. 获取考试统计
|
||||
**前端组件**: `ExamStats` (src/features/exam/components/ExamStats.tsx)
|
||||
**API调用**: `examService.getStats(examId)`
|
||||
**后端文件**: `analytics.controller.ts` -> `getScoreDistribution`
|
||||
**后端路由**: `analytics.routes.ts` -> `GET /analytics/distribution`
|
||||
**参数**: examId (路径参数)
|
||||
**返回数据**:
|
||||
- averageScore: 平均分
|
||||
- passRate: 及格率
|
||||
- maxScore: 最高分
|
||||
- minScore: 最低分
|
||||
- scoreDistribution: 分数分布
|
||||
- wrongQuestions: 错题列表
|
||||
|
||||
## 认证相关API
|
||||
|
||||
### 1. 用户登录
|
||||
**前端组件**: `LoginForm` (src/features/auth/components/LoginForm.tsx)
|
||||
**API调用**: `authService.login(username)`
|
||||
**后端文件**: `auth.controller.ts` -> `login`
|
||||
**后端路由**: `auth.routes.ts` -> `POST /api/auth/login`
|
||||
**参数**: username (邮箱或手机号)
|
||||
**返回数据**:
|
||||
- token: 认证令牌
|
||||
- user: 用户信息
|
||||
|
||||
### 2. 获取用户信息
|
||||
**前端组件**: 多个组件使用
|
||||
**API调用**: `authService.me()`
|
||||
**后端文件**: `auth.controller.ts` -> `me`
|
||||
**后端路由**: `auth.routes.ts` -> `GET /api/auth/me`
|
||||
**参数**: 无
|
||||
**返回数据**: 用户详细信息
|
||||
|
||||
### 3. 更新用户资料
|
||||
**前端组件**: `ProfileSettings` (src/features/settings/components/ProfileSettings.tsx)
|
||||
**API调用**: `authService.updateProfile(data)`
|
||||
**后端文件**: `auth.controller.ts` -> `updateProfile`
|
||||
**后端路由**: `auth.routes.ts` -> `PUT /api/auth/profile`
|
||||
**参数**:
|
||||
- name: 姓名
|
||||
- email: 邮箱
|
||||
- phone: 手机号
|
||||
- avatar: 头像
|
||||
|
||||
### 4. 修改密码
|
||||
**前端组件**: `SecuritySettings` (src/features/settings/components/SecuritySettings.tsx)
|
||||
**API调用**: `authService.changePassword(data)`
|
||||
**后端文件**: `auth.controller.ts` -> `changePassword`
|
||||
**后端路由**: `auth.routes.ts` -> `PUT /api/auth/change-password`
|
||||
**参数**:
|
||||
- oldPassword: 旧密码
|
||||
- newPassword: 新密码
|
||||
|
||||
## 组织/班级相关API
|
||||
|
||||
### 1. 获取班级列表
|
||||
**前端组件**: `ClassCard` (src/features/class/components/ClassCard.tsx)
|
||||
**API调用**: `orgService.getClasses(role)`
|
||||
**后端文件**: `org.controller.ts` -> `getClasses`
|
||||
**后端路由**: `org.routes.ts` -> `GET /api/org/classes`
|
||||
**参数**: role (可选,角色过滤)
|
||||
**返回数据**: 班级列表
|
||||
|
||||
### 2. 获取班级成员
|
||||
**前端组件**: `ClassSettings` (src/features/class/components/ClassSettings.tsx)
|
||||
**API调用**: `orgService.getClassMembers(classId)`
|
||||
**后端文件**: `org.controller.ts` -> `getClassMembers`
|
||||
**后端路由**: `org.routes.ts` -> `GET /api/org/classes/:classId/members`
|
||||
**参数**: classId (路径参数)
|
||||
**返回数据**: 班级成员列表
|
||||
|
||||
### 3. 加入班级
|
||||
**前端组件**: `JoinClassModal` (src/features/class/components/JoinClassModal.tsx)
|
||||
**API调用**: `orgService.joinClass(inviteCode)`
|
||||
**后端文件**: `org.controller.ts` -> `joinClass`
|
||||
**后端路由**: `org.routes.ts` -> `POST /api/org/classes/join`
|
||||
**参数**: inviteCode (邀请码)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
### 4. 创建班级
|
||||
**前端组件**: `CreateClassModal` (src/features/class/components/CreateClassModal.tsx)
|
||||
**API调用**: `orgService.createClass(data)`
|
||||
**后端文件**: `org.controller.ts` -> `createClass`
|
||||
**后端路由**: `org.routes.ts` -> `POST /api/org/classes`
|
||||
**参数**:
|
||||
- name: 班级名称
|
||||
- description: 班级描述
|
||||
- grade: 年级
|
||||
- subject: 科目
|
||||
|
||||
## 题库相关API
|
||||
|
||||
### 1. 搜索题目
|
||||
**前端组件**: `QuestionCard` (src/features/question/components/QuestionCard.tsx)
|
||||
**API调用**: `questionService.search(filter)`
|
||||
**后端文件**: `question.controller.ts` -> `search`
|
||||
**后端路由**: `question.routes.ts` -> `POST /api/questions/search`
|
||||
**参数**:
|
||||
- subjectId: 科目ID
|
||||
- difficulty: 难度
|
||||
- type: 题型
|
||||
- keyword: 关键词
|
||||
**返回数据**: 题目列表
|
||||
|
||||
### 2. 解析文本
|
||||
**前端组件**: `ImportModal` (src/features/exam/components/ImportModal.tsx)
|
||||
**API调用**: `questionService.parseText(rawText)`
|
||||
**后端文件**: `question.controller.ts` -> `parseText`
|
||||
**后端路由**: `question.routes.ts` -> `POST /api/questions/parse-text`
|
||||
**参数**: text (原始文本)
|
||||
**返回数据**: 解析后的题目列表
|
||||
|
||||
### 3. 创建题目
|
||||
**前端组件**: `QuestionModal` (src/features/question/components/QuestionModal.tsx)
|
||||
**API调用**: `questionService.create(data)`
|
||||
**后端文件**: `question.controller.ts` -> `create`
|
||||
**后端路由**: `question.routes.ts` -> `POST /api/questions`
|
||||
**参数**:
|
||||
- content: 题目内容
|
||||
- type: 题型
|
||||
- difficulty: 难度
|
||||
- answer: 答案
|
||||
- explanation: 解析
|
||||
- knowledgePoints: 知识点
|
||||
|
||||
### 4. 更新题目
|
||||
**前端组件**: `QuestionModal` (src/features/question/components/QuestionModal.tsx)
|
||||
**API调用**: `questionService.update(id, data)`
|
||||
**后端文件**: `question.controller.ts` -> `update`
|
||||
**后端路由**: `question.routes.ts` -> `PUT /api/questions/:id`
|
||||
**参数**:
|
||||
- id: 题目ID
|
||||
- content: 题目内容
|
||||
- type: 题型
|
||||
- difficulty: 难度
|
||||
- answer: 答案
|
||||
- explanation: 解析
|
||||
- knowledgePoints: 知识点
|
||||
|
||||
### 5. 删除题目
|
||||
**前端组件**: `QuestionModal` (src/features/question/components/QuestionModal.tsx)
|
||||
**API调用**: `questionService.delete(id)`
|
||||
**后端文件**: `question.controller.ts` -> `delete`
|
||||
**后端路由**: `question.routes.ts` -> `DELETE /api/questions/:id`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
## 作业相关API
|
||||
|
||||
### 1. 获取教师作业列表
|
||||
**前端组件**: `TeacherAssignmentList` (src/features/assignment/components/TeacherAssignmentList.tsx)
|
||||
**API调用**: `assignmentService.getTeachingAssignments()`
|
||||
**后端文件**: `assignment.controller.ts` -> `getTeachingAssignments`
|
||||
**后端路由**: `assignment.routes.ts` -> `GET /api/assignments/teaching`
|
||||
**参数**: 无
|
||||
**返回数据**: 教师作业列表
|
||||
|
||||
### 2. 获取学生作业列表
|
||||
**前端组件**: `StudentAssignmentList` (src/features/assignment/components/StudentAssignmentList.tsx)
|
||||
**API调用**: `assignmentService.getStudentAssignments()`
|
||||
**后端文件**: `assignment.controller.ts` -> `getStudentAssignments`
|
||||
**后端路由**: `assignment.routes.ts` -> `GET /api/assignments/learning`
|
||||
**参数**: 无
|
||||
**返回数据**: 学生作业列表
|
||||
|
||||
### 3. 发布作业
|
||||
**前端组件**: `CreateAssignmentModal` (src/features/assignment/components/CreateAssignmentModal.tsx)
|
||||
**API调用**: `assignmentService.publishAssignment(data)`
|
||||
**后端文件**: `assignment.controller.ts` -> `publishAssignment`
|
||||
**后端路由**: `assignment.routes.ts` -> `POST /api/assignments`
|
||||
**参数**:
|
||||
- title: 作业标题
|
||||
- description: 作业描述
|
||||
- classId: 班级ID
|
||||
- examId: 考试ID
|
||||
- startTime: 开始时间
|
||||
- endTime: 结束时间
|
||||
|
||||
### 4. 获取作业统计
|
||||
**前端组件**: `AssignmentStats` (src/features/assignment/components/AssignmentStats.tsx)
|
||||
**API调用**: `assignmentService.getAssignmentStats(id)`
|
||||
**后端文件**: `assignment.controller.ts` -> `getAssignmentStats`
|
||||
**后端路由**: `assignment.routes.ts` -> `GET /api/assignments/:id/stats`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 作业统计数据
|
||||
|
||||
## 批改相关API
|
||||
|
||||
### 1. 获取提交列表
|
||||
**前端组件**: `GradingBoard` (src/features/grading/components/GradingBoard.tsx)
|
||||
**API调用**: `gradingService.getSubmissions(assignmentId)`
|
||||
**后端文件**: `grading.controller.ts` -> `getSubmissions`
|
||||
**后端路由**: `grading.routes.ts` -> `GET /api/grading/:assignmentId/list`
|
||||
**参数**: assignmentId (路径参数)
|
||||
**返回数据**: 提交列表
|
||||
|
||||
### 2. 获取试卷
|
||||
**前端组件**: `GradingBoard` (src/features/grading/components/GradingBoard.tsx)
|
||||
**API调用**: `gradingService.getPaper(submissionId)`
|
||||
**后端文件**: `grading.controller.ts` -> `getPaper`
|
||||
**后端路由**: `grading.routes.ts` -> `GET /api/grading/submissions/:submissionId`
|
||||
**参数**: submissionId (路径参数)
|
||||
**返回数据**: 试卷详情
|
||||
|
||||
## 提交相关API
|
||||
|
||||
### 1. 获取学生试卷
|
||||
**前端组件**: `StudentExamRunner` (src/views/StudentExamRunner.tsx)
|
||||
**API调用**: `submissionService.getStudentPaper(id)`
|
||||
**后端文件**: `submission.controller.ts` -> `getStudentPaper`
|
||||
**后端路由**: `submission.routes.ts` -> `GET /api/submissions/:id/paper`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 学生试卷详情
|
||||
|
||||
### 2. 提交考试
|
||||
**前端组件**: `StudentExamRunner` (src/views/StudentExamRunner.tsx)
|
||||
**API调用**: `submissionService.submitExam(data)`
|
||||
**后端文件**: `submission.controller.ts` -> `submitExam`
|
||||
**后端路由**: `submission.routes.ts` -> `POST /api/submissions/:assignmentId/submit`
|
||||
**参数**:
|
||||
- assignmentId: 作业ID
|
||||
- answers: 答案
|
||||
- submitTime: 提交时间
|
||||
|
||||
### 3. 获取提交结果
|
||||
**前端组件**: `StudentResult` (src/views/StudentResult.tsx)
|
||||
**API调用**: `submissionService.getSubmissionResult(assignmentId)`
|
||||
**后端文件**: `submission.controller.ts` -> `getSubmissionResult`
|
||||
**后端路由**: `submission.routes.ts` -> `GET /api/submissions/:assignmentId/result`
|
||||
**参数**: assignmentId (路径参数)
|
||||
**返回数据**: 提交结果详情
|
||||
|
||||
## 分析统计API
|
||||
|
||||
### 1. 获取班级表现
|
||||
**前端组件**: `ClassAnalysis` (src/features/class/components/ClassAnalysis.tsx)
|
||||
**API调用**: `analyticsService.getClassPerformance()`
|
||||
**后端文件**: `analytics.controller.ts` -> `getClassPerformance`
|
||||
**后端路由**: `analytics.routes.ts` -> `GET /api/analytics/class/performance`
|
||||
**参数**: 无
|
||||
**返回数据**: 班级表现数据
|
||||
|
||||
### 2. 获取学生成长
|
||||
**前端组件**: `StudentDashboard` (src/features/dashboard/components/StudentDashboard.tsx)
|
||||
**API调用**: `analyticsService.getStudentGrowth()`
|
||||
**后端文件**: `analytics.controller.ts` -> `getStudentGrowth`
|
||||
**后端路由**: `analytics.routes.ts` -> `GET /api/analytics/student/growth`
|
||||
**参数**: 无
|
||||
**返回数据**: 学生成长数据
|
||||
|
||||
### 3. 获取能力雷达图
|
||||
**前端组件**: `TeacherDashboard` (src/features/dashboard/components/TeacherDashboard.tsx)
|
||||
**API调用**: `analyticsService.getRadar()`
|
||||
**后端文件**: `analytics.controller.ts` -> `getRadar`
|
||||
**后端路由**: `analytics.routes.ts` -> `GET /api/analytics/radar`
|
||||
**参数**: 无
|
||||
**返回数据**: 能力雷达图数据
|
||||
|
||||
### 4. 获取学生能力雷达图
|
||||
**前端组件**: `StudentDashboard` (src/features/dashboard/components/StudentDashboard.tsx)
|
||||
**API调用**: `analyticsService.getStudentRadar()`
|
||||
**后端文件**: `analytics.controller.ts` -> `getStudentRadar`
|
||||
**后端路由**: `analytics.routes.ts` -> `GET /api/analytics/student/radar`
|
||||
**参数**: 无
|
||||
**返回数据**: 学生能力雷达图数据
|
||||
|
||||
### 5. 获取成绩分布
|
||||
**前端组件**: `TeacherDashboard` (src/features/dashboard/components/TeacherDashboard.tsx)
|
||||
**API调用**: `analyticsService.getScoreDistribution()`
|
||||
**后端文件**: `analytics.controller.ts` -> `getScoreDistribution`
|
||||
**后端路由**: `analytics.routes.ts` -> `GET /api/analytics/distribution`
|
||||
**参数**: 无
|
||||
**返回数据**: 成绩分布数据
|
||||
|
||||
### 6. 获取教师统计数据
|
||||
**前端组件**: `TeacherDashboard` (src/features/dashboard/components/TeacherDashboard.tsx)
|
||||
**API调用**: `analyticsService.getTeacherStats()`
|
||||
**后端文件**: `analytics.controller.ts` -> `getTeacherStats`
|
||||
**后端路由**: `analytics.routes.ts` -> `GET /api/analytics/teacher-stats`
|
||||
**参数**: 无
|
||||
**返回数据**: 教师统计数据
|
||||
|
||||
## 课程相关API
|
||||
|
||||
### 1. 获取科目列表
|
||||
**前端组件**: `SubjectSelector` (src/features/question/components/SubjectSelector.tsx)
|
||||
**API调用**: `curriculumService.getSubjects()`
|
||||
**后端文件**: `curriculum.controller.ts` -> `getSubjects`
|
||||
**后端路由**: `curriculum.routes.ts` -> `GET /api/curriculum/subjects`
|
||||
**参数**: 无
|
||||
**返回数据**: 科目列表
|
||||
|
||||
### 2. 获取教材树
|
||||
**前端组件**: `KnowledgeGraph` (src/features/curriculum/components/KnowledgeGraph.tsx)
|
||||
**API调用**: `curriculumService.getTree(id)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `getTree
|
||||
**后端路由**: `curriculum.routes.ts` -> `GET /api/curriculum/textbooks/:id/tree`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 教材树形结构
|
||||
|
||||
### 3. 获取科目教材
|
||||
**前端组件**: `TextbookModal` (src/features/curriculum/components/TextbookModal.tsx)
|
||||
**API调用**: `curriculumService.getTextbooksBySubject(subjectId)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `getTextbooksBySubject`
|
||||
**后端路由**: `curriculum.routes.ts` -> `GET /api/curriculum/subjects/:subjectId/textbooks`
|
||||
**参数**: subjectId (路径参数)
|
||||
**返回数据**: 教材列表
|
||||
|
||||
### 4. 创建教材
|
||||
**前端组件**: `TextbookModal` (src/features/curriculum/components/TextbookModal.tsx)
|
||||
**API调用**: `curriculumService.createTextbook(data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `createTextbook`
|
||||
**后端路由**: `curriculum.routes.ts` -> `POST /api/curriculum/textbooks`
|
||||
**参数**:
|
||||
- name: 教材名称
|
||||
- subjectId: 科目ID
|
||||
- description: 教材描述
|
||||
|
||||
### 5. 更新教材
|
||||
**前端组件**: `TextbookModal` (src/features/curriculum/components/TextbookModal.tsx)
|
||||
**API调用**: `curriculumService.updateTextbook(id, data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `updateTextbook`
|
||||
**后端路由**: `curriculum.routes.ts` -> `PUT /api/curriculum/textbooks/:id`
|
||||
**参数**:
|
||||
- id: 教材ID
|
||||
- name: 教材名称
|
||||
- subjectId: 科目ID
|
||||
- description: 教材描述
|
||||
|
||||
### 6. 删除教材
|
||||
**前端组件**: `TextbookModal` (src/features/curriculum/components/TextbookModal.tsx)
|
||||
**API调用**: `curriculumService.deleteTextbook(id)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `deleteTextbook`
|
||||
**后端路由**: `curriculum.routes.ts` -> `DELETE /api/curriculum/textbooks/:id`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
### 7. 创建单元
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.createUnit(data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `createUnit
|
||||
**后端路由**: `curriculum.routes.ts` -> `POST /api/curriculum/units`
|
||||
**参数**:
|
||||
- name: 单元名称
|
||||
- textbookId: 教材ID
|
||||
- description: 单元描述
|
||||
|
||||
### 8. 更新单元
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.updateUnit(id, data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `updateUnit
|
||||
**后端路由**: `curriculum.routes.ts` -> `PUT /api/curriculum/units/:id`
|
||||
**参数**:
|
||||
- id: 单元ID
|
||||
- name: 单元名称
|
||||
- textbookId: 教材ID
|
||||
- description: 单元描述
|
||||
|
||||
### 9. 删除单元
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.deleteUnit(id)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `deleteUnit
|
||||
**后端路由**: `curriculum.routes.ts` -> `DELETE /api/curriculum/units/:id`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
### 10. 创建课时
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.createLesson(data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `createLesson
|
||||
**后端路由**: `curriculum.routes.ts` -> `POST /api/curriculum/lessons`
|
||||
**参数**:
|
||||
- name: 课时名称
|
||||
- unitId: 单元ID
|
||||
- description: 课时描述
|
||||
|
||||
### 11. 更新课时
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.updateLesson(id, data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `updateLesson
|
||||
**后端路由**: `curriculum.routes.ts` -> `PUT /api/curriculum/lessons/:id`
|
||||
**参数**:
|
||||
- id: 课时ID
|
||||
- name: 课时名称
|
||||
- unitId: 单元ID
|
||||
- description: 课时描述
|
||||
|
||||
### 12. 删除课时
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.deleteLesson(id)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `deleteLesson
|
||||
**后端路由**: `curriculum.routes.ts` -> `DELETE /api/curriculum/lessons/:id`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
### 13. 创建知识点
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.createKnowledgePoint(data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `createKnowledgePoint
|
||||
**后端路由**: `curriculum.routes.ts` -> `POST /api/curriculum/knowledge-points`
|
||||
**参数**:
|
||||
- name: 知识点名称
|
||||
- lessonId: 课时ID
|
||||
- description: 知识点描述
|
||||
|
||||
### 14. 更新知识点
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.updateKnowledgePoint(id, data)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `updateKnowledgePoint
|
||||
**后端路由**: `curriculum.routes.ts` -> `PUT /api/curriculum/knowledge-points/:id`
|
||||
**参数**:
|
||||
- id: 知识点ID
|
||||
- name: 知识点名称
|
||||
- lessonId: 课时ID
|
||||
- description: 知识点描述
|
||||
|
||||
### 15. 删除知识点
|
||||
**前端组件**: `CurriculumNodeModal` (src/features/curriculum/components/CurriculumNodeModal.tsx)
|
||||
**API调用**: `curriculumService.deleteKnowledgePoint(id)`
|
||||
**后端文件**: `curriculum.controller.ts` -> `deleteKnowledgePoint
|
||||
**后端路由**: `curriculum.routes.ts` -> `DELETE /api/curriculum/knowledge-points/:id`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
## 消息相关API
|
||||
|
||||
### 1. 获取消息列表
|
||||
**前端组件**: `NotificationList` (src/features/dashboard/components/NotificationList.tsx)
|
||||
**API调用**: `messageService.getMessages()`
|
||||
**后端文件**: `message.controller.ts` -> `getMessages`
|
||||
**后端路由**: `message.routes.ts` -> `GET /api/common/messages`
|
||||
**参数**: 无
|
||||
**返回数据**: 消息列表
|
||||
|
||||
### 2. 标记已读
|
||||
**前端组件**: `NotificationList` (src/features/dashboard/components/NotificationList.tsx)
|
||||
**API调用**: `messageService.markAsRead(id)`
|
||||
**后端文件**: `message.controller.ts` -> `markAsRead
|
||||
**后端路由**: `message.routes.ts` -> `POST /api/common/messages/:id/read`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
### 3. 创建消息
|
||||
**前端组件**: `CreateMessageModal` (src/features/message/components/CreateMessageModal.tsx)
|
||||
**API调用**: `messageService.createMessage(data)`
|
||||
**后端文件**: `message.controller.ts` -> `createMessage
|
||||
**后端路由**: `message.routes.ts` -> `POST /api/common/messages`
|
||||
**参数**:
|
||||
- title: 消息标题
|
||||
- content: 消息内容
|
||||
- type: 消息类型
|
||||
- recipientIds: 接收者ID列表
|
||||
|
||||
## 日程相关API
|
||||
|
||||
### 1. 获取日程
|
||||
**前端组件**: `ScheduleList` (src/features/dashboard/components/ScheduleList.tsx)
|
||||
**API调用**: `scheduleService.getWeekSchedule()`
|
||||
**后端文件**: `schedule.controller.ts` -> `getWeekSchedule
|
||||
**后端路由**: `schedule.routes.ts` -> `GET /api/common/schedule/week`
|
||||
**参数**: 无
|
||||
**返回数据**: 周日程数据
|
||||
|
||||
### 2. 添加日程
|
||||
**前端组件**: `EventModal` (src/features/schedule/components/EventModal.tsx)
|
||||
**API调用**: `scheduleService.addEvent(data)`
|
||||
**后端文件**: `schedule.controller.ts` -> `addEvent
|
||||
**后端路由**: `schedule.routes.ts` -> `POST /api/common/schedule`
|
||||
**参数**:
|
||||
- title: 日程标题
|
||||
- startTime: 开始时间
|
||||
- endTime: 结束时间
|
||||
- type: 日程类型
|
||||
- description: 日程描述
|
||||
|
||||
### 3. 删除日程
|
||||
**前端组件**: `EventModal` (src/features/schedule/components/EventModal.tsx)
|
||||
**API调用**: `scheduleService.deleteEvent(id)`
|
||||
**后端文件**: `schedule.controller.ts` -> `deleteEvent
|
||||
**后端路由**: `schedule.routes.ts` -> `DELETE /api/common/schedule/:id`
|
||||
**参数**: id (路径参数)
|
||||
**返回数据**: 操作结果
|
||||
|
||||
## 公共API
|
||||
|
||||
### 1. 获取日程
|
||||
**前端组件**: `Timetable` (src/features/schedule/components/Timetable.tsx)
|
||||
**API调用**: `commonService.getSchedule()`
|
||||
**后端文件**: `common.controller.ts` -> `getSchedule
|
||||
**后端路由**: `common.routes.ts` -> `GET /api/common/schedule`
|
||||
**参数**: 无
|
||||
**返回数据**: 日程数据
|
||||
|
||||
## 未实现的API
|
||||
|
||||
### 1. 考试节点管理
|
||||
**需要实现**:
|
||||
- 添加考试节点: `POST /api/exams/:id/nodes`
|
||||
- 更新考试节点: `PUT /api/exams/:id/nodes/:nodeId`
|
||||
- 删除考试节点: `DELETE /api/exams/:id/nodes/:nodeId`
|
||||
|
||||
### 2. 更新考试基本信息
|
||||
**需要实现**:
|
||||
- 更新考试: `PUT /api/exams/:id`
|
||||
|
||||
### 3. 删除考试
|
||||
**需要实现**:
|
||||
- 删除考试: `DELETE /api/exams/:id`
|
||||
|
||||
### 4. 作业管理
|
||||
**需要实现**:
|
||||
- 获取作业详情: `GET /api/assignments/:id`
|
||||
- 更新作业: `PUT /api/assignments/:id`
|
||||
- 删除作业: `DELETE /api/assignments/:id`
|
||||
|
||||
### 5. 批改管理
|
||||
**需要实现**:
|
||||
- 保存批改结果: `POST /api/grading/submissions/:submissionId/grade`
|
||||
- 获取批改详情: `GET /api/grading/submissions/:submissionId/grade`
|
||||
|
||||
### 6. 提交管理
|
||||
**需要实现**:
|
||||
- 获取提交列表: `GET /api/submissions`
|
||||
- 更新提交状态: `PUT /api/submissions/:id`
|
||||
- 删除提交: `DELETE /api/submissions/:id`
|
||||
|
||||
### 7. 课程管理
|
||||
**需要实现**:
|
||||
- 获取单元详情: `GET /api/curriculum/units/:id`
|
||||
- 获取课时详情: `GET /api/curriculum/lessons/:id`
|
||||
- 获取知识点详情: `GET /api/curriculum/knowledge-points/:id`
|
||||
|
||||
### 8. 消息管理
|
||||
**需要实现**:
|
||||
- 获取消息详情: `GET /api/common/messages/:id`
|
||||
- 更新消息: `PUT /api/common/messages/:id`
|
||||
- 删除消息: `DELETE /api/common/messages/:id`
|
||||
|
||||
### 9. 日程管理
|
||||
**需要实现**:
|
||||
- 获取日程详情: `GET /api/common/schedule/:id`
|
||||
- 更新日程: `PUT /api/common/schedule/:id`
|
||||
|
||||
### 10. 公共管理
|
||||
**需要实现**:
|
||||
- 其他公共接口
|
||||
487
Model.ts
Normal file
487
Model.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Model.ts
|
||||
* 后端数据模型定义文件
|
||||
* 定义所有数据库实体模型,作为前后端开发的参考标准
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 基础审计字段 (所有模型通用)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 基础实体接口
|
||||
* 所有业务模型都应继承此接口,包含数据追踪所需的基础字段
|
||||
*/
|
||||
export interface BaseEntity {
|
||||
/** 全局唯一标识符 (GUID) */
|
||||
id: string;
|
||||
|
||||
/** 创建时间 */
|
||||
createdAt: Date;
|
||||
|
||||
/** 创建人ID */
|
||||
createdBy: string;
|
||||
|
||||
/** 最后修改时间 */
|
||||
updatedAt: Date;
|
||||
|
||||
/** 最后修改人ID */
|
||||
updatedBy: string;
|
||||
|
||||
/** 是否已软删除 */
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. 身份与权限模块
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 用户实体
|
||||
* 统一的用户账户,包含学生、老师、管理员等不同角色
|
||||
*/
|
||||
export interface ApplicationUser extends BaseEntity {
|
||||
/** 真实姓名 */
|
||||
realName: string;
|
||||
|
||||
/** 学号/工号 */
|
||||
studentId: string;
|
||||
|
||||
/** 头像URL */
|
||||
avatarUrl: string;
|
||||
|
||||
/** 性别:Male | Female | Unknown */
|
||||
gender: 'Male' | 'Female' | 'Unknown';
|
||||
|
||||
/** 当前所属学校ID(冗余字段,用于快速确定用户主要归属) */
|
||||
currentSchoolId: string;
|
||||
|
||||
/** 账号状态:Active | Disabled */
|
||||
accountStatus: 'Active' | 'Disabled';
|
||||
|
||||
/** 邮箱(可选) */
|
||||
email?: string;
|
||||
|
||||
/** 手机号(可选) */
|
||||
phone?: string;
|
||||
|
||||
/** 个人简介 */
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 组织架构模块
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 学校实体
|
||||
*/
|
||||
export interface School extends BaseEntity {
|
||||
/** 学校名称 */
|
||||
name: string;
|
||||
|
||||
/** 行政区划代码(省市区) */
|
||||
regionCode: string;
|
||||
|
||||
/** 详细地址 */
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级实体
|
||||
*/
|
||||
export interface Grade extends BaseEntity {
|
||||
/** 所属学校ID */
|
||||
schoolId: string;
|
||||
|
||||
/** 年级名称 */
|
||||
name: string;
|
||||
|
||||
/** 排序序号(用于前端显示顺序,如1=初一,2=初二) */
|
||||
sortOrder: number;
|
||||
|
||||
/** 入学年份(用于每年自动计算年级名称) */
|
||||
enrollmentYear: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级实体
|
||||
*/
|
||||
export interface Class extends BaseEntity {
|
||||
/** 所属年级ID */
|
||||
gradeId: string;
|
||||
|
||||
/** 班级名称 */
|
||||
name: string;
|
||||
|
||||
/** 邀请码(6位唯一字符) */
|
||||
inviteCode: string;
|
||||
|
||||
/** 班主任ID */
|
||||
headTeacherId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级成员实体
|
||||
*/
|
||||
export interface ClassMember extends BaseEntity {
|
||||
/** 关联班级ID */
|
||||
classId: string;
|
||||
|
||||
/** 关联用户ID */
|
||||
userId: string;
|
||||
|
||||
/** 班内角色:Student | Teacher | HeadTeacher */
|
||||
roleInClass: 'Student' | 'Teacher' | 'HeadTeacher';
|
||||
|
||||
/** 班级名片(用户在该班级显示的别名,如"张三-课代表") */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. 教材与知识图谱模块
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 学科实体
|
||||
*/
|
||||
export interface Subject extends BaseEntity {
|
||||
/** 学科名称 */
|
||||
name: string;
|
||||
|
||||
/** 学科代码(英文简写,如 MATH_JUNIOR) */
|
||||
code: string;
|
||||
|
||||
/** 图标(可选) */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 教材实体
|
||||
*/
|
||||
export interface Textbook extends BaseEntity {
|
||||
/** 所属学科ID */
|
||||
subjectId: string;
|
||||
|
||||
/** 教材名称 */
|
||||
name: string;
|
||||
|
||||
/** 封面图URL */
|
||||
coverUrl: string;
|
||||
|
||||
/** 出版社 */
|
||||
publisher: string;
|
||||
|
||||
/** 版本年份 */
|
||||
versionYear: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元实体
|
||||
*/
|
||||
export interface TextbookUnit extends BaseEntity {
|
||||
/** 所属教材ID */
|
||||
textbookId: string;
|
||||
|
||||
/** 单元名称 */
|
||||
name: string;
|
||||
|
||||
/** 排序序号 */
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 课/小节实体
|
||||
*/
|
||||
export interface TextbookLesson extends BaseEntity {
|
||||
/** 所属单元ID */
|
||||
unitId: string;
|
||||
|
||||
/** 课名称 */
|
||||
name: string;
|
||||
|
||||
/** 排序序号 */
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识点实体
|
||||
*/
|
||||
export interface KnowledgePoint extends BaseEntity {
|
||||
/** 挂载课节ID(说明这个知识点是在哪一课学的) */
|
||||
lessonId: string;
|
||||
|
||||
/** 父知识点ID(支持知识点的大点套小点结构,可选) */
|
||||
parentKnowledgePointId?: string;
|
||||
|
||||
/** 知识点名称 */
|
||||
name: string;
|
||||
|
||||
/** 难度系数(1-5星) */
|
||||
difficulty: number;
|
||||
|
||||
/** 描述/口诀 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. 题库资源模块
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 题目实体
|
||||
*/
|
||||
export interface Question extends BaseEntity {
|
||||
/** 所属学科ID */
|
||||
subjectId: string;
|
||||
|
||||
/** 题干内容(HTML格式,包含文字、公式和图片链接) */
|
||||
content: string;
|
||||
|
||||
/** 选项配置(JSON结构,存储选项内容) */
|
||||
optionsConfig?: string;
|
||||
|
||||
/** 题目类型:SingleChoice | MultipleChoice | TrueFalse | FillBlank | Subjective */
|
||||
questionType: 'SingleChoice' | 'MultipleChoice' | 'TrueFalse' | 'FillBlank' | 'Subjective';
|
||||
|
||||
/** 难度等级(1-5,默认为1) */
|
||||
difficulty: number;
|
||||
|
||||
/** 标准答案(文本或JSON格式) */
|
||||
answer: string;
|
||||
|
||||
/** 解析(HTML格式) */
|
||||
explanation?: string;
|
||||
|
||||
/** 来源(如"2023海淀期末") */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目-知识点关联实体
|
||||
*/
|
||||
export interface QuestionKnowledge extends BaseEntity {
|
||||
/** 关联题目ID */
|
||||
questionId: string;
|
||||
|
||||
/** 关联知识点ID */
|
||||
knowledgePointId: string;
|
||||
|
||||
/** 考察权重(0-100,表示该题目考察这个知识点的比重) */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. 试卷工程模块
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 试卷实体
|
||||
*/
|
||||
export interface Exam extends BaseEntity {
|
||||
/** 所属学科ID */
|
||||
subjectId: string;
|
||||
|
||||
/** 试卷标题 */
|
||||
title: string;
|
||||
|
||||
/** 总分 */
|
||||
totalScore: number;
|
||||
|
||||
/** 建议时长(分钟) */
|
||||
suggestedDuration: number;
|
||||
|
||||
/** 总题数 */
|
||||
totalQuestions: number;
|
||||
|
||||
/** 状态:Draft | Published */
|
||||
status: 'Draft' | 'Published';
|
||||
}
|
||||
|
||||
/**
|
||||
* 试卷结构节点实体(可嵌套)
|
||||
* 支持树形结构,既可以是分组节点,也可以是题目节点
|
||||
*/
|
||||
export interface ExamNode extends BaseEntity {
|
||||
/** 所属试卷ID */
|
||||
examId: string;
|
||||
|
||||
/** 父节点ID(用于分组,如"第一部分 选择题"是父节点) */
|
||||
parentNodeId?: string;
|
||||
|
||||
/** 节点类型:Group | Question */
|
||||
nodeType: 'Group' | 'Question';
|
||||
|
||||
/** 关联题目ID(如果是题目节点) */
|
||||
questionId?: string;
|
||||
|
||||
/** 设定分数(这道题在这张卷子里算多少分) */
|
||||
score: number;
|
||||
|
||||
/** 排序序号(题号或分组顺序) */
|
||||
sortOrder: number;
|
||||
|
||||
/** 描述(如果是分组节点,可以写"阅读理解"等说明) */
|
||||
description?: string;
|
||||
|
||||
/** 子节点(前端使用,后端查询时填充) */
|
||||
children?: ExamNode[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 6. 教学执行模块
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 作业发布实体
|
||||
*/
|
||||
export interface Assignment extends BaseEntity {
|
||||
/** 使用试卷ID */
|
||||
examId: string;
|
||||
|
||||
/** 发布人ID */
|
||||
publisherId: string;
|
||||
|
||||
/** 作业名称 */
|
||||
title: string;
|
||||
|
||||
/** 开始时间 */
|
||||
startTime: Date;
|
||||
|
||||
/** 截止时间 */
|
||||
endTime: Date;
|
||||
|
||||
/** 配置项(JSON格式,存储如"是否允许补交"、"考完是否立即出分"等开关) */
|
||||
configOptions?: string;
|
||||
|
||||
/** 应交人数 */
|
||||
expectedSubmissions: number;
|
||||
|
||||
/** 实交人数 */
|
||||
actualSubmissions: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生提交记录实体
|
||||
*/
|
||||
export interface StudentSubmission extends BaseEntity {
|
||||
/** 所属作业ID */
|
||||
assignmentId: string;
|
||||
|
||||
/** 学生ID */
|
||||
studentId: string;
|
||||
|
||||
/** 当前状态:Pending | InProgress | Submitted | Graded */
|
||||
status: 'Pending' | 'InProgress' | 'Submitted' | 'Graded';
|
||||
|
||||
/** 最终得分 */
|
||||
finalScore?: number;
|
||||
|
||||
/** 提交时间 */
|
||||
submitTime?: Date;
|
||||
|
||||
/** 耗时(秒) */
|
||||
timeSpent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 答题详情实体
|
||||
*/
|
||||
export interface SubmissionDetail extends BaseEntity {
|
||||
/** 所属提交ID */
|
||||
submissionId: string;
|
||||
|
||||
/** 对应试卷节点ID(说明这是哪一道题的答案) */
|
||||
examNodeId: string;
|
||||
|
||||
/** 学生答案(文本或图片URL) */
|
||||
studentAnswer?: string;
|
||||
|
||||
/** 批改数据(JSON格式,存储老师在Canvas画板上的红笔轨迹、圈阅痕迹) */
|
||||
gradingData?: string;
|
||||
|
||||
/** 本题得分 */
|
||||
score?: number;
|
||||
|
||||
/** 判题结果:Correct | Incorrect | Partial */
|
||||
judgement?: 'Correct' | 'Incorrect' | 'Partial';
|
||||
|
||||
/** 老师评语 */
|
||||
teacherComment?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 性别枚举
|
||||
*/
|
||||
export enum Gender {
|
||||
Male = 'Male',
|
||||
Female = 'Female',
|
||||
Unknown = 'Unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号状态枚举
|
||||
*/
|
||||
export enum AccountStatus {
|
||||
Active = 'Active',
|
||||
Disabled = 'Disabled'
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级角色枚举
|
||||
*/
|
||||
export enum ClassRole {
|
||||
Student = 'Student',
|
||||
Teacher = 'Teacher',
|
||||
HeadTeacher = 'HeadTeacher'
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目类型枚举
|
||||
*/
|
||||
export enum QuestionType {
|
||||
SingleChoice = 'SingleChoice',
|
||||
MultipleChoice = 'MultipleChoice',
|
||||
TrueFalse = 'TrueFalse',
|
||||
FillBlank = 'FillBlank',
|
||||
Subjective = 'Subjective'
|
||||
}
|
||||
|
||||
/**
|
||||
* 试卷状态枚举
|
||||
*/
|
||||
export enum ExamStatus {
|
||||
Draft = 'Draft',
|
||||
Published = 'Published'
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点类型枚举
|
||||
*/
|
||||
export enum NodeType {
|
||||
Group = 'Group',
|
||||
Question = 'Question'
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交状态枚举
|
||||
*/
|
||||
export enum SubmissionStatus {
|
||||
Pending = 'Pending',
|
||||
InProgress = 'InProgress',
|
||||
Submitted = 'Submitted',
|
||||
Graded = 'Graded'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题结果枚举
|
||||
*/
|
||||
export enum JudgementResult {
|
||||
Correct = 'Correct',
|
||||
Incorrect = 'Incorrect',
|
||||
Partial = 'Partial'
|
||||
}
|
||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1obzkEmO9bK14pIdAXFBkehtfLw9nWFoS
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
568
UI_DTO.ts
Normal file
568
UI_DTO.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* UI_DTO.ts
|
||||
* 前端数据传输对象(DTO)定义文件
|
||||
* 包含所有前端与后端交互的接口定义
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 0. Common / 通用
|
||||
// ============================================================
|
||||
|
||||
export interface ResultDto {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. Auth & User / 认证与用户
|
||||
// ============================================================
|
||||
|
||||
export interface UserProfileDto {
|
||||
id: string;
|
||||
realName: string;
|
||||
studentId: string;
|
||||
avatarUrl: string;
|
||||
gender: string;
|
||||
schoolId: string;
|
||||
role: 'Admin' | 'Teacher' | 'Student';
|
||||
email?: string;
|
||||
phone?: string;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
export interface RegisterDto {
|
||||
realName: string;
|
||||
studentId: string; // 学号/工号
|
||||
password: string;
|
||||
role: 'Teacher' | 'Student';
|
||||
}
|
||||
|
||||
export interface UpdateProfileDto {
|
||||
realName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordDto {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface LoginResultDto {
|
||||
token: string;
|
||||
user: UserProfileDto;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. Org / 组织架构
|
||||
// ============================================================
|
||||
|
||||
export interface SchoolDto {
|
||||
id: string;
|
||||
name: string;
|
||||
regionCode: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级DTO
|
||||
*/
|
||||
export interface GradeDto {
|
||||
id: string;
|
||||
schoolId: string; // 所属学校ID
|
||||
name: string; // 年级名称(如"2024级"或"初一")
|
||||
sortOrder: number; // 排序序号(1=初一,2=初二)
|
||||
enrollmentYear: number; // 入学年份
|
||||
}
|
||||
|
||||
|
||||
export interface ClassDto {
|
||||
id: string;
|
||||
name: string;
|
||||
inviteCode: string;
|
||||
gradeName: string;
|
||||
teacherName: string;
|
||||
studentCount: number;
|
||||
}
|
||||
|
||||
export interface CreateClassDto {
|
||||
name: string;
|
||||
gradeName: string;
|
||||
}
|
||||
|
||||
export interface ClassMemberDto {
|
||||
id: string;
|
||||
studentId: string;
|
||||
realName: string;
|
||||
avatarUrl: string;
|
||||
gender: 'Male' | 'Female';
|
||||
role: 'Student' | 'Monitor' | 'Committee'; // 班长/委员等
|
||||
recentTrend: number[]; // Last 5 scores/performances
|
||||
status: 'Active' | 'AtRisk' | 'Excellent';
|
||||
attendanceRate: number;
|
||||
}
|
||||
|
||||
export interface SchoolStructureDto {
|
||||
school: SchoolDto;
|
||||
grades: GradeNodeDto[];
|
||||
}
|
||||
|
||||
export interface GradeNodeDto {
|
||||
id: string;
|
||||
name: string;
|
||||
classes: ClassDto[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. Curriculum / 课程体系
|
||||
// ============================================================
|
||||
|
||||
export interface SubjectDto {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface TextbookDto {
|
||||
id: string;
|
||||
name: string;
|
||||
publisher: string;
|
||||
versionYear: string;
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 教材单元DTO
|
||||
*/
|
||||
export interface TextbookUnitDto {
|
||||
id: string;
|
||||
textbookId: string; // 所属教材ID
|
||||
name: string; // 单元名称(如"第十一章 三角形")
|
||||
sortOrder: number; // 排序序号
|
||||
}
|
||||
|
||||
/**
|
||||
* 课/小节DTO
|
||||
*/
|
||||
export interface TextbookLessonDto {
|
||||
id: string;
|
||||
unitId: string; // 所属单元ID
|
||||
name: string; // 课名称(如"11.1 全等三角形")
|
||||
sortOrder: number; // 排序序号
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识点DTO(可嵌套)
|
||||
*/
|
||||
export interface KnowledgePointDto {
|
||||
id: string;
|
||||
lessonId: string; // 挂载课节ID
|
||||
parentKnowledgePointId?: string; // 父知识点ID(支持大点套小点)
|
||||
name: string; // 知识点名称
|
||||
difficulty: number; // 难度系数(1-5星)
|
||||
description?: string; // 描述/口诀
|
||||
children?: KnowledgePointDto[]; // 子知识点
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程树DTO(用于前端展示完整层级)
|
||||
*/
|
||||
export interface CurriculumTreeDto {
|
||||
textbook: TextbookDto;
|
||||
units: (TextbookUnitDto & {
|
||||
lessons: (TextbookLessonDto & {
|
||||
knowledgePoints: KnowledgePointDto[];
|
||||
})[];
|
||||
})[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 旧的通用节点定义,保留用于向后兼容
|
||||
*/
|
||||
export interface UnitNodeDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'unit' | 'lesson' | 'point';
|
||||
children?: UnitNodeDto[];
|
||||
difficulty?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. Question / 题库
|
||||
// ============================================================
|
||||
|
||||
export interface QuestionSummaryDto {
|
||||
id: string;
|
||||
content: string; // HTML
|
||||
type: string;
|
||||
difficulty: number;
|
||||
knowledgePoints: string[];
|
||||
}
|
||||
|
||||
export interface ParsedQuestionDto {
|
||||
content: string;
|
||||
type: string;
|
||||
options?: string[];
|
||||
answer?: string;
|
||||
parse?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFilterDto {
|
||||
subjectId?: string;
|
||||
type?: number;
|
||||
difficulty?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目-知识点关联DTO
|
||||
*/
|
||||
export interface QuestionKnowledgeDto {
|
||||
id: string;
|
||||
questionId: string; // 关联题目ID
|
||||
knowledgePointId: string; // 关联知识点ID
|
||||
weight: number; // 考察权重(0-100)
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 5. Exam / 考试
|
||||
// ============================================================
|
||||
|
||||
export interface ExamDto {
|
||||
id: string;
|
||||
subjectId: string; // 所属学科
|
||||
title: string;
|
||||
totalScore: number;
|
||||
duration: number; // 建议时长(分钟)
|
||||
questionCount: number; // 总题数
|
||||
status: 'Draft' | 'Published';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 试卷节点DTO(可嵌套)
|
||||
* 支持树形结构,既可以是分组节点(Group),也可以是题目节点(Question)
|
||||
*/
|
||||
export interface ExamNodeDto {
|
||||
id: string; // 节点ID
|
||||
nodeType: 'Group' | 'Question'; // 节点类型
|
||||
|
||||
// === 如果是Group节点 ===
|
||||
title?: string; // 分组标题(如"第一部分 选择题")
|
||||
description?: string; // 分组说明
|
||||
|
||||
// === 如果是Question节点 ===
|
||||
questionId?: string; // 关联的题目ID
|
||||
questionContent?: string; // 题干内容(冗余字段,方便显示)
|
||||
questionType?: string; // 题目类型
|
||||
|
||||
// === 通用字段 ===
|
||||
score: number; // 本节点分数(Group为子节点分数总和,Question为本题分数)
|
||||
sortOrder: number; // 排序序号
|
||||
|
||||
// === 递归子节点 ===
|
||||
children?: ExamNodeDto[]; // 子节点(支持无限嵌套)
|
||||
}
|
||||
|
||||
/**
|
||||
* 试卷详情DTO
|
||||
* 使用树形结构代替固定的sections二层结构
|
||||
*/
|
||||
export interface ExamDetailDto extends ExamDto {
|
||||
rootNodes: ExamNodeDto[]; // 根节点列表(树形结构)
|
||||
}
|
||||
|
||||
export interface WrongQuestionAnalysisDto {
|
||||
id: string;
|
||||
content: string;
|
||||
errorRate: number; // 0-100
|
||||
difficulty: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ExamStatsDto {
|
||||
averageScore: number;
|
||||
passRate: number;
|
||||
maxScore: number;
|
||||
minScore: number;
|
||||
scoreDistribution: { range: string; count: number }[];
|
||||
wrongQuestions: WrongQuestionAnalysisDto[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 6. Assignment / 作业
|
||||
// ============================================================
|
||||
|
||||
export interface AssignmentTeacherViewDto {
|
||||
id: string;
|
||||
title: string;
|
||||
className: string;
|
||||
submittedCount: number;
|
||||
totalCount: number;
|
||||
status: 'Active' | 'Ended' | 'Scheduled';
|
||||
dueDate: string;
|
||||
examTitle: string;
|
||||
}
|
||||
|
||||
export interface AssignmentStudentViewDto {
|
||||
id: string;
|
||||
title: string;
|
||||
examTitle: string;
|
||||
endTime: string;
|
||||
status: 'Pending' | 'Graded' | 'Submitted';
|
||||
score?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 7. Submission / Student Exam / 提交与学生答题
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 学生答题卷DTO
|
||||
* 使用ExamNode树形结构
|
||||
*/
|
||||
export interface StudentExamPaperDto {
|
||||
examId: string;
|
||||
title: string;
|
||||
duration: number; // 分钟
|
||||
totalScore: number;
|
||||
rootNodes: ExamNodeDto[]; // 使用树形结构
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交答案DTO
|
||||
*/
|
||||
export interface SubmitExamDto {
|
||||
assignmentId: string;
|
||||
answers: Record<string, any>; // key: examNodeId, value: 学生答案
|
||||
timeSpent?: number; // 耗时(秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 答题详情DTO
|
||||
* 记录学生对每个题目的作答和批改情况
|
||||
*/
|
||||
export interface SubmissionDetailDto {
|
||||
id: string;
|
||||
submissionId: string; // 所属提交ID
|
||||
examNodeId: string; // 对应试卷节点ID
|
||||
studentAnswer?: string; // 学生答案(文本或图片URL)
|
||||
gradingData?: string; // 批改数据(JSON格式,Canvas画板数据)
|
||||
score?: number; // 本题得分
|
||||
judgement?: 'Correct' | 'Incorrect' | 'Partial'; // 判题结果
|
||||
teacherComment?: string; // 老师评语
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 8. Grading & Results / 批阅与结果
|
||||
// ============================================================
|
||||
|
||||
export interface StudentSubmissionSummaryDto {
|
||||
id: string; // submissionId
|
||||
studentName: string;
|
||||
studentId: string;
|
||||
avatarUrl: string;
|
||||
status: 'Submitted' | 'Graded' | 'Late';
|
||||
score?: number;
|
||||
submitTime: string;
|
||||
}
|
||||
|
||||
export interface GradingPaperDto {
|
||||
submissionId: string;
|
||||
studentName: string;
|
||||
nodes: GradingNodeDto[];
|
||||
}
|
||||
|
||||
export interface GradingNodeDto {
|
||||
examNodeId: string;
|
||||
questionId: string;
|
||||
questionContent: string;
|
||||
questionType: string;
|
||||
score: number; // max score
|
||||
studentScore?: number; // current score
|
||||
studentAnswer?: string; // Text or Image URL
|
||||
teacherAnnotation?: string; // JSON for canvas
|
||||
autoCheckResult?: boolean;
|
||||
}
|
||||
|
||||
export interface StudentResultDto extends GradingPaperDto {
|
||||
totalScore: number;
|
||||
rank: number;
|
||||
beatRate: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 9. Analytics / 分析统计
|
||||
// ============================================================
|
||||
|
||||
export interface ChartDataDto {
|
||||
labels: string[];
|
||||
datasets: {
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
fill?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface RadarChartDto {
|
||||
indicators: string[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export interface ScoreDistributionDto {
|
||||
range: string; // e.g. "90-100"
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 10. Common / Dashboard / 通用/仪表板
|
||||
// ============================================================
|
||||
|
||||
export interface ScheduleDto {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
className: string;
|
||||
subject: string;
|
||||
room: string;
|
||||
isToday: boolean;
|
||||
dayOfWeek?: number; // 1 = Monday, 7 = Sunday
|
||||
period?: number; // 1-8
|
||||
}
|
||||
|
||||
export interface CreateScheduleDto {
|
||||
subject: string;
|
||||
className: string;
|
||||
room: string;
|
||||
dayOfWeek: number;
|
||||
period: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 11. Messages / 消息
|
||||
// ============================================================
|
||||
|
||||
export interface MessageDto {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'Announcement' | 'Notification' | 'Alert';
|
||||
senderName: string;
|
||||
senderAvatar?: string;
|
||||
createdAt: string;
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
export interface CreateMessageDto {
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'Announcement' | 'Notification';
|
||||
targetClassIds?: string[]; // Optional: if empty, broadcast to all managed classes
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 12. Services Interfaces / 服务接口定义
|
||||
// ============================================================
|
||||
|
||||
export interface IAuthService {
|
||||
login(username: string): Promise<LoginResultDto>;
|
||||
register(data: RegisterDto): Promise<LoginResultDto>;
|
||||
me(): Promise<UserProfileDto>;
|
||||
updateProfile(data: UpdateProfileDto): Promise<UserProfileDto>;
|
||||
changePassword(data: ChangePasswordDto): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IOrgService {
|
||||
getClasses(role?: string): Promise<ClassDto[]>;
|
||||
getClassMembers(classId: string): Promise<ClassMemberDto[]>;
|
||||
joinClass(inviteCode: string): Promise<void>;
|
||||
createClass(data: CreateClassDto): Promise<ClassDto>;
|
||||
}
|
||||
|
||||
export interface ICurriculumService {
|
||||
getSubjects(): Promise<SubjectDto[]>;
|
||||
getTree(id: string): Promise<CurriculumTreeDto>;
|
||||
getUnits(textbookId: string): Promise<TextbookUnitDto[]>;
|
||||
getLessons(unitId: string): Promise<TextbookLessonDto[]>;
|
||||
getKnowledgePoints(lessonId: string): Promise<KnowledgePointDto[]>;
|
||||
}
|
||||
|
||||
export interface IQuestionService {
|
||||
search(filter: any): Promise<PagedResult<QuestionSummaryDto & { answer?: string, parse?: string }>>;
|
||||
parseText(rawText: string): Promise<ParsedQuestionDto[]>;
|
||||
getQuestionKnowledges(questionId: string): Promise<QuestionKnowledgeDto[]>;
|
||||
}
|
||||
|
||||
export interface IExamService {
|
||||
getMyExams(): Promise<PagedResult<ExamDto>>;
|
||||
getExamDetail(id: string): Promise<ExamDetailDto>;
|
||||
saveExam(exam: ExamDetailDto): Promise<void>;
|
||||
getStats(id: string): Promise<ExamStatsDto>;
|
||||
}
|
||||
|
||||
export interface IAssignmentService {
|
||||
getTeachingAssignments(): Promise<PagedResult<AssignmentTeacherViewDto>>;
|
||||
getStudentAssignments(): Promise<PagedResult<AssignmentStudentViewDto>>;
|
||||
publishAssignment(data: any): Promise<void>;
|
||||
getAssignmentStats(id: string): Promise<ExamStatsDto>;
|
||||
}
|
||||
|
||||
export interface IAnalyticsService {
|
||||
getClassPerformance(): Promise<ChartDataDto>;
|
||||
getStudentGrowth(): Promise<ChartDataDto>;
|
||||
getRadar(): Promise<RadarChartDto>;
|
||||
getStudentRadar(): Promise<RadarChartDto>;
|
||||
getScoreDistribution(): Promise<ScoreDistributionDto[]>;
|
||||
}
|
||||
|
||||
export interface IGradingService {
|
||||
getSubmissions(assignmentId: string): Promise<StudentSubmissionSummaryDto[]>;
|
||||
getPaper(submissionId: string): Promise<GradingPaperDto>;
|
||||
saveGrading(submissionId: string, details: SubmissionDetailDto[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ISubmissionService {
|
||||
getStudentPaper(assignmentId: string): Promise<StudentExamPaperDto>;
|
||||
submitExam(data: SubmitExamDto): Promise<void>;
|
||||
getSubmissionResult(assignmentId: string): Promise<StudentResultDto>;
|
||||
getSubmissionDetails(submissionId: string): Promise<SubmissionDetailDto[]>;
|
||||
}
|
||||
|
||||
|
||||
export interface ICommonService {
|
||||
getSchedule(): Promise<ScheduleDto[]>;
|
||||
}
|
||||
|
||||
export interface IMessageService {
|
||||
getMessages(): Promise<MessageDto[]>;
|
||||
markAsRead(id: string): Promise<void>;
|
||||
createMessage(data: CreateMessageDto): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IScheduleService {
|
||||
getWeekSchedule(): Promise<ScheduleDto[]>;
|
||||
addEvent(data: CreateScheduleDto): Promise<void>;
|
||||
deleteEvent(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 13. UI Types / UI类型定义
|
||||
// ============================================================
|
||||
|
||||
export type ViewState = 'login' | 'dashboard' | 'curriculum' | 'questions' | 'classes' | 'exams' | 'assignments' | 'settings' | 'grading' | 'student-exam' | 'student-result' | 'messages' | 'schedule';
|
||||
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"
|
||||
]
|
||||
}
|
||||
62
database/README.md
Normal file
62
database/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 数据库脚本使用说明
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `schema.sql` - 完整建表语句(11张表)
|
||||
- `seed.sql` - 初始化示例数据
|
||||
- `drop.sql` - 清理所有表(危险!仅开发用)
|
||||
|
||||
## 使用步骤
|
||||
|
||||
### 1. 创建数据库
|
||||
|
||||
```sql
|
||||
CREATE DATABASE edunexus CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE edunexus;
|
||||
```
|
||||
|
||||
### 2. 执行建表脚本
|
||||
|
||||
```bash
|
||||
mysql -u root -p edunexus < schema.sql
|
||||
```
|
||||
|
||||
或在MySQL客户端中:
|
||||
```sql
|
||||
SOURCE schema.sql;
|
||||
```
|
||||
|
||||
### 3. 插入示例数据(可选)
|
||||
|
||||
```bash
|
||||
mysql -u root -p edunexus < seed.sql
|
||||
```
|
||||
|
||||
### 4. 清理数据(仅开发)
|
||||
|
||||
```bash
|
||||
mysql -u root -p edunexus < drop.sql
|
||||
```
|
||||
|
||||
## 表结构概览
|
||||
|
||||
| 模块 | 表名 | 说明 |
|
||||
|------|------|------|
|
||||
| 身份 | application_users | 用户账号 |
|
||||
| 组织 | schools, grades, classes, class_members | 学校组织架构 |
|
||||
| 教材 | subjects, textbooks, textbook_units, textbook_lessons, knowledge_points | 教材知识体系 |
|
||||
| 题库 | questions, question_knowledge | 题目库和知识点关联 |
|
||||
| 试卷 | exams, exam_nodes | 试卷和题目节点(树形) |
|
||||
| 作业 | assignments, student_submissions, submission_details | 作业发布和提交 |
|
||||
|
||||
## 重要说明
|
||||
|
||||
- 所有表都包含审计字段(created_at, created_by等)
|
||||
- 使用UUID作为主键(VARCHAR(36))
|
||||
- 支持软删除(is_deleted字段)
|
||||
- exam_nodes表支持无限层级嵌套
|
||||
- 密码使用bcrypt hash存储
|
||||
|
||||
## 下一步
|
||||
|
||||
数据库创建完成后,请使用后端API服务连接数据库。
|
||||
28
database/drop.sql
Normal file
28
database/drop.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- =============================================
|
||||
-- EduNexus Pro - 清理脚本
|
||||
-- 危险操作!仅用于开发环境
|
||||
-- =============================================
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS `submission_details`;
|
||||
DROP TABLE IF EXISTS `student_submissions`;
|
||||
DROP TABLE IF EXISTS `assignments`;
|
||||
DROP TABLE IF EXISTS `exam_nodes`;
|
||||
DROP TABLE IF EXISTS `exams`;
|
||||
DROP TABLE IF EXISTS `question_knowledge`;
|
||||
DROP TABLE IF EXISTS `questions`;
|
||||
DROP TABLE IF EXISTS `knowledge_points`;
|
||||
DROP TABLE IF EXISTS `textbook_lessons`;
|
||||
DROP TABLE IF EXISTS `textbook_units`;
|
||||
DROP TABLE IF EXISTS `textbooks`;
|
||||
DROP TABLE IF EXISTS `subjects`;
|
||||
DROP TABLE IF EXISTS `class_members`;
|
||||
DROP TABLE IF EXISTS `classes`;
|
||||
DROP TABLE IF EXISTS `grades`;
|
||||
DROP TABLE IF EXISTS `schools`;
|
||||
DROP TABLE IF EXISTS `application_users`;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
SELECT '所有表已删除' AS Status;
|
||||
420
database/schema.sql
Normal file
420
database/schema.sql
Normal file
@@ -0,0 +1,420 @@
|
||||
-- =============================================
|
||||
-- EduNexus Pro - MySQL数据库建表脚本
|
||||
-- 基于Model.ts生成
|
||||
-- 版本: 1.0
|
||||
-- 日期: 2025-11-25
|
||||
-- =============================================
|
||||
|
||||
-- 设置字符集和时区
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
SET time_zone = '+08:00';
|
||||
|
||||
-- =============================================
|
||||
-- 1. 身份与权限模块
|
||||
-- =============================================
|
||||
|
||||
-- 用户表
|
||||
DROP TABLE IF EXISTS `application_users`;
|
||||
CREATE TABLE `application_users` (
|
||||
`id` VARCHAR(36) PRIMARY KEY COMMENT '用户ID (UUID)',
|
||||
`real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名',
|
||||
`student_id` VARCHAR(20) NULL COMMENT '学号(学生)',
|
||||
`avatar_url` VARCHAR(500) NULL COMMENT '头像URL',
|
||||
`gender` ENUM('Male', 'Female') NOT NULL DEFAULT 'Male' COMMENT '性别',
|
||||
`current_school_id` VARCHAR(36) NULL COMMENT '当前所属学校ID',
|
||||
`account_status` ENUM('Active', 'Suspended', 'Graduated') NOT NULL DEFAULT 'Active' COMMENT '账号状态',
|
||||
`email` VARCHAR(100) NULL COMMENT '邮箱',
|
||||
`phone` VARCHAR(20) NULL COMMENT '手机号',
|
||||
`bio` TEXT NULL COMMENT '个人简介',
|
||||
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码Hash',
|
||||
|
||||
-- 审计字段
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`created_by` VARCHAR(36) NOT NULL COMMENT '创建人ID',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`updated_by` VARCHAR(36) NOT NULL COMMENT '更新人ID',
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '软删除标记',
|
||||
|
||||
INDEX `idx_student_id` (`student_id`),
|
||||
INDEX `idx_email` (`email`),
|
||||
INDEX `idx_phone` (`phone`),
|
||||
INDEX `idx_school` (`current_school_id`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
|
||||
-- =============================================
|
||||
-- 2. 组织架构模块
|
||||
-- =============================================
|
||||
|
||||
-- 学校表
|
||||
DROP TABLE IF EXISTS `schools`;
|
||||
CREATE TABLE `schools` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '学校名称',
|
||||
`region_code` VARCHAR(20) NOT NULL COMMENT '地区编码',
|
||||
`address` VARCHAR(200) NULL COMMENT '学校地址',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
INDEX `idx_region` (`region_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学校表';
|
||||
|
||||
-- 年级表
|
||||
DROP TABLE IF EXISTS `grades`;
|
||||
CREATE TABLE `grades` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`school_id` VARCHAR(36) NOT NULL COMMENT '所属学校ID',
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '年级名称',
|
||||
`sort_order` INT NOT NULL COMMENT '排序序号',
|
||||
`enrollment_year` INT NOT NULL COMMENT '入学年份',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE RESTRICT,
|
||||
INDEX `idx_school_id` (`school_id`),
|
||||
INDEX `idx_enrollment_year` (`enrollment_year`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='年级表';
|
||||
|
||||
-- 班级表
|
||||
DROP TABLE IF EXISTS `classes`;
|
||||
CREATE TABLE `classes` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`grade_id` VARCHAR(36) NOT NULL COMMENT '所属年级ID',
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '班级名称',
|
||||
`invite_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '邀请码',
|
||||
`head_teacher_id` VARCHAR(36) NULL COMMENT '班主任ID',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (`head_teacher_id`) REFERENCES `application_users`(`id`) ON DELETE SET NULL,
|
||||
UNIQUE INDEX `idx_invite_code` (`invite_code`),
|
||||
INDEX `idx_grade_id` (`grade_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='班级表';
|
||||
|
||||
-- 班级成员表
|
||||
DROP TABLE IF EXISTS `class_members`;
|
||||
CREATE TABLE `class_members` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`class_id` VARCHAR(36) NOT NULL COMMENT '班级ID',
|
||||
`user_id` VARCHAR(36) NOT NULL COMMENT '用户ID',
|
||||
`role_in_class` ENUM('Student', 'Monitor', 'Committee', 'Teacher') NOT NULL DEFAULT 'Student' COMMENT '班级角色',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `application_users`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE INDEX `idx_class_user` (`class_id`, `user_id`),
|
||||
INDEX `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='班级成员表';
|
||||
|
||||
-- =============================================
|
||||
-- 3. 教材与知识图谱模块
|
||||
-- =============================================
|
||||
|
||||
-- 学科表
|
||||
DROP TABLE IF EXISTS `subjects`;
|
||||
CREATE TABLE `subjects` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '学科名称',
|
||||
`code` VARCHAR(20) NOT NULL UNIQUE COMMENT '学科代码',
|
||||
`icon` VARCHAR(50) NULL COMMENT '图标',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
INDEX `idx_code` (`code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学科表';
|
||||
|
||||
-- 教材表
|
||||
DROP TABLE IF EXISTS `textbooks`;
|
||||
CREATE TABLE `textbooks` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`subject_id` VARCHAR(36) NOT NULL COMMENT '所属学科ID',
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '教材名称',
|
||||
`publisher` VARCHAR(100) NOT NULL COMMENT '出版社',
|
||||
`version_year` VARCHAR(20) NOT NULL COMMENT '版本年份',
|
||||
`cover_url` VARCHAR(500) NULL COMMENT '封面URL',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE RESTRICT,
|
||||
INDEX `idx_subject_id` (`subject_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教材表';
|
||||
|
||||
-- 教材单元表
|
||||
DROP TABLE IF EXISTS `textbook_units`;
|
||||
CREATE TABLE `textbook_units` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`textbook_id` VARCHAR(36) NOT NULL COMMENT '所属教材ID',
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '单元名称',
|
||||
`sort_order` INT NOT NULL COMMENT '排序序号',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`textbook_id`) REFERENCES `textbooks`(`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_textbook_id` (`textbook_id`),
|
||||
INDEX `idx_sort_order` (`sort_order`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教材单元表';
|
||||
|
||||
-- 教材课程表
|
||||
DROP TABLE IF EXISTS `textbook_lessons`;
|
||||
CREATE TABLE `textbook_lessons` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`unit_id` VARCHAR(36) NOT NULL COMMENT '所属单元ID',
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '课名称',
|
||||
`sort_order` INT NOT NULL COMMENT '排序序号',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`unit_id`) REFERENCES `textbook_units`(`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_unit_id` (`unit_id`),
|
||||
INDEX `idx_sort_order` (`sort_order`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教材课程表';
|
||||
|
||||
-- 知识点表
|
||||
DROP TABLE IF EXISTS `knowledge_points`;
|
||||
CREATE TABLE `knowledge_points` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`lesson_id` VARCHAR(36) NOT NULL COMMENT '挂载课节ID',
|
||||
`parent_knowledge_point_id` VARCHAR(36) NULL COMMENT '父知识点ID',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '知识点名称',
|
||||
`difficulty` INT NOT NULL COMMENT '难度系数(1-5)',
|
||||
`description` TEXT NULL COMMENT '描述/口诀',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`lesson_id`) REFERENCES `textbook_lessons`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`parent_knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_lesson_id` (`lesson_id`),
|
||||
INDEX `idx_parent_id` (`parent_knowledge_point_id`),
|
||||
INDEX `idx_difficulty` (`difficulty`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表';
|
||||
|
||||
-- =============================================
|
||||
-- 4. 题库资源模块
|
||||
-- =============================================
|
||||
|
||||
-- 题目表
|
||||
DROP TABLE IF EXISTS `questions`;
|
||||
CREATE TABLE `questions` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`subject_id` VARCHAR(36) NOT NULL COMMENT '所属学科ID',
|
||||
`content` TEXT NOT NULL COMMENT '题干内容(HTML)',
|
||||
`options_config` JSON NULL COMMENT '选项配置(JSON)',
|
||||
`question_type` ENUM('SingleChoice', 'MultipleChoice', 'TrueFalse', 'FillBlank', 'Subjective') NOT NULL COMMENT '题目类型',
|
||||
`answer` TEXT NOT NULL COMMENT '参考答案',
|
||||
`explanation` TEXT NULL COMMENT '答案解析',
|
||||
`difficulty` INT NOT NULL DEFAULT 3 COMMENT '难度(1-5)',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE RESTRICT,
|
||||
INDEX `idx_subject_id` (`subject_id`),
|
||||
INDEX `idx_question_type` (`question_type`),
|
||||
INDEX `idx_difficulty` (`difficulty`),
|
||||
FULLTEXT INDEX `ft_content` (`content`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表';
|
||||
|
||||
-- 题目-知识点关联表
|
||||
DROP TABLE IF EXISTS `question_knowledge`;
|
||||
CREATE TABLE `question_knowledge` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`question_id` VARCHAR(36) NOT NULL COMMENT '题目ID',
|
||||
`knowledge_point_id` VARCHAR(36) NOT NULL COMMENT '知识点ID',
|
||||
`weight` INT NOT NULL DEFAULT 100 COMMENT '考察权重(0-100)',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE INDEX `idx_question_knowledge` (`question_id`, `knowledge_point_id`),
|
||||
INDEX `idx_knowledge_id` (`knowledge_point_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目知识点关联表';
|
||||
|
||||
-- =============================================
|
||||
-- 5. 试卷工程模块
|
||||
-- =============================================
|
||||
|
||||
-- 试卷表
|
||||
DROP TABLE IF EXISTS `exams`;
|
||||
CREATE TABLE `exams` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`subject_id` VARCHAR(36) NOT NULL COMMENT '所属学科ID',
|
||||
`title` VARCHAR(200) NOT NULL COMMENT '试卷标题',
|
||||
`total_score` DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT '总分',
|
||||
`suggested_duration` INT NOT NULL COMMENT '建议时长(分钟)',
|
||||
`status` ENUM('Draft', 'Published') NOT NULL DEFAULT 'Draft' COMMENT '状态',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE RESTRICT,
|
||||
INDEX `idx_subject_id` (`subject_id`),
|
||||
INDEX `idx_status` (`status`),
|
||||
INDEX `idx_created_by` (`created_by`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷表';
|
||||
|
||||
-- 试卷节点表(树形结构)
|
||||
DROP TABLE IF EXISTS `exam_nodes`;
|
||||
CREATE TABLE `exam_nodes` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`exam_id` VARCHAR(36) NOT NULL COMMENT '所属试卷ID',
|
||||
`parent_node_id` VARCHAR(36) NULL COMMENT '父节点ID',
|
||||
`node_type` ENUM('Group', 'Question') NOT NULL COMMENT '节点类型',
|
||||
`question_id` VARCHAR(36) NULL COMMENT '题目ID(Question节点)',
|
||||
`title` VARCHAR(200) NULL COMMENT '标题(Group节点)',
|
||||
`description` TEXT NULL COMMENT '描述(Group节点)',
|
||||
`score` DECIMAL(5,1) NOT NULL COMMENT '分数',
|
||||
`sort_order` INT NOT NULL COMMENT '排序序号',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`parent_node_id`) REFERENCES `exam_nodes`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE RESTRICT,
|
||||
INDEX `idx_exam_id` (`exam_id`),
|
||||
INDEX `idx_parent_node_id` (`parent_node_id`),
|
||||
INDEX `idx_sort_order` (`sort_order`),
|
||||
INDEX `idx_question_id` (`question_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷节点表';
|
||||
|
||||
-- =============================================
|
||||
-- 6. 教学执行模块
|
||||
-- =============================================
|
||||
|
||||
-- 作业表
|
||||
DROP TABLE IF EXISTS `assignments`;
|
||||
CREATE TABLE `assignments` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`exam_id` VARCHAR(36) NOT NULL COMMENT '关联试卷ID',
|
||||
`class_id` VARCHAR(36) NOT NULL COMMENT '目标班级ID',
|
||||
`title` VARCHAR(200) NOT NULL COMMENT '作业标题',
|
||||
`start_time` DATETIME NOT NULL COMMENT '开始时间',
|
||||
`end_time` DATETIME NOT NULL COMMENT '截止时间',
|
||||
`allow_late_submission` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '允许迟交',
|
||||
`auto_score_enabled` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '自动判分',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE RESTRICT,
|
||||
INDEX `idx_exam_id` (`exam_id`),
|
||||
INDEX `idx_class_id` (`class_id`),
|
||||
INDEX `idx_start_time` (`start_time`),
|
||||
INDEX `idx_end_time` (`end_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作业表';
|
||||
|
||||
-- 学生提交表
|
||||
DROP TABLE IF EXISTS `student_submissions`;
|
||||
CREATE TABLE `student_submissions` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`assignment_id` VARCHAR(36) NOT NULL COMMENT '作业ID',
|
||||
`student_id` VARCHAR(36) NOT NULL COMMENT '学生ID',
|
||||
`submission_status` ENUM('Pending', 'Submitted', 'Grading', 'Graded') NOT NULL DEFAULT 'Pending' COMMENT '提交状态',
|
||||
`submit_time` DATETIME NULL COMMENT '提交时间',
|
||||
`time_spent_seconds` INT NULL COMMENT '耗时(秒)',
|
||||
`total_score` DECIMAL(5,1) NULL COMMENT '总得分',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`assignment_id`) REFERENCES `assignments`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`student_id`) REFERENCES `application_users`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE INDEX `idx_assignment_student` (`assignment_id`, `student_id`),
|
||||
INDEX `idx_student_id` (`student_id`),
|
||||
INDEX `idx_submit_time` (`submit_time`),
|
||||
INDEX `idx_status` (`submission_status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生提交表';
|
||||
|
||||
-- 答题详情表
|
||||
DROP TABLE IF EXISTS `submission_details`;
|
||||
CREATE TABLE `submission_details` (
|
||||
`id` VARCHAR(36) PRIMARY KEY,
|
||||
`submission_id` VARCHAR(36) NOT NULL COMMENT '提交ID',
|
||||
`exam_node_id` VARCHAR(36) NOT NULL COMMENT '试卷节点ID',
|
||||
`student_answer` TEXT NULL COMMENT '学生答案',
|
||||
`grading_data` JSON NULL COMMENT '批改数据(Canvas JSON)',
|
||||
`score` DECIMAL(5,1) NULL COMMENT '得分',
|
||||
`judgement` ENUM('Correct', 'Incorrect', 'Partial') NULL COMMENT '判题结果',
|
||||
`teacher_comment` TEXT NULL COMMENT '老师评语',
|
||||
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`created_by` VARCHAR(36) NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(36) NOT NULL,
|
||||
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
FOREIGN KEY (`submission_id`) REFERENCES `student_submissions`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`exam_node_id`) REFERENCES `exam_nodes`(`id`) ON DELETE RESTRICT,
|
||||
UNIQUE INDEX `idx_submission_node` (`submission_id`, `exam_node_id`),
|
||||
INDEX `idx_node_id` (`exam_node_id`),
|
||||
INDEX `idx_judgement` (`judgement`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='答题详情表';
|
||||
|
||||
-- =============================================
|
||||
-- 完成
|
||||
-- =============================================
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 显示创建的所有表
|
||||
SHOW TABLES;
|
||||
121
database/seed.sql
Normal file
121
database/seed.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
-- =============================================
|
||||
-- EduNexus Pro - 初始化数据脚本
|
||||
-- 用于开发和测试
|
||||
-- =============================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- =============================================
|
||||
-- 1. 插入示例学科
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `subjects` (`id`, `name`, `code`, `icon`, `created_by`, `updated_by`) VALUES
|
||||
('sub-math', '数学', 'MATH', '📐', 'system', 'system'),
|
||||
('sub-chinese', '语文', 'CHINESE', '📖', 'system', 'system'),
|
||||
('sub-english', '英语', 'ENGLISH', '🌍', 'system', 'system'),
|
||||
('sub-physics', '物理', 'PHYSICS', '⚛️', 'system', 'system'),
|
||||
('sub-chemistry', '化学', 'CHEMISTRY', '🧪', 'system', 'system');
|
||||
|
||||
-- =============================================
|
||||
-- 2. 插入示例学校和组织结构
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `schools` (`id`, `name`, `region_code`, `address`, `created_by`, `updated_by`) VALUES
|
||||
('school-1', '示范中学', '110000', '北京市海淀区示范路1号', 'system', 'system');
|
||||
|
||||
INSERT INTO `grades` (`id`, `school_id`, `name`, `sort_order`, `enrollment_year`, `created_by`, `updated_by`) VALUES
|
||||
('grade-1', 'school-1', '初一年级', 1, 2024, 'system', 'system'),
|
||||
('grade-2', 'school-1', '初二年级', 2, 2023, 'system', 'system'),
|
||||
('grade-3', 'school-1', '初三年级', 3, 2022, 'system', 'system');
|
||||
|
||||
--=============================================
|
||||
-- 3. 插入示例用户(密码: password123,实际应用需要hash)
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `application_users` (`id`, `real_name`, `student_id`, `gender`, `current_school_id`, `account_status`, `email`, `phone`, `password_hash`, `created_by`, `updated_by`) VALUES
|
||||
('user-teacher-1', '张老师', NULL, 'Male', 'school-1', 'Active', 'teacher@example.com', '13800138000', '$2b$10$example_hash', 'system', 'system'),
|
||||
('user-student-1', '王小明', 'S20240001', 'Male', 'school-1', 'Active', 'student1@example.com', '13800138001', '$2b$10$example_hash', 'system', 'system'),
|
||||
('user-student-2', '李小红', 'S20240002', 'Female', 'school-1', 'Active', 'student2@example.com', '13800138002', '$2b$10$example_hash', 'system', 'system');
|
||||
|
||||
-- =============================================
|
||||
-- 4. 插入示例班级
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `classes` (`id`, `grade_id`, `name`, `invite_code`, `head_teacher_id`, `created_by`, `updated_by`) VALUES
|
||||
('class-1', 'grade-1', '初一(1)班', 'ABC123', 'user-teacher-1', 'system', 'system');
|
||||
|
||||
INSERT INTO `class_members` (`id`, `class_id`, `user_id`, `role_in_class`, `created_by`, `updated_by`) VALUES
|
||||
('cm-1', 'class-1', 'user-teacher-1', 'Teacher', 'system', 'system'),
|
||||
('cm-2', 'class-1', 'user-student-1', 'Student', 'system', 'system'),
|
||||
('cm-3', 'class-1', 'user-student-2', 'Student', 'system', 'system');
|
||||
|
||||
-- =============================================
|
||||
-- 5. 插入示例教材
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `textbooks` (`id`, `subject_id`, `name`, `publisher`, `version_year`, `cover_url`, `created_by`, `updated_by`) VALUES
|
||||
('textbook-1', 'sub-math', '义务教育教科书·数学(七年级上册)', '人民教育出版社', '2024', 'https://placehold.co/300x400/007AFF/ffffff?text=Math', 'system', 'system');
|
||||
|
||||
INSERT INTO `textbook_units` (`id`, `textbook_id`, `name`, `sort_order`, `created_by`, `updated_by`) VALUES
|
||||
('unit-1', 'textbook-1', '第一章 有理数', 1, 'system', 'system'),
|
||||
('unit-2', 'textbook-1', '第二章 整式的加减', 2, 'system', 'system');
|
||||
|
||||
INSERT INTO `textbook_lessons` (`id`, `unit_id`, `name`, `sort_order`, `created_by`, `updated_by`) VALUES
|
||||
('lesson-1-1', 'unit-1', '1.1 正数和负数', 1, 'system', 'system'),
|
||||
('lesson-1-2', 'unit-1', '1.2 有理数', 2, 'system', 'system');
|
||||
|
||||
INSERT INTO `knowledge_points` (`id`, `lesson_id`, `parent_knowledge_point_id`, `name`, `difficulty`, `description`, `created_by`, `updated_by`) VALUES
|
||||
('kp-1', 'lesson-1-1', NULL, '正数的概念', 1, '大于0的数叫做正数', 'system', 'system'),
|
||||
('kp-2', 'lesson-1-1', NULL, '负数的概念', 1, '小于0的数叫做负数', 'system', 'system'),
|
||||
('kp-3', 'lesson-1-2', NULL, '有理数的分类', 2, '有理数包括整数和分数', 'system', 'system');
|
||||
|
||||
-- =============================================
|
||||
-- 6. 插入示例题目
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `questions` (`id`, `subject_id`, `content`, `options_config`, `question_type`, `answer`, `explanation`, `difficulty`, `created_by`, `updated_by`) VALUES
|
||||
('q-1', 'sub-math', '<p>下列各数中,是负数的是( )</p>', '{"options": ["A. 0", "B. -1", "C. 1", "D. 2"]}', 'SingleChoice', 'B', '<p>负数是小于0的数,所以答案是B</p>', 1, 'system', 'system'),
|
||||
('q-2', 'sub-math', '<p>|-5| = _____</p>', NULL, 'FillBlank', '5', '<p>绝对值表示数轴上的点到原点的距离</p>', 2, 'system', 'system'),
|
||||
('q-3', 'sub-math', '<p>计算:(-3) + 5 = ?</p>', NULL, 'Subjective', '2', '<p>负数加正数,按绝对值相减,取绝对值较大数的符号</p>', 2, 'system', 'system');
|
||||
|
||||
INSERT INTO `question_knowledge` (`id`, `question_id`, `knowledge_point_id`, `weight`, `created_by`, `updated_by`) VALUES
|
||||
('qk-1', 'q-1', 'kp-2', 100, 'system', 'system'),
|
||||
('qk-2', 'q-2', 'kp-3', 80, 'system', 'system');
|
||||
|
||||
-- =============================================
|
||||
-- 7. 插入示例试卷
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `exams` (`id`, `subject_id`, `title`, `total_score`, `suggested_duration`, `status`, `created_by`, `updated_by`) VALUES
|
||||
('exam-1', 'sub-math', '第一章单元测试', 100, 90, 'Published', 'user-teacher-1', 'user-teacher-1');
|
||||
|
||||
-- 试卷节点(树形结构示例)
|
||||
INSERT INTO `exam_nodes` (`id`, `exam_id`, `parent_node_id`, `node_type`, `question_id`, `title`, `description`, `score`, `sort_order`, `created_by`, `updated_by`) VALUES
|
||||
('node-1', 'exam-1', NULL, 'Group', NULL, '第一部分:选择题', '本大题共2小题,每小题5分,共10分', 10, 1, 'user-teacher-1', 'user-teacher-1'),
|
||||
('node-1-1', 'exam-1', 'node-1', 'Question', 'q-1', NULL, NULL, 5, 1, 'user-teacher-1', 'user-teacher-1'),
|
||||
('node-1-2', 'exam-1', 'node-1', 'Question', 'q-2', NULL, NULL, 5, 2, 'user-teacher-1', 'user-teacher-1'),
|
||||
('node-2', 'exam-1', NULL, 'Group', NULL, '第二部分:解答题', '需要写出完整步骤', 90, 2, 'user-teacher-1', 'user-teacher-1'),
|
||||
('node-2-1', 'exam-1', 'node-2', 'Question', 'q-3', NULL, NULL, 90, 1, 'user-teacher-1', 'user-teacher-1');
|
||||
|
||||
-- =============================================
|
||||
-- 8. 插入示例作业
|
||||
-- =============================================
|
||||
|
||||
INSERT INTO `assignments` (`id`, `exam_id`, `class_id`, `title`, `start_time`, `end_time`, `allow_late_submission`, `auto_score_enabled`, `created_by`, `updated_by`) VALUES
|
||||
('assignment-1', 'exam-1', 'class-1', '第一章课后作业', '2024-11-20 08:00:00', '2024-11-27 23:59:59', TRUE, TRUE, 'user-teacher-1', 'user-teacher-1');
|
||||
|
||||
-- 插入学生提交记录
|
||||
INSERT INTO `student_submissions` (`id`, `assignment_id`, `student_id`, `submission_status`, `submit_time`, `time_spent_seconds`, `total_score`, `created_by`, `updated_by`) VALUES
|
||||
('sub-1', 'assignment-1', 'user-student-1', 'Graded', '2024-11-25 10:30:00', 1800, 95, 'user-student-1', 'user-teacher-1');
|
||||
|
||||
-- 插入答题详情
|
||||
INSERT INTO `submission_details` (`id`, `submission_id`, `exam_node_id`, `student_answer`, `score`, `judgement`, `teacher_comment`, `created_by`, `updated_by`) VALUES
|
||||
('sd-1', 'sub-1', 'node-1-1', 'B', 5, 'Correct', '正确', 'user-student-1', 'user-teacher-1'),
|
||||
('sd-2', 'sub-1', 'node-1-2', '5', 5, 'Correct', '正确', 'user-student-1', 'user-teacher-1'),
|
||||
('sd-3', 'sub-1', 'node-2-1', '(-3) + 5 = 5 - 3 = 2', 85, 'Partial', '步骤正确,但表述可以更规范', 'user-student-1', 'user-teacher-1');
|
||||
|
||||
-- =============================================
|
||||
-- 完成
|
||||
-- =============================================
|
||||
|
||||
SELECT '初始化数据插入完成' AS Status;
|
||||
5
metadata.json
Normal file
5
metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "EduNexus Pro",
|
||||
"description": "下一代前卫的教育管理系统,采用 Apple 风格美学,具有全面的课程跟踪和先进的学习分析功能。",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
2054
package-lock.json
generated
Normal file
2054
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "edunexus-pro",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"lucide-react": "^0.368.0",
|
||||
"framer-motion": "^11.0.24",
|
||||
"recharts": "^2.12.4",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.3"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
85
src/app/(dashboard)/assignments/page.tsx
Normal file
85
src/app/(dashboard)/assignments/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { TeacherAssignmentList } from '@/features/assignment/components/TeacherAssignmentList';
|
||||
import { StudentAssignmentList } from '@/features/assignment/components/StudentAssignmentList';
|
||||
import { CreateAssignmentModal } from '@/features/assignment/components/CreateAssignmentModal';
|
||||
import { AssignmentStats } from '@/features/assignment/components/AssignmentStats';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function AssignmentsPage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [analyzingId, setAnalyzingId] = useState<string | null>(null);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const isStudent = user.role === 'Student';
|
||||
|
||||
const handleNavigateToGrading = (id: string) => {
|
||||
// Teachers go to grading tool, Students view results (though usually handled by onViewResult)
|
||||
router.push(`/grading/${id}`);
|
||||
};
|
||||
|
||||
const handleNavigateToPreview = (id: string) => {
|
||||
router.push(`/student-exam/${id}`);
|
||||
};
|
||||
|
||||
const handleViewResult = (id: string) => {
|
||||
router.push(`/student-result/${id}`);
|
||||
};
|
||||
|
||||
if (analyzingId && !isStudent) {
|
||||
return (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, x: 20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -20 }
|
||||
} as any)}
|
||||
className="h-[calc(100vh-100px)]"
|
||||
>
|
||||
<AssignmentStats
|
||||
assignmentId={analyzingId}
|
||||
onBack={() => setAnalyzingId(null)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{isStudent ? (
|
||||
<StudentAssignmentList
|
||||
onStartExam={handleNavigateToPreview}
|
||||
onViewResult={handleViewResult}
|
||||
/>
|
||||
) : (
|
||||
<TeacherAssignmentList
|
||||
onNavigateToGrading={handleNavigateToGrading}
|
||||
onNavigateToPreview={handleNavigateToPreview}
|
||||
onAnalyze={setAnalyzingId}
|
||||
setIsCreating={setIsCreating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCreating && (
|
||||
<CreateAssignmentModal
|
||||
onClose={() => setIsCreating(false)}
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
src/app/(dashboard)/classes/page.tsx
Normal file
13
src/app/(dashboard)/classes/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ClassManagement } from '@/views/ClassManagement';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export default function ClassesPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return <ClassManagement currentUser={user} />;
|
||||
}
|
||||
9
src/app/(dashboard)/consoleconfig/page.tsx
Normal file
9
src/app/(dashboard)/consoleconfig/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ConsoleConfig } from '@/views/ConsoleConfig';
|
||||
|
||||
export default function ConsoleConfigPage() {
|
||||
return <ConsoleConfig />;
|
||||
}
|
||||
8
src/app/(dashboard)/curriculum/page.tsx
Normal file
8
src/app/(dashboard)/curriculum/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Curriculum } from '@/views/Curriculum';
|
||||
|
||||
export default function CurriculumPage() {
|
||||
return <Curriculum />;
|
||||
}
|
||||
76
src/app/(dashboard)/dashboard/page.tsx
Normal file
76
src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { CalendarClock, ListTodo, Plus } from 'lucide-react';
|
||||
import { TeacherDashboard } from '@/features/dashboard/components/TeacherDashboard';
|
||||
import { StudentDashboard } from '@/features/dashboard/components/StudentDashboard';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// Helper for adapting onNavigate to useRouter
|
||||
const useNavigationAdapter = () => {
|
||||
const router = useRouter();
|
||||
return (view: string) => {
|
||||
// Map old string views to new routes
|
||||
const routeMap: Record<string, string> = {
|
||||
'dashboard': '/dashboard',
|
||||
'assignments': '/assignments',
|
||||
'classes': '/classes',
|
||||
'exams': '/exams',
|
||||
'curriculum': '/curriculum',
|
||||
'settings': '/settings',
|
||||
'messages': '/messages',
|
||||
'schedule': '/schedule',
|
||||
'student-exam': '/student-exam', // might need ID handling
|
||||
'student-result': '/student-result'
|
||||
};
|
||||
router.push(routeMap[view] || '/dashboard');
|
||||
};
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigationAdapter();
|
||||
const router = useRouter();
|
||||
const today = new Date().toLocaleDateString('zh-CN', { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-8">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">早安, {user.realName} {user.role === 'Student' ? '同学' : '老师'} 👋</h2>
|
||||
<p className="text-gray-500 mt-1 font-medium flex items-center gap-2">
|
||||
<CalendarClock size={16} className="text-blue-500" />
|
||||
{today}
|
||||
</p>
|
||||
</div>
|
||||
{user.role === 'Teacher' && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/assignments')}
|
||||
className="flex items-center gap-2 bg-white text-gray-700 px-4 py-2.5 rounded-xl text-sm font-bold shadow-sm hover:bg-gray-50 transition-all border border-gray-100"
|
||||
>
|
||||
<ListTodo size={18} />
|
||||
待办事项 (3)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/exams')}
|
||||
className="flex items-center gap-2 bg-gray-900 text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-black hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<Plus size={18} />
|
||||
快速创建
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user.role === 'Student' ? (
|
||||
<StudentDashboard user={user} onNavigate={navigate} />
|
||||
) : (
|
||||
<TeacherDashboard user={user} onNavigate={navigate} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/app/(dashboard)/exams/page.tsx
Normal file
8
src/app/(dashboard)/exams/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ExamEngine } from '@/views/ExamEngine';
|
||||
|
||||
export default function ExamsPage() {
|
||||
return <ExamEngine />;
|
||||
}
|
||||
87
src/app/(dashboard)/layout.tsx
Normal file
87
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { Bell, Search } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'; // Import ErrorBoundary
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-screen flex items-center justify-center"><Loader2 className="animate-spin" /></div>;
|
||||
}
|
||||
|
||||
if (!user) return null; // Middleware or AuthProvider will redirect
|
||||
|
||||
const getHeaderTitle = (path: string) => {
|
||||
if (path.includes('/dashboard')) return '教学概览';
|
||||
if (path.includes('/curriculum')) return '课程标准';
|
||||
if (path.includes('/exams')) return '考试引擎';
|
||||
if (path.includes('/assignments')) return '作业发布';
|
||||
if (path.includes('/questions')) return '题库资源';
|
||||
if (path.includes('/classes')) return '班级管理';
|
||||
if (path.includes('/settings')) return '系统设置';
|
||||
if (path.includes('/messages')) return '消息中心';
|
||||
if (path.includes('/schedule')) return '课程表';
|
||||
return 'EduNexus';
|
||||
};
|
||||
|
||||
const roleName = user.role === 'Teacher' ? '教师' : (user.role === 'Admin' ? '管理员' : '学生');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-[#F2F2F7] font-sans text-gray-900">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 lg:ml-72 p-4 md:p-8 overflow-y-auto h-screen scroll-smooth">
|
||||
{/* Top Bar */}
|
||||
<header className="flex justify-between items-center mb-8 sticky top-0 z-40 py-4 -mx-4 px-4 md:-mx-8 md:px-8 bg-[#F2F2F7]/80 backdrop-blur-xl transition-all">
|
||||
<div className="flex flex-col ml-12 lg:ml-0">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 tracking-tight line-clamp-1">
|
||||
{getHeaderTitle(pathname || '')}
|
||||
</h1>
|
||||
<p className="text-xs md:text-sm text-gray-500 font-medium mt-0.5">2024-2025 学年 第一学期</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 md:gap-5">
|
||||
<div className="relative hidden md:block group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-blue-500 transition-colors" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="全局搜索..."
|
||||
className="pl-10 pr-4 py-2.5 bg-white rounded-full border-none shadow-sm text-sm focus:ring-2 focus:ring-blue-500/20 outline-none w-48 lg:w-64 transition-all placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/messages')}
|
||||
className="w-10 h-10 md:w-11 md:h-11 bg-white rounded-full flex items-center justify-center shadow-sm text-gray-500 hover:text-blue-600 hover:shadow-md transition-all relative"
|
||||
>
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-3 right-3.5 w-2 h-2 bg-red-500 rounded-full border-2 border-white"></span>
|
||||
</button>
|
||||
<div className="flex items-center gap-3 bg-white pr-4 pl-1.5 py-1.5 rounded-full shadow-sm hover:shadow-md transition-all cursor-pointer">
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt="Profile"
|
||||
className="w-8 h-8 md:w-9 md:h-9 rounded-full border border-gray-100 object-cover"
|
||||
/>
|
||||
<div className="flex flex-col hidden md:flex">
|
||||
<span className="text-sm font-semibold text-gray-900 leading-none">{user.realName}</span>
|
||||
<span className="text-[11px] text-gray-500 font-medium mt-0.5 uppercase tracking-wide">{roleName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(dashboard)/messages/page.tsx
Normal file
11
src/app/(dashboard)/messages/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Messages } from '@/views/Messages';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { user } = useAuth();
|
||||
if (!user) return null;
|
||||
return <Messages currentUser={user} />;
|
||||
}
|
||||
8
src/app/(dashboard)/questions/page.tsx
Normal file
8
src/app/(dashboard)/questions/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { QuestionBank } from '@/views/QuestionBank';
|
||||
|
||||
export default function QuestionsPage() {
|
||||
return <QuestionBank />;
|
||||
}
|
||||
10
src/app/(dashboard)/schedule/page.tsx
Normal file
10
src/app/(dashboard)/schedule/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Schedule } from '@/views/Schedule';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export default function SchedulePage() {
|
||||
const { user } = useAuth();
|
||||
return <Schedule currentUser={user || undefined} />;
|
||||
}
|
||||
8
src/app/(dashboard)/settings/page.tsx
Normal file
8
src/app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Settings } from '@/views/Settings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return <Settings />;
|
||||
}
|
||||
16
src/app/(dashboard)/student-result/[id]/page.tsx
Normal file
16
src/app/(dashboard)/student-result/[id]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { StudentResult } from '@/views/StudentResult';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function StudentResultPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<StudentResult
|
||||
assignmentId={params.id}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
src/app/(fullscreen)/grading/[id]/page.tsx
Normal file
16
src/app/(fullscreen)/grading/[id]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Grading } from '@/views/Grading';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function GradingPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Grading
|
||||
assignmentId={params.id}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
src/app/(fullscreen)/layout.tsx
Normal file
19
src/app/(fullscreen)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
||||
const { loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-screen flex items-center justify-center"><Loader2 className="animate-spin" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F2F2F7] font-sans text-gray-900">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/(fullscreen)/student-exam/[id]/page.tsx
Normal file
16
src/app/(fullscreen)/student-exam/[id]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { StudentExamRunner } from '@/views/StudentExamRunner';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function ExamRunnerPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<StudentExamRunner
|
||||
assignmentId={params.id}
|
||||
onExit={() => router.push('/assignments')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
src/app/api/auth/login/route.ts
Normal file
48
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { successResponse, errorResponse, dbDelay } from '@/lib/server-utils';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
await dbDelay();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username, password } = body;
|
||||
|
||||
// Simple mock validation
|
||||
if (!username) {
|
||||
return errorResponse('Username is required');
|
||||
}
|
||||
|
||||
let role = 'Teacher';
|
||||
let name = '李明';
|
||||
let id = 'u-tea-1';
|
||||
|
||||
if (username === 'student' || username.startsWith('s')) {
|
||||
role = 'Student';
|
||||
name = '王小明';
|
||||
id = 'u-stu-1';
|
||||
} else if (username === 'admin') {
|
||||
role = 'Admin';
|
||||
name = '系统管理员';
|
||||
id = 'u-adm-1';
|
||||
}
|
||||
|
||||
const token = `mock-jwt-token-${id}-${Date.now()}`;
|
||||
|
||||
return successResponse({
|
||||
token,
|
||||
user: {
|
||||
id,
|
||||
realName: name,
|
||||
studentId: username,
|
||||
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${name}`,
|
||||
gender: 'Male',
|
||||
schoolId: 's-1',
|
||||
role
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
return errorResponse('Invalid request body');
|
||||
}
|
||||
}
|
||||
28
src/app/api/auth/me/route.ts
Normal file
28
src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { successResponse, errorResponse, extractToken, dbDelay } from '@/lib/server-utils';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
await dbDelay();
|
||||
|
||||
const token = extractToken(request);
|
||||
if (!token) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
// In a real app, verify JWT here.
|
||||
// For mock, we return a default user or parse the mock token if it contained info.
|
||||
|
||||
return successResponse({
|
||||
id: "u-1",
|
||||
realName: "李明 (Real API)",
|
||||
studentId: "T2024001",
|
||||
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alex",
|
||||
gender: "Male",
|
||||
schoolId: "s-1",
|
||||
role: "Teacher",
|
||||
email: 'liming@school.edu',
|
||||
phone: '13800138000',
|
||||
bio: '来自真实 API 的数据'
|
||||
});
|
||||
}
|
||||
27
src/app/api/config/db/route.ts
Normal file
27
src/app/api/config/db/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { successResponse, errorResponse } from '@/lib/server-utils';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { host, port, user, password, database } = body;
|
||||
|
||||
if (!host || !user) {
|
||||
return errorResponse('Missing required fields');
|
||||
}
|
||||
|
||||
await db.testConnection({
|
||||
host,
|
||||
port: Number(port),
|
||||
user,
|
||||
password,
|
||||
database
|
||||
});
|
||||
|
||||
return successResponse({ message: 'Connection successful' });
|
||||
} catch (e: any) {
|
||||
return errorResponse(e.message || 'Connection failed', 500);
|
||||
}
|
||||
}
|
||||
23
src/app/api/org/classes/route.ts
Normal file
23
src/app/api/org/classes/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { successResponse, dbDelay } from '@/lib/server-utils';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
await dbDelay();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const role = searchParams.get('role');
|
||||
|
||||
let classes = [
|
||||
{ id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' },
|
||||
{ id: 'c-2', name: '高一 (12) 班', gradeName: '高一年级', teacherName: '张伟', studentCount: 28, inviteCode: 'M2L4Q' },
|
||||
{ id: 'c-3', name: 'AP 微积分先修班', gradeName: '高三年级', teacherName: '李明', studentCount: 15, inviteCode: 'Z9J1W' },
|
||||
{ id: 'c-4', name: '物理奥赛集训队', gradeName: '高二年级', teacherName: '王博士', studentCount: 20, inviteCode: 'H4R8T' },
|
||||
];
|
||||
|
||||
if (role === 'Student') {
|
||||
classes = classes.slice(0, 1);
|
||||
}
|
||||
|
||||
return successResponse(classes);
|
||||
}
|
||||
52
src/app/globals.css
Normal file
52
src/app/globals.css
Normal file
@@ -0,0 +1,52 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 242, 242, 247;
|
||||
--background-end-rgb: 242, 242, 247;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: #F2F2F7;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #C1C1C1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #A8A8A8;
|
||||
}
|
||||
|
||||
/* Utility to hide scrollbar but keep functionality */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Recharts Tooltip Fix */
|
||||
.recharts-tooltip-wrapper {
|
||||
z-index: 100;
|
||||
outline: none;
|
||||
}
|
||||
30
src/app/layout.tsx
Normal file
30
src/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "EduNexus Pro",
|
||||
description: "Enterprise Education Management System",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={inter.className}>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
12
src/app/loading.tsx
Normal file
12
src/app/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="h-screen w-full flex items-center justify-center bg-[#F2F2F7]/50 backdrop-blur-sm z-50">
|
||||
<div className="bg-white/80 p-8 rounded-3xl shadow-xl flex flex-col items-center backdrop-blur-xl border border-white/50">
|
||||
<Loader2 className="animate-spin text-blue-600 mb-4" size={32} />
|
||||
<p className="text-sm font-bold text-gray-500">EduNexus Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/app/login/page.tsx
Normal file
82
src/app/login/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LoginForm } from '@/features/auth/components/LoginForm';
|
||||
import { RegisterForm } from '@/features/auth/components/RegisterForm';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const { login, register } = useAuth();
|
||||
|
||||
// Adapt the legacy onLoginSuccess prop to use the Context method
|
||||
const handleLoginSuccess = (user: any) => {
|
||||
// 登录成功后,token 已存储在 localStorage
|
||||
// 使用 window.location.href 强制跳转,触发 AuthContext 重新初始化并验证 token
|
||||
window.location.href = '/dashboard';
|
||||
};
|
||||
|
||||
// We need to wrap the context calls to match the signature expected by existing components
|
||||
// Ideally, modify components to use useAuth() directly, but this is a migration adapter.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#F5F5F7]">
|
||||
{/* Fix: cast props to any to avoid framer-motion type errors */}
|
||||
<motion.div
|
||||
{...({
|
||||
animate: { scale: [1, 1.2, 1], rotate: [0, 90, 0] },
|
||||
transition: { duration: 20, repeat: Infinity, ease: "linear" }
|
||||
} as any)}
|
||||
className="absolute -top-[30%] -left-[10%] w-[800px] h-[800px] rounded-full bg-blue-400/20 blur-[100px]"
|
||||
/>
|
||||
{/* Fix: cast props to any to avoid framer-motion type errors */}
|
||||
<motion.div
|
||||
{...({
|
||||
animate: { scale: [1, 1.5, 1], rotate: [0, -60, 0] },
|
||||
transition: { duration: 15, repeat: Infinity, ease: "linear" }
|
||||
} as any)}
|
||||
className="absolute top-[20%] -right-[20%] w-[600px] h-[600px] rounded-full bg-purple-400/20 blur-[100px]"
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-md z-10 mx-4 perspective-1000">
|
||||
<AnimatePresence mode="wait">
|
||||
{mode === 'login' ? (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
key="login"
|
||||
{...({
|
||||
initial: { opacity: 0, rotateY: -90 },
|
||||
animate: { opacity: 1, rotateY: 0 },
|
||||
exit: { opacity: 0, rotateY: 90 },
|
||||
transition: { duration: 0.4 }
|
||||
} as any)}
|
||||
>
|
||||
<LoginForm
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
onSwitch={() => setMode('register')}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
key="register"
|
||||
{...({
|
||||
initial: { opacity: 0, rotateY: 90 },
|
||||
animate: { opacity: 1, rotateY: 0 },
|
||||
exit: { opacity: 0, rotateY: -90 },
|
||||
transition: { duration: 0.4 }
|
||||
} as any)}
|
||||
>
|
||||
<RegisterForm
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
onSwitch={() => setMode('login')}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/not-found.tsx
Normal file
23
src/app/not-found.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Link from 'next/link';
|
||||
import { AlertTriangle, Home } from 'lucide-react';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F2F2F7] text-center px-4">
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-3xl flex items-center justify-center mb-6 shadow-sm">
|
||||
<AlertTriangle size={40} className="text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">页面未找到</h2>
|
||||
<p className="text-gray-500 mb-8 max-w-md">
|
||||
您访问的页面可能已被移除、更名或暂时不可用。
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-xl font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 transition-all"
|
||||
>
|
||||
<Home size={18} />
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/app/page.tsx
Normal file
27
src/app/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function RootPage() {
|
||||
const router = useRouter();
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (user) {
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
router.replace('/login');
|
||||
}
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#F2F2F7]">
|
||||
<Loader2 className="animate-spin text-blue-600" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/ErrorBoundary.tsx
Normal file
99
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
};
|
||||
|
||||
private handleGoHome = () => {
|
||||
window.location.href = '/dashboard';
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="text-red-600" size={32} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
页面出错了
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
抱歉,页面遇到了一个意外错误。您可以尝试刷新页面或返回首页。
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mb-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700 mb-2">
|
||||
查看错误详情
|
||||
</summary>
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-xs text-red-600 overflow-auto max-h-40">
|
||||
<pre>{this.state.error.toString()}</pre>
|
||||
{this.state.errorInfo && (
|
||||
<pre className="mt-2">{this.state.errorInfo.componentStack}</pre>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleGoHome}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<Home size={18} />
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
161
src/components/Sidebar.tsx
Normal file
161
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
LayoutDashboard, BookOpen, FileQuestion, Users, Settings, LogOut,
|
||||
Bell, Search, GraduationCap, ScrollText, ClipboardList, Database,
|
||||
Menu, X, CalendarDays, Terminal
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { getApiMode, setApiMode } from '@/services/api';
|
||||
|
||||
const NavItem = ({ icon: Icon, label, href, isActive }: any) => (
|
||||
<Link href={href} className="block w-full">
|
||||
<div className={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 group relative overflow-hidden
|
||||
${isActive
|
||||
? 'text-white shadow-lg shadow-blue-500/30'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
|
||||
}
|
||||
`}>
|
||||
{isActive && (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
{...({
|
||||
layoutId: "activeNav",
|
||||
initial: false,
|
||||
transition: { type: "spring", stiffness: 400, damping: 30 }
|
||||
} as any)}
|
||||
className="absolute inset-0 bg-blue-600 z-0"
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex items-center gap-3">
|
||||
<Icon size={20} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span className="text-[15px] font-medium tracking-tight">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const Sidebar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const pathname = usePathname() || '';
|
||||
const router = useRouter();
|
||||
const [isMock, setIsMock] = useState(getApiMode());
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const renderNavItems = () => {
|
||||
if (user?.role === 'Student') {
|
||||
return (
|
||||
<>
|
||||
<NavItem icon={LayoutDashboard} label="我的概览" href="/dashboard" isActive={pathname === '/dashboard'} />
|
||||
<NavItem icon={ClipboardList} label="我的作业" href="/assignments" isActive={pathname.startsWith('/assignments')} />
|
||||
<NavItem icon={Users} label="我的班级" href="/classes" isActive={pathname.startsWith('/classes')} />
|
||||
<NavItem icon={CalendarDays} label="我的课表" href="/schedule" isActive={pathname === '/schedule'} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<NavItem icon={LayoutDashboard} label="教学概览" href="/dashboard" isActive={pathname === '/dashboard'} />
|
||||
<NavItem icon={BookOpen} label="课程标准" href="/curriculum" isActive={pathname === '/curriculum'} />
|
||||
<NavItem icon={ScrollText} label="考试引擎" href="/exams" isActive={pathname.startsWith('/exams')} />
|
||||
<NavItem icon={ClipboardList} label="作业发布" href="/assignments" isActive={pathname.startsWith('/assignments')} />
|
||||
<NavItem icon={FileQuestion} label="题库资源" href="/questions" isActive={pathname === '/questions'} />
|
||||
<NavItem icon={CalendarDays} label="课表管理" href="/schedule" isActive={pathname === '/schedule'} />
|
||||
<NavItem icon={Users} label="班级管理" href="/classes" isActive={pathname.startsWith('/classes')} />
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
const SidebarContent = () => (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-4 px-2 py-6 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold shadow-lg shadow-blue-500/30">
|
||||
<GraduationCap size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-xl tracking-tight block leading-none">EduNexus</span>
|
||||
<span className="text-xs text-gray-400 font-medium tracking-wide">专业版</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2">
|
||||
{renderNavItems()}
|
||||
</nav>
|
||||
|
||||
<div className="pt-6 border-t border-gray-100 space-y-2">
|
||||
<button
|
||||
onClick={() => { setApiMode(!isMock); setIsMock(!isMock); }}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors text-xs font-bold uppercase tracking-wider border ${isMock ? 'bg-amber-50 text-amber-600 border-amber-200' : 'bg-green-50 text-green-600 border-green-200'}`}
|
||||
>
|
||||
<Database size={14} />
|
||||
{isMock ? 'Mock Data' : 'Real API'}
|
||||
</button>
|
||||
|
||||
<NavItem icon={Terminal} label="控制台配置" href="/consoleconfig" isActive={pathname === '/consoleconfig'} />
|
||||
<NavItem icon={Settings} label="系统设置" href="/settings" isActive={pathname === '/settings'} />
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-2xl text-red-500 hover:bg-red-50 transition-colors group"
|
||||
>
|
||||
<LogOut size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="text-[15px] font-medium">退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="fixed w-72 h-screen pl-6 py-6 hidden lg:block z-50">
|
||||
<div className="h-full bg-white/80 backdrop-blur-xl rounded-[32px] border border-white/50 shadow-[0_20px_40px_rgba(0,0,0,0.04)] flex flex-col p-6">
|
||||
<SidebarContent />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Toggle */}
|
||||
<div className="lg:hidden fixed top-4 left-4 z-50">
|
||||
<button onClick={() => setIsMobileMenuOpen(true)} className="p-2 bg-white rounded-xl shadow-sm">
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-50 lg:hidden"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
{/* Fix: cast props to any to avoid framer-motion type errors */}
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { x: '-100%' },
|
||||
animate: { x: 0 },
|
||||
exit: { x: '-100%' },
|
||||
transition: { type: 'spring', stiffness: 300, damping: 30 }
|
||||
} as any)}
|
||||
className="fixed inset-y-0 left-0 w-72 bg-white z-50 p-6 flex flex-col shadow-2xl lg:hidden"
|
||||
>
|
||||
<div className="absolute top-4 right-4">
|
||||
<button onClick={() => setIsMobileMenuOpen(false)} className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarContent />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
36
src/components/ui/Badge.tsx
Normal file
36
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'outline';
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
className = ''
|
||||
}) => {
|
||||
const variants = {
|
||||
default: "bg-gray-100 text-gray-600",
|
||||
success: "bg-green-100 text-green-600 border-green-200",
|
||||
warning: "bg-orange-100 text-orange-600 border-orange-200",
|
||||
danger: "bg-red-100 text-red-600 border-red-200",
|
||||
info: "bg-blue-100 text-blue-600 border-blue-200",
|
||||
outline: "bg-white border border-gray-200 text-gray-500"
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-1.5 py-0.5 text-[10px]",
|
||||
md: "px-2.5 py-1 text-xs"
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`font-bold rounded border border-transparent ${variants[variant]} ${sizes[size]} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
49
src/components/ui/Button.tsx
Normal file
49
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = "font-bold rounded-xl transition-all flex items-center justify-center gap-2 active:scale-95 disabled:opacity-70 disabled:cursor-not-allowed disabled:active:scale-100";
|
||||
|
||||
const variants = {
|
||||
primary: "bg-blue-600 text-white shadow-lg shadow-blue-500/30 hover:bg-blue-700",
|
||||
secondary: "bg-gray-900 text-white shadow-lg hover:bg-black",
|
||||
outline: "bg-white border border-gray-200 text-gray-600 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600",
|
||||
ghost: "text-gray-500 hover:bg-gray-100 hover:text-gray-900",
|
||||
danger: "bg-red-50 text-red-600 hover:bg-red-100 border border-transparent",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
md: "px-5 py-2.5 text-sm",
|
||||
lg: "px-6 py-3 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={loading || disabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && <Loader2 className="animate-spin" size={size === 'sm' ? 12 : 16} />}
|
||||
{!loading && icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
40
src/components/ui/Card.tsx
Normal file
40
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
delay?: number;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ children, className = '', onClick, delay = 0, noPadding = false }) => {
|
||||
return (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.4, delay: delay, ease: [0.22, 1, 0.36, 1] },
|
||||
whileHover: onClick ? { scale: 1.02 } : {}
|
||||
} as any)}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative overflow-hidden
|
||||
bg-white/80 backdrop-blur-xl
|
||||
border border-white/20
|
||||
shadow-[0_8px_30px_rgb(0,0,0,0.04)]
|
||||
rounded-3xl
|
||||
${noPadding ? '' : 'p-6'}
|
||||
${onClick ? 'cursor-pointer transition-all' : ''}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
18
src/components/ui/ChartTooltip.tsx
Normal file
18
src/components/ui/ChartTooltip.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const ChartTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white/90 backdrop-blur-md p-4 rounded-2xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] border border-white/50 text-sm">
|
||||
<p className="font-bold text-gray-900 mb-1">{label}</p>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<p key={index} className="text-xs font-medium" style={{ color: entry.color }}>
|
||||
{entry.name}: <span className="font-bold text-base ml-1">{entry.value}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
109
src/components/ui/ErrorState.tsx
Normal file
109
src/components/ui/ErrorState.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, WifiOff, Lock, FileQuestion, RefreshCw, Home } from 'lucide-react';
|
||||
|
||||
type ErrorType = '404' | '500' | 'network' | 'auth' | 'general';
|
||||
|
||||
interface ErrorStateProps {
|
||||
type?: ErrorType;
|
||||
title?: string;
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
onGoHome?: () => void;
|
||||
showRetry?: boolean;
|
||||
}
|
||||
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||
type = 'general',
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
onGoHome,
|
||||
showRetry = true
|
||||
}) => {
|
||||
const configs = {
|
||||
'404': {
|
||||
icon: FileQuestion,
|
||||
defaultTitle: '页面不存在',
|
||||
defaultMessage: '抱歉,您访问的页面不存在或已被删除。',
|
||||
iconColor: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100'
|
||||
},
|
||||
'500': {
|
||||
icon: AlertCircle,
|
||||
defaultTitle: '服务器错误',
|
||||
defaultMessage: '服务器遇到了一个错误,请稍后重试。',
|
||||
iconColor: 'text-red-600',
|
||||
bgColor: 'bg-red-100'
|
||||
},
|
||||
network: {
|
||||
icon: WifiOff,
|
||||
defaultTitle: '网络连接失败',
|
||||
defaultMessage: '请检查您的网络连接,然后重试。',
|
||||
iconColor: 'text-gray-600',
|
||||
bgColor: 'bg-gray-100'
|
||||
},
|
||||
auth: {
|
||||
icon: Lock,
|
||||
defaultTitle: '需要登录',
|
||||
defaultMessage: '您需要登录才能访问此内容。',
|
||||
iconColor: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-100'
|
||||
},
|
||||
general: {
|
||||
icon: AlertCircle,
|
||||
defaultTitle: '出错了',
|
||||
defaultMessage: '发生了一个错误,请重试。',
|
||||
iconColor: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100'
|
||||
}
|
||||
};
|
||||
|
||||
const config = configs[type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className={`w-16 h-16 ${config.bgColor} rounded-full flex items-center justify-center mx-auto mb-4`}>
|
||||
<Icon className={config.iconColor} size={32} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{title || config.defaultTitle}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
{message || config.defaultMessage}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
{showRetry && onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
{onGoHome && (
|
||||
<button
|
||||
onClick={onGoHome}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<Home size={18} />
|
||||
返回首页
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 页面级错误组件
|
||||
export const ErrorPage: React.FC<ErrorStateProps> = (props) => (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<ErrorState {...props} />
|
||||
</div>
|
||||
);
|
||||
80
src/components/ui/HandwritingPad.tsx
Normal file
80
src/components/ui/HandwritingPad.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
onChange: (dataUrl: string) => void;
|
||||
};
|
||||
|
||||
export default function HandwritingPad({ width = 600, height = 300, onChange }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const c = canvasRef.current;
|
||||
if (!c) return;
|
||||
const context = c.getContext('2d');
|
||||
if (!context) return;
|
||||
context.lineWidth = 2;
|
||||
context.lineCap = 'round';
|
||||
context.strokeStyle = '#111827';
|
||||
setCtx(context);
|
||||
}, []);
|
||||
|
||||
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!ctx) return;
|
||||
const { x, y } = getPos(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
setDrawing(true);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!ctx || !drawing) return;
|
||||
const { x, y } = getPos(e);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setDrawing(false);
|
||||
const url = canvasRef.current?.toDataURL('image/png');
|
||||
if (url) onChange(url);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
const c = canvasRef.current;
|
||||
if (!c || !ctx) return;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
const url = c.toDataURL('image/png');
|
||||
onChange(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className="border rounded-md bg-white"
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-2 bg-gray-100 rounded-md" onClick={clear}>清空</button>
|
||||
<button className="px-3 py-2 bg-blue-600 text-white rounded-md" onClick={() => {
|
||||
const url = canvasRef.current?.toDataURL('image/png');
|
||||
if (url) onChange(url);
|
||||
}}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/ui/LoadingState.tsx
Normal file
75
src/components/ui/LoadingState.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
message = '加载中...',
|
||||
size = 'md',
|
||||
fullScreen = false
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-5 h-5',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg'
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Loader2 className={`${sizeClasses[size]} text-blue-600 animate-spin`} />
|
||||
{message && (
|
||||
<p className={`${textSizeClasses[size]} text-gray-600 font-medium animate-pulse`}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 骨架屏加载组件
|
||||
export const SkeletonCard: React.FC = () => (
|
||||
<div className="bg-white rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SkeletonTable: React.FC<{ rows?: number }> = ({ rows = 5 }) => (
|
||||
<div className="bg-white rounded-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-100 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="p-4 border-b border-gray-100 animate-pulse flex gap-4">
|
||||
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/6"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
58
src/components/ui/Modal.tsx
Normal file
58
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
icon,
|
||||
maxWidth = 'max-w-md'
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
{/* Fix: cast props to any to avoid framer-motion type errors */}
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, scale: 0.9, y: 20 },
|
||||
animate: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.9, y: 20 }
|
||||
} as any)}
|
||||
className={`bg-white w-full ${maxWidth} rounded-3xl shadow-2xl overflow-hidden relative z-10 flex flex-col`}
|
||||
>
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center text-blue-600">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full text-gray-500 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
src/components/ui/Toast.tsx
Normal file
86
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { CheckCircle2, AlertCircle, XCircle, X } from 'lucide-react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = Date.now().toString();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="fixed top-6 right-6 z-[100] flex flex-col gap-3 pointer-events-none">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
{...({
|
||||
initial: { opacity: 0, y: -20, scale: 0.9 },
|
||||
animate: { opacity: 1, y: 0, scale: 1 },
|
||||
exit: { opacity: 0, x: 50, scale: 0.9 },
|
||||
layout: true
|
||||
} as any)}
|
||||
className={`
|
||||
pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl shadow-xl border
|
||||
min-w-[300px] backdrop-blur-md
|
||||
${toast.type === 'success' ? 'bg-white/90 border-green-200 text-green-800' : ''}
|
||||
${toast.type === 'error' ? 'bg-white/90 border-red-200 text-red-800' : ''}
|
||||
${toast.type === 'info' ? 'bg-white/90 border-blue-200 text-blue-800' : ''}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
p-1 rounded-full
|
||||
${toast.type === 'success' ? 'bg-green-100 text-green-600' : ''}
|
||||
${toast.type === 'error' ? 'bg-red-100 text-red-600' : ''}
|
||||
${toast.type === 'info' ? 'bg-blue-100 text-blue-600' : ''}
|
||||
`}>
|
||||
{toast.type === 'success' && <CheckCircle2 size={18} />}
|
||||
{toast.type === 'error' && <XCircle size={18} />}
|
||||
{toast.type === 'info' && <AlertCircle size={18} />}
|
||||
</div>
|
||||
<p className="text-sm font-bold flex-1">{toast.message}</p>
|
||||
<button onClick={() => removeToast(toast.id)} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be used within a ToastProvider');
|
||||
return context;
|
||||
};
|
||||
97
src/features/assignment/components/AssignmentStats.tsx
Normal file
97
src/features/assignment/components/AssignmentStats.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ExamStatsDto } from '../../../../UI_DTO';
|
||||
import { assignmentService } from '@/services/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ArrowLeft, TrendingUp, Users, AlertCircle, Target, CheckCircle, PieChart } from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell
|
||||
} from 'recharts';
|
||||
|
||||
interface AssignmentStatsProps {
|
||||
assignmentId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const AssignmentStats: React.FC<AssignmentStatsProps> = ({ assignmentId, onBack }) => {
|
||||
const [stats, setStats] = useState<ExamStatsDto | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
assignmentService.getAssignmentStats(assignmentId).then(setStats);
|
||||
}, [assignmentId]);
|
||||
|
||||
if (!stats) return <div className="p-10 flex justify-center"><div className="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full" /></div>;
|
||||
|
||||
const StatItem = ({ label, value, icon: Icon, color }: any) => (
|
||||
<Card className="flex items-center gap-4 p-6" noPadding>
|
||||
<div className={`w-12 h-12 rounded-2xl ${color} flex items-center justify-center text-white shadow-lg`}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
<div className="text-xs font-bold text-gray-400 uppercase">{label}</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 h-full overflow-y-auto custom-scrollbar p-1">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" icon={<ArrowLeft size={20} />} onClick={onBack} />
|
||||
<h2 className="text-2xl font-bold text-gray-900">作业数据分析</h2>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatItem label="平均分" value={stats.averageScore} icon={TrendingUp} color="bg-blue-500" />
|
||||
<StatItem label="及格率" value={`${stats.passRate}%`} icon={CheckCircle} color="bg-green-500" />
|
||||
<StatItem label="最高分" value={stats.maxScore} icon={Target} color="bg-purple-500" />
|
||||
<StatItem label="最低分" value={stats.minScore} icon={AlertCircle} color="bg-orange-500" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Score Distribution */}
|
||||
<Card className="lg:col-span-2 h-96">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-6">成绩分布直方图</h3>
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={300}>
|
||||
<BarChart data={stats.scoreDistribution} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="range" axisLine={false} tickLine={false} />
|
||||
<YAxis axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{fill: '#f3f4f6'}} contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 30px -10px rgba(0,0,0,0.1)'}} />
|
||||
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||
{stats.scoreDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={index >= 3 ? '#34C759' : '#FF3B30'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* Wrong Questions Leaderboard */}
|
||||
<Card className="h-96 flex flex-col">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<AlertCircle className="text-red-500" size={20} />
|
||||
高频错题榜
|
||||
</h3>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar pr-2 space-y-4">
|
||||
{stats.wrongQuestions.map((q, i) => (
|
||||
<div key={q.id} className="bg-gray-50 p-3 rounded-xl border border-gray-100 relative overflow-hidden group">
|
||||
<div className="flex justify-between items-start mb-2 relative z-10">
|
||||
<span className="w-6 h-6 flex items-center justify-center bg-red-100 text-red-600 rounded font-bold text-xs">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-red-500">错误率 {q.errorRate}%</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 font-medium line-clamp-2 relative z-10" dangerouslySetInnerHTML={{ __html: q.content }} />
|
||||
<div className="absolute bottom-0 left-0 h-1 bg-red-500" style={{ width: `${q.errorRate}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
269
src/features/assignment/components/CreateAssignmentModal.tsx
Normal file
269
src/features/assignment/components/CreateAssignmentModal.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, CheckCircle2, Search, FileText, Users, Calendar, Clock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { ExamDto, ClassDto } from '../../../../UI_DTO';
|
||||
import { examService, orgService, assignmentService } from '@/services/api';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
interface CreateAssignmentModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ onClose, onSuccess }) => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [exams, setExams] = useState<ExamDto[]>([]);
|
||||
const [classes, setClasses] = useState<ClassDto[]>([]);
|
||||
|
||||
const [selectedExam, setSelectedExam] = useState<ExamDto | null>(null);
|
||||
const [selectedClassIds, setSelectedClassIds] = useState<string[]>([]);
|
||||
const [config, setConfig] = useState({
|
||||
title: '',
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
dueDate: new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
examService.getMyExams().then(res => setExams(res.items));
|
||||
orgService.getClasses().then(setClasses);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedExam && !config.title) {
|
||||
setConfig(prev => ({ ...prev, title: selectedExam.title + ' - 作业' }));
|
||||
}
|
||||
}, [selectedExam]);
|
||||
|
||||
const handlePublish = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await assignmentService.publishAssignment({
|
||||
examId: selectedExam?.id,
|
||||
classIds: selectedClassIds,
|
||||
...config
|
||||
});
|
||||
showToast('作业发布成功!', 'success');
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
showToast('发布失败,请重试', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ num: 1, label: '选择试卷' },
|
||||
{ num: 2, label: '选择班级' },
|
||||
{ num: 3, label: '发布设置' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
{/* Fix: cast props to any to avoid framer-motion type errors */}
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, scale: 0.95, y: 20 },
|
||||
animate: { opacity: 1, scale: 1, y: 0 }
|
||||
} as any)}
|
||||
className="bg-white w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden relative z-10 flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50 backdrop-blur">
|
||||
<h3 className="font-bold text-lg text-gray-900">发布新作业</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full text-gray-500"><X size={20}/></button>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center justify-between relative">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-gray-100 -z-10" />
|
||||
{steps.map((s) => (
|
||||
<div key={s.num} className="flex flex-col items-center gap-2 bg-white px-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all border-2 ${step >= s.num ? 'bg-blue-600 border-blue-600 text-white' : 'bg-white border-gray-200 text-gray-400'}`}>
|
||||
{step > s.num ? <CheckCircle2 size={18} /> : s.num}
|
||||
</div>
|
||||
<span className={`text-xs font-bold ${step >= s.num ? 'text-blue-600' : 'text-gray-400'}`}>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-[300px]">
|
||||
<AnimatePresence mode='wait'>
|
||||
{step === 1 && (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
key="step1"
|
||||
{...({
|
||||
initial: { x: 20, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: -20, opacity: 0 }
|
||||
} as any)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input placeholder="搜索试卷..." className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-500/20" />
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto custom-scrollbar">
|
||||
{exams.map(exam => (
|
||||
<div
|
||||
key={exam.id}
|
||||
onClick={() => setSelectedExam(exam)}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all flex items-center gap-4
|
||||
${selectedExam?.id === exam.id ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500' : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'}
|
||||
`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${selectedExam?.id === exam.id ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold ${selectedExam?.id === exam.id ? 'text-blue-800' : 'text-gray-900'}`}>{exam.title}</h4>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||
<span>{exam.questionCount} 题</span>
|
||||
<span>{exam.duration} 分钟</span>
|
||||
<span>总分 {exam.totalScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
{selectedExam?.id === exam.id && <CheckCircle2 className="text-blue-600" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
key="step2"
|
||||
{...({
|
||||
initial: { x: 20, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: -20, opacity: 0 }
|
||||
} as any)}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
{classes.map(cls => {
|
||||
const isSelected = selectedClassIds.includes(cls.id);
|
||||
return (
|
||||
<div
|
||||
key={cls.id}
|
||||
onClick={() => {
|
||||
setSelectedClassIds(prev =>
|
||||
isSelected ? prev.filter(id => id !== cls.id) : [...prev, cls.id]
|
||||
);
|
||||
}}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all flex items-start gap-4
|
||||
${isSelected ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500' : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'}
|
||||
`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isSelected ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-bold ${isSelected ? 'text-blue-800' : 'text-gray-900'}`}>{cls.name}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{cls.studentCount} 名学生</p>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${isSelected ? 'border-blue-500 bg-blue-500 text-white' : 'border-gray-300'}`}>
|
||||
{isSelected && <CheckCircle2 size={12} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
key="step3"
|
||||
{...({
|
||||
initial: { x: 20, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: -20, opacity: 0 }
|
||||
} as any)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">作业标题</label>
|
||||
<input
|
||||
value={config.title}
|
||||
onChange={e => setConfig({...config, title: e.target.value})}
|
||||
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">开始时间</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18}/>
|
||||
<input
|
||||
type="date"
|
||||
value={config.startDate}
|
||||
onChange={e => setConfig({...config, startDate: e.target.value})}
|
||||
className="w-full pl-10 pr-3 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">截止时间</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18}/>
|
||||
<input
|
||||
type="date"
|
||||
value={config.dueDate}
|
||||
onChange={e => setConfig({...config, dueDate: e.target.value})}
|
||||
className="w-full pl-10 pr-3 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
|
||||
<h4 className="font-bold text-blue-800 mb-2">发布预览</h4>
|
||||
<ul className="space-y-2 text-sm text-blue-600/80">
|
||||
<li>• 试卷:{selectedExam?.title}</li>
|
||||
<li>• 对象:共选中 {selectedClassIds.length} 个班级</li>
|
||||
<li>• 时长:{selectedExam?.duration} 分钟</li>
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 bg-gray-50/50 backdrop-blur flex justify-between items-center">
|
||||
{step > 1 ? (
|
||||
<button onClick={() => setStep(step - 1)} className="text-gray-500 font-bold hover:text-gray-900 px-4 py-2">上一步</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={step === 1 && !selectedExam || step === 2 && selectedClassIds.length === 0}
|
||||
className="bg-gray-900 text-white px-6 py-3 rounded-xl font-bold shadow-lg hover:bg-black disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-all"
|
||||
>
|
||||
下一步 <ArrowRight size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-xl font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 disabled:opacity-70 flex items-center gap-2 transition-all"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <CheckCircle2 size={18} />}
|
||||
确认发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
68
src/features/assignment/components/StudentAssignmentList.tsx
Normal file
68
src/features/assignment/components/StudentAssignmentList.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AssignmentStudentViewDto } from '../../../../UI_DTO';
|
||||
import { assignmentService } from '@/services/api';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Clock, CheckCircle, Calendar, Play, Eye } from 'lucide-react';
|
||||
|
||||
interface StudentAssignmentListProps {
|
||||
onStartExam: (id: string) => void;
|
||||
onViewResult: (id: string) => void;
|
||||
}
|
||||
|
||||
export const StudentAssignmentList: React.FC<StudentAssignmentListProps> = ({ onStartExam, onViewResult }) => {
|
||||
const [assignments, setAssignments] = useState<AssignmentStudentViewDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
assignmentService.getStudentAssignments().then(res => setAssignments(res.items));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{assignments.map((item, idx) => (
|
||||
<Card key={item.id} delay={idx * 0.1} className="flex flex-col md:flex-row items-center gap-6 group border-transparent hover:border-blue-200 transition-all">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0 text-2xl font-bold shadow-sm
|
||||
${item.status === 'Pending' ? 'bg-blue-100 text-blue-600' : (item.status === 'Graded' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500')}
|
||||
`}>
|
||||
{item.status === 'Pending' ? <Clock size={24}/> : (item.status === 'Graded' ? item.score : <CheckCircle size={24} />)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-bold text-gray-900">{item.title}</h3>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase
|
||||
${item.status === 'Pending' ? 'bg-blue-50 text-blue-600 border-blue-100' : 'bg-gray-50 text-gray-500 border-gray-200'}
|
||||
`}>
|
||||
{item.status === 'Pending' ? '待完成' : (item.status === 'Graded' ? '已批改' : '已提交')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-2">试卷: {item.examTitle}</p>
|
||||
<div className="text-xs text-gray-400 font-medium flex items-center gap-1">
|
||||
<Calendar size={12}/> 截止时间: {item.endTime}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{item.status === 'Pending' ? (
|
||||
<button
|
||||
onClick={() => onStartExam(item.id)}
|
||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 hover:scale-105 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Play size={16} fill="currentColor" /> 开始答题
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => item.status === 'Graded' && onViewResult(item.id)}
|
||||
disabled={item.status !== 'Graded'}
|
||||
className={`px-6 py-2.5 rounded-xl font-bold transition-colors flex items-center gap-2 ${item.status === 'Graded' ? 'bg-gray-100 text-gray-900 hover:bg-gray-200' : 'bg-gray-50 text-gray-400 cursor-not-allowed'}`}
|
||||
>
|
||||
<Eye size={16} /> {item.status === 'Graded' ? '查看详情' : '等待批改'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
src/features/assignment/components/TeacherAssignmentList.tsx
Normal file
138
src/features/assignment/components/TeacherAssignmentList.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AssignmentTeacherViewDto } from '../../../../UI_DTO';
|
||||
import { assignmentService } from '@/services/api';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Plus, FileText, Users, Calendar, Eye, BarChart3, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface TeacherAssignmentListProps {
|
||||
onNavigateToGrading?: (id: string) => void;
|
||||
onNavigateToPreview?: (id: string) => void;
|
||||
onAnalyze?: (id: string) => void;
|
||||
setIsCreating: (val: boolean) => void;
|
||||
}
|
||||
|
||||
export const TeacherAssignmentList: React.FC<TeacherAssignmentListProps> = ({
|
||||
onNavigateToGrading,
|
||||
onNavigateToPreview,
|
||||
onAnalyze,
|
||||
setIsCreating
|
||||
}) => {
|
||||
const [assignments, setAssignments] = useState<AssignmentTeacherViewDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
assignmentService.getTeachingAssignments().then(res => setAssignments(res.items));
|
||||
}, []);
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Active': return 'bg-blue-50 text-blue-600 border-blue-100';
|
||||
case 'Ended': return 'bg-gray-100 text-gray-600 border-gray-200';
|
||||
case 'Scheduled': return 'bg-orange-50 text-orange-600 border-orange-100';
|
||||
default: return 'bg-gray-50 text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Active': return '进行中';
|
||||
case 'Ended': return '已结束';
|
||||
case 'Scheduled': return '计划中';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">作业发布</h2>
|
||||
<p className="text-gray-500 text-sm">作业与测评管理</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="flex items-center gap-2 bg-blue-600 text-white px-5 py-2.5 rounded-full text-sm font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
<Plus size={18} />
|
||||
发布作业
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{assignments.map((item, idx) => {
|
||||
const progress = Math.round((item.submittedCount / item.totalCount) * 100);
|
||||
|
||||
return (
|
||||
<Card key={item.id} delay={idx * 0.1} className="flex flex-col md:flex-row items-center gap-6 group border-transparent hover:border-blue-200 transition-all">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0 text-xl font-bold ${item.status === 'Active' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{progress}%
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 w-full text-center md:text-left">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 mb-1">
|
||||
<h3 className="text-lg font-bold text-gray-900 truncate">{item.title}</h3>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase tracking-wider w-fit mx-auto md:mx-0 ${getStatusStyle(item.status)}`}>
|
||||
{getStatusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-sm text-gray-500 mb-2">
|
||||
<FileText size={14} />
|
||||
<span>关联试卷: <span className="font-medium text-gray-700">{item.examTitle}</span></span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center md:justify-start gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
|
||||
<Users size={14} className="text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{item.className}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded-md">
|
||||
<Calendar size={14} className="text-gray-400" />
|
||||
<span>截止: {item.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-48 flex flex-col gap-2">
|
||||
<div className="flex justify-between text-xs font-bold text-gray-500">
|
||||
<span>提交进度</span>
|
||||
<span>{item.submittedCount}/{item.totalCount}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${item.status === 'Active' ? 'bg-blue-500' : 'bg-gray-400'}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onNavigateToPreview && onNavigateToPreview(item.id)}
|
||||
title="预览试卷"
|
||||
className="p-3 rounded-xl hover:bg-purple-50 text-gray-400 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
<Eye size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAnalyze && onAnalyze(item.id)}
|
||||
title="数据分析"
|
||||
className="p-3 rounded-xl hover:bg-blue-50 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<BarChart3 size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigateToGrading && onNavigateToGrading(item.id)}
|
||||
title="进入批改"
|
||||
className="p-3 rounded-xl hover:bg-gray-100 text-gray-400 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
src/features/auth/components/LoginForm.tsx
Normal file
110
src/features/auth/components/LoginForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { authService } from '../../../services/api';
|
||||
import { GraduationCap, Loader2, ArrowRight, Lock, User } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface LoginFormProps {
|
||||
onLoginSuccess: (user: any) => void;
|
||||
onSwitch?: () => void;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess, onSwitch }) => {
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await authService.login(username, password);
|
||||
onLoginSuccess(result.user);
|
||||
} catch (err) {
|
||||
setError('登录失败,请检查账号或密码');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/80 backdrop-blur-2xl rounded-[32px] shadow-[0_20px_40px_rgba(0,0,0,0.08)] border border-white/50 p-8 md:p-10">
|
||||
<div className="flex flex-col items-center mb-10">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-blue-500/30 mb-6">
|
||||
<GraduationCap size={32} strokeWidth={2} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">欢迎回来</h1>
|
||||
<p className="text-gray-500 text-sm mt-2">EduNexus 智能教育管理系统</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1 uppercase tracking-wide flex items-center gap-1">
|
||||
<User size={12} /> 账号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium placeholder:text-gray-400"
|
||||
placeholder="请输入学号或工号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1 uppercase tracking-wide flex items-center gap-1">
|
||||
<Lock size={12} /> 密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium placeholder:text-gray-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, y: -10 },
|
||||
animate: { opacity: 1, y: 0 }
|
||||
} as any)}
|
||||
className="p-3 rounded-lg bg-red-50 text-red-500 text-sm font-medium text-center"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 rounded-xl bg-[#007AFF] text-white font-bold text-lg shadow-lg shadow-blue-500/30 hover:bg-blue-600 hover:shadow-blue-600/40 active:scale-[0.98] transition-all flex items-center justify-center gap-2 mt-4 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
立即登录 <ArrowRight size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center space-y-2">
|
||||
<p className="text-xs text-gray-400">
|
||||
忘记密码? <button className="text-blue-600 font-bold hover:underline">联系管理员</button>
|
||||
</p>
|
||||
{onSwitch && (
|
||||
<p className="text-xs text-gray-400">
|
||||
没有账号? <button onClick={onSwitch} className="text-blue-600 font-bold hover:underline">注册新账号</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
170
src/features/auth/components/RegisterForm.tsx
Normal file
170
src/features/auth/components/RegisterForm.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { authService } from '../../../services/api';
|
||||
import { GraduationCap, Loader2, ArrowRight, Lock, User, Smile, Check } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onLoginSuccess: (user: any) => void;
|
||||
onSwitch: () => void;
|
||||
}
|
||||
|
||||
export const RegisterForm: React.FC<RegisterFormProps> = ({ onLoginSuccess, onSwitch }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
realName: '',
|
||||
studentId: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'Student' as 'Teacher' | 'Student'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
setError('密码长度至少为 6 位');
|
||||
return;
|
||||
}
|
||||
if (!formData.realName || !formData.studentId) {
|
||||
setError('请完善信息');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await authService.register({
|
||||
realName: formData.realName,
|
||||
studentId: formData.studentId,
|
||||
password: formData.password,
|
||||
role: formData.role
|
||||
});
|
||||
onLoginSuccess(result.user);
|
||||
} catch (err) {
|
||||
setError('注册失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white/80 backdrop-blur-2xl rounded-[32px] shadow-[0_20px_40px_rgba(0,0,0,0.08)] border border-white/50 p-8 md:p-10">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">创建账号</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">加入 EduNexus 开启智慧学习之旅</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
{/* Role Switcher */}
|
||||
<div className="bg-gray-100 p-1 rounded-xl flex mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, role: 'Student'})}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-bold transition-all ${formData.role === 'Student' ? 'bg-white shadow text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
我是学生
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, role: 'Teacher'})}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-bold transition-all ${formData.role === 'Teacher' ? 'bg-white shadow text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
我是老师
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1 uppercase tracking-wide flex items-center gap-1">
|
||||
<Smile size={12} /> 真实姓名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.realName}
|
||||
onChange={(e) => setFormData({...formData, realName: e.target.value})}
|
||||
className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium"
|
||||
placeholder="例如:张三"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1 uppercase tracking-wide flex items-center gap-1">
|
||||
<User size={12} /> {formData.role === 'Student' ? '学号' : '工号'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.studentId}
|
||||
onChange={(e) => setFormData({...formData, studentId: e.target.value})}
|
||||
className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium"
|
||||
placeholder="请输入号码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1 uppercase tracking-wide flex items-center gap-1">
|
||||
<Lock size={12} /> 密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({...formData, password: e.target.value})}
|
||||
className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 outline-none transition-all text-gray-900 font-medium"
|
||||
placeholder="••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-500 ml-1 uppercase tracking-wide flex items-center gap-1">
|
||||
<Check size={12} /> 确认
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({...formData, confirmPassword: e.target.value})}
|
||||
className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 outline-none transition-all text-gray-900 font-medium"
|
||||
placeholder="••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
// Fix: cast props to any to avoid framer-motion type errors
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, y: -10 },
|
||||
animate: { opacity: 1, y: 0 }
|
||||
} as any)}
|
||||
className="p-3 rounded-lg bg-red-50 text-red-500 text-sm font-medium text-center"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 rounded-xl bg-gray-900 text-white font-bold text-lg shadow-lg hover:bg-black active:scale-[0.98] transition-all flex items-center justify-center gap-2 mt-2 disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
立即注册 <ArrowRight size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-400">
|
||||
已有账号? <button onClick={onSwitch} className="text-blue-600 font-bold hover:underline">直接登录</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
src/features/class/components/ClassAnalysis.tsx
Normal file
101
src/features/class/components/ClassAnalysis.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { analyticsService } from '@/services/api';
|
||||
import { RadarChartDto, ChartDataDto, ScoreDistributionDto } from '../../../../UI_DTO';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar,
|
||||
ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
BarChart, Bar, Cell
|
||||
} from 'recharts';
|
||||
|
||||
export const ClassAnalysis: React.FC = () => {
|
||||
const [radarData, setRadarData] = useState<RadarChartDto | null>(null);
|
||||
const [trendData, setTrendData] = useState<ChartDataDto | null>(null);
|
||||
const [distData, setDistData] = useState<ScoreDistributionDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
analyticsService.getRadar().then(setRadarData);
|
||||
analyticsService.getClassPerformance().then(setTrendData);
|
||||
analyticsService.getScoreDistribution().then(setDistData);
|
||||
}, []);
|
||||
|
||||
if (!radarData || !trendData) return <div className="p-10 text-center text-gray-400">加载分析数据...</div>;
|
||||
|
||||
const radarChartData = radarData.indicators.map((ind, i) => ({
|
||||
subject: ind,
|
||||
A: radarData.values[i],
|
||||
fullMark: 100
|
||||
}));
|
||||
|
||||
const areaChartData = trendData.labels.map((label, i) => ({
|
||||
name: label,
|
||||
value: trendData.datasets[0].data[i]
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-6 overflow-y-auto custom-scrollbar h-full">
|
||||
<Card className="min-h-[300px]" noPadding>
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<h3 className="font-bold text-gray-900">学科能力雷达</h3>
|
||||
</div>
|
||||
<div className="h-[280px] w-full p-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={radarChartData}>
|
||||
<PolarGrid stroke="#E5E5EA" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#6B7280', fontSize: 11, fontWeight: 600 }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 100]} tick={false} axisLine={false} />
|
||||
<Radar name="班级平均" dataKey="A" stroke="#007AFF" strokeWidth={3} fill="#007AFF" fillOpacity={0.2} />
|
||||
<Tooltip />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[300px]" noPadding>
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<h3 className="font-bold text-gray-900">近期成绩走势</h3>
|
||||
</div>
|
||||
<div className="h-[280px] w-full p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={areaChartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorVal" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#34C759" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#34C759" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 11}} />
|
||||
<YAxis axisLine={false} tickLine={false} domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Area type="monotone" dataKey="value" stroke="#34C759" fill="url(#colorVal)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[300px] lg:col-span-2" noPadding>
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<h3 className="font-bold text-gray-900">分数段分布</h3>
|
||||
</div>
|
||||
<div className="h-[250px] w-full p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={distData} barSize={40}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="range" axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{fill: '#f9fafb'}} />
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{distData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={index >= 3 ? '#007AFF' : '#E5E7EB'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
src/features/class/components/ClassCard.tsx
Normal file
60
src/features/class/components/ClassCard.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ClassDto } from '../../../../UI_DTO';
|
||||
import { MoreVertical, GraduationCap, Users, ArrowRight } from 'lucide-react';
|
||||
|
||||
export const ClassCard: React.FC<{ classData: ClassDto; idx: number; onClick: () => void }> = ({ classData, idx, onClick }) => {
|
||||
const gradients = [
|
||||
'from-blue-500 to-cyan-400',
|
||||
'from-purple-500 to-pink-400',
|
||||
'from-orange-400 to-red-400',
|
||||
'from-emerald-400 to-teal-500',
|
||||
];
|
||||
|
||||
const gradient = gradients[idx % gradients.length];
|
||||
|
||||
return (
|
||||
<Card className="group flex flex-col h-64 p-0 overflow-hidden hover:shadow-xl transition-all duration-300 cursor-pointer" delay={idx * 0.1} noPadding onClick={onClick}>
|
||||
<div className={`h-28 bg-gradient-to-br ${gradient} p-6 relative`}>
|
||||
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-2 bg-white/20 hover:bg-white/30 rounded-full backdrop-blur-md text-white transition-colors">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-white mt-2">
|
||||
<p className="text-sm font-medium opacity-90 mb-1 uppercase tracking-wide flex items-center gap-2">
|
||||
<GraduationCap size={14} />
|
||||
{classData.gradeName}
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold tracking-tight">{classData.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col justify-between bg-white relative">
|
||||
<div className="absolute -top-6 right-6 w-12 h-12 bg-white rounded-xl shadow-lg flex items-center justify-center text-blue-600 font-bold text-lg">
|
||||
{classData.id.slice(-1)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Users size={16} />
|
||||
<span className="text-sm font-bold">{classData.studentCount} 名学生</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
邀请码: <span className="text-gray-800 font-bold select-all">{classData.inviteCode}</span>
|
||||
</span>
|
||||
<div className="ml-auto p-2 bg-gray-50 rounded-full text-gray-400 group-hover:text-blue-600 group-hover:bg-blue-50 transition-colors">
|
||||
<ArrowRight size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
108
src/features/class/components/ClassSettings.tsx
Normal file
108
src/features/class/components/ClassSettings.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ClassDto } from '../../../types';
|
||||
import { useToast } from '../../../components/ui/Toast';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Save, Copy, RefreshCw, Trash2, Lock, Unlock, Share2 } from 'lucide-react';
|
||||
|
||||
export const ClassSettings: React.FC<{ classData: ClassDto }> = ({ classData }) => {
|
||||
const { showToast } = useToast();
|
||||
const [name, setName] = useState(classData.name);
|
||||
const [isJoinable, setIsJoinable] = useState(true);
|
||||
|
||||
const handleSave = () => {
|
||||
showToast('班级设置已保存', 'success');
|
||||
};
|
||||
|
||||
const handleCopyInvite = () => {
|
||||
navigator.clipboard.writeText(classData.inviteCode);
|
||||
showToast('邀请码已复制', 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto space-y-8 h-full overflow-y-auto custom-scrollbar">
|
||||
{/* Basic Info */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-2">基本信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-600">班级名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-600">所属年级</label>
|
||||
<div className="px-4 py-2 bg-gray-100 text-gray-500 rounded-xl font-medium cursor-not-allowed">
|
||||
{classData.gradeName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Invite & Access */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-2">邀请与权限</h3>
|
||||
<div className="bg-blue-50 rounded-xl p-6 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-blue-800 mb-1">班级邀请码</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl font-mono font-bold text-blue-600 tracking-widest">{classData.inviteCode}</span>
|
||||
<button onClick={handleCopyInvite} className="p-2 hover:bg-blue-100 text-blue-600 rounded-lg transition-colors" title="复制">
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-blue-100 text-blue-600 rounded-lg transition-colors" title="重置">
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold text-gray-800">允许加入</p>
|
||||
<p className="text-xs text-gray-500">关闭后学生无法申请</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsJoinable(!isJoinable)}
|
||||
className={`w-14 h-8 rounded-full relative transition-colors duration-300 ${isJoinable ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<div className={`absolute top-1 left-1 w-6 h-6 bg-white rounded-full shadow transition-transform duration-300 ${isJoinable ? 'translate-x-6' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<section className="space-y-4 pt-4">
|
||||
<h3 className="text-lg font-bold text-red-600 border-b border-red-100 pb-2">危险区域</h3>
|
||||
<div className="border border-red-100 rounded-xl divide-y divide-red-50">
|
||||
<div className="p-4 flex items-center justify-between hover:bg-red-50/30 transition-colors">
|
||||
<div>
|
||||
<p className="font-bold text-gray-800">锁定班级</p>
|
||||
<p className="text-xs text-gray-500">锁定后无法进行任何变更操作</p>
|
||||
</div>
|
||||
<Button variant="outline" className="text-orange-500 border-orange-200 hover:bg-orange-50" icon={<Lock size={16} />}>锁定</Button>
|
||||
</div>
|
||||
<div className="p-4 flex items-center justify-between hover:bg-red-50/30 transition-colors">
|
||||
<div>
|
||||
<p className="font-bold text-gray-800">转让班级</p>
|
||||
<p className="text-xs text-gray-500">将班主任权限转交给其他教师</p>
|
||||
</div>
|
||||
<Button variant="outline" className="text-gray-600" icon={<Share2 size={16} />}>转让</Button>
|
||||
</div>
|
||||
<div className="p-4 flex items-center justify-between bg-red-50/50">
|
||||
<div>
|
||||
<p className="font-bold text-red-600">解散班级</p>
|
||||
<p className="text-xs text-red-400">此操作不可逆,所有数据将被删除</p>
|
||||
</div>
|
||||
<Button variant="danger" icon={<Trash2 size={16} />}>解散</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="pt-6 flex justify-end">
|
||||
<Button onClick={handleSave} icon={<Save size={18} />}>保存更改</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
src/features/class/components/CreateClassModal.tsx
Normal file
99
src/features/class/components/CreateClassModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Users, ArrowRight } from 'lucide-react';
|
||||
import { orgService } from '@/services/api';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface CreateClassModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const CreateClassModal: React.FC<CreateClassModalProps> = ({ onClose, onSuccess }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [gradeName, setGradeName] = useState('高一年级');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
showToast('请输入班级名称', 'error');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await orgService.createClass({ name, gradeName });
|
||||
showToast('班级创建成功!', 'success');
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
showToast('创建失败,请重试', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
{/* Fix: cast props to any to avoid framer-motion type errors */}
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, scale: 0.9, y: 20 },
|
||||
animate: { opacity: 1, scale: 1, y: 0 }
|
||||
} as any)}
|
||||
className="bg-white w-full max-w-md rounded-3xl shadow-2xl overflow-hidden relative z-10 flex flex-col"
|
||||
>
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center text-blue-600">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">创建新班级</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full text-gray-500"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-600">班级名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:高一 (14) 班"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all font-bold text-gray-900"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-600">所属年级</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['高一年级', '高二年级', '高三年级'].map(grade => (
|
||||
<button
|
||||
key={grade}
|
||||
onClick={() => setGradeName(grade)}
|
||||
className={`py-2 rounded-lg text-sm font-bold transition-all border ${gradeName === grade ? 'bg-blue-50 text-blue-600 border-blue-200' : 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'}`}
|
||||
>
|
||||
{grade}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
loading={loading}
|
||||
className="w-full py-3.5 rounded-xl text-base"
|
||||
>
|
||||
确认创建 <ArrowRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
src/features/class/components/JoinClassModal.tsx
Normal file
88
src/features/class/components/JoinClassModal.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Loader2, Hash, ArrowRight } from 'lucide-react';
|
||||
import { orgService } from '@/services/api';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface JoinClassModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const JoinClassModal: React.FC<JoinClassModalProps> = ({ onClose, onSuccess }) => {
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (code.length !== 5) {
|
||||
showToast('请输入5位邀请码', 'error');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await orgService.joinClass(code);
|
||||
showToast('成功加入班级!', 'success');
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
showToast('加入失败,请检查邀请码', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
{/* Fix: cast props to any to avoid framer-motion type errors */}
|
||||
<motion.div
|
||||
{...({
|
||||
initial: { opacity: 0, scale: 0.9, y: 20 },
|
||||
animate: { opacity: 1, scale: 1, y: 0 }
|
||||
} as any)}
|
||||
className="bg-white w-full max-w-sm rounded-3xl shadow-2xl overflow-hidden relative z-10 flex flex-col"
|
||||
>
|
||||
<div className="p-6 text-center">
|
||||
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center text-blue-600 mx-auto mb-4">
|
||||
<Hash size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">加入班级</h3>
|
||||
<p className="text-sm text-gray-500">请输入老师提供的 5 位邀请码</p>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pb-8 space-y-6">
|
||||
<div className="flex justify-center gap-2">
|
||||
{[0, 1, 2, 3, 4].map((idx) => (
|
||||
<div key={idx} className={`w-12 h-14 rounded-xl border-2 flex items-center justify-center text-2xl font-bold transition-all ${code.length === idx ? 'border-blue-500 ring-4 ring-blue-100' : 'border-gray-200'} ${code[idx] ? 'bg-gray-50 text-gray-900' : 'bg-white'}`}>
|
||||
{code[idx]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hidden Input for focus handling */}
|
||||
<input
|
||||
className="absolute opacity-0 inset-0 cursor-default"
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.toUpperCase().slice(0, 5);
|
||||
if (/^[A-Z0-9]*$/.test(val)) setCode(val);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
loading={loading}
|
||||
disabled={code.length !== 5}
|
||||
className="w-full py-4 rounded-2xl text-lg"
|
||||
>
|
||||
加入班级 <ArrowRight size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
src/features/class/components/StudentRow.tsx
Normal file
63
src/features/class/components/StudentRow.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ClassMemberDto } from '../../../../UI_DTO';
|
||||
import { ShieldCheck, MoreHorizontal } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
export const StudentRow = ({ student }: { student: ClassMemberDto }) => {
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch(status) {
|
||||
case 'Excellent': return 'success';
|
||||
case 'AtRisk': return 'danger';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = {
|
||||
'Excellent': '优秀',
|
||||
'AtRisk': '预警',
|
||||
'Active': '正常'
|
||||
}[student.status] || '未知';
|
||||
|
||||
return (
|
||||
<div className="flex items-center p-4 hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-0 group">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 mr-4 overflow-hidden">
|
||||
<img src={student.avatarUrl} alt={student.realName} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 grid grid-cols-12 gap-4 items-center">
|
||||
<div className="col-span-3">
|
||||
<div className="font-bold text-gray-900 flex items-center gap-2">
|
||||
{student.realName}
|
||||
{student.role === 'Monitor' && <ShieldCheck size={14} className="text-blue-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{student.studentId}</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-gray-600">
|
||||
{student.gender === 'Male' ? '男' : '女'}
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-end gap-1 h-8">
|
||||
{(
|
||||
Array.isArray(student.recentTrend) && student.recentTrend.length
|
||||
? student.recentTrend
|
||||
: [0, 0, 0, 0, 0]
|
||||
).map((score, i) => (
|
||||
<div key={i} className="flex-1 bg-blue-100 rounded-sm relative group/bar">
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-blue-500 rounded-sm" style={{ height: `${score}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Badge variant={getStatusVariant(student.status) as any} size="sm">{statusLabel}</Badge>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg">
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/features/curriculum/components/CurriculumNodeModal.tsx
Normal file
94
src/features/curriculum/components/CurriculumNodeModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Folder, FileText, Hash, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface CurriculumNodeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'add' | 'edit';
|
||||
type: 'unit' | 'lesson' | 'point';
|
||||
initialName?: string;
|
||||
onConfirm: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const CurriculumNodeModal: React.FC<CurriculumNodeModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
type,
|
||||
initialName = '',
|
||||
onConfirm
|
||||
}) => {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName(initialName);
|
||||
}
|
||||
}, [isOpen, initialName]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!name.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onConfirm(name);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
if (type === 'unit') return <Folder size={20} />;
|
||||
if (type === 'lesson') return <FileText size={20} />;
|
||||
return <Hash size={20} />;
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
const action = mode === 'add' ? '添加' : '编辑';
|
||||
const target = type === 'unit' ? '单元' : type === 'lesson' ? '课程' : '知识点';
|
||||
return `${action}${target}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={getTitle()}
|
||||
icon={getIcon()}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-600">
|
||||
{type === 'unit' ? '单元名称' : type === 'lesson' ? '课程名称' : '知识点名称'}
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="请输入名称..."
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all font-bold text-gray-900"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleConfirm()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
loading={loading}
|
||||
className="w-full py-3.5 rounded-xl text-base"
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
确认{mode === 'add' ? '添加' : '保存'} <ArrowRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
70
src/features/curriculum/components/DeleteConfirmModal.tsx
Normal file
70
src/features/curriculum/components/DeleteConfirmModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Trash2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
onConfirm: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = '确认删除',
|
||||
description = '确定要删除该项目吗?此操作无法撤销。',
|
||||
onConfirm
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
icon={<AlertTriangle size={20} className="text-red-500" />}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-red-50 p-4 rounded-xl flex gap-3 items-start">
|
||||
<Trash2 size={20} className="text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-red-700 text-sm font-medium leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3.5 rounded-xl text-base font-bold text-gray-600 bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
loading={loading}
|
||||
className="flex-1 py-3.5 rounded-xl text-base bg-red-500 hover:bg-red-600 text-white border-transparent"
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
84
src/features/curriculum/components/KnowledgeGraph.tsx
Normal file
84
src/features/curriculum/components/KnowledgeGraph.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export const KnowledgeGraph: React.FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Mock Nodes
|
||||
const nodes = Array.from({ length: 15 }).map((_, i) => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
r: Math.random() * 10 + 5,
|
||||
color: ['#3B82F6', '#8B5CF6', '#F59E0B', '#10B981'][Math.floor(Math.random() * 4)],
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5
|
||||
}));
|
||||
|
||||
// Animation Loop
|
||||
let animationId: number;
|
||||
const render = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Update positions
|
||||
nodes.forEach(node => {
|
||||
node.x += node.vx;
|
||||
node.y += node.vy;
|
||||
if (node.x < 0 || node.x > canvas.width) node.vx *= -1;
|
||||
if (node.y < 0 || node.y > canvas.height) node.vy *= -1;
|
||||
});
|
||||
|
||||
// Draw Connections
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)';
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const dx = nodes[i].x - nodes[j].x;
|
||||
const dy = nodes[i].y - nodes[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 150) {
|
||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw Nodes
|
||||
nodes.forEach(node => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = node.color;
|
||||
});
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
animationId = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
|
||||
return () => cancelAnimationFrame(animationId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-900 rounded-2xl overflow-hidden relative flex items-center justify-center">
|
||||
<canvas ref={canvasRef} width={800} height={600} className="absolute inset-0 w-full h-full" />
|
||||
<div className="absolute bottom-6 right-6 bg-black/50 backdrop-blur text-white p-4 rounded-xl border border-white/10">
|
||||
<h4 className="font-bold text-sm mb-2">图谱图例</h4>
|
||||
<div className="space-y-2 text-xs opacity-80">
|
||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-blue-500" /> 前置知识点</div>
|
||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-purple-500" /> 核心概念</div>
|
||||
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-green-500" /> 衍生应用</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user