commit 38244630a7a58542203c3c314f9baa5b97197818
Author: SpecialX <47072643+wangxiner55@users.noreply.github.com>
Date: Fri Nov 28 19:23:19 2025 +0800
chore: initial import to Nexus_Edu
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..37ebb2f
--- /dev/null
+++ b/.gitignore
@@ -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?
diff --git a/API_BCK b/API_BCK
new file mode 100644
index 0000000..69febbc
--- /dev/null
+++ b/API_BCK
@@ -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. **实时更新**: 某些操作(如提交作业)可能需要实时更新界面状态
diff --git a/API_BCK.md b/API_BCK.md
new file mode 100644
index 0000000..6e72c8c
--- /dev/null
+++ b/API_BCK.md
@@ -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. 公共管理
+**需要实现**:
+- 其他公共接口
diff --git a/Model.ts b/Model.ts
new file mode 100644
index 0000000..8f6ced8
--- /dev/null
+++ b/Model.ts
@@ -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'
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7010181
--- /dev/null
+++ b/README.md
@@ -0,0 +1,20 @@
+
+
+
+
+# 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`
diff --git a/UI_DTO.ts b/UI_DTO.ts
new file mode 100644
index 0000000..73d1443
--- /dev/null
+++ b/UI_DTO.ts
@@ -0,0 +1,568 @@
+/**
+ * UI_DTO.ts
+ * 前端数据传输对象(DTO)定义文件
+ * 包含所有前端与后端交互的接口定义
+ */
+
+// ============================================================
+// 0. Common / 通用
+// ============================================================
+
+export interface ResultDto {
+ success: boolean;
+ message: string;
+ data?: any;
+}
+
+export interface PagedResult {
+ 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; // 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;
+ register(data: RegisterDto): Promise;
+ me(): Promise;
+ updateProfile(data: UpdateProfileDto): Promise;
+ changePassword(data: ChangePasswordDto): Promise;
+}
+
+export interface IOrgService {
+ getClasses(role?: string): Promise;
+ getClassMembers(classId: string): Promise;
+ joinClass(inviteCode: string): Promise;
+ createClass(data: CreateClassDto): Promise;
+}
+
+export interface ICurriculumService {
+ getSubjects(): Promise;
+ getTree(id: string): Promise;
+ getUnits(textbookId: string): Promise;
+ getLessons(unitId: string): Promise;
+ getKnowledgePoints(lessonId: string): Promise;
+}
+
+export interface IQuestionService {
+ search(filter: any): Promise>;
+ parseText(rawText: string): Promise;
+ getQuestionKnowledges(questionId: string): Promise;
+}
+
+export interface IExamService {
+ getMyExams(): Promise>;
+ getExamDetail(id: string): Promise;
+ saveExam(exam: ExamDetailDto): Promise;
+ getStats(id: string): Promise;
+}
+
+export interface IAssignmentService {
+ getTeachingAssignments(): Promise>;
+ getStudentAssignments(): Promise>;
+ publishAssignment(data: any): Promise;
+ getAssignmentStats(id: string): Promise;
+}
+
+export interface IAnalyticsService {
+ getClassPerformance(): Promise;
+ getStudentGrowth(): Promise;
+ getRadar(): Promise;
+ getStudentRadar(): Promise;
+ getScoreDistribution(): Promise;
+}
+
+export interface IGradingService {
+ getSubmissions(assignmentId: string): Promise;
+ getPaper(submissionId: string): Promise;
+ saveGrading(submissionId: string, details: SubmissionDetailDto[]): Promise;
+}
+
+export interface ISubmissionService {
+ getStudentPaper(assignmentId: string): Promise;
+ submitExam(data: SubmitExamDto): Promise;
+ getSubmissionResult(assignmentId: string): Promise;
+ getSubmissionDetails(submissionId: string): Promise;
+}
+
+
+export interface ICommonService {
+ getSchedule(): Promise;
+}
+
+export interface IMessageService {
+ getMessages(): Promise;
+ markAsRead(id: string): Promise;
+ createMessage(data: CreateMessageDto): Promise;
+}
+
+export interface IScheduleService {
+ getWeekSchedule(): Promise;
+ addEvent(data: CreateScheduleDto): Promise;
+ deleteEvent(id: string): Promise;
+}
+
+// ============================================================
+// 13. UI Types / UI类型定义
+// ============================================================
+
+export type ViewState = 'login' | 'dashboard' | 'curriculum' | 'questions' | 'classes' | 'exams' | 'assignments' | 'settings' | 'grading' | 'student-exam' | 'student-result' | 'messages' | 'schedule';
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..8ecddf5
--- /dev/null
+++ b/backend/.env.example
@@ -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"
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..7edfca0
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.env
+.DS_Store
+*.log
+prisma/.env
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..0efdfbb
--- /dev/null
+++ b/backend/README.md
@@ -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 `
+
+## 项目结构
+
+```
+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)
diff --git a/backend/package-lock.json b/backend/package-lock.json
new file mode 100644
index 0000000..5f777ca
--- /dev/null
+++ b/backend/package-lock.json
@@ -0,0 +1,1841 @@
+{
+ "name": "edunexus-backend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "edunexus-backend",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@prisma/client": "^5.22.0",
+ "bcryptjs": "^2.4.3",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.21.1",
+ "jsonwebtoken": "^9.0.2",
+ "uuid": "^11.0.3",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@types/bcryptjs": "^2.4.6",
+ "@types/cors": "^2.8.17",
+ "@types/express": "^5.0.0",
+ "@types/jsonwebtoken": "^9.0.7",
+ "@types/node": "^22.10.1",
+ "@types/uuid": "^10.0.0",
+ "prisma": "^5.22.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
+ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.13"
+ },
+ "peerDependencies": {
+ "prisma": "*"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
+ "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
+ "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/fetch-engine": "5.22.0",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
+ "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
+ "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
+ "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0"
+ }
+ },
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/cors": {
+ "version": "2.8.19",
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
+ "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^1"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
+ "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/mime": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
+ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
+ "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*",
+ "@types/send": "<1"
+ }
+ },
+ "node_modules/@types/serve-static/node_modules/@types/send": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
+ "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/mime": "^1",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/bcryptjs": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.13.0",
+ "raw-body": "2.5.2",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.21.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.3",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.7.1",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.3.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.13.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.19.0",
+ "serve-static": "1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
+ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "node_modules/prisma": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
+ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/engines": "5.22.0"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=16.13"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.19.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.20.6",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
+ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.25.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..b1bff6e
--- /dev/null
+++ b/backend/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
new file mode 100644
index 0000000..f13a162
--- /dev/null
+++ b/backend/prisma/schema.prisma
@@ -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")
+}
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
new file mode 100644
index 0000000..4153e5f
--- /dev/null
+++ b/backend/prisma/seed.ts
@@ -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: '已知集合 A = {1, 2, 3}, B = {2, 3, 4}, 则 A ∩ B = ( )
',
+ 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: '若集合 A ⊆ B,则下列说法正确的是 ( )
',
+ 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: '函数 f(x) = x² - 2x + 1 的最小值是 ______
',
+ 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();
+ });
diff --git a/backend/src/controllers/analytics.controller.ts b/backend/src/controllers/analytics.controller.ts
new file mode 100644
index 0000000..6dd74db
--- /dev/null
+++ b/backend/src/controllers/analytics.controller.ts
@@ -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();
+ 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' });
+ }
+};
diff --git a/backend/src/controllers/assignment.controller.ts b/backend/src/controllers/assignment.controller.ts
new file mode 100644
index 0000000..7b727c7
--- /dev/null
+++ b/backend/src/controllers/assignment.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts
new file mode 100644
index 0000000..8109e3d
--- /dev/null
+++ b/backend/src/controllers/auth.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/common.controller.ts b/backend/src/controllers/common.controller.ts
new file mode 100644
index 0000000..6ce520f
--- /dev/null
+++ b/backend/src/controllers/common.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/curriculum.controller.ts b/backend/src/controllers/curriculum.controller.ts
new file mode 100644
index 0000000..e65f9fa
--- /dev/null
+++ b/backend/src/controllers/curriculum.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/exam.controller.ts b/backend/src/controllers/exam.controller.ts
new file mode 100644
index 0000000..7eec781
--- /dev/null
+++ b/backend/src/controllers/exam.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/grading.controller.ts b/backend/src/controllers/grading.controller.ts
new file mode 100644
index 0000000..404f3d6
--- /dev/null
+++ b/backend/src/controllers/grading.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/org.controller.ts b/backend/src/controllers/org.controller.ts
new file mode 100644
index 0000000..e73f3c6
--- /dev/null
+++ b/backend/src/controllers/org.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/question.controller.ts b/backend/src/controllers/question.controller.ts
new file mode 100644
index 0000000..46a689e
--- /dev/null
+++ b/backend/src/controllers/question.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/controllers/submission.controller.ts b/backend/src/controllers/submission.controller.ts
new file mode 100644
index 0000000..16bf10d
--- /dev/null
+++ b/backend/src/controllers/submission.controller.ts
@@ -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' });
+ }
+};
diff --git a/backend/src/index.ts b/backend/src/index.ts
new file mode 100644
index 0000000..ec08e94
--- /dev/null
+++ b/backend/src/index.ts
@@ -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;
diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts
new file mode 100644
index 0000000..d23af4f
--- /dev/null
+++ b/backend/src/middleware/auth.middleware.ts
@@ -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();
+ }
+};
diff --git a/backend/src/routes/analytics.routes.ts b/backend/src/routes/analytics.routes.ts
new file mode 100644
index 0000000..9d2683d
--- /dev/null
+++ b/backend/src/routes/analytics.routes.ts
@@ -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;
diff --git a/backend/src/routes/assignment.routes.ts b/backend/src/routes/assignment.routes.ts
new file mode 100644
index 0000000..402575d
--- /dev/null
+++ b/backend/src/routes/assignment.routes.ts
@@ -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;
diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts
new file mode 100644
index 0000000..b5ad3a1
--- /dev/null
+++ b/backend/src/routes/auth.routes.ts
@@ -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;
diff --git a/backend/src/routes/common.routes.ts b/backend/src/routes/common.routes.ts
new file mode 100644
index 0000000..960d511
--- /dev/null
+++ b/backend/src/routes/common.routes.ts
@@ -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;
diff --git a/backend/src/routes/curriculum.routes.ts b/backend/src/routes/curriculum.routes.ts
new file mode 100644
index 0000000..d7b28a6
--- /dev/null
+++ b/backend/src/routes/curriculum.routes.ts
@@ -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;
diff --git a/backend/src/routes/exam.routes.ts b/backend/src/routes/exam.routes.ts
new file mode 100644
index 0000000..4c11c6f
--- /dev/null
+++ b/backend/src/routes/exam.routes.ts
@@ -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;
diff --git a/backend/src/routes/grading.routes.ts b/backend/src/routes/grading.routes.ts
new file mode 100644
index 0000000..6b28ba7
--- /dev/null
+++ b/backend/src/routes/grading.routes.ts
@@ -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;
diff --git a/backend/src/routes/org.routes.ts b/backend/src/routes/org.routes.ts
new file mode 100644
index 0000000..4e1b4b4
--- /dev/null
+++ b/backend/src/routes/org.routes.ts
@@ -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;
diff --git a/backend/src/routes/question.routes.ts b/backend/src/routes/question.routes.ts
new file mode 100644
index 0000000..47ba0b0
--- /dev/null
+++ b/backend/src/routes/question.routes.ts
@@ -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;
diff --git a/backend/src/routes/submission.routes.ts b/backend/src/routes/submission.routes.ts
new file mode 100644
index 0000000..33240eb
--- /dev/null
+++ b/backend/src/routes/submission.routes.ts
@@ -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;
diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts
new file mode 100644
index 0000000..d1b2e5c
--- /dev/null
+++ b/backend/src/services/auth.service.ts
@@ -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();
diff --git a/backend/src/services/exam.service.ts b/backend/src/services/exam.service.ts
new file mode 100644
index 0000000..2abb274
--- /dev/null
+++ b/backend/src/services/exam.service.ts
@@ -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();
diff --git a/backend/src/utils/helpers.ts b/backend/src/utils/helpers.ts
new file mode 100644
index 0000000..255872d
--- /dev/null
+++ b/backend/src/utils/helpers.ts
@@ -0,0 +1,143 @@
+import prisma from './prisma';
+
+/**
+ * 生成唯一的6位邀请码
+ * 格式:大写字母和数字组合,排除易混淆字符(0, O, I, 1)
+ */
+export async function generateInviteCode(): Promise {
+ 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>(
+ 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 {
+ const membership = await prisma.classMember.findFirst({
+ where: {
+ userId,
+ classId,
+ roleInClass: 'Teacher',
+ isDeleted: false
+ }
+ });
+
+ return !!membership;
+}
+
+/**
+ * 验证用户是否是班级成员(教师或学生)
+ */
+export async function isClassMember(userId: string, classId: string): Promise {
+ const membership = await prisma.classMember.findFirst({
+ where: {
+ userId,
+ classId,
+ isDeleted: false
+ }
+ });
+
+ return !!membership;
+}
diff --git a/backend/src/utils/prisma.ts b/backend/src/utils/prisma.ts
new file mode 100644
index 0000000..e030d2f
--- /dev/null
+++ b/backend/src/utils/prisma.ts
@@ -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;
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
new file mode 100644
index 0000000..1ba620c
--- /dev/null
+++ b/backend/tsconfig.json
@@ -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"
+ ]
+}
\ No newline at end of file
diff --git a/database/README.md b/database/README.md
new file mode 100644
index 0000000..b0558e4
--- /dev/null
+++ b/database/README.md
@@ -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服务连接数据库。
diff --git a/database/drop.sql b/database/drop.sql
new file mode 100644
index 0000000..60218aa
--- /dev/null
+++ b/database/drop.sql
@@ -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;
diff --git a/database/schema.sql b/database/schema.sql
new file mode 100644
index 0000000..d0c2f78
--- /dev/null
+++ b/database/schema.sql
@@ -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;
diff --git a/database/seed.sql b/database/seed.sql
new file mode 100644
index 0000000..1687e19
--- /dev/null
+++ b/database/seed.sql
@@ -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', '下列各数中,是负数的是( )
', '{"options": ["A. 0", "B. -1", "C. 1", "D. 2"]}', 'SingleChoice', 'B', '负数是小于0的数,所以答案是B
', 1, 'system', 'system'),
+('q-2', 'sub-math', '|-5| = _____
', NULL, 'FillBlank', '5', '绝对值表示数轴上的点到原点的距离
', 2, 'system', 'system'),
+('q-3', 'sub-math', '计算:(-3) + 5 = ?
', NULL, 'Subjective', '2', '负数加正数,按绝对值相减,取绝对值较大数的符号
', 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;
diff --git a/metadata.json b/metadata.json
new file mode 100644
index 0000000..eb5ed24
--- /dev/null
+++ b/metadata.json
@@ -0,0 +1,5 @@
+{
+ "name": "EduNexus Pro",
+ "description": "下一代前卫的教育管理系统,采用 Apple 风格美学,具有全面的课程跟踪和先进的学习分析功能。",
+ "requestFramePermissions": []
+}
\ No newline at end of file
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f25349f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2054 @@
+{
+ "name": "edunexus-pro",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "edunexus-pro",
+ "version": "2.0.0",
+ "dependencies": {
+ "clsx": "^2.1.0",
+ "framer-motion": "^11.0.24",
+ "lucide-react": "^0.368.0",
+ "next": "14.2.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "recharts": "^2.12.4",
+ "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"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.0.tgz",
+ "integrity": "sha512-4+70ELtSbRtYUuyRpAJmKC8NHBW2x1HMje9KO2Xd7IkoyucmV9SjgO+qeWMC0JWkRQXgydv1O7yKOK8nu/rITQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.0.tgz",
+ "integrity": "sha512-kHktLlw0AceuDnkVljJ/4lTJagLzDiO3klR1Fzl2APDFZ8r+aTxNaNcPmpp0xLMkgRwwk6sggYeqq0Rz9K4zzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.0.tgz",
+ "integrity": "sha512-HFSDu7lb1U3RDxXNeKH3NGRR5KyTPBSUTuIOr9jXoAso7i76gNYvnTjbuzGVWt2X5izpH908gmOYWtI7un+JrA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.0.tgz",
+ "integrity": "sha512-iQsoWziO5ZMxDWZ4ZTCAc7hbJ1C9UDj/gATSqTaMjW2bJFwAsvf9UM79AKnljBl73uPZ+V0kH4rvnHTco4Ps2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.0.tgz",
+ "integrity": "sha512-0JOk2uzLUt8fJK5LpsKKZa74zAch7bJjjgJzR9aOMs231AlE4gPYzsSm430ckZitjPGKeH5bgDZjqwqJQKIS2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.0.tgz",
+ "integrity": "sha512-uYHkuTzX0NM6biKNp7hdKTf+BF0iMV254SxO0B8PgrQkxUBKGmk5ysHKB+FYBfdf9xei/t8OIKlXJs9ckD943A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.0.tgz",
+ "integrity": "sha512-paN89nLs2dTBDtfXWty1/NVPit+q6ldwdktixYSVwiiAz647QDCd+EIYqoiS+/rPG3oXs/A7rWcJK9HVqfnMVg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.0.tgz",
+ "integrity": "sha512-j1oiidZisnymYjawFqEfeGNcE22ZQ7lGUaa4pGOCVWrWeIDkPSj8zYgS9TzMNlg17Q3wSWCQC/F5uJAhSh7qcA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.0.tgz",
+ "integrity": "sha512-6ff6F4xb+QGD1jhx/dOT9Ot7PQ/GAYekV9ykwEh2EFS/cLTyU4Y3cXkX5cNtNIhpctS5NvyjW9gIksRNErYE0A==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.0.tgz",
+ "integrity": "sha512-09DbG5vXAxz0eTFSf1uebWD36GF3D5toynRkgo2AlSrxwGZkWtJ1RhmrczRYQ17eD5bdo4FZ0ibiffdq5kc4vg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.25",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
+ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.27",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
+ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.22",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
+ "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.27.0",
+ "caniuse-lite": "^1.0.30001754",
+ "fraction.js": "^5.3.4",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.8.31",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
+ "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.0",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
+ "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.8.25",
+ "caniuse-lite": "^1.0.30001754",
+ "electron-to-chromium": "^1.5.249",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.1.4"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001757",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
+ "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.260",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz",
+ "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz",
+ "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+ "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "11.18.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
+ "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^11.18.1",
+ "motion-utils": "^11.18.1",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.368.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.368.0.tgz",
+ "integrity": "sha512-soryVrCjheZs8rbXKdINw9B8iPi5OajBJZMJ1HORig89ljcOcEokKKAgGbg3QWxSXel7JwHOfDFUdDHAKyUAMw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "11.18.1",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
+ "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^11.18.1"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "11.18.1",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
+ "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/next/-/next-14.2.0.tgz",
+ "integrity": "sha512-2T41HqJdKPqheR27ll7MFZ3gtTYvGew7cUc0PwPSyK9Ao5vvwpf9bYfP4V5YBGLckHF2kEGvrLte5BqLSv0s8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "14.2.0",
+ "@swc/helpers": "0.5.5",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.1"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "14.2.0",
+ "@next/swc-darwin-x64": "14.2.0",
+ "@next/swc-linux-arm64-gnu": "14.2.0",
+ "@next/swc-linux-arm64-musl": "14.2.0",
+ "@next/swc-linux-x64-gnu": "14.2.0",
+ "@next/swc-linux-x64-musl": "14.2.0",
+ "@next/swc-win32-arm64-msvc": "14.2.0",
+ "@next/swc-win32-ia32-msvc": "14.2.0",
+ "@next/swc-win32-x64-msvc": "14.2.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
+ "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
+ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
+ "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1e77473
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..fef1b22
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/src/app/(dashboard)/assignments/page.tsx b/src/app/(dashboard)/assignments/page.tsx
new file mode 100644
index 0000000..d778d93
--- /dev/null
+++ b/src/app/(dashboard)/assignments/page.tsx
@@ -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(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
+
+ setAnalyzingId(null)}
+ />
+
+ );
+ }
+
+ return (
+ <>
+
+ {isStudent ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isCreating && (
+ setIsCreating(false)}
+ onSuccess={() => {
+ setIsCreating(false);
+ }}
+ />
+ )}
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/classes/page.tsx b/src/app/(dashboard)/classes/page.tsx
new file mode 100644
index 0000000..9647df0
--- /dev/null
+++ b/src/app/(dashboard)/classes/page.tsx
@@ -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 ;
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/consoleconfig/page.tsx b/src/app/(dashboard)/consoleconfig/page.tsx
new file mode 100644
index 0000000..c98a607
--- /dev/null
+++ b/src/app/(dashboard)/consoleconfig/page.tsx
@@ -0,0 +1,9 @@
+
+"use client";
+
+import React from 'react';
+import { ConsoleConfig } from '@/views/ConsoleConfig';
+
+export default function ConsoleConfigPage() {
+ return ;
+}
diff --git a/src/app/(dashboard)/curriculum/page.tsx b/src/app/(dashboard)/curriculum/page.tsx
new file mode 100644
index 0000000..dda3f41
--- /dev/null
+++ b/src/app/(dashboard)/curriculum/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import React from 'react';
+import { Curriculum } from '@/views/Curriculum';
+
+export default function CurriculumPage() {
+ return ;
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx
new file mode 100644
index 0000000..6f40886
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/page.tsx
@@ -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 = {
+ '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 (
+
+
+
+
早安, {user.realName} {user.role === 'Student' ? '同学' : '老师'} 👋
+
+
+ {today}
+
+
+ {user.role === 'Teacher' && (
+
+
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"
+ >
+
+ 待办事项 (3)
+
+
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"
+ >
+
+ 快速创建
+
+
+ )}
+
+
+ {user.role === 'Student' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/exams/page.tsx b/src/app/(dashboard)/exams/page.tsx
new file mode 100644
index 0000000..e6841f9
--- /dev/null
+++ b/src/app/(dashboard)/exams/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import React from 'react';
+import { ExamEngine } from '@/views/ExamEngine';
+
+export default function ExamsPage() {
+ return ;
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
new file mode 100644
index 0000000..c15d4a0
--- /dev/null
+++ b/src/app/(dashboard)/layout.tsx
@@ -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
;
+ }
+
+ 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 (
+
+
+
+
+ {/* Top Bar */}
+
+
+
+ {getHeaderTitle(pathname || '')}
+
+
2024-2025 学年 第一学期
+
+
+
+
+
+
+
+
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"
+ >
+
+
+
+
+
+
+ {user.realName}
+ {roleName}
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/messages/page.tsx b/src/app/(dashboard)/messages/page.tsx
new file mode 100644
index 0000000..1178a77
--- /dev/null
+++ b/src/app/(dashboard)/messages/page.tsx
@@ -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 ;
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/questions/page.tsx b/src/app/(dashboard)/questions/page.tsx
new file mode 100644
index 0000000..760249b
--- /dev/null
+++ b/src/app/(dashboard)/questions/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import React from 'react';
+import { QuestionBank } from '@/views/QuestionBank';
+
+export default function QuestionsPage() {
+ return ;
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/schedule/page.tsx b/src/app/(dashboard)/schedule/page.tsx
new file mode 100644
index 0000000..f9180f4
--- /dev/null
+++ b/src/app/(dashboard)/schedule/page.tsx
@@ -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 ;
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx
new file mode 100644
index 0000000..fe9c8f7
--- /dev/null
+++ b/src/app/(dashboard)/settings/page.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import React from 'react';
+import { Settings } from '@/views/Settings';
+
+export default function SettingsPage() {
+ return ;
+}
\ No newline at end of file
diff --git a/src/app/(dashboard)/student-result/[id]/page.tsx b/src/app/(dashboard)/student-result/[id]/page.tsx
new file mode 100644
index 0000000..bbb857a
--- /dev/null
+++ b/src/app/(dashboard)/student-result/[id]/page.tsx
@@ -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 (
+ router.back()}
+ />
+ );
+}
\ No newline at end of file
diff --git a/src/app/(fullscreen)/grading/[id]/page.tsx b/src/app/(fullscreen)/grading/[id]/page.tsx
new file mode 100644
index 0000000..978d933
--- /dev/null
+++ b/src/app/(fullscreen)/grading/[id]/page.tsx
@@ -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 (
+ router.back()}
+ />
+ );
+}
\ No newline at end of file
diff --git a/src/app/(fullscreen)/layout.tsx b/src/app/(fullscreen)/layout.tsx
new file mode 100644
index 0000000..8ac3c2f
--- /dev/null
+++ b/src/app/(fullscreen)/layout.tsx
@@ -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
;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(fullscreen)/student-exam/[id]/page.tsx b/src/app/(fullscreen)/student-exam/[id]/page.tsx
new file mode 100644
index 0000000..ef5dd47
--- /dev/null
+++ b/src/app/(fullscreen)/student-exam/[id]/page.tsx
@@ -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 (
+ router.push('/assignments')}
+ />
+ );
+}
\ No newline at end of file
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..b219d70
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -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');
+ }
+}
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
new file mode 100644
index 0000000..3ce606a
--- /dev/null
+++ b/src/app/api/auth/me/route.ts
@@ -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 的数据'
+ });
+}
diff --git a/src/app/api/config/db/route.ts b/src/app/api/config/db/route.ts
new file mode 100644
index 0000000..791eb37
--- /dev/null
+++ b/src/app/api/config/db/route.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/org/classes/route.ts b/src/app/api/org/classes/route.ts
new file mode 100644
index 0000000..060c886
--- /dev/null
+++ b/src/app/api/org/classes/route.ts
@@ -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);
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..6a43c08
--- /dev/null
+++ b/src/app/globals.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..be9dd3d
--- /dev/null
+++ b/src/app/layout.tsx
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..25ba056
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,12 @@
+import { Loader2 } from 'lucide-react';
+
+export default function Loading() {
+ return (
+
+
+
+
EduNexus Loading...
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..ffef5a3
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -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 (
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
+
+ {mode === 'login' ? (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+ setMode('register')}
+ />
+
+ ) : (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+ setMode('login')}
+ />
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..a99bdd8
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,23 @@
+import Link from 'next/link';
+import { AlertTriangle, Home } from 'lucide-react';
+
+export default function NotFound() {
+ return (
+
+
+
页面未找到
+
+ 您访问的页面可能已被移除、更名或暂时不可用。
+
+
+
+ 返回首页
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..3d3c541
--- /dev/null
+++ b/src/app/page.tsx
@@ -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 (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..d3ff466
--- /dev/null
+++ b/src/components/ErrorBoundary.tsx
@@ -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 {
+ 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 (
+
+
+
+
+
+
+ 页面出错了
+
+
+
+ 抱歉,页面遇到了一个意外错误。您可以尝试刷新页面或返回首页。
+
+
+ {process.env.NODE_ENV === 'development' && this.state.error && (
+
+
+ 查看错误详情
+
+
+
{this.state.error.toString()}
+ {this.state.errorInfo && (
+
{this.state.errorInfo.componentStack}
+ )}
+
+
+ )}
+
+
+
+
+ 重试
+
+
+
+ 返回首页
+
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
new file mode 100644
index 0000000..33582ae
--- /dev/null
+++ b/src/components/Sidebar.tsx
@@ -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) => (
+
+
+ {isActive && (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+ )}
+
+
+ {label}
+
+
+
+);
+
+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 (
+ <>
+
+
+
+
+ >
+ )
+ }
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ )
+ };
+
+ const SidebarContent = () => (
+
+
+
+
+
+
+ EduNexus
+ 专业版
+
+
+
+
+ {renderNavItems()}
+
+
+
+ { 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'}`}
+ >
+
+ {isMock ? 'Mock Data' : 'Real API'}
+
+
+
+
+
+
+
+ 退出登录
+
+
+
+ );
+
+ return (
+ <>
+ {/* Desktop Sidebar */}
+
+
+ {/* Mobile Toggle */}
+
+ setIsMobileMenuOpen(true)} className="p-2 bg-white rounded-xl shadow-sm">
+
+
+
+
+ {/* Mobile Menu Overlay */}
+
+ {isMobileMenuOpen && (
+ <>
+ setIsMobileMenuOpen(false)}
+ />
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+ setIsMobileMenuOpen(false)} className="p-2 hover:bg-gray-100 rounded-full">
+
+
+
+
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..671dae3
--- /dev/null
+++ b/src/components/ui/Badge.tsx
@@ -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 = ({
+ 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 (
+
+ {children}
+
+ );
+};
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000..1b83383
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,49 @@
+
+import React from 'react';
+import { Loader2 } from 'lucide-react';
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
+ size?: 'sm' | 'md' | 'lg';
+ loading?: boolean;
+ icon?: React.ReactNode;
+}
+
+export const Button: React.FC = ({
+ 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 (
+
+ {loading && }
+ {!loading && icon}
+ {children}
+
+ );
+};
diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx
new file mode 100644
index 0000000..42cbe86
--- /dev/null
+++ b/src/components/ui/Card.tsx
@@ -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 = ({ children, className = '', onClick, delay = 0, noPadding = false }) => {
+ return (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+ {children}
+
+ );
+};
diff --git a/src/components/ui/ChartTooltip.tsx b/src/components/ui/ChartTooltip.tsx
new file mode 100644
index 0000000..5fee12d
--- /dev/null
+++ b/src/components/ui/ChartTooltip.tsx
@@ -0,0 +1,18 @@
+
+import React from 'react';
+
+export const ChartTooltip = ({ active, payload, label }: any) => {
+ if (active && payload && payload.length) {
+ return (
+
+
{label}
+ {payload.map((entry: any, index: number) => (
+
+ {entry.name}: {entry.value}
+
+ ))}
+
+ );
+ }
+ return null;
+};
diff --git a/src/components/ui/ErrorState.tsx b/src/components/ui/ErrorState.tsx
new file mode 100644
index 0000000..3f601bb
--- /dev/null
+++ b/src/components/ui/ErrorState.tsx
@@ -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 = ({
+ 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 (
+
+
+
+
+
+
+
+ {title || config.defaultTitle}
+
+
+
+ {message || config.defaultMessage}
+
+
+
+ {showRetry && onRetry && (
+
+
+ 重试
+
+ )}
+ {onGoHome && (
+
+
+ 返回首页
+
+ )}
+
+
+
+ );
+};
+
+// 页面级错误组件
+export const ErrorPage: React.FC = (props) => (
+
+
+
+);
diff --git a/src/components/ui/HandwritingPad.tsx b/src/components/ui/HandwritingPad.tsx
new file mode 100644
index 0000000..92dd86c
--- /dev/null
+++ b/src/components/ui/HandwritingPad.tsx
@@ -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(null);
+ const [drawing, setDrawing] = useState(false);
+ const [ctx, setCtx] = useState(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) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const onMouseDown = (e: React.MouseEvent) => {
+ if (!ctx) return;
+ const { x, y } = getPos(e);
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ setDrawing(true);
+ };
+
+ const onMouseMove = (e: React.MouseEvent) => {
+ 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 (
+
+
+
+ 清空
+ {
+ const url = canvasRef.current?.toDataURL('image/png');
+ if (url) onChange(url);
+ }}>保存
+
+
+ );
+}
diff --git a/src/components/ui/LoadingState.tsx b/src/components/ui/LoadingState.tsx
new file mode 100644
index 0000000..8c7063a
--- /dev/null
+++ b/src/components/ui/LoadingState.tsx
@@ -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 = ({
+ 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 = (
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+
+ if (fullScreen) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+};
+
+// 骨架屏加载组件
+export const SkeletonCard: React.FC = () => (
+
+);
+
+export const SkeletonTable: React.FC<{ rows?: number }> = ({ rows = 5 }) => (
+
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+);
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..7494224
--- /dev/null
+++ b/src/components/ui/Modal.tsx
@@ -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 = ({
+ isOpen,
+ onClose,
+ title,
+ children,
+ icon,
+ maxWidth = 'max-w-md'
+}) => {
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+
{title}
+
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..98f9677
--- /dev/null
+++ b/src/components/ui/Toast.tsx
@@ -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(undefined);
+
+export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [toasts, setToasts] = useState([]);
+
+ 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 (
+
+ {children}
+
+
+ {toasts.map((toast) => (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+
+ {toast.type === 'success' &&
}
+ {toast.type === 'error' &&
}
+ {toast.type === 'info' &&
}
+
+ {toast.message}
+ removeToast(toast.id)} className="text-gray-400 hover:text-gray-600">
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export const useToast = () => {
+ const context = useContext(ToastContext);
+ if (!context) throw new Error('useToast must be used within a ToastProvider');
+ return context;
+};
diff --git a/src/features/assignment/components/AssignmentStats.tsx b/src/features/assignment/components/AssignmentStats.tsx
new file mode 100644
index 0000000..654918f
--- /dev/null
+++ b/src/features/assignment/components/AssignmentStats.tsx
@@ -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 = ({ assignmentId, onBack }) => {
+ const [stats, setStats] = useState(null);
+
+ useEffect(() => {
+ assignmentService.getAssignmentStats(assignmentId).then(setStats);
+ }, [assignmentId]);
+
+ if (!stats) return ;
+
+ const StatItem = ({ label, value, icon: Icon, color }: any) => (
+
+
+
+
+
+
+ );
+
+ return (
+
+
+ } onClick={onBack} />
+
作业数据分析
+
+
+ {/* Key Metrics */}
+
+
+
+
+
+
+
+
+ {/* Score Distribution */}
+
+ 成绩分布直方图
+
+
+
+
+
+
+
+ {stats.scoreDistribution.map((entry, index) => (
+ | = 3 ? '#34C759' : '#FF3B30'} />
+ ))}
+ |
+
+
+
+
+ {/* Wrong Questions Leaderboard */}
+
+
+
+ 高频错题榜
+
+
+ {stats.wrongQuestions.map((q, i) => (
+
+
+
+ {i + 1}
+
+ 错误率 {q.errorRate}%
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/assignment/components/CreateAssignmentModal.tsx b/src/features/assignment/components/CreateAssignmentModal.tsx
new file mode 100644
index 0000000..183e571
--- /dev/null
+++ b/src/features/assignment/components/CreateAssignmentModal.tsx
@@ -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 = ({ onClose, onSuccess }) => {
+ const [step, setStep] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const { showToast } = useToast();
+
+ const [exams, setExams] = useState([]);
+ const [classes, setClasses] = useState([]);
+
+ const [selectedExam, setSelectedExam] = useState(null);
+ const [selectedClassIds, setSelectedClassIds] = useState([]);
+ 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 (
+
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
发布新作业
+
+
+
+
+
+
+ {steps.map((s) => (
+
+
= s.num ? 'bg-blue-600 border-blue-600 text-white' : 'bg-white border-gray-200 text-gray-400'}`}>
+ {step > s.num ? : s.num}
+
+
= s.num ? 'text-blue-600' : 'text-gray-400'}`}>{s.label}
+
+ ))}
+
+
+
+
+
+ {step === 1 && (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+
+
+
+
+
+ {exams.map(exam => (
+
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'}
+ `}
+ >
+
+
+
+
+
{exam.title}
+
+ {exam.questionCount} 题
+ {exam.duration} 分钟
+ 总分 {exam.totalScore}
+
+
+ {selectedExam?.id === exam.id &&
}
+
+ ))}
+
+
+ )}
+
+ {step === 2 && (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+ {classes.map(cls => {
+ const isSelected = selectedClassIds.includes(cls.id);
+ return (
+ {
+ 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'}
+ `}
+ >
+
+
+
+
+
{cls.name}
+
{cls.studentCount} 名学生
+
+
+ {isSelected && }
+
+
+ )
+ })}
+
+ )}
+
+ {step === 3 && (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+
+ 作业标题
+ 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"
+ />
+
+
+
+
+
开始时间
+
+
+ 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"
+ />
+
+
+
+
截止时间
+
+
+ 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"
+ />
+
+
+
+
+
+
发布预览
+
+ • 试卷:{selectedExam?.title}
+ • 对象:共选中 {selectedClassIds.length} 个班级
+ • 时长:{selectedExam?.duration} 分钟
+
+
+
+ )}
+
+
+
+
+ {step > 1 ? (
+
setStep(step - 1)} className="text-gray-500 font-bold hover:text-gray-900 px-4 py-2">上一步
+ ) : (
+
+ )}
+
+ {step < 3 ? (
+
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"
+ >
+ 下一步
+
+ ) : (
+
+ {loading ? : }
+ 确认发布
+
+ )}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/assignment/components/StudentAssignmentList.tsx b/src/features/assignment/components/StudentAssignmentList.tsx
new file mode 100644
index 0000000..5c1b92d
--- /dev/null
+++ b/src/features/assignment/components/StudentAssignmentList.tsx
@@ -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 = ({ onStartExam, onViewResult }) => {
+ const [assignments, setAssignments] = useState([]);
+
+ useEffect(() => {
+ assignmentService.getStudentAssignments().then(res => setAssignments(res.items));
+ }, []);
+
+ return (
+
+ {assignments.map((item, idx) => (
+
+
+ {item.status === 'Pending' ? : (item.status === 'Graded' ? item.score : )}
+
+
+
+
+
{item.title}
+
+ {item.status === 'Pending' ? '待完成' : (item.status === 'Graded' ? '已批改' : '已提交')}
+
+
+
试卷: {item.examTitle}
+
+ 截止时间: {item.endTime}
+
+
+
+
+ {item.status === 'Pending' ? (
+
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"
+ >
+ 开始答题
+
+ ) : (
+
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'}`}
+ >
+ {item.status === 'Graded' ? '查看详情' : '等待批改'}
+
+ )}
+
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/src/features/assignment/components/TeacherAssignmentList.tsx b/src/features/assignment/components/TeacherAssignmentList.tsx
new file mode 100644
index 0000000..fe3ae96
--- /dev/null
+++ b/src/features/assignment/components/TeacherAssignmentList.tsx
@@ -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 = ({
+ onNavigateToGrading,
+ onNavigateToPreview,
+ onAnalyze,
+ setIsCreating
+}) => {
+ const [assignments, setAssignments] = useState([]);
+
+ 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 (
+ <>
+
+
+
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"
+ >
+
+ 发布作业
+
+
+
+
+ {assignments.map((item, idx) => {
+ const progress = Math.round((item.submittedCount / item.totalCount) * 100);
+
+ return (
+
+
+ {progress}%
+
+
+
+
+
{item.title}
+
+ {getStatusLabel(item.status)}
+
+
+
+
+ 关联试卷: {item.examTitle}
+
+
+
+
+
+ {item.className}
+
+
+
+ 截止: {item.dueDate}
+
+
+
+
+
+
+ 提交进度
+ {item.submittedCount}/{item.totalCount}
+
+
+
+
+
+ onNavigateToPreview && onNavigateToPreview(item.id)}
+ title="预览试卷"
+ className="p-3 rounded-xl hover:bg-purple-50 text-gray-400 hover:text-purple-600 transition-colors"
+ >
+
+
+ onAnalyze && onAnalyze(item.id)}
+ title="数据分析"
+ className="p-3 rounded-xl hover:bg-blue-50 text-gray-400 hover:text-blue-600 transition-colors"
+ >
+
+
+ onNavigateToGrading && onNavigateToGrading(item.id)}
+ title="进入批改"
+ className="p-3 rounded-xl hover:bg-gray-100 text-gray-400 hover:text-gray-900 transition-colors"
+ >
+
+
+
+
+ );
+ })}
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/features/auth/components/LoginForm.tsx b/src/features/auth/components/LoginForm.tsx
new file mode 100644
index 0000000..f30b054
--- /dev/null
+++ b/src/features/auth/components/LoginForm.tsx
@@ -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 = ({ 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 (
+
+
+
+
+
+
欢迎回来
+
EduNexus 智能教育管理系统
+
+
+
+
+
+
+ 忘记密码? 联系管理员
+
+ {onSwitch && (
+
+ 没有账号? 注册新账号
+
+ )}
+
+
+ );
+};
diff --git a/src/features/auth/components/RegisterForm.tsx b/src/features/auth/components/RegisterForm.tsx
new file mode 100644
index 0000000..29d2fd1
--- /dev/null
+++ b/src/features/auth/components/RegisterForm.tsx
@@ -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 = ({ 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 (
+
+
+
创建账号
+
加入 EduNexus 开启智慧学习之旅
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/class/components/ClassAnalysis.tsx b/src/features/class/components/ClassAnalysis.tsx
new file mode 100644
index 0000000..c131b99
--- /dev/null
+++ b/src/features/class/components/ClassAnalysis.tsx
@@ -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(null);
+ const [trendData, setTrendData] = useState(null);
+ const [distData, setDistData] = useState([]);
+
+ useEffect(() => {
+ analyticsService.getRadar().then(setRadarData);
+ analyticsService.getClassPerformance().then(setTrendData);
+ analyticsService.getScoreDistribution().then(setDistData);
+ }, []);
+
+ if (!radarData || !trendData) return 加载分析数据...
;
+
+ 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 (
+
+
+
+
学科能力雷达
+
+
+
+
+
+
+
近期成绩走势
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
分数段分布
+
+
+
+
+
+
+
+
+ {distData.map((entry, index) => (
+ | = 3 ? '#007AFF' : '#E5E7EB'} />
+ ))}
+ |
+
+
+
+
+
+ );
+};
diff --git a/src/features/class/components/ClassCard.tsx b/src/features/class/components/ClassCard.tsx
new file mode 100644
index 0000000..04f07ce
--- /dev/null
+++ b/src/features/class/components/ClassCard.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ {classData.gradeName}
+
+
{classData.name}
+
+
+
+
+
+ {classData.id.slice(-1)}
+
+
+
+
+
+
+ {classData.studentCount} 名学生
+
+
+
+
+
+
+ 邀请码: {classData.inviteCode}
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/class/components/ClassSettings.tsx b/src/features/class/components/ClassSettings.tsx
new file mode 100644
index 0000000..f5e35b3
--- /dev/null
+++ b/src/features/class/components/ClassSettings.tsx
@@ -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 (
+
+ {/* Basic Info */}
+
+ 基本信息
+
+
+ 班级名称
+ 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"
+ />
+
+
+
所属年级
+
+ {classData.gradeName}
+
+
+
+
+
+ {/* Invite & Access */}
+
+ 邀请与权限
+
+
+
班级邀请码
+
+ {classData.inviteCode}
+
+
+
+
+
+
+
+
+
+
+
setIsJoinable(!isJoinable)}
+ className={`w-14 h-8 rounded-full relative transition-colors duration-300 ${isJoinable ? 'bg-green-500' : 'bg-gray-300'}`}
+ >
+
+
+
+
+
+
+ {/* Danger Zone */}
+
+ 危险区域
+
+
+
+
+
+
解散班级
+
此操作不可逆,所有数据将被删除
+
+
}>解散
+
+
+
+
+
+ }>保存更改
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/class/components/CreateClassModal.tsx b/src/features/class/components/CreateClassModal.tsx
new file mode 100644
index 0000000..d8fcf36
--- /dev/null
+++ b/src/features/class/components/CreateClassModal.tsx
@@ -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 = ({ 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 (
+
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
+
+
+ 班级名称
+ 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
+ />
+
+
+
+
所属年级
+
+ {['高一年级', '高二年级', '高三年级'].map(grade => (
+ 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}
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/class/components/JoinClassModal.tsx b/src/features/class/components/JoinClassModal.tsx
new file mode 100644
index 0000000..959ee26
--- /dev/null
+++ b/src/features/class/components/JoinClassModal.tsx
@@ -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 = ({ 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 (
+
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
+
+
+
加入班级
+
请输入老师提供的 5 位邀请码
+
+
+
+
+ {[0, 1, 2, 3, 4].map((idx) => (
+
+ {code[idx]}
+
+ ))}
+
+
+ {/* Hidden Input for focus handling */}
+
{
+ const val = e.target.value.toUpperCase().slice(0, 5);
+ if (/^[A-Z0-9]*$/.test(val)) setCode(val);
+ }}
+ autoFocus
+ />
+
+
+ 加入班级
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/class/components/StudentRow.tsx b/src/features/class/components/StudentRow.tsx
new file mode 100644
index 0000000..2e6d9d1
--- /dev/null
+++ b/src/features/class/components/StudentRow.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {student.realName}
+ {student.role === 'Monitor' && }
+
+
{student.studentId}
+
+
+ {student.gender === 'Male' ? '男' : '女'}
+
+
+
+ {(
+ Array.isArray(student.recentTrend) && student.recentTrend.length
+ ? student.recentTrend
+ : [0, 0, 0, 0, 0]
+ ).map((score, i) => (
+
+ ))}
+
+
+
+ {statusLabel}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/curriculum/components/CurriculumNodeModal.tsx b/src/features/curriculum/components/CurriculumNodeModal.tsx
new file mode 100644
index 0000000..dd1786c
--- /dev/null
+++ b/src/features/curriculum/components/CurriculumNodeModal.tsx
@@ -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;
+}
+
+export const CurriculumNodeModal: React.FC = ({
+ 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 ;
+ if (type === 'lesson') return ;
+ return ;
+ };
+
+ const getTitle = () => {
+ const action = mode === 'add' ? '添加' : '编辑';
+ const target = type === 'unit' ? '单元' : type === 'lesson' ? '课程' : '知识点';
+ return `${action}${target}`;
+ };
+
+ return (
+
+
+
+
+ {type === 'unit' ? '单元名称' : type === 'lesson' ? '课程名称' : '知识点名称'}
+
+ 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()}
+ />
+
+
+
+
+ 确认{mode === 'add' ? '添加' : '保存'}
+
+
+
+
+ );
+};
diff --git a/src/features/curriculum/components/DeleteConfirmModal.tsx b/src/features/curriculum/components/DeleteConfirmModal.tsx
new file mode 100644
index 0000000..38cadf9
--- /dev/null
+++ b/src/features/curriculum/components/DeleteConfirmModal.tsx
@@ -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;
+}
+
+export const DeleteConfirmModal: React.FC = ({
+ 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 (
+ }
+ >
+
+
+
+
+
+ 取消
+
+
+ 确认删除
+
+
+
+
+ );
+};
diff --git a/src/features/curriculum/components/KnowledgeGraph.tsx b/src/features/curriculum/components/KnowledgeGraph.tsx
new file mode 100644
index 0000000..92ff395
--- /dev/null
+++ b/src/features/curriculum/components/KnowledgeGraph.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import React, { useEffect, useRef } from 'react';
+
+export const KnowledgeGraph: React.FC = () => {
+ const canvasRef = useRef(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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/curriculum/components/TextbookModal.tsx b/src/features/curriculum/components/TextbookModal.tsx
new file mode 100644
index 0000000..2db5b6b
--- /dev/null
+++ b/src/features/curriculum/components/TextbookModal.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import React, { useState, useEffect } from 'react';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/Button';
+import { Book, ArrowRight } from 'lucide-react';
+
+interface TextbookModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ mode: 'add' | 'edit';
+ initialData?: {
+ name: string;
+ publisher: string;
+ versionYear: string;
+ };
+ onConfirm: (data: { name: string; publisher: string; versionYear: string }) => Promise;
+}
+
+export const TextbookModal: React.FC = ({
+ isOpen,
+ onClose,
+ mode,
+ initialData,
+ onConfirm
+}) => {
+ const [formData, setFormData] = useState({
+ name: '',
+ publisher: '',
+ versionYear: new Date().getFullYear().toString()
+ });
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (isOpen && initialData) {
+ setFormData(initialData);
+ } else if (isOpen) {
+ setFormData({
+ name: '',
+ publisher: '',
+ versionYear: new Date().getFullYear().toString()
+ });
+ }
+ }, [isOpen, initialData]);
+
+ const handleConfirm = async () => {
+ if (!formData.name.trim()) return;
+ setLoading(true);
+ try {
+ await onConfirm(formData);
+ onClose();
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ }
+ >
+
+
+ 教材名称
+ setFormData({ ...formData, name: 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
+ />
+
+
+
+
+
+
+ 确认{mode === 'add' ? '添加' : '保存'}
+
+
+
+
+ );
+};
diff --git a/src/features/curriculum/components/TreeNode.tsx b/src/features/curriculum/components/TreeNode.tsx
new file mode 100644
index 0000000..8c27db4
--- /dev/null
+++ b/src/features/curriculum/components/TreeNode.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import React, { useState, useRef, useEffect } from 'react';
+import { UnitNodeDto } from '../../../../UI_DTO';
+import {
+ ChevronRight, Folder, FileText, Hash,
+ Plus, Trash2, Edit2, GripVertical, Check, X
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+interface NodeActionProps {
+ onAdd?: () => void;
+ onEdit?: () => void;
+ onDelete?: () => void;
+}
+
+const NodeActions: React.FC = ({ onAdd, onEdit, onDelete }) => (
+
+ {onAdd && (
+
{ e.stopPropagation(); onAdd(); }} className="p-1.5 hover:bg-blue-50 text-gray-500 hover:text-blue-600 rounded-md transition-colors" title="添加子节点">
+
+
+ )}
+
{ e.stopPropagation(); onEdit?.(); }} className="p-1.5 hover:bg-amber-50 text-gray-500 hover:text-amber-600 rounded-md transition-colors" title="编辑">
+
+
+
{ e.stopPropagation(); onDelete?.(); }} className="p-1.5 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-md transition-colors" title="删除">
+
+
+
+);
+
+interface TreeNodeProps {
+ node: UnitNodeDto;
+ depth?: number;
+ onUpdate: (id: string, newName: string) => void;
+ onDelete: (id: string) => void;
+ onAddChild: (parentId: string, type: 'unit' | 'lesson' | 'point') => void;
+}
+
+export const TreeNode: React.FC = ({ node, depth = 0, onUpdate, onDelete, onAddChild }) => {
+ const [isOpen, setIsOpen] = useState(depth < 1);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editValue, setEditValue] = useState(node.name);
+ const inputRef = useRef(null);
+
+ const hasChildren = node.children && node.children.length > 0;
+
+ useEffect(() => {
+ if (isEditing) {
+ inputRef.current?.focus();
+ }
+ }, [isEditing]);
+
+ const handleSave = () => {
+ if (editValue.trim()) {
+ onUpdate(node.id, editValue);
+ } else {
+ setEditValue(node.name); // Revert if empty
+ }
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') handleSave();
+ if (e.key === 'Escape') {
+ setEditValue(node.name);
+ setIsEditing(false);
+ }
+ };
+
+ const getIcon = () => {
+ if (node.type === 'unit') return ;
+ if (node.type === 'lesson') return ;
+ return ;
+ };
+
+ const getChildType = (): 'unit' | 'lesson' | 'point' | null => {
+ if (node.type === 'unit') return 'lesson';
+ if (node.type === 'lesson') return 'point';
+ return null;
+ };
+
+ return (
+
+ {/* Connection Line */}
+ {depth > 0 && (
+
+ )}
+ {depth > 0 && (
+
+ )}
+
+
!isEditing && setIsOpen(!isOpen)}
+ className={`
+ group relative flex items-center gap-3 py-2 px-3 rounded-xl cursor-pointer transition-all duration-200
+ hover:bg-gray-50 border border-transparent hover:border-gray-100
+ ${isEditing ? 'bg-white shadow-sm border-blue-200 ring-2 ring-blue-100' : ''}
+ `}
+ style={{ marginLeft: `${depth * 1.5}rem` }}
+ >
+ {/* Drag Handle */}
+
+
+
+
+
+ {/* Expand/Collapse Arrow */}
+
+ {hasChildren && }
+
+
+ {getIcon()}
+
+ {isEditing ? (
+
e.stopPropagation()}>
+ setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={handleSave}
+ className="flex-1 bg-transparent outline-none text-sm font-medium text-gray-900 min-w-0"
+ />
+
+ { setEditValue(node.name); setIsEditing(false); }} className="text-red-500 hover:bg-red-50 p-1 rounded">
+
+ ) : (
+
+ {node.name}
+
+ )}
+
+
+ {node.type === 'point' && !isEditing && (
+
+ 难度: {node.difficulty || 1}
+
+ )}
+
+ {/* Action Buttons */}
+ {!isEditing && (
+
onAddChild(node.id, getChildType()!) : undefined}
+ onEdit={() => setIsEditing(true)}
+ onDelete={() => onDelete(node.id)}
+ />
+ )}
+
+
+
+ {isOpen && hasChildren && (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+ {node.children?.map(child => (
+
+ ))}
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/dashboard/components/NotificationList.tsx b/src/features/dashboard/components/NotificationList.tsx
new file mode 100644
index 0000000..061c69e
--- /dev/null
+++ b/src/features/dashboard/components/NotificationList.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import React, { useEffect, useState } from 'react';
+import { Bell, ChevronRight, Circle } from 'lucide-react';
+import { Card } from '@/components/ui/Card';
+import { ViewState, MessageDto } from '../../../../UI_DTO';
+import { messageService } from '@/services/api';
+import { getErrorMessage } from '@/utils/errorUtils';
+
+interface NotificationListProps {
+ role: string;
+ onNavigate: (view: ViewState) => void;
+ delay?: number;
+}
+
+export const NotificationList = ({ role, onNavigate, delay }: NotificationListProps) => {
+ const [messages, setMessages] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const loadMessages = async () => {
+ try {
+ setLoading(true);
+ const data = await messageService.getMessages();
+ // 只显示最新的3条
+ setMessages(data.slice(0, 3));
+ } catch (err) {
+ console.error('Failed to load messages:', err);
+ setMessages([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+ loadMessages();
+ }, []);
+
+ return (
+
+
+
+ {loading ? (
+
加载中...
+ ) : messages.length === 0 ? (
+
暂无通知
+ ) : (
+ messages.map((msg) => (
+
onNavigate('messages')}
+ >
+
+
+
+
+
+
{msg.title}
+
+ {new Date(msg.createdAt).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
+
+
+
{msg.content}
+
+
+
+ ))
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/dashboard/components/ScheduleList.tsx b/src/features/dashboard/components/ScheduleList.tsx
new file mode 100644
index 0000000..9717335
--- /dev/null
+++ b/src/features/dashboard/components/ScheduleList.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import React from 'react';
+import { ScheduleDto } from '../../../../UI_DTO';
+import { MapPin } from 'lucide-react';
+
+const ScheduleItem = ({ item }: { item: ScheduleDto }) => {
+ const isActive = item.startTime === '09:00'; // Logic could be improved with real time
+ return (
+
+
+
{item.startTime}
+
+
{item.endTime}
+
+
+
{item.subject}
+
{item.className}
+
+ {item.room}
+
+
+ {isActive && (
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export const ScheduleList = ({ schedule }: { schedule: ScheduleDto[] }) => {
+ return (
+
+
+ {schedule.map(item => (
+
+ ))}
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/dashboard/components/StatCard.tsx b/src/features/dashboard/components/StatCard.tsx
new file mode 100644
index 0000000..3c6092d
--- /dev/null
+++ b/src/features/dashboard/components/StatCard.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import React from 'react';
+import { Card } from '@/components/ui/Card';
+import { MoreHorizontal } from 'lucide-react';
+
+interface StatCardProps {
+ title: string;
+ value: string | number;
+ subValue: string;
+ icon: any;
+ color: string;
+ delay?: number;
+}
+
+export const StatCard: React.FC = ({ title, value, subValue, icon: Icon, color, delay }) => (
+
+
+
+
{value}
+
+ {title}
+
+ {subValue}
+
+
+
+
+);
\ No newline at end of file
diff --git a/src/features/dashboard/components/StudentDashboard.tsx b/src/features/dashboard/components/StudentDashboard.tsx
new file mode 100644
index 0000000..4ef1de3
--- /dev/null
+++ b/src/features/dashboard/components/StudentDashboard.tsx
@@ -0,0 +1,223 @@
+
+"use client";
+
+import React, { useEffect, useState } from 'react';
+import { UserProfileDto, ChartDataDto, RadarChartDto, ViewState } from '../../../../UI_DTO';
+import { analyticsService } from '@/services/api';
+import { StatCard } from './StatCard';
+import { NotificationList } from './NotificationList';
+import { Card } from '@/components/ui/Card';
+import { ChartTooltip } from '@/components/ui/ChartTooltip';
+import { LoadingState, SkeletonCard } from '@/components/ui/LoadingState';
+import { ErrorState } from '@/components/ui/ErrorState';
+import { getErrorMessage, getErrorType } from '@/utils/errorUtils';
+import { useToast } from '@/components/ui/Toast';
+import {
+ CheckCircle, ListTodo, Trophy, Clock, Target
+} from 'lucide-react';
+import {
+ ResponsiveContainer, AreaChart, Area, CartesianGrid, XAxis, YAxis, Tooltip,
+ RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar
+} from 'recharts';
+
+interface StudentDashboardProps {
+ user: UserProfileDto;
+ onNavigate: (view: ViewState) => void;
+}
+
+export const StudentDashboard = ({ user, onNavigate }: StudentDashboardProps) => {
+ const [performanceData, setPerformanceData] = useState(null);
+ const [radarData, setRadarData] = useState(null);
+
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { showToast } = useToast();
+
+ useEffect(() => {
+ const loadDashboardData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const [growth, radar] = await Promise.all([
+ analyticsService.getStudentGrowth().catch(err => {
+ console.error('Failed to load growth:', err);
+ return null;
+ }),
+ analyticsService.getStudentRadar().catch(err => {
+ console.error('Failed to load radar:', err);
+ return null;
+ })
+ ]);
+
+ setPerformanceData(growth);
+ setRadarData(radar);
+ } catch (err) {
+ console.error('Failed to load dashboard data:', err);
+ const errorMessage = getErrorMessage(err);
+ setError(errorMessage);
+ showToast(errorMessage, 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadDashboardData();
+ }, []);
+
+ const growthData = performanceData?.labels.map((label, i) => ({
+ name: label,
+ score: performanceData.datasets[0].data[i],
+ avg: performanceData.datasets[1].data[i]
+ })) || [];
+
+ const radarChartData = radarData?.indicators.map((ind, i) => ({
+ subject: ind,
+ A: radarData.values[i],
+ fullMark: 100
+ })) || [];
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+ window.location.reload()}
+ onGoHome={() => (window.location.href = '/dashboard')}
+ />
+ );
+ }
+
+ return (
+
+
onNavigate('assignments')} className="cursor-pointer">
+
+
+
onNavigate('assignments')} className="cursor-pointer">
+
+
+
+
+
+
+
+
+
个人成长曲线
+
我的成绩 vs 班级平均
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
薄弱知识点
+
+
+ {[
+ { name: '立体几何 - 空间向量', rate: 65 },
+ { name: '导数 - 极值问题', rate: 72 },
+ { name: '三角函数 - 图像变换', rate: 78 }
+ ].map((kp, i) => (
+
+
+ {kp.name}
+ {kp.rate}% 掌握
+
+
+
+ ))}
+
+
+
+
+
+ {/* Side Column */}
+
+
+
+
+
+
+
近期作业
+
+
+
+
+
期中考试模拟卷
+
截止: 今天 23:59
+
onNavigate('assignments')} className="mt-2 w-full py-1.5 bg-white rounded-lg text-xs font-bold text-blue-600 shadow-sm hover:bg-blue-50 transition-colors">去完成
+
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/components/TeacherDashboard.tsx b/src/features/dashboard/components/TeacherDashboard.tsx
new file mode 100644
index 0000000..5839da8
--- /dev/null
+++ b/src/features/dashboard/components/TeacherDashboard.tsx
@@ -0,0 +1,271 @@
+
+"use client";
+
+import React, { useEffect, useState } from 'react';
+import { UserProfileDto, ChartDataDto, RadarChartDto, ScoreDistributionDto, ScheduleDto, ViewState } from '../../../../UI_DTO';
+import { analyticsService, commonService } from '@/services/api';
+import { StatCard } from './StatCard';
+import { ScheduleList } from './ScheduleList';
+import { NotificationList } from './NotificationList';
+import { Card } from '@/components/ui/Card';
+import { ChartTooltip } from '@/components/ui/ChartTooltip';
+import { LoadingState, SkeletonCard } from '@/components/ui/LoadingState';
+import { ErrorState } from '@/components/ui/ErrorState';
+import { getErrorMessage, getErrorType } from '@/utils/errorUtils';
+import { useToast } from '@/components/ui/Toast';
+import {
+ Users, TrendingUp, FileText, CheckCircle, Plus, BookOpen, ChevronRight, Bell
+} from 'lucide-react';
+import {
+ ResponsiveContainer, AreaChart, Area, CartesianGrid, XAxis, YAxis, Tooltip,
+ BarChart, Bar, Cell, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar
+} from 'recharts';
+
+interface TeacherDashboardProps {
+ user: UserProfileDto;
+ onNavigate: (view: ViewState) => void;
+}
+
+export const TeacherDashboard = ({ user, onNavigate }: TeacherDashboardProps) => {
+ const [performanceData, setPerformanceData] = useState(null);
+ const [radarData, setRadarData] = useState(null);
+ const [distributionData, setDistributionData] = useState([]);
+ const [schedule, setSchedule] = useState([]);
+ const [stats, setStats] = useState<{ activeStudents: number; averageScore: number; pendingGrading: number; passRate: number } | null>(null);
+
+ // 新增状态
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { showToast } = useToast();
+
+ useEffect(() => {
+ const loadDashboardData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ // 并行加载所有数据
+ const [performance, radar, distribution, scheduleData, teacherStats] = await Promise.all([
+ analyticsService.getClassPerformance().catch(err => {
+ console.error('Failed to load performance:', err);
+ return null;
+ }),
+ analyticsService.getRadar().catch(err => {
+ console.error('Failed to load radar:', err);
+ return null;
+ }),
+ analyticsService.getScoreDistribution().catch(err => {
+ console.error('Failed to load distribution:', err);
+ return [];
+ }),
+ commonService.getSchedule().catch(err => {
+ console.error('Failed to load schedule:', err);
+ return [];
+ }),
+ analyticsService.getTeacherStats().catch(err => {
+ console.error('Failed to load teacher stats:', err);
+ return null;
+ })
+ ]);
+
+ setPerformanceData(performance);
+ setRadarData(radar);
+ setDistributionData(distribution);
+ setSchedule(scheduleData);
+ setStats(teacherStats);
+ } catch (err) {
+ console.error('Failed to load dashboard data:', err);
+ const errorMessage = getErrorMessage(err);
+ setError(errorMessage);
+ showToast(errorMessage, 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadDashboardData();
+ }, []);
+
+ const areaChartData = performanceData?.labels.map((label, i) => ({
+ name: label,
+ value: performanceData.datasets[0].data[i]
+ })) || [];
+
+ const radarChartData = radarData?.indicators.map((ind, i) => ({
+ subject: ind,
+ A: radarData.values[i],
+ fullMark: 100
+ })) || [];
+
+ // 加载状态
+ if (loading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // 错误状态
+ if (error) {
+ return (
+ window.location.reload()}
+ onGoHome={() => (window.location.href = '/dashboard')}
+ />
+ );
+ }
+
+ return (
+
+
onNavigate('classes')} className="cursor-pointer">
+
+
+
onNavigate('classes')} className="cursor-pointer">
+
+
+
onNavigate('assignments')} className="cursor-pointer">
+
+
+
onNavigate('classes')} className="cursor-pointer">
+
+
+
+
+ {/* Performance Trend */}
+
+
+
+
班级成绩走势
+
高一(10)班 • 最近7次测试
+
+
+ {['周', '月', '学期'].map(tab => (
+
+ {tab}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } cursor={{ stroke: '#007AFF', strokeWidth: 1, strokeDasharray: '5 5' }} />
+
+
+
+
+
+
+ {/* Score Distribution */}
+
+
+
+
+
+
+
+ } cursor={{ fill: '#F3F4F6' }} />
+
+ {distributionData.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+ {/* Knowledge Radar */}
+
+
+
+
+
+
+
+ {/* Quick Actions */}
+
+ 常用功能
+
+ {[
+ { icon: Plus, label: '发布作业', color: 'text-blue-600', bg: 'bg-blue-50 hover:bg-blue-100', action: () => onNavigate('assignments') },
+ { icon: FileText, label: '批改作业', color: 'text-orange-600', bg: 'bg-orange-50 hover:bg-orange-100', action: () => onNavigate('assignments') },
+ { icon: Users, label: '学生管理', color: 'text-green-600', bg: 'bg-green-50 hover:bg-green-100', action: () => onNavigate('classes') },
+ { icon: BookOpen, label: '备课中心', color: 'text-purple-600', bg: 'bg-purple-50 hover:bg-purple-100', action: () => onNavigate('curriculum') },
+ ].map((action, i) => (
+
+
+ {action.label}
+
+ ))}
+
+
+
+ {/* Schedule */}
+
+
+
今日课表
+ onNavigate('classes')} className="text-blue-600 hover:bg-blue-50 p-1.5 rounded-lg transition-colors">
+
+
+
+
+
+
+ {/* Notifications */}
+
+
+
+ )
+}
diff --git a/src/features/exam/components/ExamEditor.tsx b/src/features/exam/components/ExamEditor.tsx
new file mode 100644
index 0000000..7f8fbeb
--- /dev/null
+++ b/src/features/exam/components/ExamEditor.tsx
@@ -0,0 +1,512 @@
+
+"use client";
+
+import React, { useState, useEffect } from 'react';
+import { ExamDetailDto, ExamNodeDto, QuestionSummaryDto, ParsedQuestionDto } from '../../../../UI_DTO';
+import { examService, questionService } from '@/services/api';
+import { useToast } from '@/components/ui/Toast';
+import { Button } from '@/components/ui/Button';
+import { Card } from '@/components/ui/Card';
+import { ImportModal } from './ImportModal';
+import {
+ ArrowLeft, Save, Plus, Trash2, GripVertical,
+ ChevronDown, ChevronUp, FileInput, Search, Filter,
+ Clock, Hash, Calculator, FolderPlus, FileText
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+interface ExamEditorProps {
+ examId?: string;
+ onBack: () => void;
+}
+
+export const ExamEditor: React.FC = ({ examId, onBack }) => {
+ const [exam, setExam] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [expandedNodes, setExpandedNodes] = useState>(new Set());
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
+ const [questionBank, setQuestionBank] = useState([]);
+ const [showImport, setShowImport] = useState(false);
+ const { showToast } = useToast();
+
+ // Init
+ useEffect(() => {
+ const init = async () => {
+ if (examId) {
+ const data = await examService.getExamDetail(examId);
+ setExam(data);
+ // Auto expand all group nodes
+ const allGroupIds = new Set();
+ const collectGroupIds = (nodes: ExamNodeDto[]) => {
+ nodes.forEach(node => {
+ if (node.nodeType === 'Group') {
+ allGroupIds.add(node.id);
+ if (node.children) collectGroupIds(node.children);
+ }
+ });
+ };
+ collectGroupIds(data.rootNodes);
+ setExpandedNodes(allGroupIds);
+ } else {
+ // Create new template
+ const newExam: ExamDetailDto = {
+ id: '',
+ subjectId: 'sub-1',
+ title: '未命名试卷',
+ totalScore: 0,
+ duration: 120,
+ questionCount: 0,
+ status: 'Draft',
+ createdAt: new Date().toISOString().split('T')[0],
+ rootNodes: [
+ {
+ id: 'node-1',
+ nodeType: 'Group',
+ title: '第一部分:选择题',
+ description: '请选出正确答案',
+ score: 0,
+ sortOrder: 1,
+ children: []
+ }
+ ]
+ };
+ setExam(newExam);
+ setExpandedNodes(new Set(['node-1']));
+ setSelectedNodeId('node-1');
+ }
+
+ const questions = await questionService.search({});
+ setQuestionBank(questions.items);
+ setLoading(false);
+ };
+ init();
+ }, [examId]);
+
+ // Recalculate scores and question count
+ useEffect(() => {
+ if (!exam) return;
+
+ const calculateNodeStats = (nodes: ExamNodeDto[]): { score: number; count: number } => {
+ return nodes.reduce((acc, node) => {
+ if (node.nodeType === 'Question') {
+ return { score: acc.score + node.score, count: acc.count + 1 };
+ } else if (node.children) {
+ const childStats = calculateNodeStats(node.children);
+ return { score: acc.score + childStats.score, count: acc.count + childStats.count };
+ }
+ return acc;
+ }, { score: 0, count: 0 });
+ };
+
+ const stats = calculateNodeStats(exam.rootNodes);
+
+ if (stats.score !== exam.totalScore || stats.count !== exam.questionCount) {
+ setExam(prev => prev ? ({ ...prev, totalScore: stats.score, questionCount: stats.count }) : null);
+ }
+ }, [exam?.rootNodes]);
+
+ const handleSave = async () => {
+ if (!exam) return;
+ setSaving(true);
+ try {
+ await examService.saveExam(exam);
+ showToast('试卷保存成功', 'success');
+ if (!examId) onBack();
+ } catch (e) {
+ showToast('保存失败', 'error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Node Management Helper Functions
+ const findNodePath = (nodes: ExamNodeDto[], targetId: string, path: ExamNodeDto[] = []): ExamNodeDto[] | null => {
+ for (const node of nodes) {
+ const currentPath = [...path, node];
+ if (node.id === targetId) return currentPath;
+ if (node.children) {
+ const found = findNodePath(node.children, targetId, currentPath);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+
+ const updateNode = (nodes: ExamNodeDto[], targetId: string, updater: (node: ExamNodeDto) => ExamNodeDto): ExamNodeDto[] => {
+ return nodes.map(node => {
+ if (node.id === targetId) return updater(node);
+ if (node.children) {
+ return { ...node, children: updateNode(node.children, targetId, updater) };
+ }
+ return node;
+ });
+ };
+
+ const deleteNode = (nodes: ExamNodeDto[], targetId: string): ExamNodeDto[] => {
+ return nodes.filter(node => node.id !== targetId).map(node => {
+ if (node.children) {
+ return { ...node, children: deleteNode(node.children, targetId) };
+ }
+ return node;
+ });
+ };
+
+ const addChildNode = (parentId: string, newNode: ExamNodeDto) => {
+ if (!exam) return;
+ const updatedNodes = updateNode(exam.rootNodes, parentId, (parent) => ({
+ ...parent,
+ children: [...(parent.children || []), newNode]
+ }));
+ setExam({ ...exam, rootNodes: updatedNodes });
+ setExpandedNodes(prev => {
+ const next = new Set(Array.from(prev));
+ next.add(parentId);
+ return next;
+ });
+ setSelectedNodeId(newNode.id);
+ };
+
+ const addSiblingNode = (referenceId: string, newNode: ExamNodeDto) => {
+ if (!exam) return;
+
+ const path = findNodePath(exam.rootNodes, referenceId);
+ if (!path || path.length === 0) return;
+
+ if (path.length === 1) {
+ // Root level
+ const refIndex = exam.rootNodes.findIndex(n => n.id === referenceId);
+ const newRootNodes = [...exam.rootNodes];
+ newRootNodes.splice(refIndex + 1, 0, newNode);
+ setExam({ ...exam, rootNodes: newRootNodes });
+ } else {
+ // Has parent
+ const parentNode = path[path.length - 2];
+ const updatedNodes = updateNode(exam.rootNodes, parentNode.id, (parent) => {
+ const refIndex = (parent.children || []).findIndex(n => n.id === referenceId);
+ const newChildren = [...(parent.children || [])];
+ newChildren.splice(refIndex + 1, 0, newNode);
+ return { ...parent, children: newChildren };
+ });
+ setExam({ ...exam, rootNodes: updatedNodes });
+ }
+ setSelectedNodeId(newNode.id);
+ };
+
+ // UI Actions
+ const addGroupNode = (parentId?: string) => {
+ const newNode: ExamNodeDto = {
+ id: `node-${Date.now()}`,
+ nodeType: 'Group',
+ title: '新分组',
+ description: '',
+ score: 0,
+ sortOrder: 0,
+ children: []
+ };
+
+ if (parentId) {
+ addChildNode(parentId, newNode);
+ } else if (selectedNodeId) {
+ addSiblingNode(selectedNodeId, newNode);
+ } else {
+ setExam(prev => prev ? ({ ...prev, rootNodes: [...prev.rootNodes, newNode] }) : null);
+ setSelectedNodeId(newNode.id);
+ }
+ };
+
+ const addQuestionNode = (question: QuestionSummaryDto, parentId?: string) => {
+ const newNode: ExamNodeDto = {
+ id: `node-${Date.now()}`,
+ nodeType: 'Question',
+ questionId: question.id,
+ questionContent: question.content,
+ questionType: question.type,
+ score: question.type.includes('选') ? 5 : 10,
+ sortOrder: 0
+ };
+
+ if (parentId) {
+ addChildNode(parentId, newNode);
+ } else if (selectedNodeId) {
+ // Add as child of selected group, or sibling if selected is question
+ const path = findNodePath(exam?.rootNodes || [], selectedNodeId);
+ if (path) {
+ const selectedNode = path[path.length - 1];
+ if (selectedNode.nodeType === 'Group') {
+ addChildNode(selectedNodeId, newNode);
+ } else {
+ addSiblingNode(selectedNodeId, newNode);
+ }
+ }
+ } else {
+ setExam(prev => prev ? ({ ...prev, rootNodes: [...prev.rootNodes, newNode] }) : null);
+ }
+ };
+
+ const removeNode = (nodeId: string) => {
+ if (!exam) return;
+ if (!confirm('确定删除吗?如果是分组节点,其所有子节点也会被删除。')) return;
+
+ setExam({ ...exam, rootNodes: deleteNode(exam.rootNodes, nodeId) });
+ if (selectedNodeId === nodeId) setSelectedNodeId(null);
+ };
+
+ const handleImport = (imported: ParsedQuestionDto[]) => {
+ if (!exam) return;
+
+ const targetId = selectedNodeId || exam.rootNodes[0]?.id;
+ if (!targetId) {
+ showToast('请先选择一个分组', 'error');
+ return;
+ }
+
+ const path = findNodePath(exam.rootNodes, targetId);
+ if (!path) return;
+
+ const targetNode = path[path.length - 1];
+ if (targetNode.nodeType !== 'Group') {
+ showToast('请选择一个分组节点', 'error');
+ return;
+ }
+
+ const newNodes: ExamNodeDto[] = imported.map((q, i) => ({
+ id: `import-${Date.now()}-${i}`,
+ nodeType: 'Question',
+ questionId: `temp-${i}`,
+ questionContent: q.content,
+ questionType: q.type,
+ score: 5,
+ sortOrder: i
+ }));
+
+ const updatedNodes = updateNode(exam.rootNodes, targetId, (node) => ({
+ ...node,
+ children: [...(node.children || []), ...newNodes]
+ }));
+
+ setExam({ ...exam, rootNodes: updatedNodes });
+ setExpandedNodes(prev => new Set([...prev, targetId]));
+ showToast(`成功导入 ${newNodes.length} 道题目`, 'success');
+ };
+
+ // Recursive Node Renderer
+ const renderNode = (node: ExamNodeDto, depth: number = 0): JSX.Element => {
+ const isExpanded = expandedNodes.has(node.id);
+ const isSelected = selectedNodeId === node.id;
+ const hasChildren = node.children && node.children.length > 0;
+
+ return (
+
+
{ e.stopPropagation(); setSelectedNodeId(node.id); }}
+ >
+ {/* Expand/Collapse */}
+ {node.nodeType === 'Group' && (
+
{
+ e.stopPropagation();
+ setExpandedNodes(prev => {
+ const next = new Set(Array.from(prev));
+ if (next.has(node.id)) next.delete(node.id);
+ else next.add(node.id);
+ return next;
+ });
+ }}
+ className="mt-1 text-gray-400 hover:text-gray-600"
+ >
+ {isExpanded ? : }
+
+ )}
+
+
+ {node.nodeType === 'Group' ? (
+
+ ) : (
+
+
+
+ {node.questionType}
+ 分值:
+ {
+ if (!exam) return;
+ const updated = updateNode(exam.rootNodes, node.id, n => ({ ...n, score: Number(e.target.value) }));
+ setExam({ ...exam, rootNodes: updated });
+ }}
+ onClick={(e) => e.stopPropagation()}
+ className="w-12 text-xs text-center border rounded px-1"
+ />
+
+
+
+ )}
+
+
+ {/* Actions */}
+
+ {node.nodeType === 'Group' && (
+ { e.stopPropagation(); addGroupNode(node.id); }}
+ className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded"
+ title="添加子分组"
+ >
+
+
+ )}
+ { e.stopPropagation(); removeNode(node.id); }}
+ className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
+ >
+
+
+
+
+
+ {/* Children */}
+ {node.nodeType === 'Group' && isExpanded && node.children && (
+
+ {node.children.map(child => renderNode(child, depth + 1))}
+
+ )}
+
+ );
+ };
+
+ if (loading || !exam) {
+ return ;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
} />
+
+
setExam({ ...exam, title: e.target.value })}
+ className="text-xl font-bold text-gray-900 bg-transparent outline-none focus:bg-gray-50 rounded px-2 transition-colors placeholder:text-gray-400 w-96"
+ placeholder="请输入试卷标题..."
+ />
+
+
+
+
+
+ setExam({ ...exam, duration: Number(e.target.value) })}
+ className="w-12 bg-transparent outline-none text-center font-bold text-gray-900"
+ />
+ 分钟
+
+
+
+ {exam.questionCount}
+ 题
+
+
+
+ 总分
+ {exam.totalScore}
+
+
+
+ } onClick={() => addGroupNode()}>添加分组
+ } onClick={() => setShowImport(true)}>智能导入
+ }>保存试卷
+
+
+
+
+
+ {/* Main Editor Area */}
+
+
+
+ {exam.rootNodes.length === 0 ? (
+
+
试卷为空,点击右上角"添加分组"开始构建试卷
+
+ ) : (
+
+ {exam.rootNodes.map(node => renderNode(node, 0))}
+
+ )}
+
+
+
+
+ {/* Right Sidebar - Question Bank */}
+
+
+
+ {questionBank.map((q) => (
+
addQuestionNode(q)}
+ >
+
+ {q.type}
+
+ 难度 {q.difficulty}
+
+
+
+
+ {/* Add Overlay */}
+
+
+ ))}
+
+
+
+
+ {/* Modals */}
+ {showImport &&
setShowImport(false)} onImport={handleImport} />}
+
+ );
+};
diff --git a/src/features/exam/components/ExamList.tsx b/src/features/exam/components/ExamList.tsx
new file mode 100644
index 0000000..25b8361
--- /dev/null
+++ b/src/features/exam/components/ExamList.tsx
@@ -0,0 +1,126 @@
+
+"use client";
+
+import React, { useEffect, useState } from 'react';
+import { ExamDto } from '../../../../UI_DTO';
+import { examService } from '@/services/api';
+import { Card } from '@/components/ui/Card';
+import { Search, Plus, FileText, Clock, BarChart3, PenTool } from 'lucide-react';
+import { Badge } from '@/components/ui/Badge';
+import { Button } from '@/components/ui/Button';
+import { LoadingState, SkeletonCard } from '@/components/ui/LoadingState';
+import { ErrorState } from '@/components/ui/ErrorState';
+import { getErrorMessage, getErrorType } from '@/utils/errorUtils';
+import { useToast } from '@/components/ui/Toast';
+
+export const ExamList = ({ onEdit, onCreate, onStats }: { onEdit: (id: string) => void, onCreate: () => void, onStats: (id: string) => void }) => {
+ const [exams, setExams] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { showToast } = useToast();
+
+ useEffect(() => {
+ const loadExams = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const res = await examService.getMyExams();
+ setExams(res.items);
+ } catch (err) {
+ console.error('Failed to load exams:', err);
+ const errorMessage = getErrorMessage(err);
+ setError(errorMessage);
+ showToast(errorMessage, 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+ loadExams();
+ }, []);
+
+ const getStatusVariant = (status: string) => status === 'Published' ? 'success' : 'warning';
+ const getStatusLabel = (status: string) => status === 'Published' ? '已发布' : '草稿';
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+ window.location.reload()}
+ />
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
} onClick={onCreate}>新建试卷
+
+
+
+
+ {exams.map((exam, idx) => (
+
+
+
+
+
+
+ {getStatusLabel(exam.status)}
+ 创建于: {exam.createdAt}
+
+
{exam.title}
+
+
+
+ {exam.duration} 分钟
+
+
+ {exam.questionCount} 题
+
+
+ 总分: {exam.totalScore}
+
+
+
+
+ } onClick={() => onStats(exam.id)}>统计
+ } onClick={() => onEdit(exam.id)}>编辑
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/features/exam/components/ExamStats.tsx b/src/features/exam/components/ExamStats.tsx
new file mode 100644
index 0000000..f16c192
--- /dev/null
+++ b/src/features/exam/components/ExamStats.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import React, { useState, useEffect } from 'react';
+import { ExamStatsDto } from '../../../../UI_DTO';
+import { examService } from '@/services/api';
+import { Button } from '@/components/ui/Button';
+import { Card } from '@/components/ui/Card';
+import { ArrowLeft, TrendingUp, Users, AlertCircle, Target, CheckCircle } from 'lucide-react';
+import {
+ BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell
+} from 'recharts';
+
+interface ExamStatsProps {
+ examId: string;
+ onBack: () => void;
+}
+
+export const ExamStats: React.FC = ({ examId, onBack }) => {
+ const [stats, setStats] = useState(null);
+
+ useEffect(() => {
+ examService.getStats(examId).then(setStats);
+ }, [examId]);
+
+ if (!stats) return ;
+
+ const StatItem = ({ label, value, icon: Icon, color }: any) => (
+
+
+
+
+
+
+ );
+
+ return (
+
+
+ } onClick={onBack} />
+
考情分析
+
+
+ {/* Key Metrics */}
+
+
+
+
+
+
+
+
+ {/* Score Distribution */}
+
+ 成绩分布直方图
+
+
+
+
+
+
+
+ {stats.scoreDistribution.map((entry, index) => (
+ | = 3 ? '#34C759' : '#FF3B30'} />
+ ))}
+ |
+
+
+
+
+ {/* Wrong Questions Leaderboard */}
+
+
+
+ 高频错题榜
+
+
+ {stats.wrongQuestions.map((q, i) => (
+
+
+
+ {i + 1}
+
+ 错误率 {q.errorRate}%
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/exam/components/ImportModal.tsx b/src/features/exam/components/ImportModal.tsx
new file mode 100644
index 0000000..53e719c
--- /dev/null
+++ b/src/features/exam/components/ImportModal.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import React, { useState } from 'react';
+import { ParsedQuestionDto } from '../../../../UI_DTO';
+import { questionService } from '@/services/api';
+import { X, Loader2, CheckCircle2, ArrowRight, FileText } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Button } from '@/components/ui/Button';
+import { Card } from '@/components/ui/Card';
+
+interface ImportModalProps {
+ onClose: () => void;
+ onImport: (questions: ParsedQuestionDto[]) => void;
+}
+
+export const ImportModal: React.FC = ({ onClose, onImport }) => {
+ const [text, setText] = useState('');
+ const [parsed, setParsed] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [step, setStep] = useState<'input' | 'preview'>('input');
+ const [selectedIndices, setSelectedIndices] = useState([]);
+
+ const handleParse = async () => {
+ if (!text.trim()) return;
+ setLoading(true);
+ try {
+ const res = await questionService.parseText(text);
+ setParsed(res);
+ setSelectedIndices(res.map((_, i) => i)); // Select all by default
+ setStep('preview');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirmImport = () => {
+ const toImport = parsed.filter((_, i) => selectedIndices.includes(i));
+ onImport(toImport);
+ onClose();
+ };
+
+ const toggleSelect = (index: number) => {
+ if (selectedIndices.includes(index)) {
+ setSelectedIndices(prev => prev.filter(i => i !== index));
+ } else {
+ setSelectedIndices(prev => [...prev, index]);
+ }
+ };
+
+ return (
+
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
+
+ 智能文本导入
+
+
+
+
+
+ {/* Input Area */}
+
+
请粘贴试卷文本(支持 Word/PDF 复制):
+
+
+ {/* Preview Area */}
+
+ {step === 'preview' && (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+
+
+ 识别到 {parsed.length} 道题目,已选 {selectedIndices.length} 道
+
+
+ setStep('input')}>返回编辑
+ 确认导入
+
+
+
+ {parsed.map((q, idx) => (
+
toggleSelect(idx)}
+ noPadding
+ >
+
+
+ {selectedIndices.includes(idx) && }
+
+
+
+ {q.type}
+
+
+
+ 答案: {q.answer}
+ |
+ 解析: {q.parse}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/exam/components/result/ResultHeader.tsx b/src/features/exam/components/result/ResultHeader.tsx
new file mode 100644
index 0000000..ac89f49
--- /dev/null
+++ b/src/features/exam/components/result/ResultHeader.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import React from 'react';
+import { Card } from '@/components/ui/Card';
+import { Trophy, Percent } from 'lucide-react';
+
+interface ResultHeaderProps {
+ totalScore: number;
+ rank: number;
+ beatRate: number;
+}
+
+export const ResultHeader: React.FC = ({ totalScore, rank, beatRate }) => (
+
+
+
+ {totalScore}
+ 本次得分
+
+
+
+
+
+
+
+
+
+
+
+
+
超过 {beatRate}%
+
击败同班同学
+
+
+
+);
\ No newline at end of file
diff --git a/src/features/exam/components/result/ResultList.tsx b/src/features/exam/components/result/ResultList.tsx
new file mode 100644
index 0000000..c04dfc4
--- /dev/null
+++ b/src/features/exam/components/result/ResultList.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import React from 'react';
+import { GradingNodeDto } from '../../../../../UI_DTO';
+import { CheckCircle2, XCircle, MessageSquare } from 'lucide-react';
+import { Card } from '@/components/ui/Card';
+
+export const ResultList: React.FC<{ nodes: GradingNodeDto[] }> = ({ nodes }) => (
+
+
+
答题详情
+
+
+ {nodes.map((node, idx) => {
+ const isFullScore = node.studentScore === node.score;
+ return (
+
+
+
+ {idx + 1}
+ {node.questionType}
+
+
+ {isFullScore ? (
+
+ +{node.studentScore}
+
+ ) : (
+
+ {node.studentScore} / {node.score}
+
+ )}
+
+
+
+
+
+
+
+
你的答案
+
{node.studentAnswer}
+
+ {node.teacherAnnotation && (
+
+
+
+
教师评语
+
{node.teacherAnnotation}
+
+
+ )}
+
+
+ )
+ })}
+
+
+);
\ No newline at end of file
diff --git a/src/features/exam/components/runner/AnswerSheet.tsx b/src/features/exam/components/runner/AnswerSheet.tsx
new file mode 100644
index 0000000..7838f07
--- /dev/null
+++ b/src/features/exam/components/runner/AnswerSheet.tsx
@@ -0,0 +1,100 @@
+
+"use client";
+
+import React, { useMemo } from 'react';
+import { StudentExamPaperDto, ExamNodeDto } from '../../../../../UI_DTO';
+import { FileText, CheckCircle2, Circle, AlertCircle } from 'lucide-react';
+
+interface AnswerSheetProps {
+ paper: StudentExamPaperDto;
+ answers: Record;
+ currentQuestionId?: string;
+ onNavigate: (questionId: string) => void;
+}
+
+export const AnswerSheet: React.FC = ({
+ paper,
+ answers,
+ currentQuestionId,
+ onNavigate
+}) => {
+ // Flatten all questions from the tree structure
+ const allQuestions = useMemo(() => {
+ const questions: Array<{ node: ExamNodeDto; number: number }> = [];
+ let questionNumber = 1;
+
+ const traverse = (nodes: ExamNodeDto[]) => {
+ nodes.forEach(node => {
+ if (node.nodeType === 'Question') {
+ questions.push({ node, number: questionNumber });
+ questionNumber++;
+ } else if (node.children) {
+ traverse(node.children);
+ }
+ });
+ };
+
+ traverse(paper.rootNodes);
+ return questions;
+ }, [paper.rootNodes]);
+
+ const getQuestionStatus = (questionId: string) => {
+ if (answers[questionId] !== undefined && answers[questionId] !== '') {
+ return 'answered';
+ }
+ return 'unanswered';
+ };
+
+ return (
+
+
+
+
答题卡
+
+
+
+
+ {allQuestions.map(({ node, number }) => {
+ const status = getQuestionStatus(node.id);
+ const isCurrent = currentQuestionId === node.id;
+
+ return (
+ onNavigate(node.id)}
+ className={`
+ aspect-square rounded-lg font-bold text-sm transition-all
+ ${isCurrent
+ ? 'bg-blue-600 text-white ring-2 ring-blue-600 ring-offset-2 scale-110'
+ : status === 'answered'
+ ? 'bg-green-100 text-green-700 hover:bg-green-200'
+ : 'bg-gray-100 text-gray-400 hover:bg-gray-200'
+ }
+ `}
+ >
+ {number}
+
+ );
+ })}
+
+
+
+
+
+ 已答:
+
+ {Object.keys(answers).filter(k => answers[k] !== undefined && answers[k] !== '').length}
+
+
+
+
+ 未答:
+
+ {allQuestions.length - Object.keys(answers).filter(k => answers[k] !== undefined && answers[k] !== '').length}
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/exam/components/runner/ExamHeader.tsx b/src/features/exam/components/runner/ExamHeader.tsx
new file mode 100644
index 0000000..409eac3
--- /dev/null
+++ b/src/features/exam/components/runner/ExamHeader.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import React from 'react';
+import { Clock, Grid, Send, Loader2 } from 'lucide-react';
+
+interface ExamHeaderProps {
+ title: string;
+ timeLeft: number;
+ progress: number;
+ markedCount: number;
+ isSubmitting: boolean;
+ onToggleSheet: () => void;
+ onSubmit: () => void;
+}
+
+export const ExamHeader: React.FC = ({
+ title, timeLeft, progress, markedCount, isSubmitting, onToggleSheet, onSubmit
+}) => {
+ const formatTime = (seconds: number) => {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
+ };
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/exam/components/runner/RunnerQuestionCard.tsx b/src/features/exam/components/runner/RunnerQuestionCard.tsx
new file mode 100644
index 0000000..4f7d09a
--- /dev/null
+++ b/src/features/exam/components/runner/RunnerQuestionCard.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import React, { useState } from 'react';
+import HandwritingPad from '../../../../components/ui/HandwritingPad';
+import { CheckCircle2, Flag, Upload, PenTool } from 'lucide-react';
+
+interface RunnerQuestionCardProps {
+ question: any;
+ answer: any;
+ onAnswer: (val: any) => void;
+ isMarked: boolean;
+ onToggleMark: () => void;
+}
+
+export const RunnerQuestionCard: React.FC = ({
+ question,
+ answer,
+ onAnswer,
+ isMarked,
+ onToggleMark
+}) => {
+ const isChoice = ['SingleChoice','MultipleChoice','单选题','多选题'].includes(question.type);
+ const isFill = ['FillBlank','ShortAnswer','填空题','简答题'].includes(question.type);
+ const [showPad, setShowPad] = useState(false);
+ const [showUpload, setShowUpload] = useState(false);
+ return (
+
+
+
+
+ {question.type}
+
+
+ {question.score} 分
+
+
+
+
+ {isMarked ? '已标记' : '标记'}
+
+
+
+
+
+
+ {(isChoice) && question.options && (
+
+ {question.options.map((opt: string, idx: number) => {
+ const label = String.fromCharCode(65 + idx); // A, B, C...
+ const isSelected = (question.type === '单选题' || question.type === 'SingleChoice') ? answer === label : (answer || []).includes(label);
+
+ return (
+
{
+ if (question.type === '单选题' || question.type === 'SingleChoice') onAnswer(label);
+ else {
+ const current = answer || [];
+ if (current.includes(label)) onAnswer(current.filter((l: string) => l !== label));
+ else onAnswer([...current, label]);
+ }
+ }}
+ className={`
+ flex items-center gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all group
+ ${isSelected ? 'border-blue-500 bg-blue-50/50' : 'border-gray-100 hover:border-blue-200 hover:bg-gray-50'}
+ `}
+ >
+
+ {label}
+
+
{opt}
+ {isSelected &&
}
+
+ );
+ })}
+
+ )}
+
+ {(isFill) && (
+
+ )}
+
+
+ );
+};
diff --git a/src/features/exam/components/runner/SubmitConfirmModal.tsx b/src/features/exam/components/runner/SubmitConfirmModal.tsx
new file mode 100644
index 0000000..cc06089
--- /dev/null
+++ b/src/features/exam/components/runner/SubmitConfirmModal.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import React from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { AlertTriangle, CheckCircle2 } from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+
+interface SubmitConfirmModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ unansweredCount: number;
+}
+
+export const SubmitConfirmModal: React.FC = ({ isOpen, onClose, onConfirm, unansweredCount }) => {
+ return (
+
+ {isOpen && (
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
0 ? 'bg-orange-100 text-orange-500' : 'bg-blue-100 text-blue-600'}`}>
+ {unansweredCount > 0 ?
:
}
+
+
+
确认交卷?
+
+ {unansweredCount > 0 ? (
+
+ 你还有 {unansweredCount} 道题目未作答。
+
+ 提交后将无法修改答案。
+
+ ) : (
+
+ 你已完成所有题目,确定要提交试卷吗?
+
+ )}
+
+
+
+ 继续答题
+
+
+ 确认提交
+
+
+
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/grading/components/GradingBoard.tsx b/src/features/grading/components/GradingBoard.tsx
new file mode 100644
index 0000000..083c6e3
--- /dev/null
+++ b/src/features/grading/components/GradingBoard.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import React, { useRef, useState, useEffect } from 'react';
+import { GradingPaperDto } from '../../../../UI_DTO';
+import { Card } from '@/components/ui/Card';
+import { QuestionGradingRow } from './QuestionGradingRow';
+
+interface GradingBoardProps {
+ paper: GradingPaperDto;
+ currentStudentId: string;
+ tool: 'cursor' | 'pen' | 'eraser';
+ onUpdatePaper: (paper: GradingPaperDto) => void;
+}
+
+export const GradingBoard: React.FC = ({ paper, currentStudentId, tool, onUpdatePaper }) => {
+ const canvasRef = useRef(null);
+ const [isDrawing, setIsDrawing] = useState(false);
+
+ // Clear canvas when student changes
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (canvas) {
+ const ctx = canvas.getContext('2d');
+ ctx?.clearRect(0, 0, canvas.width, canvas.height);
+ }
+ }, [currentStudentId]);
+
+ // Drawing Logic
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (tool === 'cursor') return;
+ setIsDrawing(true);
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext('2d');
+ if (ctx && canvas) {
+ const rect = canvas.getBoundingClientRect();
+ ctx.beginPath();
+ ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
+ ctx.strokeStyle = tool === 'pen' ? 'red' : 'rgba(0,0,0,0)';
+ ctx.globalCompositeOperation = tool === 'pen' ? 'source-over' : 'destination-out';
+ ctx.lineWidth = tool === 'pen' ? 2 : 20;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ }
+ };
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!isDrawing || tool === 'cursor') return;
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext('2d');
+ if (ctx && canvas) {
+ const rect = canvas.getBoundingClientRect();
+ ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
+ ctx.stroke();
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsDrawing(false);
+ };
+
+ return (
+
+
+
+
+
数学期中考试答卷
+
考生: {paper.studentName} • 学号: {currentStudentId}
+
+
+
+ {paper.nodes.reduce((acc, curr) => acc + (curr.studentScore || 0), 0)}
+ / 100
+
+
总分
+
+
+
+
+
+
+ {/* Questions */}
+
+ {paper.nodes.map((node, i) => (
+ {
+ const newNodes = [...paper.nodes];
+ newNodes[i].studentScore = s;
+ onUpdatePaper({ ...paper, nodes: newNodes });
+ }}
+ />
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/grading/components/GradingToolbar.tsx b/src/features/grading/components/GradingToolbar.tsx
new file mode 100644
index 0000000..c47f834
--- /dev/null
+++ b/src/features/grading/components/GradingToolbar.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import React from 'react';
+import { ChevronLeft, Save, PenTool, Eraser, RotateCcw, MousePointer2 } from 'lucide-react';
+
+interface GradingToolbarProps {
+ tool: 'cursor' | 'pen' | 'eraser';
+ setTool: (tool: 'cursor' | 'pen' | 'eraser') => void;
+ onClearCanvas: () => void;
+ onSave: () => void;
+ onBack: () => void;
+}
+
+export const GradingToolbar: React.FC = ({ tool, setTool, onClearCanvas, onSave, onBack }) => {
+ return (
+
+
+
+
+
+
setTool('cursor')}
+ className={`p-2 rounded-md transition-all ${tool === 'cursor' ? 'bg-white shadow text-gray-900' : 'text-gray-400 hover:text-gray-600'}`}
+ title="选择/点击模式"
+ >
+
+
+
+
setTool('pen')}
+ className={`p-2 rounded-md transition-all ${tool === 'pen' ? 'bg-white shadow text-red-500' : 'text-gray-400 hover:text-gray-600'}`}
+ title="批注笔"
+ >
+
+
+
setTool('eraser')}
+ className={`p-2 rounded-md transition-all ${tool === 'eraser' ? 'bg-white shadow text-blue-500' : 'text-gray-400 hover:text-gray-600'}`}
+ title="橡皮擦"
+ >
+
+
+
+
+
+
+
+
+
+
+ 提交成绩
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/grading/components/QuestionGradingRow.tsx b/src/features/grading/components/QuestionGradingRow.tsx
new file mode 100644
index 0000000..3c73a99
--- /dev/null
+++ b/src/features/grading/components/QuestionGradingRow.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import React from 'react';
+import { GradingNodeDto } from '../../../../UI_DTO';
+import { Check, X } from 'lucide-react';
+
+interface QuestionGradingRowProps {
+ node: GradingNodeDto;
+ index: number;
+ onScoreChange: (score: number) => void;
+}
+
+export const QuestionGradingRow: React.FC = ({ node, index, onScoreChange }) => {
+ return (
+
+
+
+
+ 第 {index + 1} 题
+
+ {node.questionType}
+ 满分: {node.score}
+
+
+ 得分:
+ onScoreChange(Number(e.target.value))}
+ />
+
+
+
+
+
+ {node.studentAnswer?.startsWith('http') ? (
+
+
+
+ ) : (
+
+ {node.studentAnswer}
+
+ )}
+
+
+ onScoreChange(node.score)}
+ >
+ 满分
+
+ onScoreChange(0)}
+ >
+ 零分
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/grading/components/SubmissionSidebar.tsx b/src/features/grading/components/SubmissionSidebar.tsx
new file mode 100644
index 0000000..3ca5fe8
--- /dev/null
+++ b/src/features/grading/components/SubmissionSidebar.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import React from 'react';
+import { StudentSubmissionSummaryDto } from '../../../../UI_DTO';
+import { Check } from 'lucide-react';
+
+interface SubmissionSidebarProps {
+ submissions: StudentSubmissionSummaryDto[];
+ currentIndex: number;
+ onSelect: (index: number) => void;
+}
+
+export const SubmissionSidebar: React.FC = ({ submissions, currentIndex, onSelect }) => {
+ return (
+
+
+
提交列表 ({currentIndex + 1}/{submissions.length})
+
+
+
+ {submissions.map((sub, idx) => (
+
onSelect(idx)}
+ className={`flex items-center gap-3 p-3 mx-2 my-1 rounded-xl cursor-pointer transition-colors
+ ${idx === currentIndex ? 'bg-blue-50 text-blue-900' : 'hover:bg-gray-50 text-gray-600'}
+ `}
+ >
+
+
+ {sub.status === 'Graded' && (
+
+
+
+ )}
+
+
+
{sub.studentName}
+
{sub.studentId}
+
+ {sub.score && (
+
{sub.score}
+ )}
+
+ ))}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/message/components/CreateMessageModal.tsx b/src/features/message/components/CreateMessageModal.tsx
new file mode 100644
index 0000000..3a5bf77
--- /dev/null
+++ b/src/features/message/components/CreateMessageModal.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { X, Send, Loader2 } from 'lucide-react';
+import { messageService } from '@/services/api';
+import { useToast } from '@/components/ui/Toast';
+import { Button } from '@/components/ui/Button';
+
+interface CreateMessageModalProps {
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+export const CreateMessageModal: React.FC = ({ onClose, onSuccess }) => {
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [type, setType] = useState<'Announcement' | 'Notification'>('Notification');
+ const [loading, setLoading] = useState(false);
+ const { showToast } = useToast();
+
+ const handleCreate = async () => {
+ if (!title.trim() || !content.trim()) {
+ showToast('请完善公告信息', 'error');
+ return;
+ }
+ setLoading(true);
+ try {
+ await messageService.createMessage({ title, content, type });
+ showToast('消息发布成功', 'success');
+ onSuccess();
+ } catch (e) {
+ showToast('发布失败', 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
发布新消息
+
+
+
+
+
+ {(['Notification', 'Announcement'] as const).map(t => (
+ setType(t)}
+ className={`flex-1 py-2 rounded-lg text-sm font-bold transition-all ${type === t ? 'bg-white shadow text-blue-600' : 'text-gray-500'}`}
+ >
+ {t === 'Notification' ? '一般通知' : '重要公告'}
+
+ ))}
+
+
+
+ 标题
+ setTitle(e.target.value)}
+ 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-medium"
+ placeholder="请输入消息标题"
+ />
+
+
+
+ 内容详情
+
+
+
+ }
+ >
+ 确认发布
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/question/components/QuestionCard.tsx b/src/features/question/components/QuestionCard.tsx
new file mode 100644
index 0000000..6ab79a3
--- /dev/null
+++ b/src/features/question/components/QuestionCard.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import React, { useState } from 'react';
+import { QuestionSummaryDto } from '../../../../UI_DTO';
+import { Card } from '@/components/ui/Card';
+import { CheckCircle2, BrainCircuit, Lightbulb, ChevronDown } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+interface QuestionCardProps {
+ question: QuestionSummaryDto & { answer?: string, parse?: string };
+ isSelected: boolean;
+ onToggleSelect: () => void;
+ delay: number;
+}
+
+export const QuestionCard: React.FC = ({
+ question,
+ isSelected,
+ onToggleSelect,
+ delay
+}) => {
+ const [expanded, setExpanded] = useState(false);
+
+ const getDifficultyColor = (diff: number) => {
+ if (diff <= 2) return 'bg-green-50 text-green-700 border-green-100';
+ if (diff <= 4) return 'bg-amber-50 text-amber-700 border-amber-100';
+ return 'bg-red-50 text-red-700 border-red-100';
+ };
+
+ return (
+
+
+
+
+
+
+
+ {/* Tags Header */}
+
+
+ {question.type}
+
+
+ 难度 {question.difficulty}
+
+ {(question.knowledgePoints || []).map(kp => (
+
+
+ {kp}
+
+ ))}
+
+
+ {/* Content */}
+
+
+ {/* Analysis Toggle */}
+
+ {expanded && (
+ // Fix: cast props to any to avoid framer-motion type errors
+
+
+
+ 参考答案
+ {question.answer}
+
+
+
+
+ )}
+
+
+ {/* Footer Actions */}
+
+ ID: {question.id}
+ setExpanded(!expanded)}
+ className="text-xs font-bold text-blue-600 hover:text-blue-700 hover:bg-blue-50 px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
+ >
+ {expanded ? '收起解析' : '查看解析'}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/question/components/QuestionModal.tsx b/src/features/question/components/QuestionModal.tsx
new file mode 100644
index 0000000..36daf78
--- /dev/null
+++ b/src/features/question/components/QuestionModal.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import React, { useState, useEffect } from 'react';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/Button';
+import { HelpCircle, ArrowRight, Plus, Trash2 } from 'lucide-react';
+
+interface QuestionModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ mode: 'add' | 'edit';
+ initialData?: any;
+ onConfirm: (data: any) => Promise;
+}
+
+const questionTypes = [
+ { value: 'SingleChoice', label: '单选题' },
+ { value: 'MultipleChoice', label: '多选题' },
+ { value: 'FillBlank', label: '填空题' },
+ { value: 'ShortAnswer', label: '简答题' },
+ { value: 'Calculation', label: '计算题' }
+];
+
+export const QuestionModal: React.FC = ({
+ isOpen,
+ onClose,
+ mode,
+ initialData,
+ onConfirm
+}) => {
+ const [formData, setFormData] = useState({
+ content: '',
+ questionType: 'SingleChoice',
+ difficulty: 3,
+ answer: '',
+ explanation: '',
+ optionsConfig: { options: ['A', 'B', 'C', 'D'] } // Default for choice questions
+ });
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (isOpen && initialData) {
+ setFormData({
+ content: initialData.content || '',
+ questionType: initialData.type || 'SingleChoice',
+ difficulty: initialData.difficulty || 3,
+ answer: initialData.answer || '',
+ explanation: initialData.parse || '',
+ optionsConfig: initialData.optionsConfig || { options: ['A', 'B', 'C', 'D'] }
+ });
+ } else if (isOpen) {
+ setFormData({
+ content: '',
+ questionType: 'SingleChoice',
+ difficulty: 3,
+ answer: '',
+ explanation: '',
+ optionsConfig: { options: ['A', 'B', 'C', 'D'] }
+ });
+ }
+ }, [isOpen, initialData]);
+
+ const handleConfirm = async () => {
+ if (!formData.content.trim()) return;
+ setLoading(true);
+ try {
+ await onConfirm(formData);
+ onClose();
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ }
+ maxWidth="max-w-2xl"
+ >
+
+ {/* Type & Difficulty */}
+
+
+ 题型
+ setFormData({ ...formData, questionType: e.target.value })}
+ 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"
+ >
+ {questionTypes.map(t => (
+ {t.label}
+ ))}
+
+
+
+
难度 (1-5)
+
+ setFormData({ ...formData, difficulty: parseInt(e.target.value) })}
+ className="flex-1"
+ />
+ {formData.difficulty}
+
+
+
+
+ {/* Content */}
+
+ 题目内容
+
+
+ {/* Answer */}
+
+ 参考答案
+
+
+ {/* Explanation */}
+
+ 解析 (可选)
+
+
+
+
+ 确认{mode === 'add' ? '录入' : '保存'}
+
+
+
+
+ );
+};
diff --git a/src/features/question/components/SubjectSelector.tsx b/src/features/question/components/SubjectSelector.tsx
new file mode 100644
index 0000000..44d1518
--- /dev/null
+++ b/src/features/question/components/SubjectSelector.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import React from 'react';
+import { SubjectDto } from '../../../../UI_DTO';
+
+interface SubjectSelectorProps {
+ subjects: SubjectDto[];
+ selected: string;
+ onSelect: (id: string) => void;
+}
+
+export const SubjectSelector: React.FC = ({ subjects, selected, onSelect }) => (
+
+ {subjects.map(sub => (
+ onSelect(sub.id)}
+ className={`
+ flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold whitespace-nowrap transition-all
+ ${selected === sub.id
+ ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/30'
+ : 'bg-white text-gray-600 hover:bg-gray-50 border border-transparent hover:border-gray-200'
+ }
+ `}
+ >
+ {sub.icon}
+ {sub.name}
+
+ ))}
+
+);
\ No newline at end of file
diff --git a/src/features/schedule/components/EventModal.tsx b/src/features/schedule/components/EventModal.tsx
new file mode 100644
index 0000000..c0e0d9f
--- /dev/null
+++ b/src/features/schedule/components/EventModal.tsx
@@ -0,0 +1,145 @@
+"use client";
+
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { X, Clock, MapPin, Calendar, Book, Users, Loader2 } from 'lucide-react';
+import { useToast } from '@/components/ui/Toast';
+import { Button } from '@/components/ui/Button';
+import { CreateScheduleDto } from '../../../../UI_DTO';
+import { scheduleService } from '@/services/api';
+
+interface EventModalProps {
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+export const EventModal: React.FC = ({ onClose, onSuccess }) => {
+ const [formData, setFormData] = useState({
+ subject: '',
+ className: '',
+ room: '',
+ dayOfWeek: 1,
+ period: 1,
+ startTime: '08:00',
+ endTime: '08:45'
+ });
+ const [loading, setLoading] = useState(false);
+ const { showToast } = useToast();
+
+ const days = ['周一', '周二', '周三', '周四', '周五'];
+ const periods = [1, 2, 3, 4, 5, 6, 7, 8];
+
+ const handleSubmit = async () => {
+ if (!formData.subject || !formData.className || !formData.room) {
+ showToast('请完善课程信息', 'error');
+ return;
+ }
+ setLoading(true);
+ try {
+ await scheduleService.addEvent(formData);
+ showToast('课程添加成功', 'success');
+ onSuccess();
+ } catch (e) {
+ showToast('添加失败', 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Fix: cast props to any to avoid framer-motion type errors */}
+
+
+
添加课程
+
+
+
+
+
+ 课程名称
+ setFormData({...formData, subject: e.target.value})}
+ className="w-full p-2.5 bg-gray-50 border border-gray-200 rounded-lg outline-none focus:border-blue-500 text-sm font-medium"
+ placeholder="例如:数学"
+ />
+
+
+
+
+
+
时间安排
+
+ setFormData({...formData, dayOfWeek: Number(e.target.value)})}
+ className="flex-1 p-2.5 bg-white border border-gray-200 rounded-lg text-sm"
+ >
+ {days.map((d, i) => {d} )}
+
+ setFormData({...formData, period: Number(e.target.value)})}
+ className="flex-1 p-2.5 bg-white border border-gray-200 rounded-lg text-sm"
+ >
+ {periods.map(p => 第 {p} 节 )}
+
+
+
+
+
+
+
+ 保存课程
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/schedule/components/Timetable.tsx b/src/features/schedule/components/Timetable.tsx
new file mode 100644
index 0000000..59e07ee
--- /dev/null
+++ b/src/features/schedule/components/Timetable.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import React from 'react';
+import { ScheduleDto } from '../../../../UI_DTO';
+import { Card } from '@/components/ui/Card';
+import { Trash2 } from 'lucide-react';
+
+interface TimetableProps {
+ schedule: ScheduleDto[];
+ canEdit: boolean;
+ onDelete: (id: string) => void;
+}
+
+export const Timetable: React.FC = ({ schedule, canEdit, onDelete }) => {
+ const days = ['周一', '周二', '周三', '周四', '周五'];
+ const periods = Array.from({ length: 8 }, (_, i) => i + 1);
+
+ const getEvent = (day: number, period: number) => {
+ return schedule.find(s => s.dayOfWeek === day && s.period === period);
+ };
+
+ return (
+
+
+
+ {/* Header Row */}
+
+
时段
+ {days.map((day, i) => (
+
+ {day}
+
+ ))}
+
+
+ {/* Time Slots */}
+
+ {periods.map(p => (
+
+
+ 第 {p} 节
+
+ {days.map((_, dayIdx) => {
+ const event = getEvent(dayIdx + 1, p);
+ return (
+
+ {event ? (
+
+ {event.subject}
+ {event.className}
+
+
+ {event.room}
+
+ {canEdit && (
+ onDelete(event.id)}
+ className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 bg-white text-red-500 rounded shadow-sm hover:bg-red-50"
+ >
+
+
+ )}
+
+ ) : (
+
+ 空闲
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/settings/components/PreferenceSettings.tsx b/src/features/settings/components/PreferenceSettings.tsx
new file mode 100644
index 0000000..1d43f21
--- /dev/null
+++ b/src/features/settings/components/PreferenceSettings.tsx
@@ -0,0 +1,55 @@
+import React, { useState } from 'react';
+
+const ToggleRow = ({ label, checked, onChange }: { label: string, checked: boolean, onChange: (val: boolean) => void }) => (
+
+
{label}
+
onChange(!checked)}
+ className={`w-11 h-6 rounded-full relative transition-colors duration-300 ${checked ? 'bg-green-500' : 'bg-gray-200'}`}
+ >
+
+
+
+);
+
+export const PreferenceSettings: React.FC = () => {
+ const [settings, setSettings] = useState({
+ emailNotify: true,
+ pushNotify: true,
+ examNotify: true,
+ gradeNotify: true,
+ darkMode: false
+ });
+
+ const toggle = (key: keyof typeof settings) => {
+ setSettings(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ return (
+
+
+
通知设置
+
+ toggle('emailNotify')} />
+ toggle('pushNotify')} />
+ toggle('examNotify')} />
+ toggle('gradeNotify')} />
+
+
+
+
+
外观与语言
+
+
toggle('darkMode')} />
+
+ 系统语言
+
+ 简体中文
+ English
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/settings/components/ProfileSettings.tsx b/src/features/settings/components/ProfileSettings.tsx
new file mode 100644
index 0000000..e842fc9
--- /dev/null
+++ b/src/features/settings/components/ProfileSettings.tsx
@@ -0,0 +1,109 @@
+import React, { useState } from 'react';
+import { UserProfileDto } from '../../../types';
+import { authService } from '../../../services/api';
+import { useToast } from '../../../components/ui/Toast';
+import { Camera, User, Mail, Phone, FileText, Loader2, Check } from 'lucide-react';
+
+export const ProfileSettings: React.FC<{ user: UserProfileDto }> = ({ user }) => {
+ const [formData, setFormData] = useState({
+ realName: user.realName,
+ email: user.email || '',
+ phone: user.phone || '',
+ bio: user.bio || ''
+ });
+ const [loading, setLoading] = useState(false);
+ const { showToast } = useToast();
+
+ const handleSave = async () => {
+ setLoading(true);
+ try {
+ await authService.updateProfile(formData);
+ showToast('个人资料已更新', 'success');
+ } catch (e) {
+ showToast('更新失败,请重试', 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
{user.realName}
+
{user.role === 'Teacher' ? '教师' : '学生'} • ID: {user.studentId}
+
+
+
+
+
+
姓名
+
+
+ setFormData({...formData, realName: e.target.value})}
+ className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-100 transition-all text-sm font-medium"
+ />
+
+
+
+
+
邮箱
+
+
+ setFormData({...formData, email: e.target.value})}
+ className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-100 transition-all text-sm font-medium"
+ />
+
+
+
+
手机号
+
+
+
setFormData({...formData, phone: e.target.value})}
+ className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-100 transition-all text-sm font-medium"
+ />
+
+
+
+
+
+
+
+
+ {loading ? : }
+ 保存更改
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/features/settings/components/SecuritySettings.tsx b/src/features/settings/components/SecuritySettings.tsx
new file mode 100644
index 0000000..2957441
--- /dev/null
+++ b/src/features/settings/components/SecuritySettings.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import { authService } from '../../../services/api';
+import { useToast } from '../../../components/ui/Toast';
+import { Info, Lock, Loader2, Shield } from 'lucide-react';
+
+export const SecuritySettings: React.FC = () => {
+ const [passwords, setPasswords] = useState({ old: '', new: '', confirm: '' });
+ const [loading, setLoading] = useState(false);
+ const { showToast } = useToast();
+
+ const handleChangePassword = async () => {
+ if (passwords.new !== passwords.confirm) {
+ showToast('两次输入的密码不一致', 'error');
+ return;
+ }
+ if (passwords.new.length < 6) {
+ showToast('新密码长度不能少于 6 位', 'error');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await authService.changePassword({
+ oldPassword: passwords.old,
+ newPassword: passwords.new
+ });
+ showToast('密码修改成功,请重新登录', 'success');
+ setPasswords({ old: '', new: '', confirm: '' });
+ } catch (e) {
+ showToast('旧密码错误', 'error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
为了您的账号安全,建议定期更换密码。如果发现异常登录,请立即联系管理员。
+
+
+
+
+
当前密码
+
+
+ setPasswords({...passwords, old: e.target.value})}
+ className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-100 transition-all text-sm font-medium"
+ />
+
+
+
+
+
新密码
+
+
+ setPasswords({...passwords, new: e.target.value})}
+ className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-100 transition-all text-sm font-medium"
+ />
+
+
+
+
确认新密码
+
+
+ setPasswords({...passwords, confirm: e.target.value})}
+ className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-100 transition-all text-sm font-medium"
+ />
+
+
+
+
+
+
+
+ {loading ? : }
+ 更新密码
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/lib/auth-context.tsx b/src/lib/auth-context.tsx
new file mode 100644
index 0000000..32fb29f
--- /dev/null
+++ b/src/lib/auth-context.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import React, { createContext, useContext, useState, useEffect } from 'react';
+import { UserProfileDto } from '../../UI_DTO';
+import { authService, subscribeApiMode } from '@/services/api';
+import { useRouter, usePathname } from 'next/navigation';
+
+interface AuthContextType {
+ user: UserProfileDto | null;
+ loading: boolean;
+ login: (username: string, password: string) => Promise;
+ logout: () => void;
+ register: (data: any) => Promise;
+}
+
+const AuthContext = createContext(undefined);
+
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const router = useRouter();
+ const pathname = usePathname() || '';
+
+ const checkAuth = async () => {
+ try {
+ const userData = await authService.me();
+ setUser(userData);
+ } catch (e) {
+ setUser(null);
+ if (!pathname.includes('/login')) {
+ router.push('/login');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Re-check auth when API mode changes (Strategy Pattern hook)
+ useEffect(() => {
+ return subscribeApiMode(() => {
+ setLoading(true);
+ checkAuth();
+ });
+ }, []);
+
+ useEffect(() => {
+ checkAuth();
+ }, []);
+
+ const login = async (username: string, password: string) => {
+ setLoading(true);
+ try {
+ const res = await authService.login(username, password);
+ setUser(res.user);
+ router.push('/dashboard');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const register = async (data: any) => {
+ setLoading(true);
+ try {
+ const res = await authService.register(data);
+ setUser(res.user);
+ router.push('/dashboard');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const logout = () => {
+ setUser(null);
+ localStorage.removeItem('token');
+ router.push('/login');
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) throw new Error('useAuth must be used within an AuthProvider');
+ return context;
+};
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..90cfee4
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,35 @@
+
+// This file would typically use mysql2/promise
+// import mysql from 'mysql2/promise';
+
+// Mock DB Configuration storage (In-memory for demo, use env vars in prod)
+let dbConfig = {
+ host: 'localhost',
+ port: 3306,
+ user: 'root',
+ password: '',
+ database: 'edunexus'
+};
+
+// Mock Connection Pool
+export const db = {
+ query: async (sql: string, params?: any[]) => {
+ // In a real app:
+ // const connection = await mysql.createConnection(dbConfig);
+ // const [rows] = await connection.execute(sql, params);
+ // return rows;
+
+ console.log(`[MockDB] Executing SQL: ${sql}`, params);
+ return [];
+ },
+ testConnection: async (config: typeof dbConfig) => {
+ // Simulate connection attempt
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ if (config.host === 'error') throw new Error('Connection timed out');
+
+ // Update active config
+ dbConfig = config;
+ return true;
+ },
+ getConfig: () => dbConfig
+};
diff --git a/src/lib/server-utils.ts b/src/lib/server-utils.ts
new file mode 100644
index 0000000..abddc89
--- /dev/null
+++ b/src/lib/server-utils.ts
@@ -0,0 +1,24 @@
+
+import { NextResponse } from 'next/server';
+
+// Simulate database latency
+export const dbDelay = () => new Promise(resolve => setTimeout(resolve, 500));
+
+// Standardize JSON success response
+export function successResponse(data: any, status = 200) {
+ return NextResponse.json(data, { status });
+}
+
+// Standardize JSON error response
+export function errorResponse(message: string, status = 400) {
+ return NextResponse.json({ success: false, message }, { status });
+}
+
+// Helper to extract token from header
+export function extractToken(request: Request): string | null {
+ const authHeader = request.headers.get('authorization');
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return null;
+ }
+ return authHeader.split(' ')[1];
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..a7bdc20
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+export function middleware(request: NextRequest) {
+ // In a real app, you would validate the JWT token here.
+ // Since we are using a simulated auth in Context, we can only check for cookie presence
+ // or rely on client-side checks.
+ // However, for an enterprise structure, we define the protected paths.
+
+ const path = request.nextUrl.pathname;
+ const isPublicPath = path === '/login';
+
+ // Mock token check (in real app, check cookies)
+ // const token = request.cookies.get('token')?.value || '';
+
+ // If we had server-side auth, we would redirect here.
+ // For this demo architecture where Auth is Client-Side (Context),
+ // we rely on auth-context.tsx for redirects, but we can prevent obvious bad access.
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: [
+ '/dashboard/:path*',
+ '/login',
+ '/exams/:path*',
+ '/classes/:path*',
+ '/assignments/:path*',
+ '/settings/:path*',
+ ],
+};
\ No newline at end of file
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..8c64984
--- /dev/null
+++ b/src/services/api.ts
@@ -0,0 +1,28 @@
+
+import * as realApi from './realApi';
+
+// API Mode Management (Deprecated: Mock mode is permanently disabled)
+export const getApiMode = () => false;
+
+export const setApiMode = (isMock: boolean) => {
+ console.warn("[API] Mock mode is disabled. Always using Real API.");
+};
+
+export const subscribeApiMode = (listener: () => void) => {
+ // No-op as mode never changes
+ return () => {};
+};
+
+// Export Real Services Directly
+export const authService = realApi.realAuthService;
+export const orgService = realApi.realOrgService;
+export const curriculumService = realApi.realCurriculumService;
+export const questionService = realApi.realQuestionService;
+export const examService = realApi.realExamService;
+export const assignmentService = realApi.realAssignmentService;
+export const analyticsService = realApi.realAnalyticsService;
+export const gradingService = realApi.realGradingService;
+export const submissionService = realApi.realSubmissionService;
+export const commonService = realApi.realCommonService;
+export const messageService = realApi.realMessageService;
+export const scheduleService = realApi.realScheduleService;
diff --git a/src/services/interfaces.ts b/src/services/interfaces.ts
new file mode 100644
index 0000000..5194b97
--- /dev/null
+++ b/src/services/interfaces.ts
@@ -0,0 +1,133 @@
+import {
+ LoginResultDto, UserProfileDto, RegisterDto, UpdateProfileDto, ChangePasswordDto,
+ ClassDto, ClassMemberDto, CreateClassDto,
+ SubjectDto, CurriculumTreeDto,
+ UnitNodeDto,
+ TextbookDto,
+ QuestionSummaryDto, PagedResult, ParsedQuestionDto,
+ ExamDto, ExamDetailDto, ExamStatsDto,
+ AssignmentTeacherViewDto, AssignmentStudentViewDto,
+ StudentSubmissionSummaryDto, GradingPaperDto, StudentExamPaperDto, SubmitExamDto, StudentResultDto,
+ ChartDataDto, RadarChartDto, ScoreDistributionDto, ScheduleDto, CreateScheduleDto,
+ MessageDto, CreateMessageDto
+} from '../../UI_DTO';
+
+
+export interface IAuthService {
+ login(username: string, password: string): Promise;
+ register(data: RegisterDto): Promise;
+ me(): Promise;
+ updateProfile(data: UpdateProfileDto): Promise;
+ changePassword(data: ChangePasswordDto): Promise;
+}
+
+export interface IOrgService {
+ getClasses(role?: string): Promise;
+ getClassMembers(classId: string): Promise;
+ joinClass(inviteCode: string): Promise;
+ createClass(data: CreateClassDto): Promise;
+}
+
+export interface ICurriculumService {
+ getSubjects(): Promise;
+ getTree(id: string): Promise;
+ getTextbooksBySubject(subjectId: string): Promise;
+
+ // Textbook CRUD
+ createTextbook(data: any): Promise;
+ updateTextbook(id: string, data: any): Promise;
+ deleteTextbook(id: string): Promise;
+
+ // Unit CRUD
+ createUnit(data: any): Promise;
+ updateUnit(id: string, data: any): Promise;
+ deleteUnit(id: string): Promise;
+
+ // Lesson CRUD
+ createLesson(data: any): Promise;
+ updateLesson(id: string, data: any): Promise;
+ deleteLesson(id: string): Promise;
+
+ // KnowledgePoint CRUD
+ createKnowledgePoint(data: any): Promise;
+ updateKnowledgePoint(id: string, data: any): Promise;
+ deleteKnowledgePoint(id: string): Promise;
+}
+
+export interface IQuestionService {
+ search(filter: any): Promise>;
+ parseText(rawText: string): Promise;
+}
+
+export interface IExamService {
+ getMyExams(): Promise>;
+ getExamDetail(id: string): Promise;
+ saveExam(exam: ExamDetailDto): Promise;
+ getStats(id: string): Promise;
+}
+
+export interface IAssignmentService {
+ getTeachingAssignments(): Promise>;
+ getStudentAssignments(): Promise>;
+ publishAssignment(data: any): Promise;
+ getAssignmentStats(id: string): Promise;
+}
+
+export interface IAnalyticsService {
+ getClassPerformance(): Promise;
+ getStudentGrowth(): Promise;
+ getRadar(): Promise;
+ getStudentRadar(): Promise;
+ getScoreDistribution(): Promise;
+ getTeacherStats(): Promise<{ activeStudents: number; averageScore: number; pendingGrading: number; passRate: number }>;
+}
+
+export interface QuestionFilterDto {
+ subjectId?: string;
+ type?: string; // questionType
+ difficulty?: number;
+ difficultyMin?: number;
+ difficultyMax?: number;
+ keyword?: string;
+ createdBy?: string; // 'me' or userId
+ sortBy?: 'latest' | 'popular';
+ page?: number;
+ pageSize?: number;
+}
+
+// 12. Question Service Interface
+export interface IQuestionService {
+ search(filter: QuestionFilterDto): Promise>;
+ create(data: any): Promise;
+ update(id: string, data: any): Promise;
+ delete(id: string): Promise;
+ parseText(text: string): Promise;
+}
+
+export interface IGradingService {
+ getSubmissions(assignmentId: string): Promise;
+ getPaper(submissionId: string): Promise;
+ submitGrade(submissionId: string, grades: any[]): Promise<{ message: string; totalScore: number }>;
+}
+
+export interface ISubmissionService {
+ getStudentPaper(assignmentId: string): Promise;
+ submitExam(data: SubmitExamDto): Promise;
+ getSubmissionResult(assignmentId: string): Promise;
+}
+
+export interface ICommonService {
+ getSchedule(): Promise;
+}
+
+export interface IMessageService {
+ getMessages(): Promise;
+ markAsRead(id: string): Promise;
+ createMessage(data: CreateMessageDto): Promise;
+}
+
+export interface IScheduleService {
+ getWeekSchedule(): Promise;
+ addEvent(data: CreateScheduleDto): Promise;
+ deleteEvent(id: string): Promise;
+}
diff --git a/src/services/mockApi.ts b/src/services/mockApi.ts
new file mode 100644
index 0000000..d8d5ee3
--- /dev/null
+++ b/src/services/mockApi.ts
@@ -0,0 +1,696 @@
+
+import {
+ IAuthService, IOrgService, ICurriculumService, IQuestionService,
+ IExamService, IAssignmentService, IAnalyticsService, IGradingService,
+ ISubmissionService, ICommonService, IMessageService, IScheduleService
+} from './interfaces';
+import {
+ LoginResultDto, UserProfileDto, RegisterDto, UpdateProfileDto, ChangePasswordDto,
+ ClassDto, CreateClassDto, ClassMemberDto, SubjectDto, CurriculumTreeDto,
+ UnitNodeDto,
+ TextbookDto,
+ QuestionSummaryDto, ParsedQuestionDto, PagedResult, ExamDto, ExamDetailDto, ExamStatsDto,
+ ExamNodeDto, AssignmentTeacherViewDto, AssignmentStudentViewDto,
+ StudentSubmissionSummaryDto, GradingPaperDto, StudentExamPaperDto, SubmitExamDto, StudentResultDto,
+ ChartDataDto, RadarChartDto, ScoreDistributionDto, ScheduleDto, CreateScheduleDto,
+ MessageDto, CreateMessageDto
+} from '../../UI_DTO';
+
+const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+// --- Stateful Mock Data ---
+let MOCK_CLASSES: ClassDto[] = [
+ { id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' },
+ { id: 'c-2', name: '高一 (12) 班', gradeName: '高一年级', teacherName: '张伟', studentCount: 28, inviteCode: 'M2L4Q' },
+ { id: 'c-3', name: 'AP 微积分先修班', gradeName: '高三年级', teacherName: '李明', studentCount: 15, inviteCode: 'Z9J1W' },
+ { id: 'c-4', name: '物理奥赛集训队', gradeName: '高二年级', teacherName: '王博士', studentCount: 20, inviteCode: 'H4R8T' },
+];
+
+let MOCK_STUDENT_CLASSES: ClassDto[] = [
+ { id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' }
+];
+
+let MOCK_MESSAGES: MessageDto[] = [
+ {
+ id: 'msg-1',
+ title: '关于下周校运会的安排通知',
+ content: '各位同学、老师:\n\n下周一(11月6日)将举行第20届秋季运动会,请各班做好入场式准备。周一至周二停课两天,周三正常上课。\n\n教务处',
+ type: 'Announcement',
+ senderName: '教务处',
+ senderAvatar: 'https://api.dicebear.com/7.x/initials/svg?seed=AO',
+ createdAt: '2小时前',
+ isRead: false
+ },
+ {
+ id: 'msg-2',
+ title: '数学期中考试成绩已发布',
+ content: '高一年级数学期中考试阅卷工作已结束,请各位同学前往“考试结果”查看详情。',
+ type: 'Notification',
+ senderName: '李明',
+ senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex',
+ createdAt: '昨天 14:00',
+ isRead: true
+ },
+ {
+ id: 'msg-3',
+ title: '系统维护通知',
+ content: '系统将于本周日凌晨 02:00 - 04:00 进行例行维护,届时将无法访问,请留意。',
+ type: 'Alert',
+ senderName: '系统管理员',
+ createdAt: '2023-10-28',
+ isRead: true
+ }
+];
+
+let MOCK_SCHEDULE: ScheduleDto[] = [
+ { id: 'sch-1', dayOfWeek: 1, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
+ { id: 'sch-2', dayOfWeek: 1, period: 2, startTime: '09:00', endTime: '09:45', className: '高一 (12) 班', subject: '数学', room: 'A303', isToday: false },
+ { id: 'sch-3', dayOfWeek: 2, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
+ { id: 'sch-4', dayOfWeek: 2, period: 3, startTime: '10:00', endTime: '10:45', className: 'AP 微积分', subject: '微积分', room: 'B102', isToday: false },
+ { id: 'sch-5', dayOfWeek: 3, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
+ { id: 'sch-6', dayOfWeek: 3, period: 2, startTime: '09:00', endTime: '09:45', className: '高一 (12) 班', subject: '数学', room: 'A303', isToday: false },
+ { id: 'sch-7', dayOfWeek: 4, period: 1, startTime: '08:00', endTime: '08:45', className: '高一 (10) 班', subject: '数学', room: 'A301', isToday: false },
+ { id: 'sch-8', dayOfWeek: 5, period: 4, startTime: '11:00', endTime: '11:45', className: '奥赛集训', subject: '物理', room: 'Lab 1', isToday: false },
+];
+
+export const mockAuthService: IAuthService = {
+ login: async (username: string): Promise => {
+ await delay(800);
+
+ let role: 'Teacher' | 'Student' | 'Admin' = 'Teacher';
+ let name = '李明';
+ let id = 'u-tea-1';
+
+ if (username === 'student' || username === '123456' && username.startsWith('s')) {
+ role = 'Student';
+ name = '王小明';
+ id = 'u-stu-1';
+ } else if (username === 'admin') {
+ role = 'Admin';
+ name = '系统管理员';
+ id = 'u-adm-1';
+ }
+
+ return {
+ token: "mock-jwt-token-12345",
+ user: {
+ id: id,
+ realName: name,
+ studentId: username,
+ avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${name}`,
+ gender: 'Male',
+ schoolId: 's-1',
+ role: role,
+ email: 'liming@school.edu',
+ phone: '13800138000',
+ bio: '热爱教育,专注数学教学创新。'
+ }
+ };
+ },
+ register: async (data: RegisterDto): Promise => {
+ await delay(1200);
+ return {
+ token: "mock-jwt-token-new-user",
+ user: {
+ id: `u-${Date.now()}`,
+ realName: data.realName,
+ studentId: data.studentId,
+ avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${data.realName}`,
+ gender: 'Male',
+ schoolId: 's-1',
+ role: data.role,
+ email: '',
+ phone: '',
+ bio: '新注册用户'
+ }
+ };
+ },
+ me: async (): Promise => {
+ await delay(500);
+ return {
+ id: "u-1",
+ realName: "李明",
+ studentId: "T2024001",
+ avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alex",
+ gender: "Male",
+ schoolId: "s-1",
+ role: "Teacher",
+ email: 'liming@school.edu',
+ phone: '13800138000',
+ bio: '热爱教育,专注数学教学创新。'
+ };
+ },
+ updateProfile: async (data: UpdateProfileDto): Promise => {
+ await delay(1000);
+ return {
+ id: "u-1",
+ realName: data.realName || "李明",
+ studentId: "T2024001",
+ avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alex",
+ gender: "Male",
+ schoolId: "s-1",
+ role: "Teacher",
+ email: data.email || 'liming@school.edu',
+ phone: data.phone || '13800138000',
+ bio: data.bio || '热爱教育,专注数学教学创新。'
+ };
+ },
+ changePassword: async (data: ChangePasswordDto): Promise => {
+ await delay(1200);
+ if (data.oldPassword !== '123456') {
+ throw new Error('旧密码错误');
+ }
+ }
+};
+
+export const mockOrgService: IOrgService = {
+ getClasses: async (role?: string): Promise => {
+ await delay(600);
+ if (role === 'Student') {
+ return [...MOCK_STUDENT_CLASSES];
+ }
+ return [...MOCK_CLASSES];
+ },
+ getClassMembers: async (classId: string): Promise => {
+ await delay(600);
+ return Array.from({ length: 32 }).map((_, i) => ({
+ id: `stu-${i}`,
+ studentId: `2024${1000 + i}`,
+ realName: i % 2 === 0 ? `张${i + 1}` : `王${i + 1}`,
+ avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${classId}-stu-${i}`,
+ gender: Math.random() > 0.5 ? 'Male' : 'Female',
+ role: i === 0 ? 'Monitor' : (i < 5 ? 'Committee' : 'Student'),
+ recentTrend: [
+ Math.floor(Math.random() * 20) + 80,
+ Math.floor(Math.random() * 20) + 80,
+ Math.floor(Math.random() * 20) + 80,
+ Math.floor(Math.random() * 20) + 80,
+ Math.floor(Math.random() * 20) + 80,
+ ],
+ status: i > 28 ? 'AtRisk' : (i < 5 ? 'Excellent' : 'Active'),
+ attendanceRate: i > 30 ? 85 : 98
+ }));
+ },
+ joinClass: async (inviteCode: string): Promise => {
+ await delay(1500);
+ const targetClass = MOCK_CLASSES.find(c => c.inviteCode === inviteCode);
+ if (!targetClass) throw new Error('无效的邀请码');
+
+ const alreadyJoined = MOCK_STUDENT_CLASSES.find(c => c.id === targetClass.id);
+ if (alreadyJoined) throw new Error('你已经加入了该班级');
+
+ MOCK_STUDENT_CLASSES.push(targetClass);
+ },
+ createClass: async (data: CreateClassDto): Promise => {
+ await delay(1000);
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ let code = '';
+ for (let i = 0; i < 5; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+
+ const newClass: ClassDto = {
+ id: `c-new-${Date.now()}`,
+ name: data.name,
+ gradeName: data.gradeName,
+ teacherName: '李明',
+ studentCount: 0,
+ inviteCode: code
+ };
+
+ MOCK_CLASSES.push(newClass);
+ return newClass;
+ }
+};
+
+export const mockCurriculumService: ICurriculumService = {
+ getSubjects: async (): Promise => {
+ await delay(400);
+ return [
+ { id: 'sub-1', name: '数学', code: 'MATH', icon: '📐' },
+ { id: 'sub-2', name: '物理', code: 'PHYS', icon: '⚡' },
+ { id: 'sub-3', name: '英语', code: 'ENG', icon: '🔤' },
+ { id: 'sub-4', name: '化学', code: 'CHEM', icon: '🧪' },
+ { id: 'sub-5', name: '历史', code: 'HIST', icon: '🏛️' },
+ ];
+ },
+ getTree: async (id: string): Promise => {
+ await delay(600);
+ return {
+ textbook: { id: 'tb-1', name: '七年级数学上册', publisher: '人教版', versionYear: '2023', coverUrl: '' },
+ units: []
+ };
+ },
+ getTextbooksBySubject: async (subjectId: string): Promise => {
+ await delay(400);
+ return [
+ { id: 'tb-1', name: '七年级数学上册', publisher: '人教版', versionYear: '2023', coverUrl: '' },
+ { id: 'tb-2', name: '七年级数学下册', publisher: '人教版', versionYear: '2023', coverUrl: '' }
+ ];
+ },
+ // Stubs for CRUD
+ createTextbook: async () => { },
+ updateTextbook: async () => { },
+ deleteTextbook: async () => { },
+ createUnit: async () => { },
+ updateUnit: async () => { },
+ deleteUnit: async () => { },
+ createLesson: async () => { },
+ updateLesson: async () => { },
+ deleteLesson: async () => { },
+ createKnowledgePoint: async () => { },
+ updateKnowledgePoint: async () => { },
+ deleteKnowledgePoint: async () => { }
+};
+
+export const mockQuestionService: IQuestionService = {
+ search: async (filter: any): Promise> => {
+ await delay(600);
+ const mockQuestions = [
+ {
+ id: 'q-math-1',
+ type: '单选题',
+ difficulty: 2,
+ knowledgePoints: ['集合', '交集运算'],
+ content: `已知集合 A = {1, 2, 3}, B = {2, 3, 4}, 则 A ∩ B = ( )
+
+
A. {1}
+
B. {2, 3}
+
C. {1, 2, 3, 4}
+
D. ∅
+
`,
+ answer: 'B',
+ parse: '集合 A 与 B 的公共元素为 2 和 3,故 A ∩ B = {2, 3}。'
+ },
+ {
+ id: 'q-math-2',
+ type: '填空题',
+ difficulty: 3,
+ knowledgePoints: ['函数', '导数'],
+ content: `函数 f(x) = x lnx 在点 x = 1 处的切线方程为 ______.
`,
+ answer: 'x - y - 1 = 0',
+ parse: 'f\'(x) = lnx + 1, f\'(1) = 1. 又 f(1)=0, 故切线方程为 y-0 = 1*(x-1), 即 x-y-1=0.'
+ }
+ ];
+ const items = [...mockQuestions, ...mockQuestions];
+ return {
+ totalCount: items.length,
+ pageIndex: 1,
+ pageSize: 10,
+ items: items.map((q, i) => ({ ...q, id: `${q.id}-${i}` }))
+ };
+ },
+ parseText: async (rawText: string): Promise => {
+ await delay(1200);
+ const parsedQuestions: ParsedQuestionDto[] = [];
+ const questionBlocks = rawText.split(/\n(?=\d+\.)/g).filter(b => b.trim().length > 0);
+
+ questionBlocks.forEach(block => {
+ const stemMatch = block.match(/^\d+\.([\s\S]*?)(?=(?:A\.|Answer:|答案:|解析:|$))/);
+ let content = stemMatch ? stemMatch[1].trim() : block;
+ const optionsMatch = block.match(/([A-D])\.\s*([^\n]+)/g);
+ let type = '填空题';
+ let optionsHTML = '';
+
+ if (optionsMatch && optionsMatch.length >= 4) {
+ type = '单选题';
+ optionsHTML = `
+ ${optionsMatch.map(opt => `
${opt.trim()}
`).join('')}
+
`;
+ }
+ const answerMatch = block.match(/(?:Answer|答案)[::]\s*([^\n]+)/);
+ const answer = answerMatch ? answerMatch[1].trim() : '';
+ const parseMatch = block.match(/(?:Parse|解析|Analysis)[::]\s*([\s\S]+)/);
+ const parse = parseMatch ? parseMatch[1].trim() : '暂无解析';
+ content = `${content}
${optionsHTML}`;
+ parsedQuestions.push({ content, type, answer, parse });
+ });
+ return parsedQuestions;
+ }
+ ,
+ create: async (data: any): Promise => {
+ await delay(300);
+ return { id: `q-${Date.now()}` };
+ },
+ update: async (id: string, data: any): Promise => {
+ await delay(300);
+ return { id };
+ },
+ delete: async (id: string): Promise => {
+ await delay(300);
+ return { id };
+ }
+};
+
+export const mockExamService: IExamService = {
+ getMyExams: async (): Promise> => {
+ await delay(700);
+ return {
+ totalCount: 5,
+ pageIndex: 1,
+ pageSize: 10,
+ items: [
+ { id: 'e-1', subjectId: 'sub-1', title: '2024-2025学年第一学期期中数学考试', totalScore: 100, duration: 120, questionCount: 22, status: 'Published', createdAt: '2024-10-15' },
+ { id: 'e-2', subjectId: 'sub-1', title: '第一单元随堂测试:集合与函数', totalScore: 25, duration: 30, questionCount: 8, status: 'Draft', createdAt: '2024-10-20' },
+ ]
+ };
+ },
+ getExamDetail: async (id: string): Promise => {
+ await delay(1000);
+ return {
+ id,
+ subjectId: 'sub-1',
+ title: '2024-2025学年第一学期期中数学考试',
+ totalScore: 100,
+ duration: 120,
+ questionCount: 10,
+ status: 'Draft',
+ createdAt: '2024-10-15',
+ rootNodes: [
+ {
+ id: 'node-1',
+ nodeType: 'Group',
+ title: '第一部分:选择题',
+ description: '本大题共 8 小题,每小题 5 分,共 40 分。',
+ score: 40,
+ sortOrder: 1,
+ children: [
+ {
+ id: 'node-1-1',
+ nodeType: 'Question',
+ questionId: 'q-1',
+ questionContent: '已知集合 A={1,2}, B={2,3}, 则 A∩B=?',
+ questionType: '单选题',
+ score: 5,
+ sortOrder: 1
+ },
+ {
+ id: 'node-1-2',
+ nodeType: 'Question',
+ questionId: 'q-2',
+ questionContent: '函数 f(x) = x² + 2x - 3 的零点是?',
+ questionType: '单选题',
+ score: 5,
+ sortOrder: 2
+ }
+ ]
+ },
+ {
+ id: 'node-2',
+ nodeType: 'Group',
+ title: '第二部分:解答题',
+ description: '需要写出完整解题过程',
+ score: 60,
+ sortOrder: 2,
+ children: [
+ {
+ id: 'node-2-1',
+ nodeType: 'Group',
+ title: '(一) 计算题',
+ score: 30,
+ sortOrder: 1,
+ children: [
+ {
+ id: 'node-2-1-1',
+ nodeType: 'Question',
+ questionId: 'q-3',
+ questionContent: '计算:(1) 2x + 3 = 7',
+ questionType: '计算题',
+ score: 10,
+ sortOrder: 1
+ },
+ {
+ id: 'node-2-1-2',
+ nodeType: 'Question',
+ questionId: 'q-4',
+ questionContent: '计算:(2) 解方程组 ...',
+ questionType: '计算题',
+ score: 10,
+ sortOrder: 2
+ }
+ ]
+ },
+ {
+ id: 'node-2-2',
+ nodeType: 'Question',
+ questionId: 'q-5',
+ questionContent: '证明:等腰三角形两底角相等',
+ questionType: '证明题',
+ score: 30,
+ sortOrder: 2
+ }
+ ]
+ }
+ ]
+ };
+ },
+ saveExam: async (exam: ExamDetailDto): Promise => {
+ await delay(800);
+ console.log('Saved exam:', exam);
+ },
+ getStats: async (id: string): Promise => {
+ await delay(800);
+ return {
+ averageScore: 78.5,
+ passRate: 92.4,
+ maxScore: 100,
+ minScore: 42,
+ scoreDistribution: [{ range: '0-60', count: 2 }, { range: '90-100', count: 8 }],
+ wrongQuestions: [
+ { id: 'q-1', content: '已知集合 A={1,2}, B={2,3}, 则 A∩B=?', errorRate: 45, difficulty: 2, type: '单选题' },
+ ]
+ };
+ }
+};
+
+export const mockAssignmentService: IAssignmentService = {
+ getTeachingAssignments: async (): Promise> => {
+ await delay(500);
+ return {
+ totalCount: 4,
+ pageIndex: 1,
+ pageSize: 10,
+ items: [
+ { id: 'a-1', title: '期中考试模拟卷', examTitle: '2024-2025学年第一学期期中数学考试', className: '高一 (10) 班', submittedCount: 30, totalCount: 32, status: 'Active', dueDate: '2023-11-01' },
+ ]
+ };
+ },
+ getStudentAssignments: async (): Promise> => {
+ await delay(500);
+ return {
+ totalCount: 3,
+ pageIndex: 1,
+ pageSize: 10,
+ items: [
+ { id: 'a-1', title: '期中考试模拟卷', examTitle: '2024-2025学年第一学期期中数学考试', endTime: '2023-11-01', status: 'Pending' },
+ ]
+ }
+ },
+ publishAssignment: async (data: any): Promise => {
+ await delay(1000);
+ console.log('Published assignment:', data);
+ },
+ getAssignmentStats: async (id: string): Promise => {
+ await delay(800);
+ return {
+ averageScore: 82.5,
+ passRate: 95.0,
+ maxScore: 100,
+ minScore: 58,
+ scoreDistribution: [],
+ wrongQuestions: []
+ };
+ }
+};
+
+export const mockAnalyticsService: IAnalyticsService = {
+ getClassPerformance: async (): Promise => {
+ await delay(700);
+ return {
+ labels: ['周一', '周二', '周三'],
+ datasets: [
+ { label: '平均分', data: [78, 82, 80], borderColor: '#007AFF', backgroundColor: 'rgba(0, 122, 255, 0.1)' }
+ ]
+ };
+ },
+ getStudentGrowth: async (): Promise => {
+ await delay(700);
+ return {
+ labels: ['第一次', '第二次'],
+ datasets: [
+ { label: '我的成绩', data: [82, 85], borderColor: '#34C759', backgroundColor: 'rgba(52, 199, 89, 0.1)', fill: true },
+ { label: '班级平均', data: [75, 78], borderColor: '#8E8E93', backgroundColor: 'transparent', fill: false }
+ ]
+ };
+ },
+ getRadar: async (): Promise => {
+ await delay(700);
+ return { indicators: ['代数', '几何'], values: [85, 70] };
+ },
+ getStudentRadar: async (): Promise => {
+ await delay(700);
+ return { indicators: ['代数', '几何'], values: [90, 60] };
+ },
+ getScoreDistribution: async (): Promise => {
+ await delay(600);
+ return [{ range: '0-60', count: 2 }, { range: '90-100', count: 8 }];
+ },
+ getTeacherStats: async () => {
+ await delay(600);
+ return {
+ activeStudents: 1240,
+ averageScore: 84.5,
+ pendingGrading: 38,
+ passRate: 96
+ };
+ }
+};
+
+export const mockGradingService: IGradingService = {
+ getSubmissions: async (assignmentId: string): Promise => {
+ await delay(600);
+ return Array.from({ length: 15 }).map((_, i) => ({
+ id: `sub-${i}`,
+ studentName: `学生 ${i + 1}`,
+ studentId: `20240${i < 10 ? '0' + i : i}`,
+ avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
+ status: i < 5 ? 'Graded' : 'Submitted',
+ score: i < 5 ? 85 + (i % 10) : undefined,
+ submitTime: '2024-10-24 14:30'
+ }));
+ },
+ getPaper: async (submissionId: string): Promise => {
+ await delay(800);
+ return {
+ submissionId,
+ studentName: '王小明',
+ nodes: [
+ {
+ examNodeId: 'node-1',
+ questionId: 'q1',
+ questionContent: '已知集合 A={x|x²-2x-3<0}, B={x|y=ln(2-x)}, 求 A∩B.',
+ questionType: '计算题',
+ score: 10,
+ studentAnswer: 'https://placehold.co/600x300/png?text=Student+Handwriting+Here',
+ studentScore: undefined
+ }
+ ]
+ };
+ }
+ ,
+ submitGrade: async (submissionId: string, grades: any[]): Promise<{ message: string; totalScore: number }> => {
+ await delay(600);
+ const totalScore = grades.reduce((sum, g) => sum + (g.score || 0), 0);
+ return { message: 'ok', totalScore };
+ }
+};
+
+export const mockSubmissionService: ISubmissionService = {
+ getStudentPaper: async (assignmentId: string): Promise => {
+ await delay(1200);
+ return {
+ examId: 'e-101',
+ title: '2024-2025学年第一学期期中数学考试',
+ duration: 90,
+ totalScore: 100,
+ rootNodes: [
+ {
+ id: 'node-1',
+ nodeType: 'Group',
+ title: '一、选择题',
+ score: 40,
+ sortOrder: 1,
+ children: [
+ {
+ id: 'node-1-1',
+ nodeType: 'Question',
+ questionId: 'q-1',
+ questionContent: '已知集合 A={1,2,3}, B={2,3,4}, 则 A∩B=( )',
+ questionType: '单选题',
+ score: 5,
+ sortOrder: 1
+ }
+ ]
+ }
+ ]
+ };
+ },
+ submitExam: async (data: SubmitExamDto): Promise => {
+ await delay(1500);
+ console.log('Submitted:', data);
+ },
+ getSubmissionResult: async (assignmentId: string): Promise => {
+ await delay(800);
+ return {
+ submissionId: 'sub-my-1',
+ studentName: '我',
+ totalScore: 88,
+ rank: 5,
+ beatRate: 85,
+ nodes: [
+ {
+ examNodeId: 'node-1',
+ questionId: 'q-1',
+ questionContent: '已知集合 A={1,2,3}, B={2,3,4}, 则 A∩B=( )',
+ questionType: '单选题',
+ score: 5,
+ studentScore: 5,
+ studentAnswer: '{2,3}',
+ autoCheckResult: true
+ }
+ ]
+ }
+ }
+}
+
+export const mockCommonService: ICommonService = {
+ getSchedule: async (): Promise => {
+ await delay(300);
+ const today = new Date().getDay() || 7;
+ return MOCK_SCHEDULE.filter(s => s.dayOfWeek === today).map(s => ({ ...s, isToday: true }));
+ }
+}
+
+export const mockMessageService: IMessageService = {
+ getMessages: async (): Promise => {
+ await delay(500);
+ return [...MOCK_MESSAGES];
+ },
+ markAsRead: async (id: string): Promise => {
+ await delay(200);
+ const msg = MOCK_MESSAGES.find(m => m.id === id);
+ if (msg) msg.isRead = true;
+ },
+ createMessage: async (data: CreateMessageDto): Promise => {
+ await delay(800);
+ MOCK_MESSAGES.unshift({
+ id: `msg-${Date.now()}`,
+ title: data.title,
+ content: data.content,
+ type: data.type as any,
+ senderName: '我',
+ senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex',
+ createdAt: '刚刚',
+ isRead: true
+ });
+ }
+};
+
+export const mockScheduleService: IScheduleService = {
+ getWeekSchedule: async (): Promise => {
+ await delay(600);
+ return [...MOCK_SCHEDULE];
+ },
+ addEvent: async (data: CreateScheduleDto): Promise => {
+ await delay(800);
+ MOCK_SCHEDULE.push({
+ id: `sch-${Date.now()}`,
+ ...data,
+ isToday: false
+ });
+ },
+ deleteEvent: async (id: string): Promise => {
+ await delay(500);
+ MOCK_SCHEDULE = MOCK_SCHEDULE.filter(s => s.id !== id);
+ }
+};
diff --git a/src/services/realApi.ts b/src/services/realApi.ts
new file mode 100644
index 0000000..37190a2
--- /dev/null
+++ b/src/services/realApi.ts
@@ -0,0 +1,283 @@
+
+import {
+ IAuthService, IOrgService, ICurriculumService, IQuestionService,
+ IExamService, IAssignmentService, IAnalyticsService, IGradingService,
+ ISubmissionService, ICommonService, IMessageService, IScheduleService
+} from './interfaces';
+import {
+ LoginResultDto, UserProfileDto, RegisterDto, UpdateProfileDto, ChangePasswordDto,
+ CreateClassDto, SubmitExamDto, ExamDetailDto, CreateMessageDto, CreateScheduleDto
+} from '../../UI_DTO';
+
+
+const API_BASE_URL = 'http://localhost:3001/api'; // 直接连接到后端服务器
+const DEFAULT_TIMEOUT = 30000; // 30 秒超时
+
+// Helper to handle requests with timeout
+async function request(endpoint: string, options: RequestInit = {}, timeout: number = DEFAULT_TIMEOUT): Promise {
+ const token = localStorage.getItem('token');
+ const headers: HeadersInit = {
+ 'Content-Type': 'application/json',
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
+ ...options.headers,
+ };
+
+ // 创建 AbortController 用于超时控制
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ try {
+ const response = await fetch(`${API_BASE_URL}${endpoint}`, {
+ ...options,
+ headers,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ // 尝试获取错误详情
+ let errorMessage: string;
+ let errorDetails: any = {};
+
+ try {
+ const errorBody = await response.json();
+ errorMessage = errorBody.error || errorBody.message || `Request failed with status ${response.status}`;
+ errorDetails = errorBody;
+ } catch {
+ errorMessage = `Request failed with status ${response.status}`;
+ }
+
+ // 创建包含状态码的错误
+ const error = new Error(errorMessage) as any;
+ error.status = response.status;
+ error.details = errorDetails;
+ throw error;
+ }
+
+ // Some endpoints might return void/empty body
+ if (response.status === 204) {
+ return {} as T;
+ }
+
+ return response.json();
+ } catch (error: any) {
+ clearTimeout(timeoutId);
+
+ // 如果是 AbortError,说明请求超时
+ if (error.name === 'AbortError') {
+ const timeoutError = new Error('请求超时,请稍后重试') as any;
+ timeoutError.isTimeout = true;
+ throw timeoutError;
+ }
+
+ // 网络错误
+ if (error instanceof TypeError && error.message === 'Failed to fetch') {
+ const networkError = new Error('网络连接失败,请检查您的网络') as any;
+ networkError.isNetworkError = true;
+ throw networkError;
+ }
+
+ throw error;
+ }
+}
+
+
+export const realAuthService: IAuthService = {
+ login: async (username, password) => {
+ const isEmail = username.includes('@');
+ const requestBody = isEmail
+ ? { email: username, password }
+ : { phone: username, password };
+
+ const res = await request('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify(requestBody)
+ });
+ localStorage.setItem('token', res.token);
+ return res;
+ },
+ register: async (data: RegisterDto) => {
+ const res = await request('/auth/register', {
+ method: 'POST',
+ body: JSON.stringify(data)
+ });
+ localStorage.setItem('token', res.token);
+ return res;
+ },
+ me: () => request('/auth/me'),
+ updateProfile: (data: UpdateProfileDto) => request