From 38244630a7a58542203c3c314f9baa5b97197818 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 28 Nov 2025 19:23:19 +0800 Subject: [PATCH] chore: initial import to Nexus_Edu --- .gitignore | 25 + API_BCK | 480 ++++ API_BCK.md | 583 +++++ Model.ts | 487 ++++ README.md | 20 + UI_DTO.ts | 568 +++++ backend/.env.example | 15 + backend/.gitignore | 6 + backend/README.md | 137 ++ backend/package-lock.json | 1841 +++++++++++++++ backend/package.json | 44 + backend/prisma/schema.prisma | 526 +++++ backend/prisma/seed.ts | 570 +++++ .../src/controllers/analytics.controller.ts | 378 +++ .../src/controllers/assignment.controller.ts | 311 +++ backend/src/controllers/auth.controller.ts | 89 + backend/src/controllers/common.controller.ts | 176 ++ .../src/controllers/curriculum.controller.ts | 370 +++ backend/src/controllers/exam.controller.ts | 139 ++ backend/src/controllers/grading.controller.ts | 307 +++ backend/src/controllers/org.controller.ts | 307 +++ .../src/controllers/question.controller.ts | 235 ++ .../src/controllers/submission.controller.ts | 439 ++++ backend/src/index.ts | 73 + backend/src/middleware/auth.middleware.ts | 51 + backend/src/routes/analytics.routes.ts | 26 + backend/src/routes/assignment.routes.ts | 12 + backend/src/routes/auth.routes.ts | 16 + backend/src/routes/common.routes.ts | 32 + backend/src/routes/curriculum.routes.ts | 32 + backend/src/routes/exam.routes.ts | 34 + backend/src/routes/grading.routes.ts | 11 + backend/src/routes/org.routes.ts | 13 + backend/src/routes/question.routes.ts | 11 + backend/src/routes/submission.routes.ts | 12 + backend/src/services/auth.service.ts | 204 ++ backend/src/services/exam.service.ts | 310 +++ backend/src/utils/helpers.ts | 143 ++ backend/src/utils/prisma.ts | 17 + backend/tsconfig.json | 28 + database/README.md | 62 + database/drop.sql | 28 + database/schema.sql | 420 ++++ database/seed.sql | 121 + metadata.json | 5 + next-env.d.ts | 5 + package-lock.json | 2054 +++++++++++++++++ package.json | 30 + postcss.config.js | 6 + src/app/(dashboard)/assignments/page.tsx | 85 + src/app/(dashboard)/classes/page.tsx | 13 + src/app/(dashboard)/consoleconfig/page.tsx | 9 + src/app/(dashboard)/curriculum/page.tsx | 8 + src/app/(dashboard)/dashboard/page.tsx | 76 + src/app/(dashboard)/exams/page.tsx | 8 + src/app/(dashboard)/layout.tsx | 87 + src/app/(dashboard)/messages/page.tsx | 11 + src/app/(dashboard)/questions/page.tsx | 8 + src/app/(dashboard)/schedule/page.tsx | 10 + src/app/(dashboard)/settings/page.tsx | 8 + .../(dashboard)/student-result/[id]/page.tsx | 16 + src/app/(fullscreen)/grading/[id]/page.tsx | 16 + src/app/(fullscreen)/layout.tsx | 19 + .../(fullscreen)/student-exam/[id]/page.tsx | 16 + src/app/api/auth/login/route.ts | 48 + src/app/api/auth/me/route.ts | 28 + src/app/api/config/db/route.ts | 27 + src/app/api/org/classes/route.ts | 23 + src/app/globals.css | 52 + src/app/layout.tsx | 30 + src/app/loading.tsx | 12 + src/app/login/page.tsx | 82 + src/app/not-found.tsx | 23 + src/app/page.tsx | 27 + src/components/ErrorBoundary.tsx | 99 + src/components/Sidebar.tsx | 161 ++ src/components/ui/Badge.tsx | 36 + src/components/ui/Button.tsx | 49 + src/components/ui/Card.tsx | 40 + src/components/ui/ChartTooltip.tsx | 18 + src/components/ui/ErrorState.tsx | 109 + src/components/ui/HandwritingPad.tsx | 80 + src/components/ui/LoadingState.tsx | 75 + src/components/ui/Modal.tsx | 58 + src/components/ui/Toast.tsx | 86 + .../assignment/components/AssignmentStats.tsx | 97 + .../components/CreateAssignmentModal.tsx | 269 +++ .../components/StudentAssignmentList.tsx | 68 + .../components/TeacherAssignmentList.tsx | 138 ++ src/features/auth/components/LoginForm.tsx | 110 + src/features/auth/components/RegisterForm.tsx | 170 ++ .../class/components/ClassAnalysis.tsx | 101 + src/features/class/components/ClassCard.tsx | 60 + .../class/components/ClassSettings.tsx | 108 + .../class/components/CreateClassModal.tsx | 99 + .../class/components/JoinClassModal.tsx | 88 + src/features/class/components/StudentRow.tsx | 63 + .../components/CurriculumNodeModal.tsx | 94 + .../components/DeleteConfirmModal.tsx | 70 + .../curriculum/components/KnowledgeGraph.tsx | 84 + .../curriculum/components/TextbookModal.tsx | 112 + .../curriculum/components/TreeNode.tsx | 185 ++ .../dashboard/components/NotificationList.tsx | 76 + .../dashboard/components/ScheduleList.tsx | 44 + .../dashboard/components/StatCard.tsx | 36 + .../dashboard/components/StudentDashboard.tsx | 223 ++ .../dashboard/components/TeacherDashboard.tsx | 271 +++ src/features/exam/components/ExamEditor.tsx | 512 ++++ src/features/exam/components/ExamList.tsx | 126 + src/features/exam/components/ExamStats.tsx | 97 + src/features/exam/components/ImportModal.tsx | 142 ++ .../exam/components/result/ResultHeader.tsx | 41 + .../exam/components/result/ResultList.tsx | 58 + .../exam/components/runner/AnswerSheet.tsx | 100 + .../exam/components/runner/ExamHeader.tsx | 68 + .../components/runner/RunnerQuestionCard.tsx | 129 ++ .../components/runner/SubmitConfirmModal.tsx | 72 + .../grading/components/GradingBoard.tsx | 110 + .../grading/components/GradingToolbar.tsx | 64 + .../grading/components/QuestionGradingRow.tsx | 65 + .../grading/components/SubmissionSidebar.tsx | 51 + .../message/components/CreateMessageModal.tsx | 102 + .../question/components/QuestionCard.tsx | 107 + .../question/components/QuestionModal.tsx | 164 ++ .../question/components/SubjectSelector.tsx | 31 + .../schedule/components/EventModal.tsx | 145 ++ .../schedule/components/Timetable.tsx | 88 + .../components/PreferenceSettings.tsx | 55 + .../settings/components/ProfileSettings.tsx | 109 + .../settings/components/SecuritySettings.tsx | 96 + src/lib/auth-context.tsx | 89 + src/lib/db.ts | 35 + src/lib/server-utils.ts | 24 + src/middleware.ts | 32 + src/services/api.ts | 28 + src/services/interfaces.ts | 133 ++ src/services/mockApi.ts | 696 ++++++ src/services/realApi.ts | 283 +++ src/types.ts | 344 +++ src/utils/errorUtils.ts | 181 ++ src/views/ClassManagement.tsx | 192 ++ src/views/ConsoleConfig.tsx | 152 ++ src/views/Curriculum.tsx | 597 +++++ src/views/ExamEngine.tsx | 58 + src/views/Grading.tsx | 131 ++ src/views/Messages.tsx | 164 ++ src/views/QuestionBank.tsx | 400 ++++ src/views/Schedule.tsx | 81 + src/views/Settings.tsx | 132 ++ src/views/StudentExamRunner.tsx | 212 ++ src/views/StudentResult.tsx | 43 + tailwind.config.ts | 33 + tsconfig.json | 41 + 153 files changed, 22541 insertions(+) create mode 100644 .gitignore create mode 100644 API_BCK create mode 100644 API_BCK.md create mode 100644 Model.ts create mode 100644 README.md create mode 100644 UI_DTO.ts create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/controllers/analytics.controller.ts create mode 100644 backend/src/controllers/assignment.controller.ts create mode 100644 backend/src/controllers/auth.controller.ts create mode 100644 backend/src/controllers/common.controller.ts create mode 100644 backend/src/controllers/curriculum.controller.ts create mode 100644 backend/src/controllers/exam.controller.ts create mode 100644 backend/src/controllers/grading.controller.ts create mode 100644 backend/src/controllers/org.controller.ts create mode 100644 backend/src/controllers/question.controller.ts create mode 100644 backend/src/controllers/submission.controller.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/auth.middleware.ts create mode 100644 backend/src/routes/analytics.routes.ts create mode 100644 backend/src/routes/assignment.routes.ts create mode 100644 backend/src/routes/auth.routes.ts create mode 100644 backend/src/routes/common.routes.ts create mode 100644 backend/src/routes/curriculum.routes.ts create mode 100644 backend/src/routes/exam.routes.ts create mode 100644 backend/src/routes/grading.routes.ts create mode 100644 backend/src/routes/org.routes.ts create mode 100644 backend/src/routes/question.routes.ts create mode 100644 backend/src/routes/submission.routes.ts create mode 100644 backend/src/services/auth.service.ts create mode 100644 backend/src/services/exam.service.ts create mode 100644 backend/src/utils/helpers.ts create mode 100644 backend/src/utils/prisma.ts create mode 100644 backend/tsconfig.json create mode 100644 database/README.md create mode 100644 database/drop.sql create mode 100644 database/schema.sql create mode 100644 database/seed.sql create mode 100644 metadata.json create mode 100644 next-env.d.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app/(dashboard)/assignments/page.tsx create mode 100644 src/app/(dashboard)/classes/page.tsx create mode 100644 src/app/(dashboard)/consoleconfig/page.tsx create mode 100644 src/app/(dashboard)/curriculum/page.tsx create mode 100644 src/app/(dashboard)/dashboard/page.tsx create mode 100644 src/app/(dashboard)/exams/page.tsx create mode 100644 src/app/(dashboard)/layout.tsx create mode 100644 src/app/(dashboard)/messages/page.tsx create mode 100644 src/app/(dashboard)/questions/page.tsx create mode 100644 src/app/(dashboard)/schedule/page.tsx create mode 100644 src/app/(dashboard)/settings/page.tsx create mode 100644 src/app/(dashboard)/student-result/[id]/page.tsx create mode 100644 src/app/(fullscreen)/grading/[id]/page.tsx create mode 100644 src/app/(fullscreen)/layout.tsx create mode 100644 src/app/(fullscreen)/student-exam/[id]/page.tsx create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/config/db/route.ts create mode 100644 src/app/api/org/classes/route.ts create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/loading.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Card.tsx create mode 100644 src/components/ui/ChartTooltip.tsx create mode 100644 src/components/ui/ErrorState.tsx create mode 100644 src/components/ui/HandwritingPad.tsx create mode 100644 src/components/ui/LoadingState.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/features/assignment/components/AssignmentStats.tsx create mode 100644 src/features/assignment/components/CreateAssignmentModal.tsx create mode 100644 src/features/assignment/components/StudentAssignmentList.tsx create mode 100644 src/features/assignment/components/TeacherAssignmentList.tsx create mode 100644 src/features/auth/components/LoginForm.tsx create mode 100644 src/features/auth/components/RegisterForm.tsx create mode 100644 src/features/class/components/ClassAnalysis.tsx create mode 100644 src/features/class/components/ClassCard.tsx create mode 100644 src/features/class/components/ClassSettings.tsx create mode 100644 src/features/class/components/CreateClassModal.tsx create mode 100644 src/features/class/components/JoinClassModal.tsx create mode 100644 src/features/class/components/StudentRow.tsx create mode 100644 src/features/curriculum/components/CurriculumNodeModal.tsx create mode 100644 src/features/curriculum/components/DeleteConfirmModal.tsx create mode 100644 src/features/curriculum/components/KnowledgeGraph.tsx create mode 100644 src/features/curriculum/components/TextbookModal.tsx create mode 100644 src/features/curriculum/components/TreeNode.tsx create mode 100644 src/features/dashboard/components/NotificationList.tsx create mode 100644 src/features/dashboard/components/ScheduleList.tsx create mode 100644 src/features/dashboard/components/StatCard.tsx create mode 100644 src/features/dashboard/components/StudentDashboard.tsx create mode 100644 src/features/dashboard/components/TeacherDashboard.tsx create mode 100644 src/features/exam/components/ExamEditor.tsx create mode 100644 src/features/exam/components/ExamList.tsx create mode 100644 src/features/exam/components/ExamStats.tsx create mode 100644 src/features/exam/components/ImportModal.tsx create mode 100644 src/features/exam/components/result/ResultHeader.tsx create mode 100644 src/features/exam/components/result/ResultList.tsx create mode 100644 src/features/exam/components/runner/AnswerSheet.tsx create mode 100644 src/features/exam/components/runner/ExamHeader.tsx create mode 100644 src/features/exam/components/runner/RunnerQuestionCard.tsx create mode 100644 src/features/exam/components/runner/SubmitConfirmModal.tsx create mode 100644 src/features/grading/components/GradingBoard.tsx create mode 100644 src/features/grading/components/GradingToolbar.tsx create mode 100644 src/features/grading/components/QuestionGradingRow.tsx create mode 100644 src/features/grading/components/SubmissionSidebar.tsx create mode 100644 src/features/message/components/CreateMessageModal.tsx create mode 100644 src/features/question/components/QuestionCard.tsx create mode 100644 src/features/question/components/QuestionModal.tsx create mode 100644 src/features/question/components/SubjectSelector.tsx create mode 100644 src/features/schedule/components/EventModal.tsx create mode 100644 src/features/schedule/components/Timetable.tsx create mode 100644 src/features/settings/components/PreferenceSettings.tsx create mode 100644 src/features/settings/components/ProfileSettings.tsx create mode 100644 src/features/settings/components/SecuritySettings.tsx create mode 100644 src/lib/auth-context.tsx create mode 100644 src/lib/db.ts create mode 100644 src/lib/server-utils.ts create mode 100644 src/middleware.ts create mode 100644 src/services/api.ts create mode 100644 src/services/interfaces.ts create mode 100644 src/services/mockApi.ts create mode 100644 src/services/realApi.ts create mode 100644 src/types.ts create mode 100644 src/utils/errorUtils.ts create mode 100644 src/views/ClassManagement.tsx create mode 100644 src/views/ConsoleConfig.tsx create mode 100644 src/views/Curriculum.tsx create mode 100644 src/views/ExamEngine.tsx create mode 100644 src/views/Grading.tsx create mode 100644 src/views/Messages.tsx create mode 100644 src/views/QuestionBank.tsx create mode 100644 src/views/Schedule.tsx create mode 100644 src/views/Settings.tsx create mode 100644 src/views/StudentExamRunner.tsx create mode 100644 src/views/StudentResult.tsx create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json 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 @@ +
+GHBanner +
+ +# 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' && ( +
+ + +
+ )} +
+ + {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 学年 第一学期

+
+ +
+
+ + +
+ +
+ Profile +
+ {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 + 专业版 +
+
+ + + +
+ + + + + + +
+
+ ); + + return ( + <> + {/* Desktop Sidebar */} + + + {/* Mobile Toggle */} +
+ +
+ + {/* Mobile Menu Overlay */} + + {isMobileMenuOpen && ( + <> + setIsMobileMenuOpen(false)} + /> + {/* Fix: cast props to any to avoid framer-motion type errors */} + +
+ +
+ +
+ + )} +
+ + ); +}; 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 ( + + ); +}; 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 ( +
+ +
+ + +
+
+ ); +} 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}

+ +
+ ))} +
+
+
+ ); +}; + +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) => ( + +
+ +
+
+
{value}
+
{label}
+
+
+ ); + + return ( +
+
+
+ + {/* 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 ? ( + + ) : ( +
+ )} + + {step < 3 ? ( + + ) : ( + + )} +
+ +
+ ); +}; \ 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' ? ( + + ) : ( + + )} +
+
+ ))} +
+ ) +} \ 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 ( + <> +
+
+

作业发布

+

作业与测评管理

+
+ +
+ +
+ {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} +
+
+
+
+
+ +
+ + + +
+ + ); + })} +
+ + ); +} \ 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 智能教育管理系统

+
+ +
+
+ + setUsername(e.target.value)} + className="w-full px-4 py-3.5 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium placeholder:text-gray-400" + placeholder="请输入学号或工号" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-3.5 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium placeholder:text-gray-400" + placeholder="••••••••" + /> +
+ + {error && ( + // Fix: cast props to any to avoid framer-motion type errors + + {error} + + )} + + +
+ +
+

+ 忘记密码? +

+ {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 开启智慧学习之旅

+
+ +
+ {/* Role Switcher */} +
+ + +
+ +
+ + setFormData({...formData, realName: e.target.value})} + className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium" + placeholder="例如:张三" + /> +
+ +
+ + setFormData({...formData, studentId: e.target.value})} + className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 outline-none transition-all text-gray-900 font-medium" + placeholder="请输入号码" + /> +
+ +
+
+ + setFormData({...formData, password: e.target.value})} + className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 outline-none transition-all text-gray-900 font-medium" + placeholder="••••••" + /> +
+
+ + setFormData({...formData, confirmPassword: e.target.value})} + className="w-full px-4 py-3 rounded-xl bg-gray-50/50 border border-gray-200 focus:bg-white focus:border-blue-500 outline-none transition-all text-gray-900 font-medium" + placeholder="••••••" + /> +
+
+ + {error && ( + // Fix: cast props to any to avoid framer-motion type errors + + {error} + + )} + + +
+ +
+

+ 已有账号? +

+
+
+ ); +}; \ 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} + + +
+
+
+
+

允许加入

+

关闭后学生无法申请

+
+ +
+
+
+ + {/* 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 => ( + + ))} +
+
+ +
+ +
+
+
+
+ ); +}; \ 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.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 ( + +
+
+ + 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()} + /> +
+ +
+ +
+
+
+ ); +}; 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 ( + } + > +
+
+ +

+ {description} +

+
+ +
+ + +
+
+
+ ); +}; 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 + /> +
+ +
+
+ + setFormData({ ...formData, publisher: 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" + /> +
+
+ + setFormData({ ...formData, versionYear: e.target.value })} + placeholder="例如:2024" + 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" + /> +
+
+ +
+ +
+
+
+ ); +}; 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 && ( + + )} + + +
+); + +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" + /> + + +
+ ) : ( + + {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

+ +
+
+ +
+
+ ) +} 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 => ( + + ))} +
+
+
+ + + + + + + + + + + + } 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) => ( + + ))} +
+
+ + {/* Schedule */} + +
+

今日课表

+ +
+ +
+ + {/* 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' && ( + + )} + +
+ {node.nodeType === 'Group' ? ( +
+
+ + { + if (!exam) return; + const updated = updateNode(exam.rootNodes, node.id, n => ({ ...n, title: e.target.value })); + setExam({ ...exam, rootNodes: updated }); + }} + onClick={(e) => e.stopPropagation()} + className="font-bold text-gray-900 bg-transparent outline-none flex-1" + placeholder="分组标题" + /> +
+ { + if (!exam) return; + const updated = updateNode(exam.rootNodes, node.id, n => ({ ...n, description: e.target.value })); + setExam({ ...exam, rootNodes: updated }); + }} + onClick={(e) => e.stopPropagation()} + className="text-sm text-gray-500 bg-transparent outline-none w-full" + placeholder="分组描述(可选)" + /> +
+ ) : ( +
+
+ + {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' && ( + + )} + +
+
+ + {/* Children */} + {node.nodeType === 'Group' && isExpanded && node.children && ( +
+ {node.children.map(child => renderNode(child, depth + 1))} +
+ )} +
+ ); + }; + + if (loading || !exam) { + return
; + } + + return ( +
+ {/* Header */} +
+
+ + + +
+
+
+ +
+ {/* 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 ( +
+
+
+

考试引擎

+

创建、管理及发布考试

+
+
+
+ + +
+ +
+
+ +
+ {exams.map((exam, idx) => ( + +
+ +
+
+
+ {getStatusLabel(exam.status)} + 创建于: {exam.createdAt} +
+

{exam.title}

+
+
+ + {exam.duration} 分钟 +
+
+ {exam.questionCount} 题 +
+
+ 总分: {exam.totalScore} +
+
+
+
+ + +
+
+ ))} +
+
+ ); +}; 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) => ( + +
+ +
+
+
{value}
+
{label}
+
+
+ ); + + return ( +
+
+
+ + {/* 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 */} +