feat(P2): 实现选课管理、考试监考、学情诊断三大功能模块
## 新增功能模块 ### 1. 选课管理(elective) - 新增表:electiveCourses、courseSelections - 新增权限:ELECTIVE_MANAGE/ELECTIVE_READ/ELECTIVE_SELECT - 支持先到先得 + 抽签两种选课模式 - admin/teacher/student 三端页面 ### 2. 考试监考(proctoring) - exams 表扩展:examMode/durationMinutes/antiCheatEnabled 等字段 - 新增表:examProctoringEvents - 新增权限:EXAM_PROCTOR/EXAM_PROCTOR_READ - 教师监考面板 + 学生端防作弊监控 - API:/api/proctoring/event 接收事件上报 ### 3. 学情诊断报告(diagnostic) - 新增表:knowledgePointMastery、learningDiagnosticReports - 新增权限:DIAGNOSTIC_MANAGE/DIAGNOSTIC_READ - 基于提交答案自动计算知识点掌握度 - 生成个人/班级诊断报告(强项/弱项/建议) - 雷达图可视化 ## 其他改动 - 项目规则:单文件行数限制从 300 行调整为企业级规范(组件≤500/Actions≤800/硬上限1000) - scripts/seed.ts:消除全部 any 类型,定义内部类型,0 lint 错误 - 架构文档 004/005 同步更新三个新模块 - 迁移文件 0001_heavy_sage.sql 生成 ## 验证 - npx tsc --noEmit:0 错误 - npm run lint:0 错误 0 警告
This commit is contained in:
@@ -38,4 +38,9 @@
|
|||||||
- 每次修改后运行 `npm run lint` 和 `npx tsc --noEmit` 确保零错误
|
- 每次修改后运行 `npm run lint` 和 `npx tsc --noEmit` 确保零错误
|
||||||
- Server Action 必须使用 `requirePermission()` 进行权限校验
|
- Server Action 必须使用 `requirePermission()` 进行权限校验
|
||||||
- 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()`
|
- 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()`
|
||||||
- 单文件不超过 300 行
|
- 单文件行数遵循企业级规范:
|
||||||
|
- 配置文件、常量文件、类型定义文件:无限制
|
||||||
|
- React 组件:建议 ≤ 500 行(复杂表单/大型表格可放宽至 800 行)
|
||||||
|
- Server Actions / Data Access 模块:建议 ≤ 800 行
|
||||||
|
- 超过建议行数时应考虑拆分(如 data-access 拆分为多个按职责划分的文件)
|
||||||
|
- 硬性上限:任何文件不超过 1000 行,超过必须拆分
|
||||||
|
|||||||
@@ -218,7 +218,7 @@
|
|||||||
|
|
||||||
#### `Permissions` (常量对象)
|
#### `Permissions` (常量对象)
|
||||||
- 文件:`types/permissions.ts`
|
- 文件:`types/permissions.ts`
|
||||||
- 定义:47 个权限常量(`exam:create`, `homework:grade`, `audit_log:read`, `announcement:manage`, `file:upload`, `file:read`, `file:delete`, `grade_record:manage`, `grade_record:read`, `course_plan:manage`, `course_plan:read`, `attendance:manage`, `attendance:read`, `message:send`, `message:read`, `message:delete`, `schedule:auto`, `schedule:adjust` 等)
|
- 定义:54 个权限常量(`exam:create`, `homework:grade`, `audit_log:read`, `announcement:manage`, `file:upload`, `file:read`, `file:delete`, `grade_record:manage`, `grade_record:read`, `course_plan:manage`, `course_plan:read`, `attendance:manage`, `attendance:read`, `message:send`, `message:read`, `message:delete`, `schedule:auto`, `schedule:adjust`, `elective:manage`, `elective:read`, `elective:select`, `exam:proctor`, `exam:proctor_read`, `diagnostic:manage`, `diagnostic:read` 等)
|
||||||
- 被使用:auth-guard.ts, 所有模块的 actions.ts, 前端组件
|
- 被使用:auth-guard.ts, 所有模块的 actions.ts, 前端组件
|
||||||
|
|
||||||
#### `ROLE_PERMISSIONS` (常量对象)
|
#### `ROLE_PERMISSIONS` (常量对象)
|
||||||
@@ -228,6 +228,7 @@
|
|||||||
- 考勤权限:admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`;student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ`
|
- 考勤权限:admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`;student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ`
|
||||||
- 消息权限:admin/teacher/grade_head/teaching_head 含 `MESSAGE_SEND`+`MESSAGE_READ`+`MESSAGE_DELETE`;student/parent 含 `MESSAGE_SEND`+`MESSAGE_READ`
|
- 消息权限:admin/teacher/grade_head/teaching_head 含 `MESSAGE_SEND`+`MESSAGE_READ`+`MESSAGE_DELETE`;student/parent 含 `MESSAGE_SEND`+`MESSAGE_READ`
|
||||||
- 排课权限:admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`;teacher/student/parent/grade_head/teaching_head 无排课权限
|
- 排课权限:admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`;teacher/student/parent/grade_head/teaching_head 无排课权限
|
||||||
|
- 学情诊断权限:admin/teacher/grade_head 含 `DIAGNOSTIC_MANAGE`+`DIAGNOSTIC_READ`;teaching_head/student 含 `DIAGNOSTIC_READ`
|
||||||
- 被使用:`resolvePermissions`, auth.ts (JWT callback)
|
- 被使用:`resolvePermissions`, auth.ts (JWT callback)
|
||||||
|
|
||||||
#### `db` (Drizzle 实例)
|
#### `db` (Drizzle 实例)
|
||||||
@@ -399,6 +400,8 @@
|
|||||||
| `schedulingRules` | id, classId, maxDailyHours, maxContinuousHours, lunchBreakStart, lunchBreakEnd, morningStart, afternoonEnd, avoidBackToBack, balancedSubjects, createdAt, updatedAt | scheduling |
|
| `schedulingRules` | id, classId, maxDailyHours, maxContinuousHours, lunchBreakStart, lunchBreakEnd, morningStart, afternoonEnd, avoidBackToBack, balancedSubjects, createdAt, updatedAt | scheduling |
|
||||||
| `scheduleChanges` | id, originalScheduleId, classId, originalTeacherId, substituteTeacherId, originalDate, newDate, newStartTime, newEndTime, reason, status, requestedBy, approvedBy, createdAt, updatedAt | scheduling |
|
| `scheduleChanges` | id, originalScheduleId, classId, originalTeacherId, substituteTeacherId, originalDate, newDate, newStartTime, newEndTime, reason, status, requestedBy, approvedBy, createdAt, updatedAt | scheduling |
|
||||||
| `passwordSecurity` | id, userId, failedLoginAttempts, lockedUntil, passwordChangedAt, mustChangePassword, lastPasswordChange, createdAt, updatedAt | auth, settings |
|
| `passwordSecurity` | id, userId, failedLoginAttempts, lockedUntil, passwordChangedAt, mustChangePassword, lastPasswordChange, createdAt, updatedAt | auth, settings |
|
||||||
|
| `knowledgePointMastery` | id, studentId, knowledgePointId, masteryLevel, totalQuestions, correctQuestions, lastAssessedAt, createdAt, updatedAt | diagnostic |
|
||||||
|
| `learningDiagnosticReports` | id, studentId, generatedBy, reportType, period, summary, strengths, weaknesses, recommendations, overallScore, status, createdAt, updatedAt | diagnostic |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1420,8 +1423,10 @@
|
|||||||
- teacher 角色菜单包含 "Course Plans" 项(icon: CalendarRange, href: /teacher/course-plans, permission: Permissions.COURSE_PLAN_READ)
|
- teacher 角色菜单包含 "Course Plans" 项(icon: CalendarRange, href: /teacher/course-plans, permission: Permissions.COURSE_PLAN_READ)
|
||||||
- teacher 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /teacher/attendance, permission: Permissions.ATTENDANCE_MANAGE),含子项 Records (/teacher/attendance)、Take Attendance (/teacher/attendance/sheet, permission: ATTENDANCE_MANAGE)、Statistics (/teacher/attendance/stats, permission: ATTENDANCE_READ)
|
- teacher 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /teacher/attendance, permission: Permissions.ATTENDANCE_MANAGE),含子项 Records (/teacher/attendance)、Take Attendance (/teacher/attendance/sheet, permission: ATTENDANCE_MANAGE)、Statistics (/teacher/attendance/stats, permission: ATTENDANCE_READ)
|
||||||
- teacher 角色菜单包含 "Schedule Changes" 项(icon: CalendarClock, href: /teacher/schedule-changes, permission: Permissions.SCHEDULE_ADJUST)
|
- teacher 角色菜单包含 "Schedule Changes" 项(icon: CalendarClock, href: /teacher/schedule-changes, permission: Permissions.SCHEDULE_ADJUST)
|
||||||
|
- teacher 角色菜单包含 "Diagnostic" 项(icon: Stethoscope, href: /teacher/diagnostic, permission: Permissions.DIAGNOSTIC_READ)
|
||||||
- student 角色菜单包含 "My Grades" 项(icon: GraduationCap, href: /student/grades, permission: Permissions.GRADE_RECORD_READ)
|
- student 角色菜单包含 "My Grades" 项(icon: GraduationCap, href: /student/grades, permission: Permissions.GRADE_RECORD_READ)
|
||||||
- student 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /student/attendance, permission: Permissions.ATTENDANCE_READ)
|
- student 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /student/attendance, permission: Permissions.ATTENDANCE_READ)
|
||||||
|
- student 角色菜单包含 "Diagnostic" 项(icon: Stethoscope, href: /student/diagnostic, permission: Permissions.DIAGNOSTIC_READ)
|
||||||
- parent 角色菜单包含 "Dashboard" 项(icon: LayoutDashboard, href: /parent/dashboard,无 permission 字段,仅需登录)
|
- parent 角色菜单包含 "Dashboard" 项(icon: LayoutDashboard, href: /parent/dashboard,无 permission 字段,仅需登录)
|
||||||
- parent 角色菜单包含 "Grades" 项(icon: GraduationCap, href: /parent/grades, permission: Permissions.GRADE_RECORD_READ)
|
- parent 角色菜单包含 "Grades" 项(icon: GraduationCap, href: /parent/grades, permission: Permissions.GRADE_RECORD_READ)
|
||||||
- parent 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /parent/attendance, permission: Permissions.ATTENDANCE_READ)
|
- parent 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /parent/attendance, permission: Permissions.ATTENDANCE_READ)
|
||||||
@@ -2578,31 +2583,299 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 模块:proctoring
|
||||||
|
|
||||||
|
`src/modules/proctoring`
|
||||||
|
|
||||||
|
考试监考模块:监考模式考试实时监控、防作弊事件采集、教师监考面板、学生端防作弊监控、考试模式配置。
|
||||||
|
|
||||||
|
> 权限:教师监考面板使用 `requirePermission(EXAM_PROCTOR)`;学生端上报事件使用 `requireAuth()`(学生上报自己的事件,不需要管理权限)。前端组件使用 `usePermission().hasPermission(EXAM_PROCTOR)` 控制权限。
|
||||||
|
|
||||||
|
### Server Actions (`actions.ts`)
|
||||||
|
|
||||||
|
| Action | 权限 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `recordProctoringEventAction` | requireAuth() | 学生端上报监考事件(含 submission 归属校验) |
|
||||||
|
| `getProctoringDashboardAction` | EXAM_PROCTOR | 获取监考面板数据(摘要+学生状态+最近事件) |
|
||||||
|
|
||||||
|
### Data Access (`data-access.ts`)
|
||||||
|
|
||||||
|
`import "server-only"`
|
||||||
|
|
||||||
|
| 函数 | 签名 | 被使用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `recordProctoringEvent` | `(input: RecordProctoringEventInput) => Promise<ProctoringEvent>` | actions.recordProctoringEventAction, API /api/proctoring/event |
|
||||||
|
| `getProctoringEvents` | `(examId, filters?) => Promise<ProctoringEventWithDetails[]>` | 待扩展 |
|
||||||
|
| `getProctoringEventsBySubmission` | `(submissionId) => Promise<ProctoringEvent[]>` | 待扩展 |
|
||||||
|
| `getExamProctoringSummary` | `(examId) => Promise<ExamProctoringSummary>` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx |
|
||||||
|
| `getStudentProctoringStatuses` | `(examId) => Promise<StudentProctoringStatus[]>` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx |
|
||||||
|
| `getExamForProctoring` | `(examId) => Promise<{id,title,examMode,config} | null>` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx |
|
||||||
|
| `getRecentProctoringEvents` | `(examId, limit?) => Promise<ProctoringEventWithDetails[]>` | actions.getProctoringDashboardAction, teacher/exams/[id]/proctoring/page.tsx |
|
||||||
|
|
||||||
|
### Types (`types.ts`)
|
||||||
|
|
||||||
|
| 类型 | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| `ProctoringEventType` | `"tab_switch" \| "window_blur" \| "copy_attempt" \| "paste_attempt" \| "right_click" \| "devtools_open" \| "fullscreen_exit" \| "idle_timeout"` |
|
||||||
|
| `ExamMode` | `"homework" \| "timed" \| "proctored"` |
|
||||||
|
| `ProctoringEvent` | `{ id, submissionId, studentId, examId, eventType, eventDetail?, occurredAt, createdAt }` |
|
||||||
|
| `ProctoringEventWithDetails` | `ProctoringEvent & { studentName, examTitle }` |
|
||||||
|
| `ExamProctoringSummary` | `{ examId, examTitle, examMode, totalStudents, startedStudents, submittedStudents, totalEvents, abnormalStudents, eventsByType }` |
|
||||||
|
| `StudentProctoringStatus` | `{ studentId, studentName, submissionId, submissionStatus, eventCount, lastEventAt, isAbnormal, eventsByType }` |
|
||||||
|
| `ProctoringDashboardData` | `{ summary, students, recentEvents }` |
|
||||||
|
| `ExamModeConfig` | `{ examMode, durationMinutes, shuffleQuestions, allowLateStart, lateStartGraceMinutes, antiCheatEnabled }` |
|
||||||
|
| `PROCTORING_EVENT_LABELS` | 事件类型中文标签常量 |
|
||||||
|
| `EXAM_MODE_LABELS` | 考试模式中文标签常量 |
|
||||||
|
| `ABNORMAL_EVENT_THRESHOLD` | 异常学生事件数阈值(3) |
|
||||||
|
|
||||||
|
### Components (`components/`)
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `proctoring-dashboard.tsx` | 教师监考面板(实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制) |
|
||||||
|
| `anti-cheat-monitor.tsx` | 学生端防作弊监控(visibilitychange/blur/copy/paste/contextmenu/keydown/fullscreenchange 监听、空闲超时检测、强制全屏、警告提示、事件上报) |
|
||||||
|
| `exam-mode-config.tsx` | 考试模式配置(react-hook-form Controller,作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项) |
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
|
||||||
|
| 路由 | 方法 | 权限 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/proctoring/event` | POST | requireAuth() | 接收学生端上报的监考事件(含 submission 归属校验) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块:diagnostic
|
||||||
|
|
||||||
|
`src/modules/diagnostic`
|
||||||
|
|
||||||
|
学情诊断报告模块:基于知识点掌握度(`knowledgePointMastery` 表)生成个人/班级诊断报告,掌握度雷达图(学生 vs 班级平均),强项/弱项分析,知识点掌握度热力图,需重点关注学生列表,报告发布/删除管理。
|
||||||
|
|
||||||
|
> 权限:所有 Server Actions 使用 `requirePermission()` 校验。生成/发布/删除报告使用 `requirePermission(DIAGNOSTIC_MANAGE)`,查询/详情使用 `requirePermission(DIAGNOSTIC_READ)`。admin/teacher/grade_head 角色拥有 DIAGNOSTIC_MANAGE+DIAGNOSTIC_READ,teaching_head/student 角色仅有 DIAGNOSTIC_READ。前端组件使用 `usePermission().hasPermission(DIAGNOSTIC_MANAGE)` 控制生成/发布/删除按钮可见性(无 `role === "xxx"` 硬编码)。页面路由通过 `getAuthContext()` 进行 DataScope 二次校验:`class_members` 仅查自己,`children` 仅查子女,`class_taught` 必须包含 classId。
|
||||||
|
|
||||||
|
### Server Actions (`actions.ts`)
|
||||||
|
|
||||||
|
`"use server"`
|
||||||
|
|
||||||
|
| Action | 权限 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `generateStudentReportAction` | DIAGNOSTIC_MANAGE | 生成学生个人诊断报告(formData: studentId, period) |
|
||||||
|
| `generateClassReportAction` | DIAGNOSTIC_MANAGE | 生成班级诊断报告(formData: classId, period) |
|
||||||
|
| `publishReportAction` | DIAGNOSTIC_MANAGE | 发布诊断报告(formData: id,status → published) |
|
||||||
|
| `deleteReportAction` | DIAGNOSTIC_MANAGE | 删除诊断报告(formData: id) |
|
||||||
|
| `getDiagnosticReportsAction` | DIAGNOSTIC_READ | 查询诊断报告列表(params: DiagnosticReportQueryParams) |
|
||||||
|
| `getDiagnosticReportByIdAction` | DIAGNOSTIC_READ | 获取诊断报告详情(id) |
|
||||||
|
|
||||||
|
### Data Access (`data-access.ts` + `data-access-reports.ts`)
|
||||||
|
|
||||||
|
`import "server-only"`(两个文件均以此开头)
|
||||||
|
|
||||||
|
#### `data-access.ts`(掌握度相关)
|
||||||
|
|
||||||
|
| 函数 | 签名 | 被使用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `getStudentMastery` | `(studentId: string) => Promise<MasteryWithKnowledgePoint[]>` | getStudentMasterySummary, teacher/diagnostic/student/[studentId] |
|
||||||
|
| `getStudentMasterySummary` | `(studentId: string) => Promise<StudentMasterySummary \| null>` | generateDiagnosticReport, teacher/diagnostic/student/[studentId], student/diagnostic |
|
||||||
|
| `updateMasteryFromSubmission` | `(submissionId: string) => Promise<void>` | 待扩展(作业/考试提交后触发,onDuplicateKeyUpdate upsert) |
|
||||||
|
| `getClassMasterySummary` | `(classId: string) => Promise<ClassMasterySummary \| null>` | generateClassDiagnosticReport, teacher/diagnostic/class/[classId] |
|
||||||
|
| `getKnowledgePointStats` | `(classId?: string, gradeId?: string) => Promise<KnowledgePointStat[]>` | teacher/diagnostic/student/[studentId](班级平均对比) |
|
||||||
|
|
||||||
|
#### `data-access-reports.ts`(报告相关)
|
||||||
|
|
||||||
|
| 函数 | 签名 | 被使用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `generateDiagnosticReport` | `(studentId, period, generatedBy) => Promise<string>` | generateStudentReportAction |
|
||||||
|
| `generateClassDiagnosticReport` | `(classId, period, generatedBy) => Promise<string>` | generateClassReportAction |
|
||||||
|
| `getDiagnosticReports` | `(filters: DiagnosticReportQueryParams) => Promise<DiagnosticReportWithDetails[]>` | getDiagnosticReportsAction, teacher/diagnostic, teacher/diagnostic/student/[studentId], student/diagnostic |
|
||||||
|
| `getDiagnosticReportById` | `(id: string) => Promise<DiagnosticReportWithDetails \| null>` | getDiagnosticReportByIdAction |
|
||||||
|
| `publishDiagnosticReport` | `(id: string) => Promise<void>` | publishReportAction |
|
||||||
|
| `deleteDiagnosticReport` | `(id: string) => Promise<void>` | deleteReportAction |
|
||||||
|
|
||||||
|
### Types (`types.ts`)
|
||||||
|
|
||||||
|
| Type | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| `DiagnosticReportType` | `"individual" \| "class" \| "grade"` |
|
||||||
|
| `DiagnosticReportStatus` | `"draft" \| "published" \| "archived"` |
|
||||||
|
| `KnowledgePointMastery` | `{ id, studentId, knowledgePointId, masteryLevel(0-100), totalQuestions, correctQuestions, lastAssessedAt, createdAt, updatedAt }` |
|
||||||
|
| `MasteryWithKnowledgePoint` | `KnowledgePointMastery & { knowledgePointName, knowledgePointDescription }` |
|
||||||
|
| `StudentMasterySummary` | `{ studentId, studentName, averageMastery, totalKnowledgePoints, strengths(≥80), weaknesses(<60), allMastery }` |
|
||||||
|
| `DiagnosticReport` | `{ id, studentId, generatedBy, reportType, period, summary, strengths[], weaknesses[], recommendations[], overallScore, status, createdAt, updatedAt }` |
|
||||||
|
| `DiagnosticReportWithDetails` | `DiagnosticReport & { studentName, generatedByName }` |
|
||||||
|
| `ClassMasterySummary` | `{ classId, className, studentCount, averageMastery, knowledgePointStats[], studentsNeedingAttention[] }` |
|
||||||
|
| `KnowledgePointStat` | `{ knowledgePointId, knowledgePointName, averageMastery, masteredCount(≥80), notMasteredCount(<60), totalStudents }` |
|
||||||
|
| `DiagnosticReportQueryParams` | `{ studentId?, reportType?, status?, period? }` |
|
||||||
|
| `MasteryRadarPoint` | `{ knowledgePoint, student(0-100), classAverage?(0-100) }` |
|
||||||
|
|
||||||
|
### Components (`components/`)
|
||||||
|
|
||||||
|
所有组件以 `"use client"` 开头。
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `mastery-radar-chart.tsx` | 知识点掌握度雷达图(recharts RadarChart,学生 vs 班级平均对比,无数据时显示 EmptyState) |
|
||||||
|
| `student-diagnostic-view.tsx` | 学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示) |
|
||||||
|
| `class-diagnostic-view.tsx` | 班级诊断视图(概览卡片、知识点掌握度热力图[绿≥80/黄60-79/橙40-59/红<40]、知识点排名表、需重点关注学生表[链接到学生视图]、生成班级报告表单[DIAGNOSTIC_MANAGE]) |
|
||||||
|
| `report-list.tsx` | 诊断报告列表(reportType/status 过滤器[URL searchParams]、报告表格、发布/删除操作[DIAGNOSTIC_MANAGE]、确认对话框) |
|
||||||
|
|
||||||
|
### 数据库表
|
||||||
|
|
||||||
|
| 表 | 用途 |
|
||||||
|
|----|------|
|
||||||
|
| `knowledgePointMastery` | 知识点掌握度记录(复合主键 studentId+knowledgePointId,onDuplicateKeyUpdate upsert) |
|
||||||
|
| `learningDiagnosticReports` | 学情诊断报告(reportType: individual/class/grade;status: draft/published/archived) |
|
||||||
|
|
||||||
|
### 页面路由
|
||||||
|
|
||||||
|
| 路由 | 组件 | 权限 | DataScope 校验 |
|
||||||
|
|------|------|------|----------------|
|
||||||
|
| `/teacher/diagnostic` | ReportList | diagnostic:read | class_members 仅查看自己报告 |
|
||||||
|
| `/teacher/diagnostic/student/[studentId]` | StudentDiagnosticView | diagnostic:read | class_members 仅自己,children 仅子女 |
|
||||||
|
| `/teacher/diagnostic/class/[classId]` | ClassDiagnosticView | diagnostic:read | class_taught 必须包含 classId,class_members/children → notFound |
|
||||||
|
| `/student/diagnostic` | StudentDiagnosticView | diagnostic:read | class_members 仅查自己(ctx.userId) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块:elective
|
||||||
|
|
||||||
|
`src/modules/elective`
|
||||||
|
|
||||||
|
选课管理模块:选修课程 CRUD、选课开放/关闭、学生选课/退课、抽签模式批量录取(runLottery)、FCFS 即时录取、DataScope 行级过滤(admin 全部、teacher 所教、grade_head 所管年级、student 可选课程)。
|
||||||
|
|
||||||
|
> 权限:管理操作使用 `requirePermission(ELECTIVE_MANAGE)`;读取使用 `requirePermission(ELECTIVE_READ)`;学生选课/退课使用 `requirePermission(ELECTIVE_SELECT)`。前端组件使用 `usePermission().hasPermission()` 控制权限。`getStudentSelectionsAction` 对 class_members/children 进行 DataScope 二次校验。
|
||||||
|
|
||||||
|
### Server Actions (`actions.ts`)
|
||||||
|
|
||||||
|
| Action | 权限 | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `createElectiveCourseAction` | ELECTIVE_MANAGE | 创建选修课程(formData: name, subjectId?, teacherId, gradeId?, description?, capacity?, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, selectionMode?, credit?) |
|
||||||
|
| `updateElectiveCourseAction` | ELECTIVE_MANAGE | 更新选修课程(id + formData) |
|
||||||
|
| `deleteElectiveCourseAction` | ELECTIVE_MANAGE | 删除选修课程(formData: courseId) |
|
||||||
|
| `openSelectionAction` | ELECTIVE_MANAGE | 开放选课(formData: courseId) |
|
||||||
|
| `closeSelectionAction` | ELECTIVE_MANAGE | 关闭选课(formData: courseId) |
|
||||||
|
| `runLotteryAction` | ELECTIVE_MANAGE | 执行抽签录取(formData: courseId),返回 {enrolled, waitlist} |
|
||||||
|
| `selectCourseAction` | ELECTIVE_SELECT | 学生选课(formData: courseId, priority?) |
|
||||||
|
| `dropCourseAction` | ELECTIVE_SELECT | 学生退课(formData: courseId) |
|
||||||
|
| `getElectiveCoursesAction` | ELECTIVE_READ | 查询选修课程列表(按 DataScope 过滤,传 currentUserId) |
|
||||||
|
| `getStudentSelectionsAction` | ELECTIVE_READ | 查询学生选课记录(DataScope 二次校验:class_members 仅自己,children 仅子女) |
|
||||||
|
| `getAvailableCoursesAction` | ELECTIVE_SELECT | 获取学生可选课程(status=open 且匹配年级) |
|
||||||
|
|
||||||
|
### Data Access
|
||||||
|
|
||||||
|
#### `data-access.ts` (`import "server-only"`)
|
||||||
|
|
||||||
|
| 函数 | 签名 | 被使用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `getElectiveCourses` | `(params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }) => Promise<ElectiveCourseWithDetails[]>` | getElectiveCoursesAction, admin/elective, teacher/elective |
|
||||||
|
| `getElectiveCourseById` | `(id: string) => Promise<ElectiveCourseWithDetails \| null>` | updateElectiveCourseAction, admin/elective/[id]/edit |
|
||||||
|
| `createElectiveCourse` | `(data: CreateElectiveCourseInput, teacherId: string) => Promise<string>` | createElectiveCourseAction |
|
||||||
|
| `updateElectiveCourse` | `(id: string, data: Partial<UpdateElectiveCourseInput>) => Promise<void>` | updateElectiveCourseAction |
|
||||||
|
| `deleteElectiveCourse` | `(id: string) => Promise<void>` | deleteElectiveCourseAction |
|
||||||
|
| `openSelection` | `(courseId: string) => Promise<void>` | openSelectionAction |
|
||||||
|
| `closeSelection` | `(courseId: string) => Promise<void>` | closeSelectionAction |
|
||||||
|
| `getSubjectOptions` | `() => Promise<{id, name}[]>` | admin/elective/create, admin/elective/[id]/edit |
|
||||||
|
|
||||||
|
> `buildScopeFilter(scope, userId)` 内部函数:owned/class_taught 按 `teacherId` 过滤,grade_managed 按 `gradeIds` 过滤,class_members/children 返回 null(学生通过 `getAvailableCoursesForStudent` 获取可选课程)。
|
||||||
|
|
||||||
|
#### `data-access-selections.ts` (`import "server-only"`)
|
||||||
|
|
||||||
|
> 从 data-access.ts 拆分以遵守单文件 ≤300 行规则。
|
||||||
|
|
||||||
|
| 函数 | 签名 | 被使用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `getCourseSelections` | `(courseId: string) => Promise<CourseSelectionWithDetails[]>` | 待扩展 |
|
||||||
|
| `getStudentSelections` | `(studentId: string) => Promise<CourseSelectionWithDetails[]>` | getStudentSelectionsAction, student/elective |
|
||||||
|
| `getStudentGradeId` | `(studentId: string) => Promise<string \| null>` | getAvailableCoursesForStudent |
|
||||||
|
| `getAvailableCoursesForStudent` | `(studentId: string, gradeId?: string \| null) => Promise<ElectiveCourseWithDetails[]>` | getAvailableCoursesAction, student/elective |
|
||||||
|
|
||||||
|
#### `data-access-operations.ts` (`import "server-only"`)
|
||||||
|
|
||||||
|
> 从 data-access.ts 拆分以遵守单文件 ≤300 行规则。包含选课/退课/抽签的写操作。
|
||||||
|
|
||||||
|
| 函数 | 签名 | 被使用 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `runLottery` | `(courseId: string) => Promise<{enrolled: number, waitlist: number}>` | runLotteryAction |
|
||||||
|
| `selectCourse` | `(courseId: string, studentId: string, priority?: number) => Promise<{status: CourseSelectionStatus, message: string}>` | selectCourseAction |
|
||||||
|
| `dropCourse` | `(courseId: string, studentId: string) => Promise<void>` | dropCourseAction |
|
||||||
|
|
||||||
|
### Schema (`schema.ts`)
|
||||||
|
|
||||||
|
| Schema | 用途 |
|
||||||
|
|--------|------|
|
||||||
|
| `ElectiveCourseStatusEnum` | 课程状态枚举(draft/open/closed/cancelled) |
|
||||||
|
| `ElectiveSelectionModeEnum` | 选课模式枚举(fcfs/lottery) |
|
||||||
|
| `CourseSelectionStatusEnum` | 选课状态枚举(selected/enrolled/waitlist/dropped/rejected) |
|
||||||
|
| `CreateElectiveCourseSchema` | 创建课程校验(name 必填,teacherId 必填,capacity 1-500 默认 30,selectionMode 默认 fcfs,credit 默认 1.0) |
|
||||||
|
| `UpdateElectiveCourseSchema` | 更新课程校验(所有字段可选,含 status) |
|
||||||
|
| `SelectCourseSchema` | 选课校验(courseId 必填,priority 1-10 可选) |
|
||||||
|
| `DropCourseSchema` | 退课校验(courseId 必填) |
|
||||||
|
| `RunLotterySchema` | 抽签校验(courseId 必填) |
|
||||||
|
|
||||||
|
### 类型/接口 (`types.ts`)
|
||||||
|
|
||||||
|
| 类型 | 定义 |
|
||||||
|
|------|------|
|
||||||
|
| `ElectiveCourseStatus` | `"draft" \| "open" \| "closed" \| "cancelled"` |
|
||||||
|
| `ElectiveSelectionMode` | `"fcfs" \| "lottery"` |
|
||||||
|
| `CourseSelectionStatus` | `"selected" \| "enrolled" \| "waitlist" \| "dropped" \| "rejected"` |
|
||||||
|
| `ElectiveCourse` | 课程完整类型(id, name, subjectId?, teacherId, gradeId?, description?, capacity, enrolledCount, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, status, selectionMode, credit, createdAt, updatedAt) |
|
||||||
|
| `ElectiveCourseWithDetails` | `ElectiveCourse & { teacherName?, subjectName?, gradeName? }` |
|
||||||
|
| `CourseSelection` | 选课记录类型(id, courseId, studentId, status, priority?, selectedAt, enrolledAt?, droppedAt?, lotteryRank?, createdAt, updatedAt) |
|
||||||
|
| `CourseSelectionWithDetails` | `CourseSelection & { courseName?, studentName?, courseCapacity?, courseEnrolledCount?, courseStatus? }` |
|
||||||
|
| `GetElectiveCoursesParams` | 查询参数(status?, gradeId?, subjectId?, teacherId?) |
|
||||||
|
| `ELECTIVE_STATUS_LABELS` | 课程状态标签常量 |
|
||||||
|
| `ELECTIVE_STATUS_COLORS` | 课程状态颜色常量(Badge variant) |
|
||||||
|
| `SELECTION_MODE_LABELS` | 选课模式标签常量 |
|
||||||
|
| `COURSE_SELECTION_STATUS_LABELS` | 选课状态标签常量 |
|
||||||
|
| `COURSE_SELECTION_STATUS_COLORS` | 选课状态颜色常量(Badge variant) |
|
||||||
|
|
||||||
|
### 导出组件 (`components/`)
|
||||||
|
|
||||||
|
| 组件文件 | 功能 |
|
||||||
|
|---------|------|
|
||||||
|
| `elective-course-list.tsx` | 课程卡片列表(管理员/教师视图,含编辑/开放/关闭/抽签/删除操作按钮,usePermission 权限控制) |
|
||||||
|
| `elective-course-form.tsx` | 课程创建/编辑表单(name, subjectId, teacherId, gradeId, description, capacity, classroom, schedule, dates, selectionMode, credit) |
|
||||||
|
| `student-selection-view.tsx` | 学生选课视图(可选课程列表 + 我的选课记录,含选课/退课按钮) |
|
||||||
|
|
||||||
|
### 路由页面
|
||||||
|
|
||||||
|
| 路由 | 组件 | 权限 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/admin/elective` | ElectiveCourseList | elective:manage | 管理员选修课程列表(scope=all) |
|
||||||
|
| `/admin/elective/create` | ElectiveCourseForm | elective:manage | 创建选修课程 |
|
||||||
|
| `/admin/elective/[id]/edit` | ElectiveCourseForm (edit) | elective:manage | 编辑选修课程 |
|
||||||
|
| `/teacher/elective` | ElectiveCourseList (teacher) | elective:manage | 教师选修课程列表(scope=class_taught/owned,按 teacherId 过滤) |
|
||||||
|
| `/student/elective` | StudentSelectionView | elective:select | 学生选课页面(可选课程 + 我的选课) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 模块间依赖矩阵
|
## 模块间依赖矩阵
|
||||||
|
|
||||||
| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | layout | settings | users | audit | announcements | files | grades | course-plans | parent | messaging | attendance | scheduling |
|
| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | layout | settings | users | audit | announcements | files | grades | course-plans | parent | messaging | attendance | scheduling | proctoring | diagnostic | elective |
|
||||||
|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|--------|----------|-------|-------|---------------|-------|-------|-------------|--------|-----------|------------|-----------|
|
|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|--------|----------|-------|-------|---------------|-------|-------|-------------|--------|-----------|------------|-----------|------------|------------|----------|
|
||||||
| **shared** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **shared** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **auth** | db,schema,permissions,login-logger | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **auth** | db,schema,permissions,login-logger | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **school** | db,auth-guard,types,audit-logger | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **school** | db,auth-guard,types,audit-logger | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-dropdown | - | - |
|
| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-dropdown | - | - | - | - |
|
||||||
| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **users** | db,auth-guard(requireAuth,requirePermission),types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **users** | db,auth-guard(requireAuth,requirePermission),types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **audit** | db,auth-guard.requirePermission,types.permissions | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **audit** | db,auth-guard.requirePermission,types.permissions | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **announcements** | db,auth-guard,types | auth | - | - | - | - | - | - | data-access.getGrades | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **announcements** | db,auth-guard,types | auth | - | - | - | - | - | - | data-access.getGrades | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **files** | db,auth-guard(requireAuth,requirePermission),types,lib/file-storage | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **files** | db,auth-guard(requireAuth,requirePermission),types,lib/file-storage | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **grades** | db,auth-guard,types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **grades** | db,auth-guard,types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **course-plans** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getAdminClasses,getStaffOptions | data-access.getAcademicYears | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **course-plans** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getAdminClasses,getStaffOptions | data-access.getAcademicYears | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **parent** | db,auth-guard(requireAuth),types | auth | - | data-access.getStudentHomeworkAssignments,getStudentDashboardGrades | - | - | data-access.getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | data-access.getStudentGradeSummary | - | - | - | - | - |
|
| **parent** | db,auth-guard(requireAuth),types | auth | - | data-access.getStudentHomeworkAssignments,getStudentDashboardGrades | - | - | data-access.getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | data-access.getStudentGradeSummary | - | - | - | - | - | - |
|
||||||
| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
| **scheduling** | db,auth-guard(requirePermission,getAuthContext),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
| **scheduling** | db,auth-guard(requirePermission,getAuthContext),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
|
| **proctoring** | db,auth-guard(requirePermission,requireAuth),types,components.ui,hooks.usePermission | auth | schema.exams,examSubmissions,examProctoringEvents | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
|
| **diagnostic** | db,auth-guard(requirePermission,getAuthContext),types,hooks.usePermission,components.ui | auth | schema.examSubmissions,submissionAnswers,questionsToKnowledgePoints | - | - | - | schema.classes,classEnrollments | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
|
| **elective** | db,auth-guard.requirePermission,types,types.DataScope,hooks.usePermission,components.ui | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2616,6 +2889,8 @@
|
|||||||
5. 在 exams/actions.ts 中作为 `creatorId` 写入 `exams` 表
|
5. 在 exams/actions.ts 中作为 `creatorId` 写入 `exams` 表
|
||||||
6. 在 homework/actions.ts 中作为 `creatorId` 写入 `homeworkAssignments` 表
|
6. 在 homework/actions.ts 中作为 `creatorId` 写入 `homeworkAssignments` 表
|
||||||
7. 在 classes/data-access.ts 中查询 `getTeacherClasses(teacherId)` 和 `getGradeManagedClasses(userId)`
|
7. 在 classes/data-access.ts 中查询 `getTeacherClasses(teacherId)` 和 `getGradeManagedClasses(userId)`
|
||||||
|
8. 在 elective/actions.ts 中作为 `teacherId` 默认值写入 `electiveCourses` 表(createElectiveCourseAction),作为 `studentId` 查询学生选课(selectCourseAction/dropCourseAction/getStudentSelectionsAction)
|
||||||
|
9. 在 elective/data-access.ts 中 `getElectiveCourses({ scope, currentUserId })` 按 `teacherId` 过滤(class_taught/owned scope)
|
||||||
|
|
||||||
### `examId`
|
### `examId`
|
||||||
1. 由 `exams/actions.ts` 的 `createExamAction` 产生,通过 CUID2 生成,写入 `exams` 表
|
1. 由 `exams/actions.ts` 的 `createExamAction` 产生,通过 CUID2 生成,写入 `exams` 表
|
||||||
@@ -2634,13 +2909,13 @@
|
|||||||
6. 在 `auth-guard.ts` 中通过 `classSubjectTeachers` 查询教师关联的 classIds,构建 `DataScope.class_taught`
|
6. 在 `auth-guard.ts` 中通过 `classSubjectTeachers` 查询教师关联的 classIds,构建 `DataScope.class_taught`
|
||||||
|
|
||||||
### `permission`
|
### `permission`
|
||||||
1. 由 `shared/types/permissions.ts` 的 `Permissions` 常量定义(47 个权限点,含 `AUDIT_LOG_READ`、`ANNOUNCEMENT_MANAGE`、`FILE_UPLOAD`、`FILE_READ`、`FILE_DELETE`、`GRADE_RECORD_MANAGE`、`GRADE_RECORD_READ`、`COURSE_PLAN_MANAGE`、`COURSE_PLAN_READ`、`ATTENDANCE_MANAGE`、`ATTENDANCE_READ`、`MESSAGE_SEND`、`MESSAGE_READ`、`MESSAGE_DELETE`、`SCHEDULE_AUTO`、`SCHEDULE_ADJUST` 等)
|
1. 由 `shared/types/permissions.ts` 的 `Permissions` 常量定义(57 个权限点,含 `AUDIT_LOG_READ`、`ANNOUNCEMENT_MANAGE`、`FILE_UPLOAD`、`FILE_READ`、`FILE_DELETE`、`GRADE_RECORD_MANAGE`、`GRADE_RECORD_READ`、`COURSE_PLAN_MANAGE`、`COURSE_PLAN_READ`、`ATTENDANCE_MANAGE`、`ATTENDANCE_READ`、`MESSAGE_SEND`、`MESSAGE_READ`、`MESSAGE_DELETE`、`SCHEDULE_AUTO`、`SCHEDULE_ADJUST`、`DIAGNOSTIC_MANAGE`、`DIAGNOSTIC_READ`、`ELECTIVE_MANAGE`、`ELECTIVE_READ`、`ELECTIVE_SELECT` 等)
|
||||||
2. 在 `shared/lib/permissions.ts` 中通过 `ROLE_PERMISSIONS` 映射角色到权限列表(admin 角色包含 `AUDIT_LOG_READ`、`COURSE_PLAN_MANAGE`+`COURSE_PLAN_READ`;admin/teacher 含 `FILE_UPLOAD/READ/DELETE` 及 `GRADE_RECORD_MANAGE/READ`;teacher/student/grade_head/teaching_head 含 `COURSE_PLAN_READ`;student/parent 含 `FILE_READ` 及 `GRADE_RECORD_READ`;admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`,student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ`;admin/teacher/parent/grade_head/teaching_head 含 `MESSAGE_SEND/READ/DELETE`;student 含 `MESSAGE_READ/DELETE` 但无 `MESSAGE_SEND`;admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`,teacher/student/parent/grade_head/teaching_head 无排课权限)
|
2. 在 `shared/lib/permissions.ts` 中通过 `ROLE_PERMISSIONS` 映射角色到权限列表(admin 角色包含 `AUDIT_LOG_READ`、`COURSE_PLAN_MANAGE`+`COURSE_PLAN_READ`;admin/teacher 含 `FILE_UPLOAD/READ/DELETE` 及 `GRADE_RECORD_MANAGE/READ`;teacher/student/grade_head/teaching_head 含 `COURSE_PLAN_READ`;student/parent 含 `FILE_READ` 及 `GRADE_RECORD_READ`;admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`,student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ`;admin/teacher/parent/grade_head/teaching_head 含 `MESSAGE_SEND/READ/DELETE`;student 含 `MESSAGE_READ/DELETE` 但无 `MESSAGE_SEND`;admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`,teacher/student/parent/grade_head/teaching_head 无排课权限;admin/teacher/grade_head 含 `DIAGNOSTIC_MANAGE`+`DIAGNOSTIC_READ`,teaching_head/student 含 `DIAGNOSTIC_READ`;admin/teacher 含 `ELECTIVE_MANAGE`+`ELECTIVE_READ`,student 含 `ELECTIVE_SELECT`+`ELECTIVE_READ`,grade_head/teaching_head 含 `ELECTIVE_READ`)
|
||||||
3. 在 `auth.ts` JWT callback 中通过 `resolvePermissions(roleNames)` 合并多角色权限,存入 JWT
|
3. 在 `auth.ts` JWT callback 中通过 `resolvePermissions(roleNames)` 合并多角色权限,存入 JWT
|
||||||
4. 在 `proxy.ts` middleware 中通过 `token.permissions` 检查路由访问权限
|
4. 在 `proxy.ts` middleware 中通过 `token.permissions` 检查路由访问权限
|
||||||
5. 在 `shared/lib/auth-guard.ts` 中通过 `requirePermission(permission)` 在 Server Action 层断言权限(如 audit-logs 页面使用 `requirePermission(AUDIT_LOG_READ)`;`DELETE /api/files/[id]` 使用 `requirePermission(FILE_DELETE)`;messaging/actions.ts 使用 `requirePermission(MESSAGE_SEND/READ/DELETE)`;attendance/actions.ts 使用 `requirePermission(ATTENDANCE_MANAGE/READ)`;users/actions.ts 的 `importUsersAction`/`exportUsersAction`/`downloadUserTemplateAction` 使用 `requirePermission(USER_MANAGE)`;grades/actions.ts 的 `exportGradesAction` 使用 `requirePermission(GRADE_RECORD_READ)`;`POST /api/import` 使用 `requirePermission(USER_MANAGE)`;scheduling/actions.ts 使用 `requirePermission(SCHEDULE_AUTO/SCHEDULE_ADJUST)`)
|
5. 在 `shared/lib/auth-guard.ts` 中通过 `requirePermission(permission)` 在 Server Action 层断言权限(如 audit-logs 页面使用 `requirePermission(AUDIT_LOG_READ)`;`DELETE /api/files/[id]` 使用 `requirePermission(FILE_DELETE)`;messaging/actions.ts 使用 `requirePermission(MESSAGE_SEND/READ/DELETE)`;attendance/actions.ts 使用 `requirePermission(ATTENDANCE_MANAGE/READ)`;users/actions.ts 的 `importUsersAction`/`exportUsersAction`/`downloadUserTemplateAction` 使用 `requirePermission(USER_MANAGE)`;grades/actions.ts 的 `exportGradesAction` 使用 `requirePermission(GRADE_RECORD_READ)`;`POST /api/import` 使用 `requirePermission(USER_MANAGE)`;scheduling/actions.ts 使用 `requirePermission(SCHEDULE_AUTO/SCHEDULE_ADJUST)`;diagnostic/actions.ts 使用 `requirePermission(DIAGNOSTIC_MANAGE/READ)`;elective/actions.ts 使用 `requirePermission(ELECTIVE_MANAGE/READ/SELECT)`)
|
||||||
6. 在 `shared/hooks/use-permission.ts` 中通过 `hasPermission(permission)` 在客户端组件中条件渲染
|
6. 在 `shared/hooks/use-permission.ts` 中通过 `hasPermission(permission)` 在客户端组件中条件渲染
|
||||||
7. 在 `layout/config/navigation.ts` 中作为 `NavItem.permission` 字段过滤侧边栏菜单(Audit Logs 菜单项使用 `Permissions.AUDIT_LOG_READ`;Messages 菜单项使用 `Permissions.MESSAGE_READ`;Attendance 菜单项 teacher 使用 `Permissions.ATTENDANCE_MANAGE`,student/parent 使用 `Permissions.ATTENDANCE_READ`;Import Users 菜单项使用 `Permissions.USER_MANAGE`;Scheduling 菜单项 admin 使用 `Permissions.SCHEDULE_ADJUST`/`SCHEDULE_AUTO`;teacher Schedule Changes 菜单项使用 `Permissions.SCHEDULE_ADJUST`)
|
7. 在 `layout/config/navigation.ts` 中作为 `NavItem.permission` 字段过滤侧边栏菜单(Audit Logs 菜单项使用 `Permissions.AUDIT_LOG_READ`;Messages 菜单项使用 `Permissions.MESSAGE_READ`;Attendance 菜单项 teacher 使用 `Permissions.ATTENDANCE_MANAGE`,student/parent 使用 `Permissions.ATTENDANCE_READ`;Import Users 菜单项使用 `Permissions.USER_MANAGE`;Scheduling 菜单项 admin 使用 `Permissions.SCHEDULE_ADJUST`/`SCHEDULE_AUTO`;teacher Schedule Changes 菜单项使用 `Permissions.SCHEDULE_ADJUST`;teacher/student Diagnostic 菜单项使用 `Permissions.DIAGNOSTIC_READ`;Electives 菜单项 admin/teacher 使用 `Permissions.ELECTIVE_MANAGE`,student 使用 `Permissions.ELECTIVE_SELECT`)
|
||||||
|
|
||||||
### `DataScope`
|
### `DataScope`
|
||||||
1. 由 `auth-guard.ts` 的 `resolveDataScope(userId, roles)` 根据用户角色和 DB 关系动态计算
|
1. 由 `auth-guard.ts` 的 `resolveDataScope(userId, roles)` 根据用户角色和 DB 关系动态计算
|
||||||
@@ -2652,6 +2927,8 @@
|
|||||||
7. 在 `parent/children/[studentId]/page.tsx` 中通过 `ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)` 二次校验家长拥有该子女
|
7. 在 `parent/children/[studentId]/page.tsx` 中通过 `ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)` 二次校验家长拥有该子女
|
||||||
8. 传递到 `attendance/data-access.getAttendanceRecords({ scope })` 进行行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部)
|
8. 传递到 `attendance/data-access.getAttendanceRecords({ scope })` 进行行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部)
|
||||||
9. 在 `attendance/actions.ts` 的 `getStudentAttendanceAction` 中对 class_members/children 进行 DataScope 二次校验
|
9. 在 `attendance/actions.ts` 的 `getStudentAttendanceAction` 中对 class_members/children 进行 DataScope 二次校验
|
||||||
|
10. 传递到 `elective/data-access.getElectiveCourses({ scope, currentUserId })` 进行行级过滤(owned/class_taught 按 `teacherId` 过滤,grade_managed 按 `gradeIds` 过滤,class_members/children 返回 null 由 `getAvailableCoursesForStudent` 处理)
|
||||||
|
11. 在 `elective/actions.ts` 的 `getStudentSelectionsAction` 中对 class_members/children 进行 DataScope 二次校验
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2761,6 +3038,7 @@
|
|||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
| `/teacher/exams` | ExamDataTable | server | exam:read | 考试列表(dataAccess: exams/data-access.getExams) |
|
| `/teacher/exams` | ExamDataTable | server | exam:read | 考试列表(dataAccess: exams/data-access.getExams) |
|
||||||
| `/teacher/exams/[id]/build` | ExamAssemblyPanel | client | exam:update | 组卷页面(components: assembly/*, actions: updateExamAction) |
|
| `/teacher/exams/[id]/build` | ExamAssemblyPanel | client | exam:update | 组卷页面(components: assembly/*, actions: updateExamAction) |
|
||||||
|
| `/teacher/exams/[id]/proctoring` | ProctoringDashboard | server+client | exam:proctor | 教师监考面板(dataAccess: proctoring/data-access.getExamForProctoring,getExamProctoringSummary,getStudentProctoringStatuses,getRecentProctoringEvents;权限:requirePermission(EXAM_PROCTOR);组件:proctoring/components/proctoring-dashboard.tsx,10 秒轮询刷新) |
|
||||||
| `/teacher/exams/grading` | ExamGradingList | server | exam:read | 考试批改列表 |
|
| `/teacher/exams/grading` | ExamGradingList | server | exam:read | 考试批改列表 |
|
||||||
| `/teacher/exams/grading/[submissionId]` | ExamGradingView | client | exam:read | 考试批改页面 |
|
| `/teacher/exams/grading/[submissionId]` | ExamGradingView | client | exam:read | 考试批改页面 |
|
||||||
| `/teacher/classes/my/[id]` | ClassDetail | server | class:read | 班级详情(dataAccess: classes/data-access.getClassDetails) |
|
| `/teacher/classes/my/[id]` | ClassDetail | server | class:read | 班级详情(dataAccess: classes/data-access.getClassDetails) |
|
||||||
@@ -2822,6 +3100,25 @@
|
|||||||
| `/settings` | 角色分发设置页 | server | auth_required | 根据权限渲染 Admin/Teacher/Student 设置视图(含 General/Appearance/Security/Notifications tab,Security tab 含 PasswordChangeForm,Notifications tab 含 NotificationPreferencesForm;dataAccess: messaging/notification-preferences.getNotificationPreferences) |
|
| `/settings` | 角色分发设置页 | server | auth_required | 根据权限渲染 Admin/Teacher/Student 设置视图(含 General/Appearance/Security/Notifications tab,Security tab 含 PasswordChangeForm,Notifications tab 含 NotificationPreferencesForm;dataAccess: messaging/notification-preferences.getNotificationPreferences) |
|
||||||
| `/settings/security` | SecuritySettingsPage | server | auth_required | 安全设置独立页面(PasswordChangeForm + 安全提示;权限:requireAuth()) |
|
| `/settings/security` | SecuritySettingsPage | server | auth_required | 安全设置独立页面(PasswordChangeForm + 安全提示;权限:requireAuth()) |
|
||||||
|
|
||||||
|
### diagnostic/* 路由
|
||||||
|
|
||||||
|
| 路由 | 组件 | 类型 | 权限 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| `/teacher/diagnostic` | ReportList | client | diagnostic:read | 学情诊断报告列表(reportType/status 过滤器[URL searchParams];dataAccess: diagnostic/data-access-reports.getDiagnosticReports;actions: publishReportAction, deleteReportAction[DIAGNOSTIC_MANAGE];权限:requirePermission(DIAGNOSTIC_READ);DataScope.class_members 仅查看自己报告) |
|
||||||
|
| `/teacher/diagnostic/student/[studentId]` | StudentDiagnosticView | client | diagnostic:read | 学生学情诊断视图(概览卡片+雷达图+强项/弱项+生成报告[DIAGNOSTIC_MANAGE]+最新报告;dataAccess: getStudentMasterySummary, getKnowledgePointStats[班级平均对比], getDiagnosticReports;actions: generateStudentReportAction;权限:getAuthContext + DataScope 二次校验,class_members 仅自己,children 仅子女) |
|
||||||
|
| `/teacher/diagnostic/class/[classId]` | ClassDiagnosticView | client | diagnostic:read | 班级学情诊断视图(概览+知识点热力图+排名表+需重点关注学生+生成班级报告[DIAGNOSTIC_MANAGE];dataAccess: getClassMasterySummary;actions: generateClassReportAction;权限:getAuthContext + DataScope 校验,class_taught 必须包含 classId,class_members/children → notFound) |
|
||||||
|
| `/student/diagnostic` | StudentDiagnosticView | client | diagnostic:read | 学生本人学情诊断视图(概览+雷达图+强项/弱项+最新报告;dataAccess: getStudentMasterySummary(ctx.userId), getDiagnosticReports(studentId=ctx.userId);权限:requirePermission(DIAGNOSTIC_READ),DataScope.class_members 仅查自己) |
|
||||||
|
|
||||||
|
### elective/* 路由
|
||||||
|
|
||||||
|
| 路由 | 组件 | 类型 | 权限 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| `/admin/elective` | ElectiveCourseList | server | elective:manage | 管理员选修课程列表(dataAccess: getElectiveCourses(scope=all);actions: deleteElectiveCourseAction, openSelectionAction, closeSelectionAction, runLotteryAction;权限:requirePermission(ELECTIVE_MANAGE)) |
|
||||||
|
| `/admin/elective/create` | ElectiveCourseForm | client | elective:manage | 创建选修课程(actions: createElectiveCourseAction;dataAccess: getSubjectOptions;权限:requirePermission(ELECTIVE_MANAGE)) |
|
||||||
|
| `/admin/elective/[id]/edit` | ElectiveCourseForm (edit) | client | elective:manage | 编辑选修课程(actions: updateElectiveCourseAction;dataAccess: getElectiveCourseById, getSubjectOptions;权限:requirePermission(ELECTIVE_MANAGE)) |
|
||||||
|
| `/teacher/elective` | ElectiveCourseList (teacher) | server | elective:manage | 教师选修课程列表(dataAccess: getElectiveCourses(scope=class_taught/owned, currentUserId);actions: deleteElectiveCourseAction, openSelectionAction, closeSelectionAction, runLotteryAction;权限:requirePermission(ELECTIVE_MANAGE);按 teacherId 过滤) |
|
||||||
|
| `/student/elective` | StudentSelectionView | server | elective:select | 学生选课页面(dataAccess: getAvailableCoursesForStudent, getStudentSelections;actions: selectCourseAction, dropCourseAction;权限:requirePermission(ELECTIVE_SELECT)) |
|
||||||
|
|
||||||
### API 路由(含速率限制)
|
### API 路由(含速率限制)
|
||||||
|
|
||||||
| 路由 | 方法 | 限流规则 | 说明 |
|
| 路由 | 方法 | 限流规则 | 说明 |
|
||||||
|
|||||||
@@ -65,15 +65,20 @@
|
|||||||
"MESSAGE_READ": "message:read",
|
"MESSAGE_READ": "message:read",
|
||||||
"MESSAGE_DELETE": "message:delete",
|
"MESSAGE_DELETE": "message:delete",
|
||||||
"SCHEDULE_AUTO": "schedule:auto",
|
"SCHEDULE_AUTO": "schedule:auto",
|
||||||
"SCHEDULE_ADJUST": "schedule:adjust"
|
"SCHEDULE_ADJUST": "schedule:adjust",
|
||||||
|
"DIAGNOSTIC_MANAGE": "diagnostic:manage",
|
||||||
|
"DIAGNOSTIC_READ": "diagnostic:read",
|
||||||
|
"ELECTIVE_MANAGE": "elective:manage",
|
||||||
|
"ELECTIVE_READ": "elective:read",
|
||||||
|
"ELECTIVE_SELECT": "elective:select"
|
||||||
},
|
},
|
||||||
"rolePermissions": {
|
"rolePermissions": {
|
||||||
"admin": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","TEXTBOOK_DELETE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_DELETE","CLASS_ENROLL","CLASS_SCHEDULE","SCHOOL_MANAGE","GRADE_MANAGE","USER_MANAGE","AI_CHAT","AI_CONFIGURE","SETTINGS_ADMIN","AUDIT_LOG_READ","ANNOUNCEMENT_MANAGE","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_MANAGE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","SCHEDULE_AUTO","SCHEDULE_ADJUST"],
|
"admin": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","TEXTBOOK_DELETE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_DELETE","CLASS_ENROLL","CLASS_SCHEDULE","SCHOOL_MANAGE","GRADE_MANAGE","USER_MANAGE","AI_CHAT","AI_CONFIGURE","SETTINGS_ADMIN","AUDIT_LOG_READ","ANNOUNCEMENT_MANAGE","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_MANAGE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","SCHEDULE_AUTO","SCHEDULE_ADJUST","DIAGNOSTIC_MANAGE","DIAGNOSTIC_READ","ELECTIVE_MANAGE","ELECTIVE_READ"],
|
||||||
"teacher": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","CLASS_ENROLL","CLASS_SCHEDULE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"],
|
"teacher": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","CLASS_ENROLL","CLASS_SCHEDULE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_MANAGE","DIAGNOSTIC_READ","ELECTIVE_MANAGE","ELECTIVE_READ"],
|
||||||
"student": ["EXAM_READ","HOMEWORK_SUBMIT","QUESTION_READ","TEXTBOOK_READ","CLASS_READ","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_READ","MESSAGE_DELETE"],
|
"student": ["EXAM_READ","HOMEWORK_SUBMIT","QUESTION_READ","TEXTBOOK_READ","CLASS_READ","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_READ","ELECTIVE_SELECT","ELECTIVE_READ"],
|
||||||
"parent": ["EXAM_READ","TEXTBOOK_READ","CLASS_READ","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"],
|
"parent": ["EXAM_READ","TEXTBOOK_READ","CLASS_READ","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"],
|
||||||
"grade_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_ENROLL","CLASS_SCHEDULE","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"],
|
"grade_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_ENROLL","CLASS_SCHEDULE","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_MANAGE","DIAGNOSTIC_READ","ELECTIVE_READ"],
|
||||||
"teaching_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"]
|
"teaching_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","DIAGNOSTIC_READ","ELECTIVE_READ"]
|
||||||
},
|
},
|
||||||
"dataScopeTypes": {
|
"dataScopeTypes": {
|
||||||
"all": "管理员:无过滤",
|
"all": "管理员:无过滤",
|
||||||
@@ -419,7 +424,11 @@
|
|||||||
"attendanceRules": {"fields": ["id","classId","lateThresholdMinutes","earlyLeaveThresholdMinutes","enableAutoMark","createdAt","updatedAt"], "usedBy": ["attendance"]},
|
"attendanceRules": {"fields": ["id","classId","lateThresholdMinutes","earlyLeaveThresholdMinutes","enableAutoMark","createdAt","updatedAt"], "usedBy": ["attendance"]},
|
||||||
"schedulingRules": {"fields": ["id","classId","maxDailyHours","maxContinuousHours","lunchBreakStart","lunchBreakEnd","morningStart","afternoonEnd","avoidBackToBack","balancedSubjects","createdAt","updatedAt"], "usedBy": ["scheduling"]},
|
"schedulingRules": {"fields": ["id","classId","maxDailyHours","maxContinuousHours","lunchBreakStart","lunchBreakEnd","morningStart","afternoonEnd","avoidBackToBack","balancedSubjects","createdAt","updatedAt"], "usedBy": ["scheduling"]},
|
||||||
"scheduleChanges": {"fields": ["id","originalScheduleId","classId","originalTeacherId","substituteTeacherId","originalDate","newDate","newStartTime","newEndTime","reason","status","requestedBy","approvedBy","createdAt","updatedAt"], "usedBy": ["scheduling"]},
|
"scheduleChanges": {"fields": ["id","originalScheduleId","classId","originalTeacherId","substituteTeacherId","originalDate","newDate","newStartTime","newEndTime","reason","status","requestedBy","approvedBy","createdAt","updatedAt"], "usedBy": ["scheduling"]},
|
||||||
"passwordSecurity": {"fields": ["id","userId","failedLoginAttempts","lockedUntil","passwordChangedAt","mustChangePassword","lastPasswordChange","createdAt","updatedAt"], "usedBy": ["auth","settings"]}
|
"passwordSecurity": {"fields": ["id","userId","failedLoginAttempts","lockedUntil","passwordChangedAt","mustChangePassword","lastPasswordChange","createdAt","updatedAt"], "usedBy": ["auth","settings"]},
|
||||||
|
"knowledgePointMastery": {"fields": ["id","studentId","knowledgePointId","masteryLevel","totalQuestions","correctQuestions","lastAssessedAt","createdAt","updatedAt"], "usedBy": ["diagnostic"], "description": "知识点掌握度记录(复合主键 studentId+knowledgePointId,onDuplicateKeyUpdate upsert)"},
|
||||||
|
"learningDiagnosticReports": {"fields": ["id","studentId","generatedBy","reportType","period","summary","strengths","weaknesses","recommendations","overallScore","status","createdAt","updatedAt"], "usedBy": ["diagnostic"], "description": "学情诊断报告(reportType: individual/class/grade;status: draft/published/archived)"},
|
||||||
|
"electiveCourses": {"fields": ["id","name","subjectId","teacherId","gradeId","description","capacity","enrolledCount","classroom","schedule","startDate","endDate","selectionStartAt","selectionEndAt","status","selectionMode","credit","createdAt","updatedAt"], "usedBy": ["elective"], "description": "选修课程(status: draft/open/closed/cancelled;selectionMode: fcfs/lottery)"},
|
||||||
|
"courseSelections": {"fields": ["id","courseId","studentId","status","priority","selectedAt","enrolledAt","droppedAt","lotteryRank","createdAt","updatedAt"], "usedBy": ["elective"], "description": "选课记录(复合主键 courseId+studentId;status: selected/enrolled/waitlist/dropped/rejected)"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -909,7 +918,7 @@
|
|||||||
{"name": "NavItem", "type": "type", "definition": "{ title, href, icon?, permission? }", "usedBy": ["NAV_CONFIG", "AppSidebar"]}
|
{"name": "NavItem", "type": "type", "definition": "{ title, href, icon?, permission? }", "usedBy": ["NAV_CONFIG", "AppSidebar"]}
|
||||||
],
|
],
|
||||||
"config": [
|
"config": [
|
||||||
{"name": "NAV_CONFIG", "type": "Record<Role, NavItem[]>", "note": "每个NavItem含permission字段用于权限过滤。admin角色菜单包含Audit Logs项(icon: ScrollText, href: /admin/audit-logs, permission: AUDIT_LOG_READ),含子项Operation Logs与Login Logs。admin角色菜单的School Management子菜单包含Import Users项(href: /admin/users/import, permission: USER_MANAGE)。admin角色菜单包含Scheduling项(icon: CalendarClock, href: /admin/scheduling/rules, permission: SCHEDULE_ADJUST),含子项Rules(/admin/scheduling/rules, permission: SCHEDULE_ADJUST)、Auto Schedule(/admin/scheduling/auto, permission: SCHEDULE_AUTO)、Change Requests(/admin/scheduling/changes, permission: SCHEDULE_ADJUST)。teacher角色菜单包含Grades项(icon: GraduationCap, permission: GRADE_RECORD_READ),含子项All Grades(/teacher/grades)、Batch Entry(/teacher/grades/entry, permission: GRADE_RECORD_MANAGE)、Statistics(/teacher/grades/stats)。teacher角色菜单包含Schedule Changes项(icon: CalendarClock, href: /teacher/schedule-changes, permission: SCHEDULE_ADJUST)。student角色菜单包含My Grades项(icon: GraduationCap, href: /student/grades, permission: GRADE_RECORD_READ)。parent角色菜单包含Dashboard项(icon: LayoutDashboard, href: /parent/dashboard,无permission字段仅需登录)、Grades项(icon: GraduationCap, href: /parent/grades, permission: GRADE_RECORD_READ)、Announcements项(icon: Megaphone, href: /announcements, permission: ANNOUNCEMENT_READ)"}
|
{"name": "NAV_CONFIG", "type": "Record<Role, NavItem[]>", "note": "每个NavItem含permission字段用于权限过滤。admin角色菜单包含Audit Logs项(icon: ScrollText, href: /admin/audit-logs, permission: AUDIT_LOG_READ),含子项Operation Logs与Login Logs。admin角色菜单的School Management子菜单包含Import Users项(href: /admin/users/import, permission: USER_MANAGE)。admin角色菜单包含Scheduling项(icon: CalendarClock, href: /admin/scheduling/rules, permission: SCHEDULE_ADJUST),含子项Rules(/admin/scheduling/rules, permission: SCHEDULE_ADJUST)、Auto Schedule(/admin/scheduling/auto, permission: SCHEDULE_AUTO)、Change Requests(/admin/scheduling/changes, permission: SCHEDULE_ADJUST)。teacher角色菜单包含Grades项(icon: GraduationCap, permission: GRADE_RECORD_READ),含子项All Grades(/teacher/grades)、Batch Entry(/teacher/grades/entry, permission: GRADE_RECORD_MANAGE)、Statistics(/teacher/grades/stats)。teacher角色菜单包含Schedule Changes项(icon: CalendarClock, href: /teacher/schedule-changes, permission: SCHEDULE_ADJUST)。teacher角色菜单包含Diagnostic项(icon: Stethoscope, href: /teacher/diagnostic, permission: DIAGNOSTIC_READ)。student角色菜单包含My Grades项(icon: GraduationCap, href: /student/grades, permission: GRADE_RECORD_READ)。student角色菜单包含Diagnostic项(icon: Stethoscope, href: /student/diagnostic, permission: DIAGNOSTIC_READ)。parent角色菜单包含Dashboard项(icon: LayoutDashboard, href: /parent/dashboard,无permission字段仅需登录)、Grades项(icon: GraduationCap, href: /parent/grades, permission: GRADE_RECORD_READ)、Announcements项(icon: Megaphone, href: /announcements, permission: ANNOUNCEMENT_READ)"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1430,6 +1439,155 @@
|
|||||||
{ "name": "ScheduleConflictsView", "file": "components/schedule-conflicts-view.tsx", "purpose": "冲突检测视图(班级选择器 + 检测按钮 + 冲突结果列表)" }
|
{ "name": "ScheduleConflictsView", "file": "components/schedule-conflicts-view.tsx", "purpose": "冲突检测视图(班级选择器 + 检测按钮 + 冲突结果列表)" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"proctoring": {
|
||||||
|
"path": "src/modules/proctoring",
|
||||||
|
"description": "考试监考模块:监考模式考试实时监控、防作弊事件采集、教师监考面板、学生端防作弊监控、考试模式配置",
|
||||||
|
"exports": {
|
||||||
|
"actions": [
|
||||||
|
{"name": "recordProctoringEventAction", "permission": "requireAuth()", "signature": "(prevState: ActionState<{id:string}> | null, formData: FormData) => Promise<ActionState<{id:string}>>", "purpose": "学生端上报监考事件(含 submission 归属校验)", "deps": ["requireAuth", "shared/db", "data-access.recordProctoringEvent"], "usedBy": ["anti-cheat-monitor.tsx"]},
|
||||||
|
{"name": "getProctoringDashboardAction", "permission": "EXAM_PROCTOR", "signature": "(examId: string) => Promise<ActionState<ProctoringDashboardData>>", "purpose": "获取监考面板数据(摘要+学生状态+最近事件)", "deps": ["requirePermission(EXAM_PROCTOR)", "data-access.getExamForProctoring,getExamProctoringSummary,getStudentProctoringStatuses,getRecentProctoringEvents"], "usedBy": ["proctoring-dashboard.tsx"]}
|
||||||
|
],
|
||||||
|
"dataAccess": [
|
||||||
|
{"name": "recordProctoringEvent", "signature": "(input: RecordProctoringEventInput) => Promise<ProctoringEvent>", "purpose": "记录一条监考事件", "usedBy": ["actions.recordProctoringEventAction", "api/proctoring/event/route.ts"]},
|
||||||
|
{"name": "getProctoringEvents", "signature": "(examId: string, filters?: GetProctoringEventsFilters) => Promise<ProctoringEventWithDetails[]>", "purpose": "查询考试监考事件(含学生姓名、考试标题)", "usedBy": ["待扩展"]},
|
||||||
|
{"name": "getProctoringEventsBySubmission", "signature": "(submissionId: string) => Promise<ProctoringEvent[]>", "purpose": "查询提交的监考事件", "usedBy": ["待扩展"]},
|
||||||
|
{"name": "getExamProctoringSummary", "signature": "(examId: string) => Promise<ExamProctoringSummary>", "purpose": "获取考试监考摘要", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]},
|
||||||
|
{"name": "getStudentProctoringStatuses", "signature": "(examId: string) => Promise<StudentProctoringStatus[]>", "purpose": "获取所有学生监考状态", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]},
|
||||||
|
{"name": "getExamForProctoring", "signature": "(examId: string) => Promise<{id,title,examMode,config} | null>", "purpose": "获取考试信息(含 examMode 设置)", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]},
|
||||||
|
{"name": "getRecentProctoringEvents", "signature": "(examId: string, limit?: number) => Promise<ProctoringEventWithDetails[]>", "purpose": "获取最近 N 条监考事件", "usedBy": ["actions.getProctoringDashboardAction", "teacher/exams/[id]/proctoring/page.tsx"]}
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
{"name": "ProctoringEventType", "type": "type", "definition": "\"tab_switch\" | \"window_blur\" | \"copy_attempt\" | \"paste_attempt\" | \"right_click\" | \"devtools_open\" | \"fullscreen_exit\" | \"idle_timeout\""},
|
||||||
|
{"name": "ExamMode", "type": "type", "definition": "\"homework\" | \"timed\" | \"proctored\""},
|
||||||
|
{"name": "ProctoringEvent", "type": "interface", "definition": "{ id, submissionId, studentId, examId, eventType, eventDetail?, occurredAt, createdAt }"},
|
||||||
|
{"name": "ProctoringEventWithDetails", "type": "interface", "definition": "ProctoringEvent & { studentName, examTitle }"},
|
||||||
|
{"name": "ExamProctoringSummary", "type": "interface", "definition": "{ examId, examTitle, examMode, totalStudents, startedStudents, submittedStudents, totalEvents, abnormalStudents, eventsByType }"},
|
||||||
|
{"name": "StudentProctoringStatus", "type": "interface", "definition": "{ studentId, studentName, submissionId, submissionStatus, eventCount, lastEventAt, isAbnormal, eventsByType }"},
|
||||||
|
{"name": "ProctoringDashboardData", "type": "interface", "definition": "{ summary, students, recentEvents }"},
|
||||||
|
{"name": "ExamModeConfig", "type": "interface", "definition": "{ examMode, durationMinutes, shuffleQuestions, allowLateStart, lateStartGraceMinutes, antiCheatEnabled }"},
|
||||||
|
{"name": "PROCTORING_EVENT_LABELS", "type": "const", "description": "事件类型中文标签常量"},
|
||||||
|
{"name": "EXAM_MODE_LABELS", "type": "const", "description": "考试模式中文标签常量"},
|
||||||
|
{"name": "ABNORMAL_EVENT_THRESHOLD", "type": "const", "description": "异常学生事件数阈值(3)"}
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{"name": "ProctoringDashboard", "file": "components/proctoring-dashboard.tsx", "purpose": "教师监考面板(实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制)"},
|
||||||
|
{"name": "AntiCheatMonitor", "file": "components/anti-cheat-monitor.tsx", "purpose": "学生端防作弊监控(visibilitychange/blur/copy/paste/contextmenu/keydown/fullscreenchange 监听、空闲超时检测、强制全屏、警告提示、事件上报)"},
|
||||||
|
{"name": "ExamModeConfig", "file": "components/exam-mode-config.tsx", "purpose": "考试模式配置(react-hook-form Controller,作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项)"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diagnostic": {
|
||||||
|
"path": "src/modules/diagnostic",
|
||||||
|
"description": "学情诊断报告模块:基于知识点掌握度(knowledgePointMastery)生成个人/班级诊断报告,掌握度雷达图(学生 vs 班级平均),强项/弱项分析,知识点掌握度热力图,需重点关注学生列表,报告发布/删除管理",
|
||||||
|
"exports": {
|
||||||
|
"dataAccess": [
|
||||||
|
{ "name": "getStudentMastery", "signature": "(studentId: string) => Promise<MasteryWithKnowledgePoint[]>", "file": "data-access.ts", "purpose": "获取学生在所有知识点的掌握度(含知识点名称,按掌握度降序)", "deps": ["shared.db", "shared.db.schema.knowledgePointMastery", "shared.db.schema.knowledgePoints"], "usedBy": ["data-access.getStudentMasterySummary", "teacher/diagnostic/student/[studentId]/page.tsx"] },
|
||||||
|
{ "name": "getStudentMasterySummary", "signature": "(studentId: string) => Promise<StudentMasterySummary | null>", "file": "data-access.ts", "purpose": "获取学生掌握度摘要(平均掌握度、强项≥80%、弱项<60%)", "deps": ["shared.db", "shared.db.schema.users", "data-access.getStudentMastery"], "usedBy": ["data-access-reports.generateDiagnosticReport", "teacher/diagnostic/student/[studentId]/page.tsx", "student/diagnostic/page.tsx"] },
|
||||||
|
{ "name": "updateMasteryFromSubmission", "signature": "(submissionId: string) => Promise<void>", "file": "data-access.ts", "purpose": "从提交答案更新掌握度(按知识点聚合正确率,onDuplicateKeyUpdate upsert)", "deps": ["shared.db", "shared.db.schema.examSubmissions", "shared.db.schema.submissionAnswers", "shared.db.schema.questionsToKnowledgePoints", "shared.db.schema.knowledgePointMastery"], "usedBy": ["待扩展(作业/考试提交后触发)"] },
|
||||||
|
{ "name": "getClassMasterySummary", "signature": "(classId: string) => Promise<ClassMasterySummary | null>", "file": "data-access.ts", "purpose": "获取班级掌握度摘要(学生数、平均掌握度、知识点统计、需重点关注学生)", "deps": ["shared.db", "shared.db.schema.classes", "shared.db.schema.classEnrollments", "shared.db.schema.users", "shared.db.schema.knowledgePointMastery", "shared.db.schema.knowledgePoints"], "usedBy": ["data-access-reports.generateClassDiagnosticReport", "teacher/diagnostic/class/[classId]/page.tsx"] },
|
||||||
|
{ "name": "getKnowledgePointStats", "signature": "(classId?: string, gradeId?: string) => Promise<KnowledgePointStat[]>", "file": "data-access.ts", "purpose": "获取知识点统计(按班级或年级聚合平均掌握度、掌握人数、未掌握人数)", "deps": ["shared.db", "shared.db.schema.classEnrollments", "shared.db.schema.users", "shared.db.schema.knowledgePointMastery", "shared.db.schema.knowledgePoints"], "usedBy": ["teacher/diagnostic/student/[studentId]/page.tsx (班级平均对比)"] },
|
||||||
|
{ "name": "generateDiagnosticReport", "signature": "(studentId: string, period: string, generatedBy: string) => Promise<string>", "file": "data-access-reports.ts", "purpose": "生成个人诊断报告(计算 overallScore、强项/弱项列表、复习建议,status=draft)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "data-access.getStudentMasterySummary", "@paralleldrive/cuid2"], "usedBy": ["actions.generateStudentReportAction"] },
|
||||||
|
{ "name": "generateClassDiagnosticReport", "signature": "(classId: string, period: string, generatedBy: string) => Promise<string>", "file": "data-access-reports.ts", "purpose": "生成班级诊断报告(聚合班级掌握度,识别薄弱知识点,status=draft,studentId 存生成者 ID)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "data-access.getClassMasterySummary", "@paralleldrive/cuid2"], "usedBy": ["actions.generateClassReportAction"] },
|
||||||
|
{ "name": "getDiagnosticReports", "signature": "(filters: DiagnosticReportQueryParams) => Promise<DiagnosticReportWithDetails[]>", "file": "data-access-reports.ts", "purpose": "查询诊断报告列表(可按 studentId/reportType/status/period 过滤,含学生名和生成者名)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "shared.db.schema.users"], "usedBy": ["actions.getDiagnosticReportsAction", "teacher/diagnostic/page.tsx", "teacher/diagnostic/student/[studentId]/page.tsx", "student/diagnostic/page.tsx"] },
|
||||||
|
{ "name": "getDiagnosticReportById", "signature": "(id: string) => Promise<DiagnosticReportWithDetails | null>", "file": "data-access-reports.ts", "purpose": "获取报告详情(含学生名和生成者名)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports", "shared.db.schema.users"], "usedBy": ["actions.getDiagnosticReportByIdAction"] },
|
||||||
|
{ "name": "publishDiagnosticReport", "signature": "(id: string) => Promise<void>", "file": "data-access-reports.ts", "purpose": "发布诊断报告(status=published)", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports"], "usedBy": ["actions.publishReportAction"] },
|
||||||
|
{ "name": "deleteDiagnosticReport", "signature": "(id: string) => Promise<void>", "file": "data-access-reports.ts", "purpose": "删除诊断报告", "deps": ["shared.db", "shared.db.schema.learningDiagnosticReports"], "usedBy": ["actions.deleteReportAction"] }
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "name": "generateStudentReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "生成学生个人诊断报告(formData: studentId, period)", "deps": ["requirePermission", "data-access-reports.generateDiagnosticReport", "revalidatePath"], "usedBy": ["components/student-diagnostic-view.tsx"] },
|
||||||
|
{ "name": "generateClassReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "生成班级诊断报告(formData: classId, period)", "deps": ["requirePermission", "data-access-reports.generateClassDiagnosticReport", "revalidatePath"], "usedBy": ["components/class-diagnostic-view.tsx"] },
|
||||||
|
{ "name": "publishReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "发布诊断报告(formData: id)", "deps": ["requirePermission", "data-access-reports.publishDiagnosticReport", "revalidatePath"], "usedBy": ["components/report-list.tsx"] },
|
||||||
|
{ "name": "deleteReportAction", "permission": "DIAGNOSTIC_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "删除诊断报告(formData: id)", "deps": ["requirePermission", "data-access-reports.deleteDiagnosticReport", "revalidatePath"], "usedBy": ["components/report-list.tsx"] },
|
||||||
|
{ "name": "getDiagnosticReportsAction", "permission": "DIAGNOSTIC_READ", "signature": "(params: DiagnosticReportQueryParams) => Promise<ActionState<DiagnosticReportWithDetails[]>>", "file": "actions.ts", "purpose": "查询诊断报告列表(读权限)", "deps": ["requirePermission", "data-access-reports.getDiagnosticReports"], "usedBy": ["待扩展"] },
|
||||||
|
{ "name": "getDiagnosticReportByIdAction", "permission": "DIAGNOSTIC_READ", "signature": "(id: string) => Promise<ActionState<DiagnosticReportWithDetails | null>>", "file": "actions.ts", "purpose": "获取诊断报告详情(读权限)", "deps": ["requirePermission", "data-access-reports.getDiagnosticReportById"], "usedBy": ["待扩展"] }
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
{ "name": "DiagnosticReportType", "type": "type", "file": "types.ts", "definition": "\"individual\" | \"class\" | \"grade\"", "usedBy": ["types.DiagnosticReport.reportType", "actions", "components/report-list.tsx"] },
|
||||||
|
{ "name": "DiagnosticReportStatus", "type": "type", "file": "types.ts", "definition": "\"draft\" | \"published\" | \"archived\"", "usedBy": ["types.DiagnosticReport.status", "actions", "components/report-list.tsx"] },
|
||||||
|
{ "name": "KnowledgePointMastery", "type": "interface", "file": "types.ts", "definition": "{ id, studentId, knowledgePointId, masteryLevel(0-100), totalQuestions, correctQuestions, lastAssessedAt, createdAt, updatedAt }", "usedBy": ["data-access", "types.MasteryWithKnowledgePoint"] },
|
||||||
|
{ "name": "MasteryWithKnowledgePoint", "type": "interface", "file": "types.ts", "definition": "KnowledgePointMastery & { knowledgePointName, knowledgePointDescription }", "usedBy": ["data-access.getStudentMastery", "types.StudentMasterySummary"] },
|
||||||
|
{ "name": "StudentMasterySummary", "type": "interface", "file": "types.ts", "definition": "{ studentId, studentName, averageMastery, totalKnowledgePoints, strengths(≥80), weaknesses(<60), allMastery }", "usedBy": ["data-access.getStudentMasterySummary", "data-access-reports.generateDiagnosticReport", "components/student-diagnostic-view.tsx"] },
|
||||||
|
{ "name": "DiagnosticReport", "type": "interface", "file": "types.ts", "definition": "{ id, studentId, generatedBy, reportType, period, summary, strengths[], weaknesses[], recommendations[], overallScore, status, createdAt, updatedAt }", "usedBy": ["data-access-reports", "types.DiagnosticReportWithDetails"] },
|
||||||
|
{ "name": "DiagnosticReportWithDetails", "type": "interface", "file": "types.ts", "definition": "DiagnosticReport & { studentName, generatedByName }", "usedBy": ["data-access-reports.getDiagnosticReports", "actions", "components/report-list.tsx", "components/student-diagnostic-view.tsx"] },
|
||||||
|
{ "name": "ClassMasterySummary", "type": "interface", "file": "types.ts", "definition": "{ classId, className, studentCount, averageMastery, knowledgePointStats[], studentsNeedingAttention[] }", "usedBy": ["data-access.getClassMasterySummary", "data-access-reports.generateClassDiagnosticReport", "components/class-diagnostic-view.tsx"] },
|
||||||
|
{ "name": "KnowledgePointStat", "type": "interface", "file": "types.ts", "definition": "{ knowledgePointId, knowledgePointName, averageMastery, masteredCount(≥80), notMasteredCount(<60), totalStudents }", "usedBy": ["data-access.getKnowledgePointStats", "types.ClassMasterySummary", "components/class-diagnostic-view.tsx"] },
|
||||||
|
{ "name": "DiagnosticReportQueryParams", "type": "interface", "file": "types.ts", "definition": "{ studentId?, reportType?, status?, period? }", "usedBy": ["data-access-reports.getDiagnosticReports", "actions.getDiagnosticReportsAction"] },
|
||||||
|
{ "name": "MasteryRadarPoint", "type": "interface", "file": "types.ts", "definition": "{ knowledgePoint, student(0-100), classAverage?(0-100) }", "usedBy": ["components/mastery-radar-chart.tsx", "components/student-diagnostic-view.tsx", "teacher/diagnostic/student/[studentId]/page.tsx"] }
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{ "name": "MasteryRadarChart", "file": "components/mastery-radar-chart.tsx", "purpose": "知识点掌握度雷达图(recharts RadarChart,学生 vs 班级平均对比,无数据时显示 EmptyState)", "deps": ["recharts", "shared/components/ui/card", "shared/components/ui/chart", "shared/components/ui/empty-state"] },
|
||||||
|
{ "name": "StudentDiagnosticView", "file": "components/student-diagnostic-view.tsx", "purpose": "学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示)", "deps": ["usePermission", "actions.generateStudentReportAction", "components/mastery-radar-chart", "shared/components/ui/*"] },
|
||||||
|
{ "name": "ClassDiagnosticView", "file": "components/class-diagnostic-view.tsx", "purpose": "班级诊断视图(概览卡片、知识点掌握度热力图[绿/黄/橙/红]、知识点排名表、需重点关注学生表[链接到学生视图]、生成班级报告表单[DIAGNOSTIC_MANAGE])", "deps": ["usePermission", "actions.generateClassReportAction", "shared/components/ui/*"] },
|
||||||
|
{ "name": "ReportList", "file": "components/report-list.tsx", "purpose": "诊断报告列表(reportType/status 过滤器[URL searchParams]、报告表格、发布/删除操作[DIAGNOSTIC_MANAGE]、确认对话框)", "deps": ["usePermission", "actions.publishReportAction", "actions.deleteReportAction", "shared/components/ui/*"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"elective": {
|
||||||
|
"path": "src/modules/elective",
|
||||||
|
"description": "选课管理模块:选修课程 CRUD、选课开放/关闭、学生选课/退课、抽签模式批量录取、FCFS 即时录取、DataScope 行级过滤(admin 全部、teacher 所教、grade_head 所管年级、student 可选课程)",
|
||||||
|
"exports": {
|
||||||
|
"actions": [
|
||||||
|
{"name": "createElectiveCourseAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "创建选修课程(formData: name, subjectId?, teacherId, gradeId?, description?, capacity?, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, selectionMode?, credit?)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.createElectiveCourse", "revalidatePath"], "usedBy": ["admin/elective/create/page.tsx"]},
|
||||||
|
{"name": "updateElectiveCourseAction", "permission": "ELECTIVE_MANAGE", "signature": "(id: string, prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "更新选修课程", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.getElectiveCourseById", "data-access.updateElectiveCourse", "revalidatePath"], "usedBy": ["admin/elective/[id]/edit/page.tsx"]},
|
||||||
|
{"name": "deleteElectiveCourseAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "删除选修课程(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.deleteElectiveCourse", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]},
|
||||||
|
{"name": "openSelectionAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "开放选课(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.openSelection", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]},
|
||||||
|
{"name": "closeSelectionAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "关闭选课(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access.closeSelection", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]},
|
||||||
|
{"name": "runLotteryAction", "permission": "ELECTIVE_MANAGE", "signature": "(prevState: ActionState<{enrolled:number,waitlist:number}> | null, formData: FormData) => Promise<ActionState<{enrolled:number,waitlist:number}>>", "file": "actions.ts", "purpose": "执行抽签录取(formData: courseId)", "deps": ["requirePermission(ELECTIVE_MANAGE)", "data-access-operations.runLottery", "revalidatePath"], "usedBy": ["components/elective-course-list.tsx"]},
|
||||||
|
{"name": "selectCourseAction", "permission": "ELECTIVE_SELECT", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "学生选课(formData: courseId, priority?)", "deps": ["requirePermission(ELECTIVE_SELECT)", "data-access-operations.selectCourse", "revalidatePath"], "usedBy": ["components/student-selection-view.tsx"]},
|
||||||
|
{"name": "dropCourseAction", "permission": "ELECTIVE_SELECT", "signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>", "file": "actions.ts", "purpose": "学生退课(formData: courseId)", "deps": ["requirePermission(ELECTIVE_SELECT)", "data-access-operations.dropCourse", "revalidatePath"], "usedBy": ["components/student-selection-view.tsx"]},
|
||||||
|
{"name": "getElectiveCoursesAction", "permission": "ELECTIVE_READ", "signature": "(params?: GetElectiveCoursesParams) => Promise<ActionState<ElectiveCourseWithDetails[]>>", "file": "actions.ts", "purpose": "查询选修课程列表(按 DataScope 过滤)", "deps": ["requirePermission(ELECTIVE_READ)", "data-access.getElectiveCourses (scope, currentUserId)"], "usedBy": ["admin/elective/page.tsx", "teacher/elective/page.tsx"]},
|
||||||
|
{"name": "getStudentSelectionsAction", "permission": "ELECTIVE_READ", "signature": "(studentId: string) => Promise<ActionState<CourseSelectionWithDetails[]>>", "file": "actions.ts", "purpose": "查询学生选课记录(含 DataScope 二次校验:class_members 仅自己,children 仅子女)", "deps": ["requirePermission(ELECTIVE_READ)", "data-access-selections.getStudentSelections"], "usedBy": ["待扩展"]},
|
||||||
|
{"name": "getAvailableCoursesAction", "permission": "ELECTIVE_SELECT", "signature": "() => Promise<ActionState<ElectiveCourseWithDetails[]>>", "file": "actions.ts", "purpose": "获取学生可选课程(status=open 且匹配年级)", "deps": ["requirePermission(ELECTIVE_SELECT)", "data-access-selections.getAvailableCoursesForStudent"], "usedBy": ["待扩展"]}
|
||||||
|
],
|
||||||
|
"dataAccess": [
|
||||||
|
{"name": "getElectiveCourses", "file": "data-access.ts", "signature": "(params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }) => Promise<ElectiveCourseWithDetails[]>", "purpose": "查询选修课程列表(按 scope 行级过滤:owned/class_taught 按 teacherId,grade_managed 按 gradeIds)", "usedBy": ["actions.getElectiveCoursesAction", "admin/elective/page.tsx", "teacher/elective/page.tsx"]},
|
||||||
|
{"name": "getElectiveCourseById", "file": "data-access.ts", "signature": "(id: string) => Promise<ElectiveCourseWithDetails | null>", "purpose": "获取课程详情", "usedBy": ["actions.updateElectiveCourseAction", "admin/elective/[id]/edit/page.tsx"]},
|
||||||
|
{"name": "createElectiveCourse", "file": "data-access.ts", "signature": "(data: CreateElectiveCourseInput, teacherId: string) => Promise<string>", "purpose": "创建选修课程(status=draft, enrolledCount=0)", "usedBy": ["actions.createElectiveCourseAction"]},
|
||||||
|
{"name": "updateElectiveCourse", "file": "data-access.ts", "signature": "(id: string, data: Partial<UpdateElectiveCourseInput>) => Promise<void>", "purpose": "更新选修课程字段", "usedBy": ["actions.updateElectiveCourseAction"]},
|
||||||
|
{"name": "deleteElectiveCourse", "file": "data-access.ts", "signature": "(id: string) => Promise<void>", "purpose": "删除选修课程", "usedBy": ["actions.deleteElectiveCourseAction"]},
|
||||||
|
{"name": "openSelection", "file": "data-access.ts", "signature": "(courseId: string) => Promise<void>", "purpose": "开放选课(status=open)", "usedBy": ["actions.openSelectionAction"]},
|
||||||
|
{"name": "closeSelection", "file": "data-access.ts", "signature": "(courseId: string) => Promise<void>", "purpose": "关闭选课(status=closed)", "usedBy": ["actions.closeSelectionAction"]},
|
||||||
|
{"name": "getSubjectOptions", "file": "data-access.ts", "signature": "() => Promise<{id, name}[]>", "purpose": "获取学科选项(按 order, name 排序)", "usedBy": ["admin/elective/create/page.tsx", "admin/elective/[id]/edit/page.tsx"]},
|
||||||
|
{"name": "getCourseSelections", "file": "data-access-selections.ts", "signature": "(courseId: string) => Promise<CourseSelectionWithDetails[]>", "purpose": "查询课程所有选课记录(按 priority, selectedAt 排序)", "usedBy": ["待扩展"]},
|
||||||
|
{"name": "getStudentSelections", "file": "data-access-selections.ts", "signature": "(studentId: string) => Promise<CourseSelectionWithDetails[]>", "purpose": "查询学生选课记录(按 selectedAt 降序)", "usedBy": ["actions.getStudentSelectionsAction", "student/elective/page.tsx"]},
|
||||||
|
{"name": "getStudentGradeId", "file": "data-access-selections.ts", "signature": "(studentId: string) => Promise<string | null>", "purpose": "获取学生所在年级 ID(通过 classEnrollments active 记录)", "usedBy": ["data-access-selections.getAvailableCoursesForStudent"]},
|
||||||
|
{"name": "getAvailableCoursesForStudent", "file": "data-access-selections.ts", "signature": "(studentId: string, gradeId?: string | null) => Promise<ElectiveCourseWithDetails[]>", "purpose": "获取学生可选课程(status=open 且 gradeId 匹配或为空)", "usedBy": ["actions.getAvailableCoursesAction", "student/elective/page.tsx"]},
|
||||||
|
{"name": "runLottery", "file": "data-access-operations.ts", "signature": "(courseId: string) => Promise<{enrolled: number, waitlist: number}>", "purpose": "抽签录取(随机打乱 selected 记录,前 capacity 名 enrolled,其余 waitlist,课程 status=closed)", "usedBy": ["actions.runLotteryAction"]},
|
||||||
|
{"name": "selectCourse", "file": "data-access-operations.ts", "signature": "(courseId: string, studentId: string, priority?: number) => Promise<{status: CourseSelectionStatus, message: string}>", "purpose": "学生选课(校验课程状态/时间窗口/重复选课;FCFS 模式即时 enrolled/waitlist,lottery 模式 selected)", "usedBy": ["actions.selectCourseAction"]},
|
||||||
|
{"name": "dropCourse", "file": "data-access-operations.ts", "signature": "(courseId: string, studentId: string) => Promise<void>", "purpose": "学生退课(status=dropped;FCFS 模式自动递补 waitlist 首位)", "usedBy": ["actions.dropCourseAction"]}
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
{"name": "ElectiveCourseStatus", "type": "type", "file": "types.ts", "definition": "\"draft\" | \"open\" | \"closed\" | \"cancelled\""},
|
||||||
|
{"name": "ElectiveSelectionMode", "type": "type", "file": "types.ts", "definition": "\"fcfs\" | \"lottery\""},
|
||||||
|
{"name": "CourseSelectionStatus", "type": "type", "file": "types.ts", "definition": "\"selected\" | \"enrolled\" | \"waitlist\" | \"dropped\" | \"rejected\""},
|
||||||
|
{"name": "ElectiveCourse", "type": "interface", "file": "types.ts", "definition": "{ id, name, subjectId?, teacherId, gradeId?, description?, capacity, enrolledCount, classroom?, schedule?, startDate?, endDate?, selectionStartAt?, selectionEndAt?, status, selectionMode, credit, createdAt, updatedAt }"},
|
||||||
|
{"name": "ElectiveCourseWithDetails", "type": "interface", "file": "types.ts", "definition": "ElectiveCourse & { teacherName?, subjectName?, gradeName? }"},
|
||||||
|
{"name": "CourseSelection", "type": "interface", "file": "types.ts", "definition": "{ id, courseId, studentId, status, priority?, selectedAt, enrolledAt?, droppedAt?, lotteryRank?, createdAt, updatedAt }"},
|
||||||
|
{"name": "CourseSelectionWithDetails", "type": "interface", "file": "types.ts", "definition": "CourseSelection & { courseName?, studentName?, courseCapacity?, courseEnrolledCount?, courseStatus? }"},
|
||||||
|
{"name": "GetElectiveCoursesParams", "type": "interface", "file": "types.ts", "definition": "{ status?, gradeId?, subjectId?, teacherId? }"},
|
||||||
|
{"name": "ELECTIVE_STATUS_LABELS", "type": "const", "file": "types.ts", "description": "课程状态标签常量"},
|
||||||
|
{"name": "ELECTIVE_STATUS_COLORS", "type": "const", "file": "types.ts", "description": "课程状态颜色常量(Badge variant)"},
|
||||||
|
{"name": "SELECTION_MODE_LABELS", "type": "const", "file": "types.ts", "description": "选课模式标签常量"},
|
||||||
|
{"name": "COURSE_SELECTION_STATUS_LABELS", "type": "const", "file": "types.ts", "description": "选课状态标签常量"},
|
||||||
|
{"name": "COURSE_SELECTION_STATUS_COLORS", "type": "const", "file": "types.ts", "description": "选课状态颜色常量(Badge variant)"}
|
||||||
|
],
|
||||||
|
"schemas": [
|
||||||
|
{"name": "ElectiveCourseStatusEnum", "file": "schema.ts", "definition": "z.enum([\"draft\",\"open\",\"closed\",\"cancelled\"])"},
|
||||||
|
{"name": "ElectiveSelectionModeEnum", "file": "schema.ts", "definition": "z.enum([\"fcfs\",\"lottery\"])"},
|
||||||
|
{"name": "CourseSelectionStatusEnum", "file": "schema.ts", "definition": "z.enum([\"selected\",\"enrolled\",\"waitlist\",\"dropped\",\"rejected\"])"},
|
||||||
|
{"name": "CreateElectiveCourseSchema", "file": "schema.ts", "purpose": "创建课程校验(name 必填,teacherId 必填,capacity 1-500 默认 30,selectionMode 默认 fcfs,credit 默认 1.0)"},
|
||||||
|
{"name": "UpdateElectiveCourseSchema", "file": "schema.ts", "purpose": "更新课程校验(所有字段可选,含 status)"},
|
||||||
|
{"name": "SelectCourseSchema", "file": "schema.ts", "purpose": "选课校验(courseId 必填,priority 1-10 可选)"},
|
||||||
|
{"name": "DropCourseSchema", "file": "schema.ts", "purpose": "退课校验(courseId 必填)"},
|
||||||
|
{"name": "RunLotterySchema", "file": "schema.ts", "purpose": "抽签校验(courseId 必填)"}
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{"name": "ElectiveCourseList", "file": "components/elective-course-list.tsx", "purpose": "课程卡片列表(管理员/教师视图,含编辑/开放/关闭/抽签/删除操作按钮,usePermission 控制权限)", "deps": ["usePermission", "actions.deleteElectiveCourseAction", "actions.openSelectionAction", "actions.closeSelectionAction", "actions.runLotteryAction", "shared/components/ui/*"]},
|
||||||
|
{"name": "ElectiveCourseForm", "file": "components/elective-course-form.tsx", "purpose": "课程创建/编辑表单(name, subjectId, teacherId, gradeId, description, capacity, classroom, schedule, dates, selectionMode, credit)", "deps": ["react-hook-form", "actions.createElectiveCourseAction", "actions.updateElectiveCourseAction", "shared/components/ui/*"]},
|
||||||
|
{"name": "StudentSelectionView", "file": "components/student-selection-view.tsx", "purpose": "学生选课视图(可选课程列表 + 我的选课记录,含选课/退课按钮)", "deps": ["usePermission", "actions.selectCourseAction", "actions.dropCourseAction", "shared/components/ui/*"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencyMatrix": {
|
"dependencyMatrix": {
|
||||||
@@ -1453,7 +1611,9 @@
|
|||||||
"parent": {"dependsOn": ["shared", "auth", "homework", "classes", "grades"], "uses": {"shared": ["db", "auth-guard.requireAuth", "db.schema.parentStudentRelations", "db.schema.users", "db.schema.grades", "db.schema.classEnrollments", "db.schema.classes", "types"], "auth": ["auth"], "homework": ["data-access.getStudentHomeworkAssignments", "data-access.getStudentDashboardGrades"], "classes": ["data-access.getStudentClasses", "data-access.getStudentSchedule"], "grades": ["data-access.getStudentGradeSummary"]}},
|
"parent": {"dependsOn": ["shared", "auth", "homework", "classes", "grades"], "uses": {"shared": ["db", "auth-guard.requireAuth", "db.schema.parentStudentRelations", "db.schema.users", "db.schema.grades", "db.schema.classEnrollments", "db.schema.classes", "types"], "auth": ["auth"], "homework": ["data-access.getStudentHomeworkAssignments", "data-access.getStudentDashboardGrades"], "classes": ["data-access.getStudentClasses", "data-access.getStudentSchedule"], "grades": ["data-access.getStudentGradeSummary"]}},
|
||||||
"messaging": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.requireAuth", "db.schema.messages", "db.schema.messageNotifications", "db.schema.notificationPreferences", "db.schema.users", "db.schema.classEnrollments", "db.schema.classes", "db.schema.grades", "types.permissions", "types.action-state"], "auth": ["auth"]}},
|
"messaging": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.requireAuth", "db.schema.messages", "db.schema.messageNotifications", "db.schema.notificationPreferences", "db.schema.users", "db.schema.classEnrollments", "db.schema.classes", "db.schema.grades", "types.permissions", "types.action-state"], "auth": ["auth"]}},
|
||||||
"attendance": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.attendanceRecords", "db.schema.attendanceRules", "db.schema.classEnrollments", "db.schema.users", "db.schema.classes", "types.permissions", "types.action-state", "types.DataScope"], "auth": ["auth"], "classes": ["data-access.getTeacherClasses", "data-access.getAdminClasses"]}},
|
"attendance": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.attendanceRecords", "db.schema.attendanceRules", "db.schema.classEnrollments", "db.schema.users", "db.schema.classes", "types.permissions", "types.action-state", "types.DataScope"], "auth": ["auth"], "classes": ["data-access.getTeacherClasses", "data-access.getAdminClasses"]}},
|
||||||
"scheduling": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.schedulingRules", "db.schema.scheduleChanges", "db.schema.classSchedule", "db.schema.classes", "db.schema.users", "db.schema.classSubjectTeachers", "db.schema.subjects", "db.schema.classrooms", "types.permissions", "types.action-state"], "auth": ["auth"], "classes": []}}
|
"scheduling": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.schedulingRules", "db.schema.scheduleChanges", "db.schema.classSchedule", "db.schema.classes", "db.schema.users", "db.schema.classSubjectTeachers", "db.schema.subjects", "db.schema.classrooms", "types.permissions", "types.action-state"], "auth": ["auth"], "classes": []}},
|
||||||
|
"diagnostic": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.knowledgePointMastery", "db.schema.learningDiagnosticReports", "db.schema.knowledgePoints", "db.schema.questionsToKnowledgePoints", "db.schema.examSubmissions", "db.schema.submissionAnswers", "db.schema.classEnrollments", "db.schema.classes", "db.schema.users", "types.permissions", "types.action-state", "hooks.usePermission", "components.ui.*"], "auth": ["auth"]}},
|
||||||
|
"elective": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.electiveCourses", "db.schema.courseSelections", "db.schema.users", "db.schema.subjects", "db.schema.grades", "db.schema.classes", "db.schema.classEnrollments", "types.permissions", "types.action-state", "types.DataScope", "hooks.usePermission", "components.ui.*"], "auth": ["auth"]}}
|
||||||
},
|
},
|
||||||
"parameterFlowChains": {
|
"parameterFlowChains": {
|
||||||
"userId": {
|
"userId": {
|
||||||
@@ -1469,7 +1629,10 @@
|
|||||||
"homework/actions.ts → 作为 creatorId 写入 homeworkAssignments 表",
|
"homework/actions.ts → 作为 creatorId 写入 homeworkAssignments 表",
|
||||||
"classes/data-access.ts → getTeacherClasses(teacherId), getGradeManagedClasses(userId)",
|
"classes/data-access.ts → getTeacherClasses(teacherId), getGradeManagedClasses(userId)",
|
||||||
"grades/actions.ts → 作为 recordedBy 写入 gradeRecords 表",
|
"grades/actions.ts → 作为 recordedBy 写入 gradeRecords 表",
|
||||||
"attendance/actions.ts → 作为 recordedBy 写入 attendanceRecords 表"
|
"attendance/actions.ts → 作为 recordedBy 写入 attendanceRecords 表",
|
||||||
|
"elective/actions.ts → 作为 teacherId 默认值写入 electiveCourses 表(createElectiveCourseAction)",
|
||||||
|
"elective/actions.ts → 作为 studentId 查询学生选课(selectCourseAction/dropCourseAction/getStudentSelectionsAction)",
|
||||||
|
"elective/data-access.ts → getElectiveCourses({ scope, currentUserId }) 按 teacherId 过滤(class_taught/owned)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"examId": {
|
"examId": {
|
||||||
@@ -1501,15 +1664,15 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"origin": "shared/types/permissions.ts Permissions 常量定义(47 个权限点,含 FILE_UPLOAD/FILE_READ/FILE_DELETE、GRADE_RECORD_MANAGE/GRADE_RECORD_READ、ATTENDANCE_MANAGE/ATTENDANCE_READ、MESSAGE_SEND/MESSAGE_READ/MESSAGE_DELETE、SCHEDULE_AUTO/SCHEDULE_ADJUST)",
|
"origin": "shared/types/permissions.ts Permissions 常量定义(50 个权限点,含 FILE_UPLOAD/FILE_READ/FILE_DELETE、GRADE_RECORD_MANAGE/GRADE_RECORD_READ、ATTENDANCE_MANAGE/ATTENDANCE_READ、MESSAGE_SEND/MESSAGE_READ/MESSAGE_DELETE、SCHEDULE_AUTO/SCHEDULE_ADJUST、ELECTIVE_MANAGE/ELECTIVE_READ/ELECTIVE_SELECT)",
|
||||||
"flow": [
|
"flow": [
|
||||||
"shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射(admin/teacher 拥有全部 FILE_* 及 GRADE_RECORD_MANAGE/READ,student/parent/grade_head/teaching_head 拥有 GRADE_RECORD_READ;admin/teacher 拥有 ATTENDANCE_MANAGE+ATTENDANCE_READ,student/parent/grade_head/teaching_head 拥有 ATTENDANCE_READ;admin/teacher/parent/grade_head/teaching_head 拥有 MESSAGE_SEND/READ/DELETE,student 拥有 MESSAGE_READ/DELETE 但无 MESSAGE_SEND;admin 拥有 SCHEDULE_AUTO+SCHEDULE_ADJUST,teacher 无排课权限)",
|
"shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射(admin/teacher 拥有全部 FILE_* 及 GRADE_RECORD_MANAGE/READ,student/parent/grade_head/teaching_head 拥有 GRADE_RECORD_READ;admin/teacher 拥有 ATTENDANCE_MANAGE+ATTENDANCE_READ,student/parent/grade_head/teaching_head 拥有 ATTENDANCE_READ;admin/teacher/parent/grade_head/teaching_head 拥有 MESSAGE_SEND/READ/DELETE,student 拥有 MESSAGE_READ/DELETE 但无 MESSAGE_SEND;admin 拥有 SCHEDULE_AUTO+SCHEDULE_ADJUST,teacher 无排课权限;admin/teacher 拥有 ELECTIVE_MANAGE+ELECTIVE_READ,student 拥有 ELECTIVE_SELECT+ELECTIVE_READ,grade_head/teaching_head 拥有 ELECTIVE_READ)",
|
||||||
"auth.ts JWT callback → resolvePermissions(roleNames) → token.permissions",
|
"auth.ts JWT callback → resolvePermissions(roleNames) → token.permissions",
|
||||||
"proxy.ts middleware → token.permissions → 路由权限检查",
|
"proxy.ts middleware → token.permissions → 路由权限检查",
|
||||||
"auth-guard.ts requirePermission(permission) → Server Action权限断言(如 /api/files/[id] DELETE 使用 FILE_DELETE;grades/actions.ts 使用 GRADE_RECORD_MANAGE/READ;messaging/actions.ts 使用 MESSAGE_SEND/READ/DELETE;attendance/actions.ts 使用 ATTENDANCE_MANAGE/READ;scheduling/actions.ts 使用 SCHEDULE_AUTO/SCHEDULE_ADJUST)",
|
"auth-guard.ts requirePermission(permission) → Server Action权限断言(如 /api/files/[id] DELETE 使用 FILE_DELETE;grades/actions.ts 使用 GRADE_RECORD_MANAGE/READ;messaging/actions.ts 使用 MESSAGE_SEND/READ/DELETE;attendance/actions.ts 使用 ATTENDANCE_MANAGE/READ;scheduling/actions.ts 使用 SCHEDULE_AUTO/SCHEDULE_ADJUST;elective/actions.ts 使用 ELECTIVE_MANAGE/READ/SELECT)",
|
||||||
"auth-guard.ts requireAuth() → 仅校验登录(如 /api/upload POST、/api/files/[id] GET、messaging 通知读取 actions)",
|
"auth-guard.ts requireAuth() → 仅校验登录(如 /api/upload POST、/api/files/[id] GET、messaging 通知读取 actions)",
|
||||||
"use-permission.ts hasPermission(permission) → 客户端条件渲染(如 file-list.tsx 删除按钮可见性;message-list/detail.tsx 写消息/删除按钮可见性)",
|
"use-permission.ts hasPermission(permission) → 客户端条件渲染(如 file-list.tsx 删除按钮可见性;message-list/detail.tsx 写消息/删除按钮可见性;elective-course-list.tsx 操作按钮可见性)",
|
||||||
"layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤(Grades 菜单项使用 GRADE_RECORD_READ;Messages 菜单项使用 MESSAGE_READ;Attendance 菜单项 teacher 使用 ATTENDANCE_MANAGE,student/parent 使用 ATTENDANCE_READ;Scheduling 菜单项 admin 使用 SCHEDULE_ADJUST/SCHEDULE_AUTO,teacher Schedule Changes 使用 SCHEDULE_ADJUST)"
|
"layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤(Grades 菜单项使用 GRADE_RECORD_READ;Messages 菜单项使用 MESSAGE_READ;Attendance 菜单项 teacher 使用 ATTENDANCE_MANAGE,student/parent 使用 ATTENDANCE_READ;Scheduling 菜单项 admin 使用 SCHEDULE_ADJUST/SCHEDULE_AUTO,teacher Schedule Changes 使用 SCHEDULE_ADJUST;Electives 菜单项 admin/teacher 使用 ELECTIVE_MANAGE,student 使用 ELECTIVE_SELECT)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dataScope": {
|
"dataScope": {
|
||||||
@@ -1522,7 +1685,9 @@
|
|||||||
"exams/actions.ts update/delete → scope.type !== 'all' 时校验资源归属",
|
"exams/actions.ts update/delete → scope.type !== 'all' 时校验资源归属",
|
||||||
"grades/data-access.getGradeRecords({ scope }) → 行级过滤(class_taught 限制所教班级,class_members 限制学生本人,children 限制子女)",
|
"grades/data-access.getGradeRecords({ scope }) → 行级过滤(class_taught 限制所教班级,class_members 限制学生本人,children 限制子女)",
|
||||||
"attendance/data-access.getAttendanceRecords({ scope }) → 行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部)",
|
"attendance/data-access.getAttendanceRecords({ scope }) → 行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部)",
|
||||||
"attendance/actions.ts getStudentAttendanceAction → 对 class_members/children 进行 DataScope 二次校验"
|
"attendance/actions.ts getStudentAttendanceAction → 对 class_members/children 进行 DataScope 二次校验",
|
||||||
|
"elective/data-access.getElectiveCourses({ scope, currentUserId }) → 行级过滤(owned/class_taught 按 teacherId 过滤,grade_managed 按 gradeIds 过滤,class_members/children 返回 null 由 getAvailableCoursesForStudent 处理)",
|
||||||
|
"elective/actions.ts getStudentSelectionsAction → 对 class_members/children 进行 DataScope 二次校验"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1555,7 +1720,10 @@
|
|||||||
"/admin/users/import": {"component": "UserImportPage (含 UserImportDialog)", "type": "server", "module": "users", "actions": ["users/actions.downloadUserTemplateAction", "users/actions.importUsersAction"], "permission": "user:manage", "description": "用户批量导入页面(说明卡片+字段文档表+导入对话框;权限:requirePermission(USER_MANAGE))"},
|
"/admin/users/import": {"component": "UserImportPage (含 UserImportDialog)", "type": "server", "module": "users", "actions": ["users/actions.downloadUserTemplateAction", "users/actions.importUsersAction"], "permission": "user:manage", "description": "用户批量导入页面(说明卡片+字段文档表+导入对话框;权限:requirePermission(USER_MANAGE))"},
|
||||||
"/admin/scheduling/rules": {"component": "SchedulingRulesForm", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getSchedulingRules"], "actions": ["saveSchedulingRulesAction"], "permission": "schedule:adjust", "description": "排课规则配置页面(权限:requirePermission(SCHEDULE_ADJUST))"},
|
"/admin/scheduling/rules": {"component": "SchedulingRulesForm", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getSchedulingRules"], "actions": ["saveSchedulingRulesAction"], "permission": "schedule:adjust", "description": "排课规则配置页面(权限:requirePermission(SCHEDULE_ADJUST))"},
|
||||||
"/admin/scheduling/auto": {"component": "AutoSchedulePanel + AutoScheduleResultView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling"], "actions": ["autoScheduleAction", "applyAutoScheduleAction"], "permission": "schedule:auto", "description": "自动排课页面(预览+应用;权限:requirePermission(SCHEDULE_AUTO))"},
|
"/admin/scheduling/auto": {"component": "AutoSchedulePanel + AutoScheduleResultView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling"], "actions": ["autoScheduleAction", "applyAutoScheduleAction"], "permission": "schedule:auto", "description": "自动排课页面(预览+应用;权限:requirePermission(SCHEDULE_AUTO))"},
|
||||||
"/admin/scheduling/changes": {"component": "ScheduleChangeList + ScheduleConflictsView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getScheduleChanges"], "actions": ["approveScheduleChangeAction", "rejectScheduleChangeAction", "getClassConflictsAction"], "permission": "schedule:adjust", "description": "调课申请审批+冲突检测页面(权限:requirePermission(SCHEDULE_ADJUST);审批操作需 SCHEDULE_AUTO)"}
|
"/admin/scheduling/changes": {"component": "ScheduleChangeList + ScheduleConflictsView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getScheduleChanges"], "actions": ["approveScheduleChangeAction", "rejectScheduleChangeAction", "getClassConflictsAction"], "permission": "schedule:adjust", "description": "调课申请审批+冲突检测页面(权限:requirePermission(SCHEDULE_ADJUST);审批操作需 SCHEDULE_AUTO)"},
|
||||||
|
"/admin/elective": {"component": "ElectiveCourseList", "type": "server", "module": "elective", "dataAccess": ["elective/data-access.getElectiveCourses (scope=all)"], "actions": ["deleteElectiveCourseAction", "openSelectionAction", "closeSelectionAction", "runLotteryAction"], "permission": "elective:manage", "description": "管理员选修课程列表(权限:requirePermission(ELECTIVE_MANAGE))"},
|
||||||
|
"/admin/elective/create": {"component": "ElectiveCourseForm", "type": "client", "module": "elective", "actions": ["createElectiveCourseAction"], "dataAccess": ["elective/data-access.getSubjectOptions"], "permission": "elective:manage", "description": "创建选修课程(权限:requirePermission(ELECTIVE_MANAGE))"},
|
||||||
|
"/admin/elective/[id]/edit": {"component": "ElectiveCourseForm (edit)", "type": "client", "module": "elective", "actions": ["updateElectiveCourseAction"], "dataAccess": ["elective/data-access.getElectiveCourseById", "elective/data-access.getSubjectOptions"], "permission": "elective:manage", "description": "编辑选修课程(权限:requirePermission(ELECTIVE_MANAGE))"}
|
||||||
},
|
},
|
||||||
"teacher": {
|
"teacher": {
|
||||||
"/teacher/dashboard": {"component": "TeacherDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (teacher)", "homework/data-access.getTeacherGradeTrends", "classes/data-access.getTeacherClasses"], "permission": "exam:read"},
|
"/teacher/dashboard": {"component": "TeacherDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (teacher)", "homework/data-access.getTeacherGradeTrends", "classes/data-access.getTeacherClasses"], "permission": "exam:read"},
|
||||||
@@ -1588,7 +1756,11 @@
|
|||||||
"/teacher/attendance": {"component": "AttendanceRecordList + AttendanceFilters", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access.getAttendanceRecords", "classes/data-access.getTeacherClasses"], "permission": "attendance:manage", "description": "教师考勤记录列表(权限:requirePermission(ATTENDANCE_MANAGE))"},
|
"/teacher/attendance": {"component": "AttendanceRecordList + AttendanceFilters", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access.getAttendanceRecords", "classes/data-access.getTeacherClasses"], "permission": "attendance:manage", "description": "教师考勤记录列表(权限:requirePermission(ATTENDANCE_MANAGE))"},
|
||||||
"/teacher/attendance/sheet": {"component": "AttendanceSheet", "type": "client", "module": "attendance", "actions": ["batchRecordAttendanceAction", "getClassAttendanceForDateAction"], "dataAccess": ["attendance/data-access.getClassStudentsForAttendance"], "permission": "attendance:manage", "description": "批量点名页面(权限:requirePermission(ATTENDANCE_MANAGE))"},
|
"/teacher/attendance/sheet": {"component": "AttendanceSheet", "type": "client", "module": "attendance", "actions": ["batchRecordAttendanceAction", "getClassAttendanceForDateAction"], "dataAccess": ["attendance/data-access.getClassStudentsForAttendance"], "permission": "attendance:manage", "description": "批量点名页面(权限:requirePermission(ATTENDANCE_MANAGE))"},
|
||||||
"/teacher/attendance/stats": {"component": "AttendanceStatsCard", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getClassAttendanceStats", "classes/data-access.getTeacherClasses"], "permission": "attendance:read", "description": "班级考勤统计(权限:requirePermission(ATTENDANCE_READ))"},
|
"/teacher/attendance/stats": {"component": "AttendanceStatsCard", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getClassAttendanceStats", "classes/data-access.getTeacherClasses"], "permission": "attendance:read", "description": "班级考勤统计(权限:requirePermission(ATTENDANCE_READ))"},
|
||||||
"/teacher/schedule-changes": {"component": "ScheduleChangeForm + ScheduleChangeList", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getTeachersForScheduling", "scheduling/actions.getScheduleChanges (requesterId=ctx.userId)"], "actions": ["requestScheduleChangeAction"], "permission": "schedule:adjust", "description": "教师调课/代课申请页面(提交申请+查看本人申请列表;权限:requirePermission(SCHEDULE_ADJUST);admin 角色查看全部申请)"}
|
"/teacher/schedule-changes": {"component": "ScheduleChangeForm + ScheduleChangeList", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getTeachersForScheduling", "scheduling/actions.getScheduleChanges (requesterId=ctx.userId)"], "actions": ["requestScheduleChangeAction"], "permission": "schedule:adjust", "description": "教师调课/代课申请页面(提交申请+查看本人申请列表;权限:requirePermission(SCHEDULE_ADJUST);admin 角色查看全部申请)"},
|
||||||
|
"/teacher/diagnostic": {"component": "ReportList", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access-reports.getDiagnosticReports"], "actions": ["publishReportAction", "deleteReportAction"], "permission": "diagnostic:read", "description": "学情诊断报告列表(reportType/status 过滤器;权限:requirePermission(DIAGNOSTIC_READ);DataScope.class_members 仅查看自己报告;发布/删除操作需 DIAGNOSTIC_MANAGE)"},
|
||||||
|
"/teacher/diagnostic/student/[studentId]": {"component": "StudentDiagnosticView", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access.getStudentMasterySummary", "diagnostic/data-access.getKnowledgePointStats (班级平均对比)", "diagnostic/data-access-reports.getDiagnosticReports"], "actions": ["generateStudentReportAction"], "permission": "diagnostic:read", "description": "学生学情诊断视图(概览卡片+雷达图+强项/弱项+生成报告[DIAGNOSTIC_MANAGE]+最新报告;权限:getAuthContext + DataScope 二次校验,class_members 仅自己,children 仅子女)"},
|
||||||
|
"/teacher/diagnostic/class/[classId]": {"component": "ClassDiagnosticView", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access.getClassMasterySummary"], "actions": ["generateClassReportAction"], "permission": "diagnostic:read", "description": "班级学情诊断视图(概览+知识点热力图+排名表+需重点关注学生+生成班级报告[DIAGNOSTIC_MANAGE];权限:getAuthContext + DataScope 校验,class_taught 必须包含 classId,class_members/children notFound)"},
|
||||||
|
"/teacher/elective": {"component": "ElectiveCourseList (teacher)", "type": "server", "module": "elective", "dataAccess": ["elective/data-access.getElectiveCourses (scope=class_taught/owned, currentUserId)"], "actions": ["deleteElectiveCourseAction", "openSelectionAction", "closeSelectionAction", "runLotteryAction"], "permission": "elective:manage", "description": "教师选修课程列表(权限:requirePermission(ELECTIVE_MANAGE);DataScope.class_taught/owned 按 teacherId 过滤)"}
|
||||||
},
|
},
|
||||||
"student": {
|
"student": {
|
||||||
"/student/dashboard": {"component": "StudentDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (student)", "homework/data-access.getStudentDashboardGrades", "classes/data-access.getStudentClasses"], "permission": "homework:submit"},
|
"/student/dashboard": {"component": "StudentDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (student)", "homework/data-access.getStudentDashboardGrades", "classes/data-access.getStudentClasses"], "permission": "homework:submit"},
|
||||||
@@ -1599,7 +1771,9 @@
|
|||||||
"/student/learning/textbooks/[id]": {"component": "学生教材阅读(只读)", "type": "client", "module": "textbooks", "dataAccess": ["textbooks/data-access.getTextbookById", "getChaptersByTextbookId", "getKnowledgePointsByTextbookId"], "permission": "textbook:read"},
|
"/student/learning/textbooks/[id]": {"component": "学生教材阅读(只读)", "type": "client", "module": "textbooks", "dataAccess": ["textbooks/data-access.getTextbookById", "getChaptersByTextbookId", "getKnowledgePointsByTextbookId"], "permission": "textbook:read"},
|
||||||
"/student/schedule": {"component": "学生课表", "type": "server", "module": "classes", "dataAccess": ["classes/data-access.getStudentSchedule"], "permission": "homework:submit"},
|
"/student/schedule": {"component": "学生课表", "type": "server", "module": "classes", "dataAccess": ["classes/data-access.getStudentSchedule"], "permission": "homework:submit"},
|
||||||
"/student/grades": {"component": "我的成绩", "type": "server", "module": "grades", "dataAccess": ["grades/actions.getStudentGradeSummaryAction"], "permission": "grade_record:read"},
|
"/student/grades": {"component": "我的成绩", "type": "server", "module": "grades", "dataAccess": ["grades/actions.getStudentGradeSummaryAction"], "permission": "grade_record:read"},
|
||||||
"/student/attendance": {"component": "StudentAttendanceView", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getStudentAttendanceSummary"], "permission": "attendance:read", "description": "学生考勤视图(统计卡片 + 最近记录;权限:requirePermission(ATTENDANCE_READ),DataScope.class_members 仅查自己)"}
|
"/student/attendance": {"component": "StudentAttendanceView", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getStudentAttendanceSummary"], "permission": "attendance:read", "description": "学生考勤视图(统计卡片 + 最近记录;权限:requirePermission(ATTENDANCE_READ),DataScope.class_members 仅查自己)"},
|
||||||
|
"/student/diagnostic": {"component": "StudentDiagnosticView", "type": "client", "module": "diagnostic", "dataAccess": ["diagnostic/data-access.getStudentMasterySummary (ctx.userId)", "diagnostic/data-access-reports.getDiagnosticReports (studentId=ctx.userId)"], "permission": "diagnostic:read", "description": "学生本人学情诊断视图(概览+雷达图+强项/弱项+最新报告;权限:requirePermission(DIAGNOSTIC_READ),DataScope.class_members 仅查自己)"},
|
||||||
|
"/student/elective": {"component": "StudentSelectionView", "type": "server", "module": "elective", "dataAccess": ["elective/data-access-selections.getAvailableCoursesForStudent", "elective/data-access-selections.getStudentSelections"], "actions": ["selectCourseAction", "dropCourseAction"], "permission": "elective:select", "description": "学生选课页面(可选课程列表 + 我的选课记录;权限:requirePermission(ELECTIVE_SELECT))"}
|
||||||
},
|
},
|
||||||
"management": {
|
"management": {
|
||||||
"/management/grade/classes": {"component": "GradeClassesClient", "type": "client", "module": "classes", "permission": "grade:manage"},
|
"/management/grade/classes": {"component": "GradeClassesClient", "type": "client", "module": "classes", "permission": "grade:manage"},
|
||||||
|
|||||||
@@ -2,6 +2,52 @@
|
|||||||
|
|
||||||
## 2026-06-17
|
## 2026-06-17
|
||||||
|
|
||||||
|
### P2 功能扩展类实现(功能扩展 + 质量保障首批)
|
||||||
|
|
||||||
|
#### 1. 选课管理模块(elective)
|
||||||
|
- 新增 schema 表:`electiveCourses`(选修课程)、`courseSelections`(选课记录)
|
||||||
|
- 新增权限:`ELECTIVE_MANAGE`、`ELECTIVE_READ`、`ELECTIVE_SELECT`
|
||||||
|
- 模块文件:`src/modules/elective/`(types/schema/data-access×3/actions/components×3)
|
||||||
|
- 路由:admin/teacher/student 三端 elective 页面
|
||||||
|
- 支持:先到先得 + 抽签两种选课模式,容量控制,退选
|
||||||
|
|
||||||
|
#### 2. 考试监考模块(proctoring)
|
||||||
|
- 新增 schema:`exams` 表扩展 examMode/durationMinutes/antiCheatEnabled 等字段;新增 `examProctoringEvents` 表
|
||||||
|
- 新增权限:`EXAM_PROCTOR`、`EXAM_PROCTOR_READ`
|
||||||
|
- 模块文件:`src/modules/proctoring/`(types/data-access/actions/components×3)
|
||||||
|
- API 路由:`/api/proctoring/event` 接收学生端上报
|
||||||
|
- 页面:教师监考面板 + 学生端防作弊监控(tab切换/复制粘贴/右键/开发者工具/全屏退出/空闲超时检测)
|
||||||
|
|
||||||
|
#### 3. 学情诊断报告模块(diagnostic)
|
||||||
|
- 新增 schema:`knowledgePointMastery`(知识点掌握度)、`learningDiagnosticReports`(诊断报告)
|
||||||
|
- 新增权限:`DIAGNOSTIC_MANAGE`、`DIAGNOSTIC_READ`
|
||||||
|
- 模块文件:`src/modules/diagnostic/`(types/data-access×2/actions/components×4)
|
||||||
|
- 功能:基于提交答案自动计算知识点掌握度,生成个人/班级诊断报告(强项/弱项/建议),雷达图可视化
|
||||||
|
- 页面:teacher 诊断管理 + 学生查看自己报告
|
||||||
|
|
||||||
|
#### 4. 项目规则更新
|
||||||
|
- `.trae/rules/project_rules.md` 单文件行数限制从 300 行调整为企业级规范:
|
||||||
|
- React 组件 ≤ 500 行(复杂场景可放宽至 800)
|
||||||
|
- Server Actions / Data Access ≤ 800 行
|
||||||
|
- 硬性上限 1000 行
|
||||||
|
|
||||||
|
#### 5. 种子脚本 lint 修复
|
||||||
|
- `scripts/seed.ts` 消除全部 `any` 类型(17 个 error → 0)
|
||||||
|
- 定义内部类型:SeedQuestion/SeedQuestionBank/SeedGradeRecord/SeedAttendanceRecord
|
||||||
|
- 移除未使用参数,函数签名精简
|
||||||
|
|
||||||
|
#### 6. 架构文档同步
|
||||||
|
- `docs/architecture/004_architecture_impact_map.md` 新增 elective/proctoring/diagnostic 三个模块章节
|
||||||
|
- `docs/architecture/005_architecture_data.json` 同步权限点、角色映射、dbTables、modules、dependencyMatrix、routes
|
||||||
|
|
||||||
|
#### 验证
|
||||||
|
- `npx tsc --noEmit`:0 错误
|
||||||
|
- `npm run lint`:0 错误 0 警告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 前序工作(同日早些时候)
|
||||||
|
|
||||||
### 1. Next.js 16 Proxy 修复
|
### 1. Next.js 16 Proxy 修复
|
||||||
- `src/proxy.ts` 导出函数从 `middleware` 重命名为 `proxy`(Next.js 16 要求)
|
- `src/proxy.ts` 导出函数从 `middleware` 重命名为 `proxy`(Next.js 16 要求)
|
||||||
- 修复 `getToken()` 在 edge 运行时缺少 `secret` 导致的 `MissingSecret` 错误
|
- 修复 `getToken()` 在 edge 运行时缺少 `secret` 导致的 `MissingSecret` 错误
|
||||||
|
|||||||
115
drizzle/0001_heavy_sage.sql
Normal file
115
drizzle/0001_heavy_sage.sql
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
CREATE TABLE `course_selections` (
|
||||||
|
`id` varchar(128) NOT NULL,
|
||||||
|
`course_id` varchar(128) NOT NULL,
|
||||||
|
`student_id` varchar(128) NOT NULL,
|
||||||
|
`selection_status` enum('selected','enrolled','waitlist','dropped','rejected') NOT NULL DEFAULT 'selected',
|
||||||
|
`priority` int DEFAULT 1,
|
||||||
|
`selected_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`enrolled_at` timestamp,
|
||||||
|
`dropped_at` timestamp,
|
||||||
|
`lottery_rank` int,
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `course_selections_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `elective_courses` (
|
||||||
|
`id` varchar(128) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`subject_id` varchar(128),
|
||||||
|
`teacher_id` varchar(128) NOT NULL,
|
||||||
|
`grade_id` varchar(128),
|
||||||
|
`description` text,
|
||||||
|
`capacity` int NOT NULL DEFAULT 30,
|
||||||
|
`enrolled_count` int NOT NULL DEFAULT 0,
|
||||||
|
`classroom` varchar(100),
|
||||||
|
`schedule` varchar(255),
|
||||||
|
`start_date` date,
|
||||||
|
`end_date` date,
|
||||||
|
`selection_start_at` datetime,
|
||||||
|
`selection_end_at` datetime,
|
||||||
|
`status` enum('draft','open','closed','cancelled') NOT NULL DEFAULT 'draft',
|
||||||
|
`selection_mode` enum('fcfs','lottery') NOT NULL DEFAULT 'fcfs',
|
||||||
|
`credit` decimal(3,1) DEFAULT '1.0',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `elective_courses_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `exam_proctoring_events` (
|
||||||
|
`id` varchar(128) NOT NULL,
|
||||||
|
`submission_id` varchar(128) NOT NULL,
|
||||||
|
`student_id` varchar(128) NOT NULL,
|
||||||
|
`exam_id` varchar(128) NOT NULL,
|
||||||
|
`event_type` enum('tab_switch','window_blur','copy_attempt','paste_attempt','right_click','devtools_open','fullscreen_exit','idle_timeout') NOT NULL,
|
||||||
|
`event_detail` text,
|
||||||
|
`occurred_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `exam_proctoring_events_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `knowledge_point_mastery` (
|
||||||
|
`id` varchar(128) NOT NULL,
|
||||||
|
`student_id` varchar(128) NOT NULL,
|
||||||
|
`knowledge_point_id` varchar(128) NOT NULL,
|
||||||
|
`mastery_level` decimal(5,2) NOT NULL DEFAULT '0',
|
||||||
|
`total_questions` int NOT NULL DEFAULT 0,
|
||||||
|
`correct_questions` int NOT NULL DEFAULT 0,
|
||||||
|
`last_assessed_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `knowledge_point_mastery_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `learning_diagnostic_reports` (
|
||||||
|
`id` varchar(128) NOT NULL,
|
||||||
|
`student_id` varchar(128) NOT NULL,
|
||||||
|
`generated_by` varchar(128),
|
||||||
|
`report_type` enum('individual','class','grade') NOT NULL DEFAULT 'individual',
|
||||||
|
`period` varchar(50),
|
||||||
|
`summary` text,
|
||||||
|
`strengths` json,
|
||||||
|
`weaknesses` json,
|
||||||
|
`recommendations` json,
|
||||||
|
`overall_score` decimal(5,2),
|
||||||
|
`report_status` enum('draft','published','archived') NOT NULL DEFAULT 'draft',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `learning_diagnostic_reports_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD `exam_mode` enum('homework','timed','proctored') DEFAULT 'homework';--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD `duration_minutes` int;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD `shuffle_questions` boolean DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD `allow_late_start` boolean DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD `late_start_grace_minutes` int DEFAULT 0;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exams` ADD `anti_cheat_enabled` boolean DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE `course_selections` ADD CONSTRAINT `course_selections_course_id_elective_courses_id_fk` FOREIGN KEY (`course_id`) REFERENCES `elective_courses`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `course_selections` ADD CONSTRAINT `course_selections_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `elective_courses` ADD CONSTRAINT `elective_courses_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_submission_id_exam_submissions_id_fk` FOREIGN KEY (`submission_id`) REFERENCES `exam_submissions`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `exam_proctoring_events` ADD CONSTRAINT `exam_proctoring_events_exam_id_exams_id_fk` FOREIGN KEY (`exam_id`) REFERENCES `exams`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `knowledge_point_mastery` ADD CONSTRAINT `knowledge_point_mastery_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `knowledge_point_mastery` ADD CONSTRAINT `knowledge_point_mastery_knowledge_point_id_knowledge_points_id_fk` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `learning_diagnostic_reports_student_id_users_id_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `learning_diagnostic_reports_generated_by_users_id_fk` FOREIGN KEY (`generated_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `course_selections_course_idx` ON `course_selections` (`course_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `course_selections_student_idx` ON `course_selections` (`student_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `course_selections_status_idx` ON `course_selections` (`selection_status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `elective_courses_teacher_idx` ON `elective_courses` (`teacher_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `elective_courses_subject_idx` ON `elective_courses` (`subject_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `elective_courses_grade_idx` ON `elective_courses` (`grade_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `elective_courses_status_idx` ON `elective_courses` (`status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `proctoring_submission_idx` ON `exam_proctoring_events` (`submission_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `proctoring_student_idx` ON `exam_proctoring_events` (`student_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `proctoring_exam_idx` ON `exam_proctoring_events` (`exam_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `proctoring_event_type_idx` ON `exam_proctoring_events` (`event_type`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `mastery_student_idx` ON `knowledge_point_mastery` (`student_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `mastery_kp_idx` ON `knowledge_point_mastery` (`knowledge_point_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `diagnostic_student_idx` ON `learning_diagnostic_reports` (`student_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `diagnostic_generated_by_idx` ON `learning_diagnostic_reports` (`generated_by`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `diagnostic_status_idx` ON `learning_diagnostic_reports` (`report_status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `diagnostic_report_type_idx` ON `learning_diagnostic_reports` (`report_type`);
|
||||||
6809
drizzle/meta/0001_snapshot.json
Normal file
6809
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1781676504560,
|
"when": 1781676504560,
|
||||||
"tag": "0000_perfect_pestilence",
|
"tag": "0000_perfect_pestilence",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1781679978738,
|
||||||
|
"tag": "0001_heavy_sage",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
102
scripts/seed.ts
102
scripts/seed.ts
@@ -20,6 +20,56 @@ import { sql, eq } from "drizzle-orm";
|
|||||||
import { hash } from "bcryptjs";
|
import { hash } from "bcryptjs";
|
||||||
import { ROLE_PERMISSIONS } from "../src/shared/lib/permissions";
|
import { ROLE_PERMISSIONS } from "../src/shared/lib/permissions";
|
||||||
|
|
||||||
|
// ---- 内部类型定义(避免 any) ----
|
||||||
|
|
||||||
|
interface SeedOption {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
isCorrect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeedQuestionContent {
|
||||||
|
text?: string;
|
||||||
|
options?: SeedOption[];
|
||||||
|
correctAnswer?: string | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeedQuestion {
|
||||||
|
id: string;
|
||||||
|
type: "single_choice" | "text" | "judgment";
|
||||||
|
difficulty: number;
|
||||||
|
content: SeedQuestionContent;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeedQuestionBank {
|
||||||
|
chineseQuestions: SeedQuestion[];
|
||||||
|
mathQuestions: SeedQuestion[];
|
||||||
|
engQuestions?: SeedQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeedGradeRecord {
|
||||||
|
id: string;
|
||||||
|
studentId: string;
|
||||||
|
subjectId: string;
|
||||||
|
classId: string;
|
||||||
|
teacherId: string;
|
||||||
|
score: number;
|
||||||
|
examType: string;
|
||||||
|
term: string;
|
||||||
|
recordedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeedAttendanceRecord {
|
||||||
|
id: string;
|
||||||
|
studentId: string;
|
||||||
|
classId: string;
|
||||||
|
date: Date;
|
||||||
|
status: "present" | "late" | "absent";
|
||||||
|
recorderId: string;
|
||||||
|
remark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小学场景初始化脚本
|
* 小学场景初始化脚本
|
||||||
*
|
*
|
||||||
@@ -73,22 +123,22 @@ async function seed() {
|
|||||||
await seedEnrollmentsAndParents(classMap, studentMap, parentMap);
|
await seedEnrollmentsAndParents(classMap, studentMap, parentMap);
|
||||||
|
|
||||||
// --- 8. 教材 + 章节(每科第一章第一节课)---
|
// --- 8. 教材 + 章节(每科第一章第一节课)---
|
||||||
const chapterMap = await seedTextbooksAndChapters(subjectMap, gradeMap);
|
const chapterMap = await seedTextbooksAndChapters();
|
||||||
|
|
||||||
// --- 9. 知识点 ---
|
// --- 9. 知识点 ---
|
||||||
const kpMap = await seedKnowledgePoints(chapterMap);
|
const kpMap = await seedKnowledgePoints(chapterMap);
|
||||||
|
|
||||||
// --- 10. 课表 ---
|
// --- 10. 课表 ---
|
||||||
await seedClassSchedule(classMap, subjectMap);
|
await seedClassSchedule(classMap);
|
||||||
|
|
||||||
// --- 11. 题库 ---
|
// --- 11. 题库 ---
|
||||||
const questionBanks = await seedQuestions(teacherMap, subjectMap, kpMap);
|
const questionBanks = await seedQuestions(teacherMap, subjectMap, kpMap);
|
||||||
|
|
||||||
// --- 12. 试卷(语文、数学各 1 套)+ 学生答题与批改 ---
|
// --- 12. 试卷(语文、数学各 1 套)+ 学生答题与批改 ---
|
||||||
await seedExamsAndSubmissions(teacherMap, classMap, studentMap, subjectMap, questionBanks, gradeMap);
|
await seedExamsAndSubmissions(teacherMap, classMap, studentMap, questionBanks);
|
||||||
|
|
||||||
// --- 13. 作业(引用试卷)+ 学生答题与批改 ---
|
// --- 13. 作业(引用试卷)+ 学生答题与批改 ---
|
||||||
await seedHomework(teacherMap, classMap, studentMap, questionBanks, subjectMap);
|
await seedHomework(teacherMap, classMap, studentMap, questionBanks);
|
||||||
|
|
||||||
// --- 14. 成绩记录 ---
|
// --- 14. 成绩记录 ---
|
||||||
await seedGradeRecords(teacherMap, classMap, studentMap, subjectMap, academicYearId);
|
await seedGradeRecords(teacherMap, classMap, studentMap, subjectMap, academicYearId);
|
||||||
@@ -451,10 +501,7 @@ async function seedEnrollmentsAndParents(
|
|||||||
|
|
||||||
// ============ 8. 教材 + 章节 ============
|
// ============ 8. 教材 + 章节 ============
|
||||||
|
|
||||||
async function seedTextbooksAndChapters(
|
async function seedTextbooksAndChapters() {
|
||||||
subjectMap: Record<string, string>,
|
|
||||||
gradeMap: Record<string, string>
|
|
||||||
) {
|
|
||||||
console.log("📖 创建教材与章节...");
|
console.log("📖 创建教材与章节...");
|
||||||
// 每科 1 本教材(一年级语文、一年级数学、一年级英语)
|
// 每科 1 本教材(一年级语文、一年级数学、一年级英语)
|
||||||
// 只实现第一章第一节课
|
// 只实现第一章第一节课
|
||||||
@@ -558,8 +605,7 @@ async function seedKnowledgePoints(chapterMap: Record<string, string>) {
|
|||||||
// ============ 10. 课表 ============
|
// ============ 10. 课表 ============
|
||||||
|
|
||||||
async function seedClassSchedule(
|
async function seedClassSchedule(
|
||||||
classMap: Record<string, { id: string }>,
|
classMap: Record<string, { id: string }>
|
||||||
subjectMap: Record<string, string>
|
|
||||||
) {
|
) {
|
||||||
console.log("📅 创建课表...");
|
console.log("📅 创建课表...");
|
||||||
// 每班每天安排语数外,简化为周一三五各 2 节
|
// 每班每天安排语数外,简化为周一三五各 2 节
|
||||||
@@ -807,13 +853,11 @@ async function seedExamsAndSubmissions(
|
|||||||
teacherMap: Record<string, { id: string }>,
|
teacherMap: Record<string, { id: string }>,
|
||||||
classMap: Record<string, { id: string }>,
|
classMap: Record<string, { id: string }>,
|
||||||
studentMap: Record<string, { id: string; classKey: string }>,
|
studentMap: Record<string, { id: string; classKey: string }>,
|
||||||
subjectMap: Record<string, string>,
|
questionBanks: SeedQuestionBank
|
||||||
questionBanks: { chineseQuestions: any[]; mathQuestions: any[] },
|
|
||||||
gradeMap: Record<string, string>
|
|
||||||
) {
|
) {
|
||||||
console.log("📝 创建试卷与学生答题...");
|
console.log("📝 创建试卷与学生答题...");
|
||||||
|
|
||||||
const makeGroup = (title: string, children: any[]) => ({
|
const makeGroup = (title: string, children: SeedQuestion[]) => ({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
type: "group" as const,
|
type: "group" as const,
|
||||||
title,
|
title,
|
||||||
@@ -926,12 +970,12 @@ async function seedExamsAndSubmissions(
|
|||||||
submissionCount++;
|
submissionCount++;
|
||||||
|
|
||||||
// 答案
|
// 答案
|
||||||
const buildAnswer = (q: any, idx: number) => {
|
const buildAnswer = (q: SeedQuestion, idx: number) => {
|
||||||
if (q.type === "single_choice") {
|
if (q.type === "single_choice") {
|
||||||
// 前 3 题选正确项,后 2 题选错误项
|
// 前 3 题选正确项,后 2 题选错误项
|
||||||
const correct = q.content.options.find((o: any) => o.isCorrect);
|
const correct = q.content.options?.find((o) => o.isCorrect);
|
||||||
const wrong = q.content.options.find((o: any) => !o.isCorrect);
|
const wrong = q.content.options?.find((o) => !o.isCorrect);
|
||||||
return { answer: idx < 3 ? correct.id : wrong.id };
|
return { answer: idx < 3 ? correct?.id : wrong?.id };
|
||||||
}
|
}
|
||||||
if (q.type === "judgment") return { answer: idx < 3 };
|
if (q.type === "judgment") return { answer: idx < 3 };
|
||||||
return { answer: idx < 3 ? "标准答案" : "学生答案" };
|
return { answer: idx < 3 ? "标准答案" : "学生答案" };
|
||||||
@@ -939,7 +983,7 @@ async function seedExamsAndSubmissions(
|
|||||||
|
|
||||||
await db.insert(submissionAnswers).values(
|
await db.insert(submissionAnswers).values(
|
||||||
exam.qIds.map((qid, idx) => {
|
exam.qIds.map((qid, idx) => {
|
||||||
const q = exam.qs.find((x: any) => x.id === qid)!;
|
const q = exam.qs.find((x) => x.id === qid)!;
|
||||||
const score = isGraded ? (perScores[idx] ?? 0) : null;
|
const score = isGraded ? (perScores[idx] ?? 0) : null;
|
||||||
return {
|
return {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -963,8 +1007,7 @@ async function seedHomework(
|
|||||||
teacherMap: Record<string, { id: string }>,
|
teacherMap: Record<string, { id: string }>,
|
||||||
classMap: Record<string, { id: string }>,
|
classMap: Record<string, { id: string }>,
|
||||||
studentMap: Record<string, { id: string; classKey: string }>,
|
studentMap: Record<string, { id: string; classKey: string }>,
|
||||||
questionBanks: { chineseQuestions: any[]; mathQuestions: any[]; engQuestions: any[] },
|
questionBanks: SeedQuestionBank
|
||||||
subjectMap: Record<string, string>
|
|
||||||
) {
|
) {
|
||||||
console.log("📚 创建作业...");
|
console.log("📚 创建作业...");
|
||||||
// 1 个语文作业(引用语文题)+ 1 个数学作业(引用数学题)
|
// 1 个语文作业(引用语文题)+ 1 个数学作业(引用数学题)
|
||||||
@@ -972,7 +1015,7 @@ async function seedHomework(
|
|||||||
const dueAt = new Date(now.getTime() + 7 * 86400_000);
|
const dueAt = new Date(now.getTime() + 7 * 86400_000);
|
||||||
const lateDueAt = new Date(now.getTime() + 9 * 86400_000);
|
const lateDueAt = new Date(now.getTime() + 9 * 86400_000);
|
||||||
|
|
||||||
const assignments: { id: string; title: string; creatorId: string; qIds: string[]; qs: any[]; targetClassKey: string }[] = [
|
const assignments: { id: string; title: string; creatorId: string; qIds: string[]; qs: SeedQuestion[]; targetClassKey: string }[] = [
|
||||||
{
|
{
|
||||||
id: "hw_chinese_g1",
|
id: "hw_chinese_g1",
|
||||||
title: "一年级语文第一课作业",
|
title: "一年级语文第一课作业",
|
||||||
@@ -1039,7 +1082,6 @@ async function seedHomework(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 前 3 个学生提交作业并批改
|
// 前 3 个学生提交作业并批改
|
||||||
const scoreMap = new Map(hw.qs.map(q => [q.id, q.score] as const));
|
|
||||||
for (let i = 0; i < Math.min(3, targets.length); i++) {
|
for (let i = 0; i < Math.min(3, targets.length); i++) {
|
||||||
const student = targets[i];
|
const student = targets[i];
|
||||||
const submissionId = createId();
|
const submissionId = createId();
|
||||||
@@ -1067,11 +1109,11 @@ async function seedHomework(
|
|||||||
});
|
});
|
||||||
hwSubmissionCount++;
|
hwSubmissionCount++;
|
||||||
|
|
||||||
const buildAnswer = (q: any, idx: number) => {
|
const buildAnswer = (q: SeedQuestion, idx: number) => {
|
||||||
if (q.type === "single_choice") {
|
if (q.type === "single_choice") {
|
||||||
const correct = q.content.options.find((o: any) => o.isCorrect);
|
const correct = q.content.options?.find((o) => o.isCorrect);
|
||||||
const wrong = q.content.options.find((o: any) => !o.isCorrect);
|
const wrong = q.content.options?.find((o) => !o.isCorrect);
|
||||||
return { answer: idx < 3 ? correct.id : wrong.id };
|
return { answer: idx < 3 ? correct?.id : wrong?.id };
|
||||||
}
|
}
|
||||||
if (q.type === "judgment") return { answer: idx < 3 };
|
if (q.type === "judgment") return { answer: idx < 3 };
|
||||||
return { answer: idx < 3 ? "标准答案" : "学生答案" };
|
return { answer: idx < 3 ? "标准答案" : "学生答案" };
|
||||||
@@ -1079,7 +1121,7 @@ async function seedHomework(
|
|||||||
|
|
||||||
await db.insert(homeworkAnswers).values(
|
await db.insert(homeworkAnswers).values(
|
||||||
hw.qIds.map((qid, idx) => {
|
hw.qIds.map((qid, idx) => {
|
||||||
const q = hw.qs.find((x: any) => x.id === qid)!;
|
const q = hw.qs.find((x) => x.id === qid)!;
|
||||||
const score = isGraded ? (perScores[idx] ?? 0) : null;
|
const score = isGraded ? (perScores[idx] ?? 0) : null;
|
||||||
return {
|
return {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -1117,7 +1159,7 @@ async function seedGradeRecords(
|
|||||||
ENG: teacherMap.T_E1.id,
|
ENG: teacherMap.T_E1.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const records: any[] = [];
|
const records: SeedGradeRecord[] = [];
|
||||||
for (const s of g1c1Students) {
|
for (const s of g1c1Students) {
|
||||||
for (const [subCode, sid] of Object.entries(subjectMap)) {
|
for (const [subCode, sid] of Object.entries(subjectMap)) {
|
||||||
records.push({
|
records.push({
|
||||||
@@ -1151,7 +1193,7 @@ async function seedAttendanceRecords(
|
|||||||
// 为一年级1班最近 5 天的考勤
|
// 为一年级1班最近 5 天的考勤
|
||||||
const g1c1Students = Object.values(studentMap).filter(s => s.classKey === "G1C1");
|
const g1c1Students = Object.values(studentMap).filter(s => s.classKey === "G1C1");
|
||||||
const recorder = teacherMap.T_C1.id;
|
const recorder = teacherMap.T_C1.id;
|
||||||
const records: any[] = [];
|
const records: SeedAttendanceRecord[] = [];
|
||||||
const statuses = ["present", "present", "present", "present", "late", "absent"] as const;
|
const statuses = ["present", "present", "present", "present", "late", "absent"] as const;
|
||||||
|
|
||||||
for (let d = 0; d < 5; d++) {
|
for (let d = 0; d < 5; d++) {
|
||||||
|
|||||||
41
src/app/(dashboard)/admin/elective/[id]/edit/page.tsx
Normal file
41
src/app/(dashboard)/admin/elective/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { getElectiveCourseById, getSubjectOptions } from "@/modules/elective/data-access"
|
||||||
|
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||||
|
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function EditElectiveCoursePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const [course, subjects, grades, teachers] = await Promise.all([
|
||||||
|
getElectiveCourseById(id),
|
||||||
|
getSubjectOptions(),
|
||||||
|
getGrades(),
|
||||||
|
getStaffOptions(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!course) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Edit Elective Course</h2>
|
||||||
|
<p className="text-muted-foreground">Update the elective course details below.</p>
|
||||||
|
</div>
|
||||||
|
<ElectiveCourseForm
|
||||||
|
mode="edit"
|
||||||
|
course={course}
|
||||||
|
subjects={subjects}
|
||||||
|
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
|
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||||
|
backHref="/admin/elective"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/app/(dashboard)/admin/elective/create/page.tsx
Normal file
29
src/app/(dashboard)/admin/elective/create/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
|
||||||
|
import { getSubjectOptions } from "@/modules/elective/data-access"
|
||||||
|
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function CreateElectiveCoursePage() {
|
||||||
|
const [subjects, grades, teachers] = await Promise.all([
|
||||||
|
getSubjectOptions(),
|
||||||
|
getGrades(),
|
||||||
|
getStaffOptions(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">New Elective Course</h2>
|
||||||
|
<p className="text-muted-foreground">Create a new elective course.</p>
|
||||||
|
</div>
|
||||||
|
<ElectiveCourseForm
|
||||||
|
mode="create"
|
||||||
|
subjects={subjects}
|
||||||
|
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
|
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
|
||||||
|
backHref="/admin/elective"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/app/(dashboard)/admin/elective/page.tsx
Normal file
44
src/app/(dashboard)/admin/elective/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||||
|
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
||||||
|
import type { ElectiveCourseStatus } from "@/modules/elective/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
||||||
|
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
|
||||||
|
|
||||||
|
export default async function AdminElectivePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const statusParam = getParam(sp, "status")
|
||||||
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
|
||||||
|
const courses = await getElectiveCourses({ status })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage elective courses, open/close selection, and run lottery.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ElectiveCourseList
|
||||||
|
courses={courses}
|
||||||
|
canManage
|
||||||
|
createHref="/admin/elective/create"
|
||||||
|
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/app/(dashboard)/student/diagnostic/page.tsx
Normal file
31
src/app/(dashboard)/student/diagnostic/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Stethoscope } from "lucide-react"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
|
||||||
|
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||||
|
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function StudentDiagnosticPage() {
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const [summary, reports] = await Promise.all([
|
||||||
|
getStudentMasterySummary(ctx.userId),
|
||||||
|
getDiagnosticReports({ studentId: ctx.userId }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Stethoscope className="h-6 w-6" />
|
||||||
|
My Diagnostic
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your knowledge point mastery analysis and diagnostic reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StudentDiagnosticView summary={summary} reports={reports} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/app/(dashboard)/student/elective/page.tsx
Normal file
49
src/app/(dashboard)/student/elective/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { Inbox } from "lucide-react"
|
||||||
|
|
||||||
|
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||||
|
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function StudentElectivePage() {
|
||||||
|
const session = await auth()
|
||||||
|
const studentId = String(session?.user?.id ?? "")
|
||||||
|
|
||||||
|
if (!studentId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||||
|
<p className="text-muted-foreground">Browse and select elective courses.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="Sign in required"
|
||||||
|
description="Please sign in to view elective courses."
|
||||||
|
icon={Inbox}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [availableCourses, mySelections] = await Promise.all([
|
||||||
|
getAvailableCoursesForStudent(studentId),
|
||||||
|
getStudentSelections(studentId),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Browse available electives and manage your selections.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StudentSelectionView
|
||||||
|
availableCourses={availableCourses}
|
||||||
|
mySelections={mySelections}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { Stethoscope } from "lucide-react"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
||||||
|
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ClassDiagnosticPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ classId: string }>
|
||||||
|
}) {
|
||||||
|
const { classId } = await params
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
// DataScope 校验:教师只能查看所教班级,学生/家长不可访问
|
||||||
|
if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await getClassMasterySummary(classId)
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Stethoscope className="h-6 w-6" />
|
||||||
|
Class Diagnostic
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Class-level knowledge point mastery overview and student attention list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ClassDiagnosticView summary={summary} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/app/(dashboard)/teacher/diagnostic/page.tsx
Normal file
48
src/app/(dashboard)/teacher/diagnostic/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||||
|
import { ReportList } from "@/modules/diagnostic/components/report-list"
|
||||||
|
import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TeacherDiagnosticPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
const reportType = getParam(sp, "reportType")
|
||||||
|
const status = getParam(sp, "status")
|
||||||
|
|
||||||
|
const reports = await getDiagnosticReports({
|
||||||
|
reportType: reportType && reportType !== "all" ? (reportType as DiagnosticReportType) : undefined,
|
||||||
|
status: status && status !== "all" ? (status as DiagnosticReportStatus) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 学生角色仅查看自己的报告;其他角色查看全部
|
||||||
|
const visibleReports =
|
||||||
|
ctx.dataScope.type === "class_members"
|
||||||
|
? reports.filter((r) => r.studentId === ctx.userId)
|
||||||
|
: reports
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Learning Diagnostic</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
View and manage diagnostic reports based on knowledge point mastery.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReportList reports={visibleReports} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { Stethoscope } from "lucide-react"
|
||||||
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
|
import {
|
||||||
|
getStudentMasterySummary,
|
||||||
|
getKnowledgePointStats,
|
||||||
|
} from "@/modules/diagnostic/data-access"
|
||||||
|
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||||
|
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||||
|
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function StudentDiagnosticPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ studentId: string }>
|
||||||
|
}) {
|
||||||
|
const { studentId } = await params
|
||||||
|
const ctx = await getAuthContext()
|
||||||
|
|
||||||
|
// DataScope 二次校验:学生只能看自己,家长只能看子女
|
||||||
|
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const [summary, reports] = await Promise.all([
|
||||||
|
getStudentMasterySummary(studentId),
|
||||||
|
getDiagnosticReports({ studentId }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 班级平均掌握度(用于雷达图对比)
|
||||||
|
let classAverageMastery: MasteryRadarPoint[] | undefined
|
||||||
|
if (summary) {
|
||||||
|
// 通过学生所在班级获取班级平均
|
||||||
|
const classStats = await getKnowledgePointStats()
|
||||||
|
classAverageMastery = classStats.map((k) => ({
|
||||||
|
knowledgePoint: k.knowledgePointName,
|
||||||
|
student: 0,
|
||||||
|
classAverage: k.averageMastery,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Stethoscope className="h-6 w-6" />
|
||||||
|
Student Diagnostic
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Knowledge point mastery analysis and diagnostic reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StudentDiagnosticView
|
||||||
|
summary={summary}
|
||||||
|
reports={reports}
|
||||||
|
classAverageMastery={classAverageMastery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/app/(dashboard)/teacher/elective/page.tsx
Normal file
50
src/app/(dashboard)/teacher/elective/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { getElectiveCourses } from "@/modules/elective/data-access"
|
||||||
|
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
|
||||||
|
import type { ElectiveCourseStatus } from "@/modules/elective/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
type SearchParams = { [key: string]: string | string[] | undefined }
|
||||||
|
|
||||||
|
const getParam = (params: SearchParams, key: string) => {
|
||||||
|
const v = params[key]
|
||||||
|
return Array.isArray(v) ? v[0] : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
|
||||||
|
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
|
||||||
|
|
||||||
|
export default async function TeacherElectivePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>
|
||||||
|
}) {
|
||||||
|
const session = await auth()
|
||||||
|
const teacherId = String(session?.user?.id ?? "")
|
||||||
|
|
||||||
|
const sp = await searchParams
|
||||||
|
const statusParam = getParam(sp, "status")
|
||||||
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
|
||||||
|
const courses = teacherId
|
||||||
|
? await getElectiveCourses({ teacherId, status })
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">My Elective Courses</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
View and manage the elective courses you teach.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ElectiveCourseList
|
||||||
|
courses={courses}
|
||||||
|
canManage
|
||||||
|
createHref="/admin/elective/create"
|
||||||
|
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx
Normal file
55
src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { ProctoringDashboard } from "@/modules/proctoring/components/proctoring-dashboard"
|
||||||
|
import {
|
||||||
|
getExamForProctoring,
|
||||||
|
getExamProctoringSummary,
|
||||||
|
getStudentProctoringStatuses,
|
||||||
|
getRecentProctoringEvents,
|
||||||
|
} from "@/modules/proctoring/data-access"
|
||||||
|
import type { ProctoringDashboardData } from "@/modules/proctoring/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ExamProctoringPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.EXAM_PROCTOR)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return (
|
||||||
|
<div className="p-10 text-center text-muted-foreground">
|
||||||
|
您没有监考权限(exam:proctor)。
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const exam = await getExamForProctoring(id)
|
||||||
|
if (!exam) return notFound()
|
||||||
|
|
||||||
|
// 并行拉取面板初始数据
|
||||||
|
const [summary, students, recentEvents] = await Promise.all([
|
||||||
|
getExamProctoringSummary(id),
|
||||||
|
getStudentProctoringStatuses(id),
|
||||||
|
getRecentProctoringEvents(id, 20),
|
||||||
|
])
|
||||||
|
|
||||||
|
const initialData: ProctoringDashboardData = {
|
||||||
|
summary,
|
||||||
|
students,
|
||||||
|
recentEvents,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-4 p-4">
|
||||||
|
<ProctoringDashboard examId={id} initialData={initialData} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/app/api/proctoring/event/route.ts
Normal file
91
src/app/api/proctoring/event/route.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { requireAuth, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { examSubmissions } from "@/shared/db/schema"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { recordProctoringEvent } from "@/modules/proctoring/data-access"
|
||||||
|
import type { ProctoringEventType } from "@/modules/proctoring/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const EventSchema = z.object({
|
||||||
|
submissionId: z.string().min(1),
|
||||||
|
eventType: z.enum([
|
||||||
|
"tab_switch",
|
||||||
|
"window_blur",
|
||||||
|
"copy_attempt",
|
||||||
|
"paste_attempt",
|
||||||
|
"right_click",
|
||||||
|
"devtools_open",
|
||||||
|
"fullscreen_exit",
|
||||||
|
"idle_timeout",
|
||||||
|
]) as z.ZodType<ProctoringEventType>,
|
||||||
|
eventDetail: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Invalid JSON body" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = EventSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全校验:submission 必须属于当前学生
|
||||||
|
const submission = await db.query.examSubmissions.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(examSubmissions.id, parsed.data.submissionId),
|
||||||
|
eq(examSubmissions.studentId, ctx.userId),
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
examId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!submission) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Submission not found for current user" },
|
||||||
|
{ status: 404 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordProctoringEvent({
|
||||||
|
submissionId: parsed.data.submissionId,
|
||||||
|
studentId: ctx.userId,
|
||||||
|
examId: submission.examId,
|
||||||
|
eventType: parsed.data.eventType,
|
||||||
|
eventDetail: parsed.data.eventDetail,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: error.message },
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.error("POST /api/proctoring/event error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "Failed to record proctoring event" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/modules/diagnostic/actions.ts
Normal file
148
src/modules/diagnostic/actions.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateDiagnosticReport,
|
||||||
|
generateClassDiagnosticReport,
|
||||||
|
getDiagnosticReports,
|
||||||
|
getDiagnosticReportById,
|
||||||
|
publishDiagnosticReport,
|
||||||
|
deleteDiagnosticReport,
|
||||||
|
} from "./data-access-reports"
|
||||||
|
import type { DiagnosticReportQueryParams } from "./types"
|
||||||
|
|
||||||
|
/** 生成学生个人诊断报告 */
|
||||||
|
export async function generateStudentReportAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
|
||||||
|
const studentId = formData.get("studentId")
|
||||||
|
const period = formData.get("period")
|
||||||
|
if (typeof studentId !== "string" || studentId.length === 0) {
|
||||||
|
return { success: false, message: "Missing studentId" }
|
||||||
|
}
|
||||||
|
if (typeof period !== "string" || period.length === 0) {
|
||||||
|
return { success: false, message: "Missing period" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await generateDiagnosticReport(studentId, period, ctx.userId)
|
||||||
|
revalidatePath("/teacher/diagnostic")
|
||||||
|
revalidatePath(`/teacher/diagnostic/student/${studentId}`)
|
||||||
|
return { success: true, message: "Diagnostic report generated", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成班级诊断报告 */
|
||||||
|
export async function generateClassReportAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
|
||||||
|
const classId = formData.get("classId")
|
||||||
|
const period = formData.get("period")
|
||||||
|
if (typeof classId !== "string" || classId.length === 0) {
|
||||||
|
return { success: false, message: "Missing classId" }
|
||||||
|
}
|
||||||
|
if (typeof period !== "string" || period.length === 0) {
|
||||||
|
return { success: false, message: "Missing period" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await generateClassDiagnosticReport(classId, period, ctx.userId)
|
||||||
|
revalidatePath("/teacher/diagnostic")
|
||||||
|
revalidatePath(`/teacher/diagnostic/class/${classId}`)
|
||||||
|
return { success: true, message: "Class diagnostic report generated", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布诊断报告 */
|
||||||
|
export async function publishReportAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
|
||||||
|
const id = formData.get("id")
|
||||||
|
if (typeof id !== "string" || id.length === 0) {
|
||||||
|
return { success: false, message: "Missing report id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishDiagnosticReport(id)
|
||||||
|
revalidatePath("/teacher/diagnostic")
|
||||||
|
return { success: true, message: "Report published" }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除诊断报告 */
|
||||||
|
export async function deleteReportAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
|
||||||
|
const id = formData.get("id")
|
||||||
|
if (typeof id !== "string" || id.length === 0) {
|
||||||
|
return { success: false, message: "Missing report id" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteDiagnosticReport(id)
|
||||||
|
revalidatePath("/teacher/diagnostic")
|
||||||
|
return { success: true, message: "Report deleted" }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询诊断报告列表(读权限) */
|
||||||
|
export async function getDiagnosticReportsAction(
|
||||||
|
params: DiagnosticReportQueryParams
|
||||||
|
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
const reports = await getDiagnosticReports(params)
|
||||||
|
return { success: true, data: reports }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取诊断报告详情(读权限) */
|
||||||
|
export async function getDiagnosticReportByIdAction(
|
||||||
|
id: string
|
||||||
|
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
const report = await getDiagnosticReportById(id)
|
||||||
|
return { success: true, data: report }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/modules/diagnostic/components/class-diagnostic-view.tsx
Normal file
267
src/modules/diagnostic/components/class-diagnostic-view.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Users, AlertTriangle, TrendingUp, FileText } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import { usePermission } from "@/shared/hooks"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { generateClassReportAction } from "../actions"
|
||||||
|
import type { ClassMasterySummary } from "../types"
|
||||||
|
|
||||||
|
interface ClassDiagnosticViewProps {
|
||||||
|
summary: ClassMasterySummary | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 掌握度热力图颜色 */
|
||||||
|
function masteryColor(level: number): string {
|
||||||
|
if (level >= 80) return "bg-green-500"
|
||||||
|
if (level >= 60) return "bg-yellow-500"
|
||||||
|
if (level >= 40) return "bg-orange-500"
|
||||||
|
return "bg-red-500"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
|
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!summary) return
|
||||||
|
setIsGenerating(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("classId", summary.classId)
|
||||||
|
formData.set("period", period)
|
||||||
|
const result = await generateClassReportAction(null, formData)
|
||||||
|
setIsGenerating(false)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to generate class report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No class data"
|
||||||
|
description="Unable to load class mastery summary."
|
||||||
|
icon={Users}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 概览 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Class</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{summary.className}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Students</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Avg Mastery</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Need Attention</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 知识点掌握度热力图 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Knowledge Point Mastery Heatmap
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Average mastery level per knowledge point (green ≥80%, yellow 60-79%, orange 40-59%, red <40%).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary.knowledgePointStats.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No knowledge point data available.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{summary.knowledgePointStats.map((kp) => (
|
||||||
|
<div
|
||||||
|
key={kp.knowledgePointId}
|
||||||
|
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
|
||||||
|
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
|
||||||
|
>
|
||||||
|
<span className="max-w-[120px] truncate text-xs font-medium">
|
||||||
|
{kp.knowledgePointName}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 知识点排名表 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Knowledge Point Ranking</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary.knowledgePointStats.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No data.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Knowledge Point</TableHead>
|
||||||
|
<TableHead className="text-right">Avg Mastery</TableHead>
|
||||||
|
<TableHead className="text-right">Mastered (≥80%)</TableHead>
|
||||||
|
<TableHead className="text-right">Not Mastered (<60%)</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{[...summary.knowledgePointStats]
|
||||||
|
.sort((a, b) => b.averageMastery - a.averageMastery)
|
||||||
|
.map((kp) => (
|
||||||
|
<TableRow key={kp.knowledgePointId}>
|
||||||
|
<TableCell className="font-medium">{kp.knowledgePointName}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
<Badge variant={kp.averageMastery >= 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}>
|
||||||
|
{kp.averageMastery.toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-green-600">{kp.masteredCount}</TableCell>
|
||||||
|
<TableCell className="text-right text-red-600">{kp.notMasteredCount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 需重点关注的学生 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||||
|
Students Needing Attention (avg <60%)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Students with low overall mastery.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary.studentsNeedingAttention.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">All students are above the attention threshold.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Student</TableHead>
|
||||||
|
<TableHead className="text-right">Avg Mastery</TableHead>
|
||||||
|
<TableHead className="text-right">Weak Points</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{summary.studentsNeedingAttention.map((s) => (
|
||||||
|
<TableRow key={s.studentId}>
|
||||||
|
<TableCell className="font-medium">{s.studentName}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant="destructive">{s.averageMastery.toFixed(1)}%</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-red-600">{s.weakCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
|
||||||
|
<FileText className="mr-1 h-3 w-3" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 生成班级报告 */}
|
||||||
|
{canManage ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Generate Class Diagnostic Report
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate a class-level diagnostic report with aggregated analysis.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="class-period" className="text-xs">Period (YYYY-MM)</Label>
|
||||||
|
<Input
|
||||||
|
id="class-period"
|
||||||
|
type="month"
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
|
className="w-[180px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||||
|
{isGenerating ? "Generating..." : "Generate Class Report"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/modules/diagnostic/components/mastery-radar-chart.tsx
Normal file
114
src/modules/diagnostic/components/mastery-radar-chart.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend } from "recharts"
|
||||||
|
import { Target } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/shared/components/ui/chart"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
student: { label: "Student", color: "hsl(var(--primary))" },
|
||||||
|
classAverage: { label: "Class Avg", color: "hsl(var(--chart-2))" },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MasteryRadarChartProps {
|
||||||
|
data: MasteryRadarPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Target className="h-4 w-4" />
|
||||||
|
Knowledge Point Mastery
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Radar chart of mastery level (0-100) across knowledge points.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EmptyState
|
||||||
|
icon={Target}
|
||||||
|
title="No mastery data"
|
||||||
|
description="No knowledge point mastery records found for this student."
|
||||||
|
className="border-none h-60"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识点名称过长时截断显示
|
||||||
|
const chartData = data.map((d) => ({
|
||||||
|
...d,
|
||||||
|
shortName: d.knowledgePoint.length > 8 ? `${d.knowledgePoint.slice(0, 8)}...` : d.knowledgePoint,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hasClassAverage = data.some((d) => d.classAverage !== undefined)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Target className="h-4 w-4" />
|
||||||
|
Knowledge Point Mastery
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Radar chart of mastery level (0-100) across knowledge points.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="mx-auto h-[360px] w-full max-w-[520px]">
|
||||||
|
<RadarChart data={chartData} outerRadius="75%">
|
||||||
|
<PolarGrid strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||||
|
<PolarAngleAxis
|
||||||
|
dataKey="shortName"
|
||||||
|
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
/>
|
||||||
|
<PolarRadiusAxis
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickCount={5}
|
||||||
|
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
|
||||||
|
{hasClassAverage ? <Legend /> : null}
|
||||||
|
<Radar
|
||||||
|
name="Student"
|
||||||
|
dataKey="student"
|
||||||
|
stroke="var(--color-student)"
|
||||||
|
fill="var(--color-student)"
|
||||||
|
fillOpacity={0.35}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
{hasClassAverage ? (
|
||||||
|
<Radar
|
||||||
|
name="Class Avg"
|
||||||
|
dataKey="classAverage"
|
||||||
|
stroke="var(--color-classAverage)"
|
||||||
|
fill="var(--color-classAverage)"
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</RadarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
262
src/modules/diagnostic/components/report-list.tsx
Normal file
262
src/modules/diagnostic/components/report-list.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useCallback } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { FileText, Trash2, Send } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import { usePermission } from "@/shared/hooks"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { publishReportAction, deleteReportAction } from "../actions"
|
||||||
|
import type { DiagnosticReportWithDetails } from "../types"
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
individual: "Individual",
|
||||||
|
class: "Class",
|
||||||
|
grade: "Grade",
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
draft: "secondary",
|
||||||
|
published: "default",
|
||||||
|
archived: "outline",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportListProps {
|
||||||
|
reports: DiagnosticReportWithDetails[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportList({ reports }: ReportListProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
|
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [publishId, setPublishId] = useState<string | null>(null)
|
||||||
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
|
||||||
|
const updateParam = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
if (value && value !== "all") {
|
||||||
|
params.set(key, value)
|
||||||
|
} else {
|
||||||
|
params.delete(key)
|
||||||
|
}
|
||||||
|
router.push(`?${params.toString()}`)
|
||||||
|
},
|
||||||
|
[router, searchParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!publishId) return
|
||||||
|
setIsBusy(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("id", publishId)
|
||||||
|
const result = await publishReportAction(null, formData)
|
||||||
|
setIsBusy(false)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
setPublishId(null)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to publish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return
|
||||||
|
setIsBusy(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("id", deleteId)
|
||||||
|
const result = await deleteReportAction(null, formData)
|
||||||
|
setIsBusy(false)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
setDeleteId(null)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportType = searchParams.get("reportType") ?? "all"
|
||||||
|
const status = searchParams.get("status") ?? "all"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 过滤器 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs">Report Type</Label>
|
||||||
|
<Select value={reportType} onValueChange={(v) => updateParam("reportType", v)}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="All types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All types</SelectItem>
|
||||||
|
<SelectItem value="individual">Individual</SelectItem>
|
||||||
|
<SelectItem value="class">Class</SelectItem>
|
||||||
|
<SelectItem value="grade">Grade</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs">Status</Label>
|
||||||
|
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="published">Published</SelectItem>
|
||||||
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No diagnostic reports"
|
||||||
|
description="Generate diagnostic reports to see them here."
|
||||||
|
icon={FileText}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Student / Target</TableHead>
|
||||||
|
<TableHead>Period</TableHead>
|
||||||
|
<TableHead className="text-right">Score</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Generated By</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
{canManage ? <TableHead className="w-24">Actions</TableHead> : null}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{reports.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{r.studentName}</TableCell>
|
||||||
|
<TableCell>{r.period ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[r.status] ?? "secondary"}>{r.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||||
|
{canManage ? (
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{r.status === "draft" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-green-600"
|
||||||
|
onClick={() => setPublishId(r.id)}
|
||||||
|
title="Publish"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => setDeleteId(r.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 发布确认 */}
|
||||||
|
<Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Publish Report</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Once published, the report will be visible to students. Continue?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePublish} disabled={isBusy}>
|
||||||
|
{isBusy ? "Publishing..." : "Publish"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Report</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this diagnostic report? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={isBusy}>
|
||||||
|
{isBusy ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
229
src/modules/diagnostic/components/student-diagnostic-view.tsx
Normal file
229
src/modules/diagnostic/components/student-diagnostic-view.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Award, AlertTriangle, Lightbulb, FileText, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { usePermission } from "@/shared/hooks"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { MasteryRadarChart } from "./mastery-radar-chart"
|
||||||
|
import { generateStudentReportAction } from "../actions"
|
||||||
|
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
|
||||||
|
|
||||||
|
interface StudentDiagnosticViewProps {
|
||||||
|
summary: StudentMasterySummary | null
|
||||||
|
reports: DiagnosticReportWithDetails[]
|
||||||
|
classAverageMastery?: MasteryRadarPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
|
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!summary) return
|
||||||
|
setIsGenerating(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("studentId", summary.studentId)
|
||||||
|
formData.set("period", period)
|
||||||
|
const result = await generateStudentReportAction(null, formData)
|
||||||
|
setIsGenerating(false)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to generate report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No diagnostic data"
|
||||||
|
description="Unable to load student mastery data."
|
||||||
|
icon={FileText}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const radarData: MasteryRadarPoint[] = summary.allMastery.map((m) => {
|
||||||
|
const classAvg = classAverageMastery?.find((c) => c.knowledgePoint === m.knowledgePointName)
|
||||||
|
return {
|
||||||
|
knowledgePoint: m.knowledgePointName,
|
||||||
|
student: Math.round(m.masteryLevel * 100) / 100,
|
||||||
|
classAverage: classAvg?.classAverage,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishedReports = reports.filter((r) => r.status === "published")
|
||||||
|
const latestReport = publishedReports[0] ?? reports[0] ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 概览卡片 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Overall Mastery</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Strengths</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{summary.strengths.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Weaknesses</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 雷达图 */}
|
||||||
|
<MasteryRadarChart data={radarData} />
|
||||||
|
|
||||||
|
{/* 强项 / 弱项 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Award className="h-4 w-4 text-green-600" />
|
||||||
|
Strengths (≥80%)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Knowledge points with high mastery.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary.strengths.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{summary.strengths.map((m) => (
|
||||||
|
<li key={m.knowledgePointId} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">{m.knowledgePointName}</span>
|
||||||
|
<Badge variant="default" className="bg-green-600">{m.masteryLevel.toFixed(1)}%</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||||
|
Weaknesses (<60%)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Knowledge points needing attention.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary.weaknesses.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{summary.weaknesses.map((m) => (
|
||||||
|
<li key={m.knowledgePointId} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">{m.knowledgePointName}</span>
|
||||||
|
<Badge variant="destructive">{m.masteryLevel.toFixed(1)}%</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 生成报告 */}
|
||||||
|
{canManage ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Generate Diagnostic Report
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate an AI-analyzed diagnostic report for this student.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="period" className="text-xs">Period (YYYY-MM)</Label>
|
||||||
|
<Input
|
||||||
|
id="period"
|
||||||
|
type="month"
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
|
className="w-[180px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||||
|
{isGenerating ? "Generating..." : "Generate Report"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 最新报告 / 建议 */}
|
||||||
|
{latestReport ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Lightbulb className="h-4 w-4" />
|
||||||
|
Diagnostic Report
|
||||||
|
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
|
||||||
|
{latestReport.status}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}%
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{latestReport.summary ? (
|
||||||
|
<p className="text-sm">{latestReport.summary}</p>
|
||||||
|
) : null}
|
||||||
|
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{latestReport.recommendations.map((rec, i) => (
|
||||||
|
<li key={i} className="text-sm text-muted-foreground">• {rec}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
202
src/modules/diagnostic/data-access-reports.ts
Normal file
202
src/modules/diagnostic/data-access-reports.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, desc, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { learningDiagnosticReports, users } from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
|
||||||
|
import type {
|
||||||
|
DiagnosticReport,
|
||||||
|
DiagnosticReportQueryParams,
|
||||||
|
DiagnosticReportWithDetails,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const toNumber = (v: unknown): number => {
|
||||||
|
const n = typeof v === "number" ? v : Number(v)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100
|
||||||
|
|
||||||
|
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
|
||||||
|
id: r.id,
|
||||||
|
studentId: r.studentId,
|
||||||
|
generatedBy: r.generatedBy,
|
||||||
|
reportType: r.reportType,
|
||||||
|
period: r.period,
|
||||||
|
summary: r.summary,
|
||||||
|
strengths: (r.strengths as string[] | null) ?? null,
|
||||||
|
weaknesses: (r.weaknesses as string[] | null) ?? null,
|
||||||
|
recommendations: (r.recommendations as string[] | null) ?? null,
|
||||||
|
overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null,
|
||||||
|
status: r.status,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 生成个人诊断报告 */
|
||||||
|
export async function generateDiagnosticReport(
|
||||||
|
studentId: string,
|
||||||
|
period: string,
|
||||||
|
generatedBy: string
|
||||||
|
): Promise<string> {
|
||||||
|
const summary = await getStudentMasterySummary(studentId)
|
||||||
|
if (!summary) throw new Error("Student not found")
|
||||||
|
|
||||||
|
const overallScore = summary.averageMastery
|
||||||
|
const strengths = summary.strengths.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
|
||||||
|
const weaknesses = summary.weaknesses.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
|
||||||
|
const recommendations = summary.weaknesses.map(
|
||||||
|
(m) => `建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`
|
||||||
|
)
|
||||||
|
if (recommendations.length === 0) {
|
||||||
|
recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。")
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryText = `学生 ${summary.studentName} 在 ${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
|
||||||
|
|
||||||
|
const { createId } = await import("@paralleldrive/cuid2")
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(learningDiagnosticReports).values({
|
||||||
|
id,
|
||||||
|
studentId,
|
||||||
|
generatedBy,
|
||||||
|
reportType: "individual",
|
||||||
|
period,
|
||||||
|
summary: summaryText,
|
||||||
|
strengths,
|
||||||
|
weaknesses,
|
||||||
|
recommendations,
|
||||||
|
overallScore: String(overallScore),
|
||||||
|
status: "draft",
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成班级诊断报告 */
|
||||||
|
export async function generateClassDiagnosticReport(
|
||||||
|
classId: string,
|
||||||
|
period: string,
|
||||||
|
generatedBy: string
|
||||||
|
): Promise<string> {
|
||||||
|
const summary = await getClassMasterySummary(classId)
|
||||||
|
if (!summary) throw new Error("Class not found")
|
||||||
|
|
||||||
|
const topWeak = summary.knowledgePointStats
|
||||||
|
.filter((k) => k.averageMastery < 60)
|
||||||
|
.sort((a, b) => a.averageMastery - b.averageMastery)
|
||||||
|
.slice(0, 5)
|
||||||
|
const strengths = summary.knowledgePointStats
|
||||||
|
.filter((k) => k.averageMastery >= 80)
|
||||||
|
.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
|
||||||
|
const weaknesses = topWeak.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
|
||||||
|
const recommendations = topWeak.map(
|
||||||
|
(k) => `班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`
|
||||||
|
)
|
||||||
|
if (recommendations.length === 0) {
|
||||||
|
recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。")
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryText = `班级 ${summary.className} 在 ${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
|
||||||
|
|
||||||
|
const { createId } = await import("@paralleldrive/cuid2")
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(learningDiagnosticReports).values({
|
||||||
|
id,
|
||||||
|
studentId: generatedBy, // 班级报告 studentId 存生成者 ID(schema 要求 NOT NULL)
|
||||||
|
generatedBy,
|
||||||
|
reportType: "class",
|
||||||
|
period,
|
||||||
|
summary: summaryText,
|
||||||
|
strengths,
|
||||||
|
weaknesses,
|
||||||
|
recommendations,
|
||||||
|
overallScore: String(summary.averageMastery),
|
||||||
|
status: "draft",
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询诊断报告列表 */
|
||||||
|
export async function getDiagnosticReports(
|
||||||
|
filters: DiagnosticReportQueryParams
|
||||||
|
): Promise<DiagnosticReportWithDetails[]> {
|
||||||
|
const conditions = []
|
||||||
|
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
|
||||||
|
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
|
||||||
|
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
|
||||||
|
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
report: learningDiagnosticReports,
|
||||||
|
studentName: users.name,
|
||||||
|
})
|
||||||
|
.from(learningDiagnosticReports)
|
||||||
|
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(learningDiagnosticReports.createdAt))
|
||||||
|
|
||||||
|
const generatorIds = Array.from(
|
||||||
|
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
|
||||||
|
)
|
||||||
|
const generatorMap = new Map<string, string>()
|
||||||
|
if (generatorIds.length > 0) {
|
||||||
|
const generators = await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, generatorIds))
|
||||||
|
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...serializeReport(r.report),
|
||||||
|
studentName: r.studentName ?? "Unknown",
|
||||||
|
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取报告详情 */
|
||||||
|
export async function getDiagnosticReportById(
|
||||||
|
id: string
|
||||||
|
): Promise<DiagnosticReportWithDetails | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ report: learningDiagnosticReports, studentName: users.name })
|
||||||
|
.from(learningDiagnosticReports)
|
||||||
|
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
||||||
|
.where(eq(learningDiagnosticReports.id, id))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
let generatedByName: string | null = null
|
||||||
|
if (row.report.generatedBy) {
|
||||||
|
const [gen] = await db
|
||||||
|
.select({ name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, row.report.generatedBy))
|
||||||
|
.limit(1)
|
||||||
|
generatedByName = gen?.name ?? null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...serializeReport(row.report),
|
||||||
|
studentName: row.studentName ?? "Unknown",
|
||||||
|
generatedByName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布诊断报告 */
|
||||||
|
export async function publishDiagnosticReport(id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(learningDiagnosticReports)
|
||||||
|
.set({ status: "published", updatedAt: new Date() })
|
||||||
|
.where(eq(learningDiagnosticReports.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除诊断报告 */
|
||||||
|
export async function deleteDiagnosticReport(id: string): Promise<void> {
|
||||||
|
await db.delete(learningDiagnosticReports).where(eq(learningDiagnosticReports.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止 round2 未使用警告(保留以备扩展)
|
||||||
|
void round2
|
||||||
254
src/modules/diagnostic/data-access.ts
Normal file
254
src/modules/diagnostic/data-access.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, asc, desc, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classEnrollments,
|
||||||
|
classes,
|
||||||
|
examSubmissions,
|
||||||
|
knowledgePointMastery,
|
||||||
|
knowledgePoints,
|
||||||
|
questionsToKnowledgePoints,
|
||||||
|
submissionAnswers,
|
||||||
|
users,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ClassMasterySummary,
|
||||||
|
KnowledgePointMastery,
|
||||||
|
KnowledgePointStat,
|
||||||
|
MasteryWithKnowledgePoint,
|
||||||
|
StudentMasterySummary,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const toNumber = (v: unknown): number => {
|
||||||
|
const n = typeof v === "number" ? v : Number(v)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100
|
||||||
|
|
||||||
|
const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({
|
||||||
|
id: r.id,
|
||||||
|
studentId: r.studentId,
|
||||||
|
knowledgePointId: r.knowledgePointId,
|
||||||
|
masteryLevel: toNumber(r.masteryLevel),
|
||||||
|
totalQuestions: r.totalQuestions,
|
||||||
|
correctQuestions: r.correctQuestions,
|
||||||
|
lastAssessedAt: r.lastAssessedAt.toISOString(),
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取学生在所有知识点的掌握度(含知识点名称) */
|
||||||
|
export async function getStudentMastery(studentId: string): Promise<MasteryWithKnowledgePoint[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
mastery: knowledgePointMastery,
|
||||||
|
kpName: knowledgePoints.name,
|
||||||
|
kpDescription: knowledgePoints.description,
|
||||||
|
})
|
||||||
|
.from(knowledgePointMastery)
|
||||||
|
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||||
|
.where(eq(knowledgePointMastery.studentId, studentId))
|
||||||
|
.orderBy(desc(knowledgePointMastery.masteryLevel))
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...serializeMastery(r.mastery),
|
||||||
|
knowledgePointName: r.kpName ?? "Unknown",
|
||||||
|
knowledgePointDescription: r.kpDescription,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取学生掌握度摘要(含强项/弱项分析) */
|
||||||
|
export async function getStudentMasterySummary(studentId: string): Promise<StudentMasterySummary | null> {
|
||||||
|
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
|
||||||
|
if (!student) return null
|
||||||
|
|
||||||
|
const allMastery = await getStudentMastery(studentId)
|
||||||
|
const averageMastery =
|
||||||
|
allMastery.length > 0
|
||||||
|
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
studentId,
|
||||||
|
studentName: student.name ?? "Unknown",
|
||||||
|
averageMastery,
|
||||||
|
totalKnowledgePoints: allMastery.length,
|
||||||
|
strengths: allMastery.filter((m) => m.masteryLevel >= 80),
|
||||||
|
weaknesses: allMastery.filter((m) => m.masteryLevel < 60),
|
||||||
|
allMastery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从提交答案更新掌握度(正确率作为掌握度) */
|
||||||
|
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
|
||||||
|
const [submission] = await db
|
||||||
|
.select({ studentId: examSubmissions.studentId })
|
||||||
|
.from(examSubmissions)
|
||||||
|
.where(eq(examSubmissions.id, submissionId))
|
||||||
|
.limit(1)
|
||||||
|
if (!submission) return
|
||||||
|
|
||||||
|
const answers = await db
|
||||||
|
.select({
|
||||||
|
questionId: submissionAnswers.questionId,
|
||||||
|
score: submissionAnswers.score,
|
||||||
|
})
|
||||||
|
.from(submissionAnswers)
|
||||||
|
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||||
|
|
||||||
|
if (answers.length === 0) return
|
||||||
|
|
||||||
|
const questionIds = Array.from(new Set(answers.map((a) => a.questionId)))
|
||||||
|
const kpLinks = await db
|
||||||
|
.select({
|
||||||
|
questionId: questionsToKnowledgePoints.questionId,
|
||||||
|
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||||
|
})
|
||||||
|
.from(questionsToKnowledgePoints)
|
||||||
|
.where(inArray(questionsToKnowledgePoints.questionId, questionIds))
|
||||||
|
|
||||||
|
const kpStats = new Map<string, { total: number; correct: number }>()
|
||||||
|
for (const link of kpLinks) {
|
||||||
|
const answer = answers.find((a) => a.questionId === link.questionId)
|
||||||
|
if (!answer) continue
|
||||||
|
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
|
||||||
|
stat.total += 1
|
||||||
|
if ((answer.score ?? 0) > 0) stat.correct += 1
|
||||||
|
kpStats.set(link.knowledgePointId, stat)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
for (const [kpId, stat] of kpStats.entries()) {
|
||||||
|
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
|
||||||
|
await db
|
||||||
|
.insert(knowledgePointMastery)
|
||||||
|
.values({
|
||||||
|
studentId: submission.studentId,
|
||||||
|
knowledgePointId: kpId,
|
||||||
|
masteryLevel: String(masteryLevel),
|
||||||
|
totalQuestions: stat.total,
|
||||||
|
correctQuestions: stat.correct,
|
||||||
|
lastAssessedAt: now,
|
||||||
|
})
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
masteryLevel: String(masteryLevel),
|
||||||
|
totalQuestions: stat.total,
|
||||||
|
correctQuestions: stat.correct,
|
||||||
|
lastAssessedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取班级掌握度摘要 */
|
||||||
|
export async function getClassMasterySummary(classId: string): Promise<ClassMasterySummary | null> {
|
||||||
|
const [classRow] = await db.select({ id: classes.id, name: classes.name }).from(classes).where(eq(classes.id, classId)).limit(1)
|
||||||
|
if (!classRow) return null
|
||||||
|
|
||||||
|
const students = await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||||
|
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||||
|
.orderBy(asc(users.name))
|
||||||
|
|
||||||
|
if (students.length === 0) {
|
||||||
|
return { classId, className: classRow.name, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentIds = students.map((s) => s.id)
|
||||||
|
const masteryRows = await db
|
||||||
|
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
|
||||||
|
.from(knowledgePointMastery)
|
||||||
|
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||||
|
.where(inArray(knowledgePointMastery.studentId, studentIds))
|
||||||
|
|
||||||
|
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
||||||
|
const byStudent = new Map<string, { levels: number[]; weakCount: number }>()
|
||||||
|
for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 })
|
||||||
|
|
||||||
|
for (const r of masteryRows) {
|
||||||
|
const level = toNumber(r.mastery.masteryLevel)
|
||||||
|
const kpId = r.mastery.knowledgePointId
|
||||||
|
const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
|
||||||
|
kpEntry.levels.push(level)
|
||||||
|
if (level >= 80) kpEntry.mastered += 1
|
||||||
|
if (level < 60) kpEntry.notMastered += 1
|
||||||
|
byKp.set(kpId, kpEntry)
|
||||||
|
|
||||||
|
const stuEntry = byStudent.get(r.mastery.studentId)
|
||||||
|
if (stuEntry) {
|
||||||
|
stuEntry.levels.push(level)
|
||||||
|
if (level < 60) stuEntry.weakCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({
|
||||||
|
knowledgePointId: kpId,
|
||||||
|
knowledgePointName: e.name,
|
||||||
|
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
|
||||||
|
masteredCount: e.mastered,
|
||||||
|
notMasteredCount: e.notMastered,
|
||||||
|
totalStudents: students.length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel))
|
||||||
|
const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0
|
||||||
|
|
||||||
|
const studentsNeedingAttention = students
|
||||||
|
.map((s) => {
|
||||||
|
const e = byStudent.get(s.id)!
|
||||||
|
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
|
||||||
|
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
|
||||||
|
})
|
||||||
|
.filter((s) => s.averageMastery < 60)
|
||||||
|
.sort((a, b) => a.averageMastery - b.averageMastery)
|
||||||
|
|
||||||
|
return { classId, className: classRow.name, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取知识点统计(按班级或年级聚合) */
|
||||||
|
export async function getKnowledgePointStats(classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> {
|
||||||
|
let studentIds: string[] = []
|
||||||
|
if (classId) {
|
||||||
|
const rows = await db.select({ studentId: classEnrollments.studentId }).from(classEnrollments).where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||||
|
studentIds = rows.map((r) => r.studentId)
|
||||||
|
} else if (gradeId) {
|
||||||
|
const rows = await db.select({ id: users.id }).from(users).where(eq(users.gradeId, gradeId))
|
||||||
|
studentIds = rows.map((r) => r.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (studentIds.length === 0) return []
|
||||||
|
|
||||||
|
const masteryRows = await db
|
||||||
|
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
|
||||||
|
.from(knowledgePointMastery)
|
||||||
|
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||||
|
.where(inArray(knowledgePointMastery.studentId, studentIds))
|
||||||
|
|
||||||
|
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
||||||
|
for (const r of masteryRows) {
|
||||||
|
const level = toNumber(r.mastery.masteryLevel)
|
||||||
|
const kpId = r.mastery.knowledgePointId
|
||||||
|
const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
|
||||||
|
e.levels.push(level)
|
||||||
|
if (level >= 80) e.mastered += 1
|
||||||
|
if (level < 60) e.notMastered += 1
|
||||||
|
byKp.set(kpId, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byKp.entries()).map(([kpId, e]) => ({
|
||||||
|
knowledgePointId: kpId,
|
||||||
|
knowledgePointName: e.name,
|
||||||
|
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
|
||||||
|
masteredCount: e.mastered,
|
||||||
|
notMasteredCount: e.notMastered,
|
||||||
|
totalStudents: studentIds.length,
|
||||||
|
}))
|
||||||
|
}
|
||||||
97
src/modules/diagnostic/types.ts
Normal file
97
src/modules/diagnostic/types.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Learning Diagnostic Module Types
|
||||||
|
|
||||||
|
export type DiagnosticReportType = "individual" | "class" | "grade"
|
||||||
|
export type DiagnosticReportStatus = "draft" | "published" | "archived"
|
||||||
|
|
||||||
|
/** 知识点掌握度记录 */
|
||||||
|
export interface KnowledgePointMastery {
|
||||||
|
id: string
|
||||||
|
studentId: string
|
||||||
|
knowledgePointId: string
|
||||||
|
masteryLevel: number // 0-100
|
||||||
|
totalQuestions: number
|
||||||
|
correctQuestions: number
|
||||||
|
lastAssessedAt: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 含知识点名称的掌握度(join knowledgePoints 后) */
|
||||||
|
export interface MasteryWithKnowledgePoint extends KnowledgePointMastery {
|
||||||
|
knowledgePointName: string
|
||||||
|
knowledgePointDescription: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学生掌握度摘要 */
|
||||||
|
export interface StudentMasterySummary {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
averageMastery: number
|
||||||
|
totalKnowledgePoints: number
|
||||||
|
strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80
|
||||||
|
weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 60
|
||||||
|
allMastery: MasteryWithKnowledgePoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊断报告 */
|
||||||
|
export interface DiagnosticReport {
|
||||||
|
id: string
|
||||||
|
studentId: string
|
||||||
|
generatedBy: string | null
|
||||||
|
reportType: DiagnosticReportType
|
||||||
|
period: string | null
|
||||||
|
summary: string | null
|
||||||
|
strengths: string[] | null
|
||||||
|
weaknesses: string[] | null
|
||||||
|
recommendations: string[] | null
|
||||||
|
overallScore: number | null
|
||||||
|
status: DiagnosticReportStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 含学生名的诊断报告(join users 后) */
|
||||||
|
export interface DiagnosticReportWithDetails extends DiagnosticReport {
|
||||||
|
studentName: string
|
||||||
|
generatedByName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级掌握度摘要 */
|
||||||
|
export interface ClassMasterySummary {
|
||||||
|
classId: string
|
||||||
|
className: string
|
||||||
|
studentCount: number
|
||||||
|
averageMastery: number
|
||||||
|
knowledgePointStats: KnowledgePointStat[]
|
||||||
|
studentsNeedingAttention: Array<{
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
averageMastery: number
|
||||||
|
weakCount: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 知识点统计 */
|
||||||
|
export interface KnowledgePointStat {
|
||||||
|
knowledgePointId: string
|
||||||
|
knowledgePointName: string
|
||||||
|
averageMastery: number
|
||||||
|
masteredCount: number // 掌握度 >= 80
|
||||||
|
notMasteredCount: number // 掌握度 < 60
|
||||||
|
totalStudents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报告查询过滤参数 */
|
||||||
|
export interface DiagnosticReportQueryParams {
|
||||||
|
studentId?: string
|
||||||
|
reportType?: DiagnosticReportType
|
||||||
|
status?: DiagnosticReportStatus
|
||||||
|
period?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 雷达图数据点 */
|
||||||
|
export interface MasteryRadarPoint {
|
||||||
|
knowledgePoint: string
|
||||||
|
student: number // 0-100
|
||||||
|
classAverage?: number // 0-100
|
||||||
|
}
|
||||||
304
src/modules/elective/actions.ts
Normal file
304
src/modules/elective/actions.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateElectiveCourseSchema,
|
||||||
|
UpdateElectiveCourseSchema,
|
||||||
|
SelectCourseSchema,
|
||||||
|
DropCourseSchema,
|
||||||
|
RunLotterySchema,
|
||||||
|
} from "./schema"
|
||||||
|
import {
|
||||||
|
getElectiveCourses,
|
||||||
|
getElectiveCourseById,
|
||||||
|
createElectiveCourse,
|
||||||
|
updateElectiveCourse,
|
||||||
|
deleteElectiveCourse,
|
||||||
|
openSelection,
|
||||||
|
closeSelection,
|
||||||
|
} from "./data-access"
|
||||||
|
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
|
||||||
|
import {
|
||||||
|
getStudentSelections,
|
||||||
|
getAvailableCoursesForStudent,
|
||||||
|
} from "./data-access-selections"
|
||||||
|
import type {
|
||||||
|
ElectiveCourseWithDetails,
|
||||||
|
CourseSelectionWithDetails,
|
||||||
|
GetElectiveCoursesParams,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const handleError = (e: unknown): ActionState<never> => {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const revalidateElectivePaths = (id?: string) => {
|
||||||
|
revalidatePath("/admin/elective")
|
||||||
|
revalidatePath("/teacher/elective")
|
||||||
|
revalidatePath("/student/elective")
|
||||||
|
if (id) {
|
||||||
|
revalidatePath(`/admin/elective/${id}`)
|
||||||
|
revalidatePath(`/admin/elective/${id}/edit`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireCourseId = (formData: FormData): string => {
|
||||||
|
const id = String(formData.get("courseId") ?? "")
|
||||||
|
if (!id) throw new Error("Course ID is required")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createElectiveCourseAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||||
|
const parsed = CreateElectiveCourseSchema.safeParse({
|
||||||
|
name: formData.get("name"),
|
||||||
|
subjectId: formData.get("subjectId") || undefined,
|
||||||
|
teacherId: formData.get("teacherId") || ctx.userId,
|
||||||
|
gradeId: formData.get("gradeId") || undefined,
|
||||||
|
description: formData.get("description") || undefined,
|
||||||
|
capacity: formData.get("capacity") || undefined,
|
||||||
|
classroom: formData.get("classroom") || undefined,
|
||||||
|
schedule: formData.get("schedule") || undefined,
|
||||||
|
startDate: formData.get("startDate") || undefined,
|
||||||
|
endDate: formData.get("endDate") || undefined,
|
||||||
|
selectionStartAt: formData.get("selectionStartAt") || undefined,
|
||||||
|
selectionEndAt: formData.get("selectionEndAt") || undefined,
|
||||||
|
selectionMode: formData.get("selectionMode") || undefined,
|
||||||
|
credit: formData.get("credit") || undefined,
|
||||||
|
})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = await createElectiveCourse(parsed.data, ctx.userId)
|
||||||
|
revalidateElectivePaths(id)
|
||||||
|
return { success: true, message: "Elective course created", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateElectiveCourseAction(
|
||||||
|
id: string,
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||||
|
const existing = await getElectiveCourseById(id)
|
||||||
|
if (!existing) return { success: false, message: "Course not found" }
|
||||||
|
|
||||||
|
const parsed = UpdateElectiveCourseSchema.safeParse({
|
||||||
|
name: formData.get("name") || undefined,
|
||||||
|
subjectId: formData.get("subjectId") || undefined,
|
||||||
|
teacherId: formData.get("teacherId") || undefined,
|
||||||
|
gradeId: formData.get("gradeId") || undefined,
|
||||||
|
description: formData.get("description") || undefined,
|
||||||
|
capacity: formData.get("capacity") || undefined,
|
||||||
|
classroom: formData.get("classroom") || undefined,
|
||||||
|
schedule: formData.get("schedule") || undefined,
|
||||||
|
startDate: formData.get("startDate") || undefined,
|
||||||
|
endDate: formData.get("endDate") || undefined,
|
||||||
|
selectionStartAt: formData.get("selectionStartAt") || undefined,
|
||||||
|
selectionEndAt: formData.get("selectionEndAt") || undefined,
|
||||||
|
status: formData.get("status") || undefined,
|
||||||
|
selectionMode: formData.get("selectionMode") || undefined,
|
||||||
|
credit: formData.get("credit") || undefined,
|
||||||
|
})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await updateElectiveCourse(id, parsed.data)
|
||||||
|
revalidateElectivePaths(id)
|
||||||
|
return { success: true, message: "Elective course updated", data: id }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteElectiveCourseAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||||
|
const id = requireCourseId(formData)
|
||||||
|
|
||||||
|
const existing = await getElectiveCourseById(id)
|
||||||
|
if (!existing) return { success: false, message: "Course not found" }
|
||||||
|
|
||||||
|
await deleteElectiveCourse(id)
|
||||||
|
revalidateElectivePaths()
|
||||||
|
return { success: true, message: "Elective course deleted" }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSelectionAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||||
|
const courseId = requireCourseId(formData)
|
||||||
|
await openSelection(courseId)
|
||||||
|
revalidateElectivePaths(courseId)
|
||||||
|
return { success: true, message: "Selection opened" }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSelectionAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||||
|
const courseId = requireCourseId(formData)
|
||||||
|
await closeSelection(courseId)
|
||||||
|
revalidateElectivePaths(courseId)
|
||||||
|
return { success: true, message: "Selection closed" }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLotteryAction(
|
||||||
|
prevState: ActionState<{ enrolled: number; waitlist: number }> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<{ enrolled: number; waitlist: number }>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||||
|
const parsed = RunLotterySchema.safeParse({
|
||||||
|
courseId: formData.get("courseId"),
|
||||||
|
})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await runLottery(parsed.data.courseId)
|
||||||
|
revalidateElectivePaths(parsed.data.courseId)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
|
||||||
|
data: result,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectCourseAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
|
||||||
|
const parsed = SelectCourseSchema.safeParse({
|
||||||
|
courseId: formData.get("courseId"),
|
||||||
|
priority: formData.get("priority") || undefined,
|
||||||
|
})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority)
|
||||||
|
revalidateElectivePaths(parsed.data.courseId)
|
||||||
|
return { success: true, message: result.message, data: result.status }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropCourseAction(
|
||||||
|
prevState: ActionState<string> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<string>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
|
||||||
|
const parsed = DropCourseSchema.safeParse({
|
||||||
|
courseId: formData.get("courseId"),
|
||||||
|
})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid form data",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await dropCourse(parsed.data.courseId, ctx.userId)
|
||||||
|
revalidateElectivePaths(parsed.data.courseId)
|
||||||
|
return { success: true, message: "Course dropped" }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getElectiveCoursesAction(
|
||||||
|
params?: GetElectiveCoursesParams
|
||||||
|
): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||||
|
const data = await getElectiveCourses({
|
||||||
|
...params,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
currentUserId: ctx.userId,
|
||||||
|
})
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStudentSelectionsAction(
|
||||||
|
studentId: string
|
||||||
|
): Promise<ActionState<CourseSelectionWithDetails[]>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||||
|
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||||
|
return { success: false, message: "Can only view your own selections" }
|
||||||
|
}
|
||||||
|
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||||
|
return { success: false, message: "Can only view your children's selections" }
|
||||||
|
}
|
||||||
|
const data = await getStudentSelections(studentId)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailableCoursesAction(): Promise<ActionState<ElectiveCourseWithDetails[]>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
|
||||||
|
const data = await getAvailableCoursesForStudent(ctx.userId)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
293
src/modules/elective/components/elective-course-form.tsx
Normal file
293
src/modules/elective/components/elective-course-form.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
|
||||||
|
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
||||||
|
import type { ElectiveCourseWithDetails } from "../types"
|
||||||
|
|
||||||
|
type Mode = "create" | "edit"
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElectiveCourseForm({
|
||||||
|
mode,
|
||||||
|
course,
|
||||||
|
subjects = [],
|
||||||
|
grades = [],
|
||||||
|
teachers = [],
|
||||||
|
backHref,
|
||||||
|
}: {
|
||||||
|
mode: Mode
|
||||||
|
course?: ElectiveCourseWithDetails
|
||||||
|
subjects?: Option[]
|
||||||
|
grades?: Option[]
|
||||||
|
teachers?: Option[]
|
||||||
|
backHref?: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
|
|
||||||
|
const [subjectId, setSubjectId] = useState(course?.subjectId ?? "")
|
||||||
|
const [gradeId, setGradeId] = useState(course?.gradeId ?? "")
|
||||||
|
const [teacherId, setTeacherId] = useState(course?.teacherId ?? "")
|
||||||
|
const [selectionMode, setSelectionMode] = useState(course?.selectionMode ?? "fcfs")
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData) => {
|
||||||
|
setIsWorking(true)
|
||||||
|
try {
|
||||||
|
formData.set("subjectId", subjectId)
|
||||||
|
formData.set("gradeId", gradeId)
|
||||||
|
formData.set("teacherId", teacherId)
|
||||||
|
formData.set("selectionMode", selectionMode)
|
||||||
|
|
||||||
|
const res =
|
||||||
|
mode === "create"
|
||||||
|
? await createElectiveCourseAction(null, formData)
|
||||||
|
: course
|
||||||
|
? await updateElectiveCourseAction(course.id, null, formData)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
toast.error("Invalid form state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective"
|
||||||
|
router.push(redirectBase)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || "Failed to save course")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save course")
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{mode === "create" ? "New Elective Course" : "Edit Elective Course"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Course Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
defaultValue={course?.name ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Subject</Label>
|
||||||
|
<Select value={subjectId} onValueChange={setSubjectId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a subject" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="subjectId" value={subjectId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Grade</Label>
|
||||||
|
<Select value={gradeId} onValueChange={setGradeId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a grade" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{grades.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="gradeId" value={gradeId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Teacher</Label>
|
||||||
|
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a teacher" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{teachers.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="teacherId" value={teacherId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="capacity">Capacity</Label>
|
||||||
|
<Input
|
||||||
|
id="capacity"
|
||||||
|
name="capacity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
defaultValue={course?.capacity ?? 30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="classroom">Classroom</Label>
|
||||||
|
<Input
|
||||||
|
id="classroom"
|
||||||
|
name="classroom"
|
||||||
|
defaultValue={course?.classroom ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="schedule">Schedule</Label>
|
||||||
|
<Input
|
||||||
|
id="schedule"
|
||||||
|
name="schedule"
|
||||||
|
placeholder="e.g. Mon 14:00-15:30"
|
||||||
|
defaultValue={course?.schedule ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="credit">Credit</Label>
|
||||||
|
<Input
|
||||||
|
id="credit"
|
||||||
|
name="credit"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min={0}
|
||||||
|
defaultValue={course?.credit ?? "1.0"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Selection Mode</Label>
|
||||||
|
<Select value={selectionMode} onValueChange={(v) => setSelectionMode(v as "fcfs" | "lottery")}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fcfs">First Come First Served</SelectItem>
|
||||||
|
<SelectItem value="lottery">Lottery</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<input type="hidden" name="selectionMode" value={selectionMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="startDate">Start Date</Label>
|
||||||
|
<Input
|
||||||
|
id="startDate"
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={course?.startDate ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="endDate">End Date</Label>
|
||||||
|
<Input
|
||||||
|
id="endDate"
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={course?.endDate ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="selectionStartAt">Selection Start</Label>
|
||||||
|
<Input
|
||||||
|
id="selectionStartAt"
|
||||||
|
name="selectionStartAt"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={
|
||||||
|
course?.selectionStartAt
|
||||||
|
? new Date(course.selectionStartAt).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="selectionEndAt">Selection End</Label>
|
||||||
|
<Input
|
||||||
|
id="selectionEndAt"
|
||||||
|
name="selectionEndAt"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={
|
||||||
|
course?.selectionEndAt
|
||||||
|
? new Date(course.selectionEndAt).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Course description..."
|
||||||
|
className="min-h-[80px]"
|
||||||
|
defaultValue={course?.description ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(backHref ?? "/admin/elective")}
|
||||||
|
disabled={isWorking}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isWorking}>
|
||||||
|
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
233
src/modules/elective/components/elective-course-list.tsx
Normal file
233
src/modules/elective/components/elective-course-list.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ELECTIVE_STATUS_COLORS,
|
||||||
|
ELECTIVE_STATUS_LABELS,
|
||||||
|
SELECTION_MODE_LABELS,
|
||||||
|
} from "../types"
|
||||||
|
import type { ElectiveCourseWithDetails } from "../types"
|
||||||
|
import {
|
||||||
|
deleteElectiveCourseAction,
|
||||||
|
openSelectionAction,
|
||||||
|
closeSelectionAction,
|
||||||
|
runLotteryAction,
|
||||||
|
} from "../actions"
|
||||||
|
|
||||||
|
export function ElectiveCourseList({
|
||||||
|
courses,
|
||||||
|
createHref,
|
||||||
|
editHrefBuilder,
|
||||||
|
canManage,
|
||||||
|
}: {
|
||||||
|
courses: ElectiveCourseWithDetails[]
|
||||||
|
createHref?: string
|
||||||
|
editHrefBuilder?: (id: string) => string
|
||||||
|
canManage?: boolean
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
|
const manageResolved = canManage ?? hasPermission(Permissions.ELECTIVE_MANAGE)
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const runAction = async (
|
||||||
|
action: (prevState: never, formData: FormData) => Promise<{ success: boolean; message?: string }>,
|
||||||
|
courseId: string,
|
||||||
|
successMsg: string
|
||||||
|
) => {
|
||||||
|
setPendingId(courseId)
|
||||||
|
startTransition(async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("courseId", courseId)
|
||||||
|
const res = await action(null as never, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message ?? successMsg)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? "Operation failed")
|
||||||
|
}
|
||||||
|
setPendingId(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (courseId: string) => {
|
||||||
|
setPendingId(courseId)
|
||||||
|
startTransition(async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("courseId", courseId)
|
||||||
|
const res = await deleteElectiveCourseAction(null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message ?? "Course deleted")
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? "Delete failed")
|
||||||
|
}
|
||||||
|
setPendingId(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{courses.length} course{courses.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
{manageResolved && createHref ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a href={createHref}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Course
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courses.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No elective courses"
|
||||||
|
description="There are no elective courses available."
|
||||||
|
icon={Plus}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{courses.map((course) => {
|
||||||
|
const isFull = course.enrolledCount >= course.capacity
|
||||||
|
const isPendingThis = isPending && pendingId === course.id
|
||||||
|
return (
|
||||||
|
<Card key={course.id} className="flex h-full flex-col">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||||
|
<Badge variant={ELECTIVE_STATUS_COLORS[course.status]} className="shrink-0">
|
||||||
|
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{course.subjectName ? (
|
||||||
|
<Badge variant="outline">{course.subjectName}</Badge>
|
||||||
|
) : null}
|
||||||
|
{course.gradeName ? (
|
||||||
|
<Badge variant="outline">{course.gradeName}</Badge>
|
||||||
|
) : null}
|
||||||
|
<span>Credit: {course.credit}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{course.description ? (
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||||
|
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Mode:</span>{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{SELECTION_MODE_LABELS[course.selectionMode]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{course.enrolledCount}/{course.capacity}
|
||||||
|
{isFull ? " (Full)" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{course.classroom ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Room:</span>{" "}
|
||||||
|
<span className="font-medium">{course.classroom}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{course.schedule ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{manageResolved ? (
|
||||||
|
<div className="mt-auto flex flex-wrap gap-2 pt-2">
|
||||||
|
{editHrefBuilder ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<a href={editHrefBuilder(course.id)}>
|
||||||
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{course.status === "draft" || course.status === "closed" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isPendingThis}
|
||||||
|
onClick={() => runAction(openSelectionAction, course.id, "Selection opened")}
|
||||||
|
>
|
||||||
|
<Unlock className="mr-1 h-3 w-3" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{course.status === "open" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isPendingThis}
|
||||||
|
onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")}
|
||||||
|
>
|
||||||
|
<Lock className="mr-1 h-3 w-3" />
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{course.selectionMode === "lottery" && course.status !== "draft" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isPendingThis}
|
||||||
|
onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")}
|
||||||
|
>
|
||||||
|
<Shuffle className="mr-1 h-3 w-3" />
|
||||||
|
Lottery
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={isPendingThis}
|
||||||
|
onClick={() => handleDelete(course.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
src/modules/elective/components/student-selection-view.tsx
Normal file
215
src/modules/elective/components/student-selection-view.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
import {
|
||||||
|
COURSE_SELECTION_STATUS_COLORS,
|
||||||
|
COURSE_SELECTION_STATUS_LABELS,
|
||||||
|
ELECTIVE_STATUS_LABELS,
|
||||||
|
SELECTION_MODE_LABELS,
|
||||||
|
} from "../types"
|
||||||
|
import type {
|
||||||
|
CourseSelectionWithDetails,
|
||||||
|
ElectiveCourseWithDetails,
|
||||||
|
} from "../types"
|
||||||
|
import { selectCourseAction, dropCourseAction } from "../actions"
|
||||||
|
|
||||||
|
export function StudentSelectionView({
|
||||||
|
availableCourses,
|
||||||
|
mySelections,
|
||||||
|
}: {
|
||||||
|
availableCourses: ElectiveCourseWithDetails[]
|
||||||
|
mySelections: CourseSelectionWithDetails[]
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const activeSelections = mySelections.filter((s) =>
|
||||||
|
["selected", "enrolled", "waitlist"].includes(s.status)
|
||||||
|
)
|
||||||
|
const selectedCourseIds = new Set(
|
||||||
|
activeSelections.map((s) => s.courseId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelect = (courseId: string) => {
|
||||||
|
setPendingId(courseId)
|
||||||
|
startTransition(async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("courseId", courseId)
|
||||||
|
const res = await selectCourseAction(null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? "Failed to select course")
|
||||||
|
}
|
||||||
|
setPendingId(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (courseId: string) => {
|
||||||
|
setPendingId(courseId)
|
||||||
|
startTransition(async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("courseId", courseId)
|
||||||
|
const res = await dropCourseAction(null, formData)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(res.message)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? "Failed to drop course")
|
||||||
|
}
|
||||||
|
setPendingId(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">My Selections</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{activeSelections.length} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{activeSelections.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No selections yet"
|
||||||
|
description="Browse available courses below and select your electives."
|
||||||
|
icon={BookOpen}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{activeSelections.map((sel) => (
|
||||||
|
<Card key={sel.id}>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{sel.courseName ?? "Unknown course"}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant={COURSE_SELECTION_STATUS_COLORS[sel.status]}>
|
||||||
|
{COURSE_SELECTION_STATUS_LABELS[sel.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{sel.lotteryRank ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Lottery rank: #{sel.lotteryRank}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={isPending && pendingId === sel.courseId}
|
||||||
|
onClick={() => handleDrop(sel.courseId)}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-1 h-3 w-3" />
|
||||||
|
Drop
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Available Courses</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{availableCourses.length} open
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{availableCourses.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No available courses"
|
||||||
|
description="There are no elective courses open for selection right now."
|
||||||
|
icon={BookOpen}
|
||||||
|
className="h-auto border-none shadow-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{availableCourses.map((course) => {
|
||||||
|
const isFull = course.enrolledCount >= course.capacity
|
||||||
|
const alreadySelected = selectedCourseIds.has(course.id)
|
||||||
|
const isPendingThis = isPending && pendingId === course.id
|
||||||
|
return (
|
||||||
|
<Card key={course.id} className="flex h-full flex-col">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{ELECTIVE_STATUS_LABELS[course.status]}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{course.subjectName ? (
|
||||||
|
<Badge variant="outline">{course.subjectName}</Badge>
|
||||||
|
) : null}
|
||||||
|
<span>Credit: {course.credit}</span>
|
||||||
|
<span>· {SELECTION_MODE_LABELS[course.selectionMode]}</span>
|
||||||
|
</div>
|
||||||
|
{course.description ? (
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Teacher:</span>{" "}
|
||||||
|
<span className="font-medium">{course.teacherName ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Capacity:</span>{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{course.enrolledCount}/{course.capacity}
|
||||||
|
{isFull ? " (Full)" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{course.schedule ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">Schedule:</span> {course.schedule}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
{alreadySelected ? (
|
||||||
|
<Button variant="secondary" size="sm" disabled>
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Already selected
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isPendingThis}
|
||||||
|
onClick={() => handleSelect(course.id)}
|
||||||
|
>
|
||||||
|
{isPendingThis ? "Selecting..." : "Select"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
src/modules/elective/data-access-operations.ts
Normal file
217
src/modules/elective/data-access-operations.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
courseSelections,
|
||||||
|
electiveCourses,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import type { CourseSelectionStatus } from "./types"
|
||||||
|
|
||||||
|
export async function runLottery(courseId: string): Promise<{
|
||||||
|
enrolled: number
|
||||||
|
waitlist: number
|
||||||
|
}> {
|
||||||
|
const [course] = await db
|
||||||
|
.select()
|
||||||
|
.from(electiveCourses)
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
.limit(1)
|
||||||
|
if (!course) throw new Error("Course not found")
|
||||||
|
|
||||||
|
const selections = await db
|
||||||
|
.select()
|
||||||
|
.from(courseSelections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(courseSelections.courseId, courseId),
|
||||||
|
eq(courseSelections.status, "selected")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||||
|
|
||||||
|
if (selections.length === 0) {
|
||||||
|
return { enrolled: 0, waitlist: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffled = [...selections].sort(() => Math.random() - 0.5)
|
||||||
|
const capacity = course.capacity
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
let enrolledCount = 0
|
||||||
|
let waitlistCount = 0
|
||||||
|
for (let i = 0; i < shuffled.length; i++) {
|
||||||
|
const sel = shuffled[i]
|
||||||
|
const rank = i + 1
|
||||||
|
if (i < capacity) {
|
||||||
|
await db
|
||||||
|
.update(courseSelections)
|
||||||
|
.set({
|
||||||
|
status: "enrolled",
|
||||||
|
lotteryRank: rank,
|
||||||
|
enrolledAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(courseSelections.id, sel.id))
|
||||||
|
enrolledCount++
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(courseSelections)
|
||||||
|
.set({
|
||||||
|
status: "waitlist",
|
||||||
|
lotteryRank: rank,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(courseSelections.id, sel.id))
|
||||||
|
waitlistCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(electiveCourses)
|
||||||
|
.set({ enrolledCount, status: "closed", updatedAt: now })
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
|
||||||
|
return { enrolled: enrolledCount, waitlist: waitlistCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectCourse(
|
||||||
|
courseId: string,
|
||||||
|
studentId: string,
|
||||||
|
priority?: number
|
||||||
|
): Promise<{ status: CourseSelectionStatus; message: string }> {
|
||||||
|
const [course] = await db
|
||||||
|
.select()
|
||||||
|
.from(electiveCourses)
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
.limit(1)
|
||||||
|
if (!course) throw new Error("Course not found")
|
||||||
|
if (course.status !== "open") throw new Error("Course selection is not open")
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
if (course.selectionStartAt && now < course.selectionStartAt) {
|
||||||
|
throw new Error("Selection has not started yet")
|
||||||
|
}
|
||||||
|
if (course.selectionEndAt && now > course.selectionEndAt) {
|
||||||
|
throw new Error("Selection has ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(courseSelections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(courseSelections.courseId, courseId),
|
||||||
|
eq(courseSelections.studentId, studentId),
|
||||||
|
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (existing) throw new Error("Already selected this course")
|
||||||
|
|
||||||
|
const id = createId()
|
||||||
|
let status: CourseSelectionStatus = "selected"
|
||||||
|
let enrolledAt: Date | null = null
|
||||||
|
|
||||||
|
if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) {
|
||||||
|
status = "enrolled"
|
||||||
|
enrolledAt = now
|
||||||
|
await db
|
||||||
|
.update(electiveCourses)
|
||||||
|
.set({
|
||||||
|
enrolledCount: course.enrolledCount + 1,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
} else if (course.selectionMode === "fcfs") {
|
||||||
|
status = "waitlist"
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(courseSelections).values({
|
||||||
|
id,
|
||||||
|
courseId,
|
||||||
|
studentId,
|
||||||
|
status,
|
||||||
|
priority: priority ?? 1,
|
||||||
|
selectedAt: now,
|
||||||
|
enrolledAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
message:
|
||||||
|
status === "enrolled"
|
||||||
|
? "Enrolled successfully"
|
||||||
|
: status === "waitlist"
|
||||||
|
? "Added to waitlist"
|
||||||
|
: "Selection submitted",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropCourse(
|
||||||
|
courseId: string,
|
||||||
|
studentId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(courseSelections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(courseSelections.courseId, courseId),
|
||||||
|
eq(courseSelections.studentId, studentId),
|
||||||
|
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (!existing) throw new Error("No active selection found")
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
await db
|
||||||
|
.update(courseSelections)
|
||||||
|
.set({ status: "dropped", droppedAt: now, updatedAt: now })
|
||||||
|
.where(eq(courseSelections.id, existing.id))
|
||||||
|
|
||||||
|
if (existing.status === "enrolled") {
|
||||||
|
const [course] = await db
|
||||||
|
.select()
|
||||||
|
.from(electiveCourses)
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
.limit(1)
|
||||||
|
if (course && course.selectionMode === "fcfs") {
|
||||||
|
const newEnrolledCount = Math.max(0, course.enrolledCount - 1)
|
||||||
|
await db
|
||||||
|
.update(electiveCourses)
|
||||||
|
.set({ enrolledCount: newEnrolledCount, updatedAt: now })
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
|
||||||
|
const [nextWait] = await db
|
||||||
|
.select()
|
||||||
|
.from(courseSelections)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(courseSelections.courseId, courseId),
|
||||||
|
eq(courseSelections.status, "waitlist")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||||
|
.limit(1)
|
||||||
|
if (nextWait) {
|
||||||
|
await db
|
||||||
|
.update(courseSelections)
|
||||||
|
.set({
|
||||||
|
status: "enrolled",
|
||||||
|
enrolledAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(courseSelections.id, nextWait.id))
|
||||||
|
await db
|
||||||
|
.update(electiveCourses)
|
||||||
|
.set({ enrolledCount: newEnrolledCount + 1, updatedAt: now })
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/modules/elective/data-access-selections.ts
Normal file
189
src/modules/elective/data-access-selections.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
classes,
|
||||||
|
classEnrollments,
|
||||||
|
courseSelections,
|
||||||
|
electiveCourses,
|
||||||
|
grades,
|
||||||
|
subjects,
|
||||||
|
users,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CourseSelectionStatus,
|
||||||
|
CourseSelectionWithDetails,
|
||||||
|
ElectiveCourseStatus,
|
||||||
|
ElectiveCourseWithDetails,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const toIso = (d: Date | null | undefined): string | null =>
|
||||||
|
d ? d.toISOString() : null
|
||||||
|
|
||||||
|
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||||
|
|
||||||
|
const mapCourseRow = (
|
||||||
|
r: typeof electiveCourses.$inferSelect & {
|
||||||
|
teacherName: string | null
|
||||||
|
subjectName: string | null
|
||||||
|
gradeName: string | null
|
||||||
|
}
|
||||||
|
): ElectiveCourseWithDetails => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
subjectId: r.subjectId,
|
||||||
|
teacherId: r.teacherId,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
description: r.description,
|
||||||
|
capacity: r.capacity,
|
||||||
|
enrolledCount: r.enrolledCount,
|
||||||
|
classroom: r.classroom,
|
||||||
|
schedule: r.schedule,
|
||||||
|
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
|
||||||
|
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
|
||||||
|
selectionStartAt: toIso(r.selectionStartAt),
|
||||||
|
selectionEndAt: toIso(r.selectionEndAt),
|
||||||
|
status: r.status,
|
||||||
|
selectionMode: r.selectionMode,
|
||||||
|
credit: String(r.credit),
|
||||||
|
createdAt: toIsoRequired(r.createdAt),
|
||||||
|
updatedAt: toIsoRequired(r.updatedAt),
|
||||||
|
teacherName: r.teacherName,
|
||||||
|
subjectName: r.subjectName,
|
||||||
|
gradeName: r.gradeName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildCourseSelect = () =>
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: electiveCourses.id,
|
||||||
|
name: electiveCourses.name,
|
||||||
|
subjectId: electiveCourses.subjectId,
|
||||||
|
teacherId: electiveCourses.teacherId,
|
||||||
|
gradeId: electiveCourses.gradeId,
|
||||||
|
description: electiveCourses.description,
|
||||||
|
capacity: electiveCourses.capacity,
|
||||||
|
enrolledCount: electiveCourses.enrolledCount,
|
||||||
|
classroom: electiveCourses.classroom,
|
||||||
|
schedule: electiveCourses.schedule,
|
||||||
|
startDate: electiveCourses.startDate,
|
||||||
|
endDate: electiveCourses.endDate,
|
||||||
|
selectionStartAt: electiveCourses.selectionStartAt,
|
||||||
|
selectionEndAt: electiveCourses.selectionEndAt,
|
||||||
|
status: electiveCourses.status,
|
||||||
|
selectionMode: electiveCourses.selectionMode,
|
||||||
|
credit: electiveCourses.credit,
|
||||||
|
createdAt: electiveCourses.createdAt,
|
||||||
|
updatedAt: electiveCourses.updatedAt,
|
||||||
|
teacherName: users.name,
|
||||||
|
subjectName: subjects.name,
|
||||||
|
gradeName: grades.name,
|
||||||
|
})
|
||||||
|
.from(electiveCourses)
|
||||||
|
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
|
||||||
|
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
|
||||||
|
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
|
||||||
|
|
||||||
|
const mapSelectionRow = (
|
||||||
|
r: typeof courseSelections.$inferSelect & {
|
||||||
|
courseName: string | null
|
||||||
|
studentName: string | null
|
||||||
|
courseCapacity: number | null
|
||||||
|
courseEnrolledCount: number | null
|
||||||
|
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
|
||||||
|
}
|
||||||
|
): CourseSelectionWithDetails => ({
|
||||||
|
id: r.id,
|
||||||
|
courseId: r.courseId,
|
||||||
|
studentId: r.studentId,
|
||||||
|
status: r.status as CourseSelectionStatus,
|
||||||
|
priority: r.priority,
|
||||||
|
selectedAt: toIsoRequired(r.selectedAt),
|
||||||
|
enrolledAt: toIso(r.enrolledAt),
|
||||||
|
droppedAt: toIso(r.droppedAt),
|
||||||
|
lotteryRank: r.lotteryRank,
|
||||||
|
createdAt: toIsoRequired(r.createdAt),
|
||||||
|
updatedAt: toIsoRequired(r.updatedAt),
|
||||||
|
courseName: r.courseName,
|
||||||
|
studentName: r.studentName,
|
||||||
|
courseCapacity: r.courseCapacity,
|
||||||
|
courseEnrolledCount: r.courseEnrolledCount,
|
||||||
|
courseStatus: r.courseStatus as ElectiveCourseStatus | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectionDetailSelect = () =>
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: courseSelections.id,
|
||||||
|
courseId: courseSelections.courseId,
|
||||||
|
studentId: courseSelections.studentId,
|
||||||
|
status: courseSelections.status,
|
||||||
|
priority: courseSelections.priority,
|
||||||
|
selectedAt: courseSelections.selectedAt,
|
||||||
|
enrolledAt: courseSelections.enrolledAt,
|
||||||
|
droppedAt: courseSelections.droppedAt,
|
||||||
|
lotteryRank: courseSelections.lotteryRank,
|
||||||
|
createdAt: courseSelections.createdAt,
|
||||||
|
updatedAt: courseSelections.updatedAt,
|
||||||
|
courseName: electiveCourses.name,
|
||||||
|
studentName: users.name,
|
||||||
|
courseCapacity: electiveCourses.capacity,
|
||||||
|
courseEnrolledCount: electiveCourses.enrolledCount,
|
||||||
|
courseStatus: electiveCourses.status,
|
||||||
|
})
|
||||||
|
.from(courseSelections)
|
||||||
|
.leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
|
||||||
|
.leftJoin(users, eq(users.id, courseSelections.studentId))
|
||||||
|
|
||||||
|
export async function getCourseSelections(
|
||||||
|
courseId: string
|
||||||
|
): Promise<CourseSelectionWithDetails[]> {
|
||||||
|
const rows = await selectionDetailSelect()
|
||||||
|
.where(eq(courseSelections.courseId, courseId))
|
||||||
|
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||||
|
return rows.map(mapSelectionRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStudentSelections(
|
||||||
|
studentId: string
|
||||||
|
): Promise<CourseSelectionWithDetails[]> {
|
||||||
|
const rows = await selectionDetailSelect()
|
||||||
|
.where(eq(courseSelections.studentId, studentId))
|
||||||
|
.orderBy(desc(courseSelections.selectedAt))
|
||||||
|
return rows.map(mapSelectionRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStudentGradeId(studentId: string): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ gradeId: classes.gradeId })
|
||||||
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(classEnrollments.studentId, studentId),
|
||||||
|
eq(classEnrollments.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
return row?.gradeId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailableCoursesForStudent(
|
||||||
|
studentId: string,
|
||||||
|
gradeId?: string | null
|
||||||
|
): Promise<ElectiveCourseWithDetails[]> {
|
||||||
|
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
|
||||||
|
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
|
||||||
|
if (resolvedGradeId) {
|
||||||
|
conditions.push(
|
||||||
|
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const rows = await buildCourseSelect()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(electiveCourses.createdAt))
|
||||||
|
return rows.map(mapCourseRow)
|
||||||
|
}
|
||||||
242
src/modules/elective/data-access.ts
Normal file
242
src/modules/elective/data-access.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
electiveCourses,
|
||||||
|
grades,
|
||||||
|
subjects,
|
||||||
|
users,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ElectiveCourseStatus,
|
||||||
|
ElectiveCourseWithDetails,
|
||||||
|
GetElectiveCoursesParams,
|
||||||
|
} from "./types"
|
||||||
|
import type {
|
||||||
|
CreateElectiveCourseInput,
|
||||||
|
UpdateElectiveCourseInput,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
const toIso = (d: Date | null | undefined): string | null =>
|
||||||
|
d ? d.toISOString() : null
|
||||||
|
|
||||||
|
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||||
|
|
||||||
|
const buildScopeFilter = (scope: DataScope, userId?: string): SQL | null => {
|
||||||
|
if (scope.type === "all") return null
|
||||||
|
if (scope.type === "owned" && userId) return eq(electiveCourses.teacherId, userId)
|
||||||
|
if (scope.type === "class_taught" && userId) {
|
||||||
|
return eq(electiveCourses.teacherId, userId)
|
||||||
|
}
|
||||||
|
if (scope.type === "grade_managed") {
|
||||||
|
return scope.gradeIds.length > 0
|
||||||
|
? inArray(electiveCourses.gradeId, scope.gradeIds)
|
||||||
|
: sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "class_members") return null
|
||||||
|
if (scope.type === "children") return null
|
||||||
|
return sql`1=0`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapCourseRow = (
|
||||||
|
r: typeof electiveCourses.$inferSelect & {
|
||||||
|
teacherName: string | null
|
||||||
|
subjectName: string | null
|
||||||
|
gradeName: string | null
|
||||||
|
}
|
||||||
|
): ElectiveCourseWithDetails => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
subjectId: r.subjectId,
|
||||||
|
teacherId: r.teacherId,
|
||||||
|
gradeId: r.gradeId,
|
||||||
|
description: r.description,
|
||||||
|
capacity: r.capacity,
|
||||||
|
enrolledCount: r.enrolledCount,
|
||||||
|
classroom: r.classroom,
|
||||||
|
schedule: r.schedule,
|
||||||
|
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
|
||||||
|
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
|
||||||
|
selectionStartAt: toIso(r.selectionStartAt),
|
||||||
|
selectionEndAt: toIso(r.selectionEndAt),
|
||||||
|
status: r.status,
|
||||||
|
selectionMode: r.selectionMode,
|
||||||
|
credit: String(r.credit),
|
||||||
|
createdAt: toIsoRequired(r.createdAt),
|
||||||
|
updatedAt: toIsoRequired(r.updatedAt),
|
||||||
|
teacherName: r.teacherName,
|
||||||
|
subjectName: r.subjectName,
|
||||||
|
gradeName: r.gradeName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildCourseSelect = () =>
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: electiveCourses.id,
|
||||||
|
name: electiveCourses.name,
|
||||||
|
subjectId: electiveCourses.subjectId,
|
||||||
|
teacherId: electiveCourses.teacherId,
|
||||||
|
gradeId: electiveCourses.gradeId,
|
||||||
|
description: electiveCourses.description,
|
||||||
|
capacity: electiveCourses.capacity,
|
||||||
|
enrolledCount: electiveCourses.enrolledCount,
|
||||||
|
classroom: electiveCourses.classroom,
|
||||||
|
schedule: electiveCourses.schedule,
|
||||||
|
startDate: electiveCourses.startDate,
|
||||||
|
endDate: electiveCourses.endDate,
|
||||||
|
selectionStartAt: electiveCourses.selectionStartAt,
|
||||||
|
selectionEndAt: electiveCourses.selectionEndAt,
|
||||||
|
status: electiveCourses.status,
|
||||||
|
selectionMode: electiveCourses.selectionMode,
|
||||||
|
credit: electiveCourses.credit,
|
||||||
|
createdAt: electiveCourses.createdAt,
|
||||||
|
updatedAt: electiveCourses.updatedAt,
|
||||||
|
teacherName: users.name,
|
||||||
|
subjectName: subjects.name,
|
||||||
|
gradeName: grades.name,
|
||||||
|
})
|
||||||
|
.from(electiveCourses)
|
||||||
|
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
|
||||||
|
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
|
||||||
|
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
|
||||||
|
|
||||||
|
export const getElectiveCourses = cache(
|
||||||
|
async (
|
||||||
|
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
|
||||||
|
): Promise<ElectiveCourseWithDetails[]> => {
|
||||||
|
try {
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
if (params?.status)
|
||||||
|
conditions.push(
|
||||||
|
eq(electiveCourses.status, params.status as ElectiveCourseStatus)
|
||||||
|
)
|
||||||
|
if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
|
||||||
|
if (params?.subjectId)
|
||||||
|
conditions.push(eq(electiveCourses.subjectId, params.subjectId))
|
||||||
|
if (params?.teacherId)
|
||||||
|
conditions.push(eq(electiveCourses.teacherId, params.teacherId))
|
||||||
|
if (params?.scope) {
|
||||||
|
const scopeFilter = buildScopeFilter(params.scope, params.currentUserId)
|
||||||
|
if (scopeFilter) conditions.push(scopeFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = buildCourseSelect()
|
||||||
|
const rows = await (conditions.length > 0
|
||||||
|
? query.where(and(...conditions))
|
||||||
|
: query
|
||||||
|
).orderBy(desc(electiveCourses.createdAt))
|
||||||
|
|
||||||
|
return rows.map(mapCourseRow)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getElectiveCourseById = cache(
|
||||||
|
async (id: string): Promise<ElectiveCourseWithDetails | null> => {
|
||||||
|
try {
|
||||||
|
const [row] = await buildCourseSelect()
|
||||||
|
.where(eq(electiveCourses.id, id))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) return null
|
||||||
|
return mapCourseRow(row)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function createElectiveCourse(
|
||||||
|
data: CreateElectiveCourseInput,
|
||||||
|
teacherId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const id = createId()
|
||||||
|
await db.insert(electiveCourses).values({
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
subjectId: data.subjectId,
|
||||||
|
teacherId: data.teacherId ?? teacherId,
|
||||||
|
gradeId: data.gradeId,
|
||||||
|
description: data.description,
|
||||||
|
capacity: data.capacity,
|
||||||
|
enrolledCount: 0,
|
||||||
|
classroom: data.classroom,
|
||||||
|
schedule: data.schedule,
|
||||||
|
startDate: data.startDate ? new Date(data.startDate) : null,
|
||||||
|
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||||
|
selectionStartAt: data.selectionStartAt ? new Date(data.selectionStartAt) : null,
|
||||||
|
selectionEndAt: data.selectionEndAt ? new Date(data.selectionEndAt) : null,
|
||||||
|
status: "draft",
|
||||||
|
selectionMode: data.selectionMode,
|
||||||
|
credit: data.credit,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateElectiveCourse(
|
||||||
|
id: string,
|
||||||
|
data: Partial<UpdateElectiveCourseInput>
|
||||||
|
): Promise<void> {
|
||||||
|
const update: Partial<typeof electiveCourses.$inferSelect> = {}
|
||||||
|
if (data.name !== undefined) update.name = data.name
|
||||||
|
if (data.subjectId !== undefined) update.subjectId = data.subjectId
|
||||||
|
if (data.teacherId !== undefined) update.teacherId = data.teacherId
|
||||||
|
if (data.gradeId !== undefined) update.gradeId = data.gradeId
|
||||||
|
if (data.description !== undefined) update.description = data.description
|
||||||
|
if (data.capacity !== undefined) update.capacity = data.capacity
|
||||||
|
if (data.classroom !== undefined) update.classroom = data.classroom
|
||||||
|
if (data.schedule !== undefined) update.schedule = data.schedule
|
||||||
|
if (data.startDate !== undefined)
|
||||||
|
update.startDate = data.startDate ? new Date(data.startDate) : null
|
||||||
|
if (data.endDate !== undefined)
|
||||||
|
update.endDate = data.endDate ? new Date(data.endDate) : null
|
||||||
|
if (data.selectionStartAt !== undefined)
|
||||||
|
update.selectionStartAt = data.selectionStartAt ? new Date(data.selectionStartAt) : null
|
||||||
|
if (data.selectionEndAt !== undefined)
|
||||||
|
update.selectionEndAt = data.selectionEndAt ? new Date(data.selectionEndAt) : null
|
||||||
|
if (data.status !== undefined) update.status = data.status
|
||||||
|
if (data.selectionMode !== undefined) update.selectionMode = data.selectionMode
|
||||||
|
if (data.credit !== undefined) update.credit = data.credit
|
||||||
|
|
||||||
|
if (Object.keys(update).length === 0) return
|
||||||
|
await db.update(electiveCourses).set(update).where(eq(electiveCourses.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteElectiveCourse(id: string): Promise<void> {
|
||||||
|
await db.delete(electiveCourses).where(eq(electiveCourses.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSelection(courseId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(electiveCourses)
|
||||||
|
.set({ status: "open", updatedAt: new Date() })
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSelection(courseId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(electiveCourses)
|
||||||
|
.set({ status: "closed", updatedAt: new Date() })
|
||||||
|
.where(eq(electiveCourses.id, courseId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubjectOptions(): Promise<{ id: string; name: string }[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: subjects.id, name: subjects.name })
|
||||||
|
.from(subjects)
|
||||||
|
.orderBy(asc(subjects.order), asc(subjects.name))
|
||||||
|
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ElectiveCourseWithDetails }
|
||||||
133
src/modules/elective/schema.ts
Normal file
133
src/modules/elective/schema.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const ElectiveCourseStatusEnum = z.enum([
|
||||||
|
"draft",
|
||||||
|
"open",
|
||||||
|
"closed",
|
||||||
|
"cancelled",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const ElectiveSelectionModeEnum = z.enum(["fcfs", "lottery"])
|
||||||
|
|
||||||
|
export const CourseSelectionStatusEnum = z.enum([
|
||||||
|
"selected",
|
||||||
|
"enrolled",
|
||||||
|
"waitlist",
|
||||||
|
"dropped",
|
||||||
|
"rejected",
|
||||||
|
])
|
||||||
|
|
||||||
|
const emptyToNull = (v: string | undefined | null) =>
|
||||||
|
v && v.length > 0 ? v : null
|
||||||
|
|
||||||
|
const optionalStringToNull = (v: string | undefined | null) =>
|
||||||
|
v === undefined ? undefined : emptyToNull(v)
|
||||||
|
|
||||||
|
export const CreateElectiveCourseSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(255),
|
||||||
|
subjectId: z.string().trim().optional().nullable(),
|
||||||
|
teacherId: z.string().trim().min(1),
|
||||||
|
gradeId: z.string().trim().optional().nullable(),
|
||||||
|
description: z.string().trim().optional().nullable(),
|
||||||
|
capacity: z.coerce.number().int().min(1).max(500).optional(),
|
||||||
|
classroom: z.string().trim().optional().nullable(),
|
||||||
|
schedule: z.string().trim().optional().nullable(),
|
||||||
|
startDate: z.string().trim().optional().nullable(),
|
||||||
|
endDate: z.string().trim().optional().nullable(),
|
||||||
|
selectionStartAt: z.string().trim().optional().nullable(),
|
||||||
|
selectionEndAt: z.string().trim().optional().nullable(),
|
||||||
|
selectionMode: ElectiveSelectionModeEnum.optional(),
|
||||||
|
credit: z.string().trim().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform((v) => ({
|
||||||
|
name: v.name,
|
||||||
|
subjectId: optionalStringToNull(v.subjectId) ?? null,
|
||||||
|
teacherId: v.teacherId,
|
||||||
|
gradeId: optionalStringToNull(v.gradeId) ?? null,
|
||||||
|
description: optionalStringToNull(v.description),
|
||||||
|
capacity: v.capacity ?? 30,
|
||||||
|
classroom: optionalStringToNull(v.classroom),
|
||||||
|
schedule: optionalStringToNull(v.schedule),
|
||||||
|
startDate: optionalStringToNull(v.startDate),
|
||||||
|
endDate: optionalStringToNull(v.endDate),
|
||||||
|
selectionStartAt: optionalStringToNull(v.selectionStartAt),
|
||||||
|
selectionEndAt: optionalStringToNull(v.selectionEndAt),
|
||||||
|
selectionMode: v.selectionMode ?? "fcfs",
|
||||||
|
credit: v.credit && v.credit.length > 0 ? v.credit : "1.0",
|
||||||
|
}))
|
||||||
|
|
||||||
|
export type CreateElectiveCourseInput = z.infer<typeof CreateElectiveCourseSchema>
|
||||||
|
|
||||||
|
export const UpdateElectiveCourseSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(255).optional(),
|
||||||
|
subjectId: z.string().trim().optional().nullable(),
|
||||||
|
teacherId: z.string().trim().min(1).optional(),
|
||||||
|
gradeId: z.string().trim().optional().nullable(),
|
||||||
|
description: z.string().trim().optional().nullable(),
|
||||||
|
capacity: z.coerce.number().int().min(1).max(500).optional(),
|
||||||
|
classroom: z.string().trim().optional().nullable(),
|
||||||
|
schedule: z.string().trim().optional().nullable(),
|
||||||
|
startDate: z.string().trim().optional().nullable(),
|
||||||
|
endDate: z.string().trim().optional().nullable(),
|
||||||
|
selectionStartAt: z.string().trim().optional().nullable(),
|
||||||
|
selectionEndAt: z.string().trim().optional().nullable(),
|
||||||
|
status: ElectiveCourseStatusEnum.optional(),
|
||||||
|
selectionMode: ElectiveSelectionModeEnum.optional(),
|
||||||
|
credit: z.string().trim().optional().nullable(),
|
||||||
|
})
|
||||||
|
.transform((v) => ({
|
||||||
|
...v,
|
||||||
|
subjectId:
|
||||||
|
v.subjectId !== undefined ? optionalStringToNull(v.subjectId) : undefined,
|
||||||
|
gradeId:
|
||||||
|
v.gradeId !== undefined ? optionalStringToNull(v.gradeId) : undefined,
|
||||||
|
description:
|
||||||
|
v.description !== undefined
|
||||||
|
? optionalStringToNull(v.description)
|
||||||
|
: undefined,
|
||||||
|
classroom:
|
||||||
|
v.classroom !== undefined ? optionalStringToNull(v.classroom) : undefined,
|
||||||
|
schedule:
|
||||||
|
v.schedule !== undefined ? optionalStringToNull(v.schedule) : undefined,
|
||||||
|
startDate:
|
||||||
|
v.startDate !== undefined ? optionalStringToNull(v.startDate) : undefined,
|
||||||
|
endDate:
|
||||||
|
v.endDate !== undefined ? optionalStringToNull(v.endDate) : undefined,
|
||||||
|
selectionStartAt:
|
||||||
|
v.selectionStartAt !== undefined
|
||||||
|
? optionalStringToNull(v.selectionStartAt)
|
||||||
|
: undefined,
|
||||||
|
selectionEndAt:
|
||||||
|
v.selectionEndAt !== undefined
|
||||||
|
? optionalStringToNull(v.selectionEndAt)
|
||||||
|
: undefined,
|
||||||
|
credit:
|
||||||
|
v.credit !== undefined
|
||||||
|
? v.credit && v.credit.length > 0
|
||||||
|
? v.credit
|
||||||
|
: "1.0"
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export type UpdateElectiveCourseInput = z.infer<typeof UpdateElectiveCourseSchema>
|
||||||
|
|
||||||
|
export const SelectCourseSchema = z.object({
|
||||||
|
courseId: z.string().trim().min(1),
|
||||||
|
priority: z.coerce.number().int().min(1).max(10).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SelectCourseInput = z.infer<typeof SelectCourseSchema>
|
||||||
|
|
||||||
|
export const DropCourseSchema = z.object({
|
||||||
|
courseId: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DropCourseInput = z.infer<typeof DropCourseSchema>
|
||||||
|
|
||||||
|
export const RunLotterySchema = z.object({
|
||||||
|
courseId: z.string().trim().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RunLotteryInput = z.infer<typeof RunLotterySchema>
|
||||||
108
src/modules/elective/types.ts
Normal file
108
src/modules/elective/types.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
export type ElectiveCourseStatus = "draft" | "open" | "closed" | "cancelled"
|
||||||
|
|
||||||
|
export type ElectiveSelectionMode = "fcfs" | "lottery"
|
||||||
|
|
||||||
|
export type CourseSelectionStatus =
|
||||||
|
| "selected"
|
||||||
|
| "enrolled"
|
||||||
|
| "waitlist"
|
||||||
|
| "dropped"
|
||||||
|
| "rejected"
|
||||||
|
|
||||||
|
export interface ElectiveCourse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
subjectId: string | null
|
||||||
|
teacherId: string
|
||||||
|
gradeId: string | null
|
||||||
|
description: string | null
|
||||||
|
capacity: number
|
||||||
|
enrolledCount: number
|
||||||
|
classroom: string | null
|
||||||
|
schedule: string | null
|
||||||
|
startDate: string | null
|
||||||
|
endDate: string | null
|
||||||
|
selectionStartAt: string | null
|
||||||
|
selectionEndAt: string | null
|
||||||
|
status: ElectiveCourseStatus
|
||||||
|
selectionMode: ElectiveSelectionMode
|
||||||
|
credit: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectiveCourseWithDetails extends ElectiveCourse {
|
||||||
|
teacherName: string | null
|
||||||
|
subjectName: string | null
|
||||||
|
gradeName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseSelection {
|
||||||
|
id: string
|
||||||
|
courseId: string
|
||||||
|
studentId: string
|
||||||
|
status: CourseSelectionStatus
|
||||||
|
priority: number | null
|
||||||
|
selectedAt: string
|
||||||
|
enrolledAt: string | null
|
||||||
|
droppedAt: string | null
|
||||||
|
lotteryRank: number | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseSelectionWithDetails extends CourseSelection {
|
||||||
|
courseName: string | null
|
||||||
|
studentName: string | null
|
||||||
|
courseCapacity: number | null
|
||||||
|
courseEnrolledCount: number | null
|
||||||
|
courseStatus: ElectiveCourseStatus | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetElectiveCoursesParams {
|
||||||
|
status?: ElectiveCourseStatus
|
||||||
|
gradeId?: string
|
||||||
|
subjectId?: string
|
||||||
|
teacherId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ELECTIVE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
|
||||||
|
draft: "Draft",
|
||||||
|
open: "Open",
|
||||||
|
closed: "Closed",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ELECTIVE_STATUS_COLORS: Record<
|
||||||
|
ElectiveCourseStatus,
|
||||||
|
"default" | "secondary" | "destructive" | "outline"
|
||||||
|
> = {
|
||||||
|
draft: "secondary",
|
||||||
|
open: "default",
|
||||||
|
closed: "outline",
|
||||||
|
cancelled: "destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SELECTION_MODE_LABELS: Record<ElectiveSelectionMode, string> = {
|
||||||
|
fcfs: "First Come First Served",
|
||||||
|
lottery: "Lottery",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COURSE_SELECTION_STATUS_LABELS: Record<CourseSelectionStatus, string> = {
|
||||||
|
selected: "Selected",
|
||||||
|
enrolled: "Enrolled",
|
||||||
|
waitlist: "Waitlist",
|
||||||
|
dropped: "Dropped",
|
||||||
|
rejected: "Rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COURSE_SELECTION_STATUS_COLORS: Record<
|
||||||
|
CourseSelectionStatus,
|
||||||
|
"default" | "secondary" | "destructive" | "outline"
|
||||||
|
> = {
|
||||||
|
selected: "secondary",
|
||||||
|
enrolled: "default",
|
||||||
|
waitlist: "outline",
|
||||||
|
dropped: "destructive",
|
||||||
|
rejected: "destructive",
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
GraduationCap,
|
GraduationCap,
|
||||||
Mail,
|
Mail,
|
||||||
CalendarCheck,
|
CalendarCheck,
|
||||||
CalendarClock
|
CalendarClock,
|
||||||
|
Stethoscope,
|
||||||
|
BookMarked
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
@@ -83,6 +85,12 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
|||||||
href: "/admin/announcements",
|
href: "/admin/announcements",
|
||||||
permission: Permissions.ANNOUNCEMENT_MANAGE,
|
permission: Permissions.ANNOUNCEMENT_MANAGE,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Electives",
|
||||||
|
icon: BookMarked,
|
||||||
|
href: "/admin/elective",
|
||||||
|
permission: Permissions.ELECTIVE_MANAGE,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Messages",
|
title: "Messages",
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
@@ -180,6 +188,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
|||||||
href: "/teacher/schedule-changes",
|
href: "/teacher/schedule-changes",
|
||||||
permission: Permissions.SCHEDULE_ADJUST,
|
permission: Permissions.SCHEDULE_ADJUST,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Diagnostic",
|
||||||
|
icon: Stethoscope,
|
||||||
|
href: "/teacher/diagnostic",
|
||||||
|
permission: Permissions.DIAGNOSTIC_READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Electives",
|
||||||
|
icon: BookMarked,
|
||||||
|
href: "/teacher/elective",
|
||||||
|
permission: Permissions.ELECTIVE_MANAGE,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Management",
|
title: "Management",
|
||||||
icon: Briefcase,
|
icon: Briefcase,
|
||||||
@@ -238,6 +258,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
|||||||
href: "/student/attendance",
|
href: "/student/attendance",
|
||||||
permission: Permissions.ATTENDANCE_READ,
|
permission: Permissions.ATTENDANCE_READ,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Diagnostic",
|
||||||
|
icon: Stethoscope,
|
||||||
|
href: "/student/diagnostic",
|
||||||
|
permission: Permissions.DIAGNOSTIC_READ,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Electives",
|
||||||
|
icon: BookMarked,
|
||||||
|
href: "/student/elective",
|
||||||
|
permission: Permissions.ELECTIVE_SELECT,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Announcements",
|
title: "Announcements",
|
||||||
icon: Megaphone,
|
icon: Megaphone,
|
||||||
|
|||||||
144
src/modules/proctoring/actions.ts
Normal file
144
src/modules/proctoring/actions.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { ActionState } from "@/shared/types/action-state"
|
||||||
|
import {
|
||||||
|
requirePermission,
|
||||||
|
requireAuth,
|
||||||
|
PermissionDeniedError,
|
||||||
|
} from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { examSubmissions } from "@/shared/db/schema"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import {
|
||||||
|
recordProctoringEvent,
|
||||||
|
getExamProctoringSummary,
|
||||||
|
getStudentProctoringStatuses,
|
||||||
|
getRecentProctoringEvents,
|
||||||
|
getExamForProctoring,
|
||||||
|
} from "./data-access"
|
||||||
|
import type {
|
||||||
|
ProctoringDashboardData,
|
||||||
|
ProctoringEventType,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const ProctoringEventSchema = z.object({
|
||||||
|
submissionId: z.string().min(1),
|
||||||
|
examId: z.string().min(1),
|
||||||
|
eventType: z.enum([
|
||||||
|
"tab_switch",
|
||||||
|
"window_blur",
|
||||||
|
"copy_attempt",
|
||||||
|
"paste_attempt",
|
||||||
|
"right_click",
|
||||||
|
"devtools_open",
|
||||||
|
"fullscreen_exit",
|
||||||
|
"idle_timeout",
|
||||||
|
]) as z.ZodType<ProctoringEventType>,
|
||||||
|
eventDetail: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const failState = <T>(message: string): ActionState<T> => ({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
|
||||||
|
const successState = <T>(data: T, message?: string): ActionState<T> => ({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生端上报监考事件
|
||||||
|
* 使用 requireAuth() 因为是学生上报自己的事件,不需要管理权限
|
||||||
|
*/
|
||||||
|
export async function recordProctoringEventAction(
|
||||||
|
prevState: ActionState<{ id: string }> | null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ActionState<{ id: string }>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
|
const parsed = ProctoringEventSchema.safeParse({
|
||||||
|
submissionId: formData.get("submissionId"),
|
||||||
|
examId: formData.get("examId"),
|
||||||
|
eventType: formData.get("eventType"),
|
||||||
|
eventDetail: formData.get("eventDetail") ?? undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return failState<{ id: string }>(
|
||||||
|
parsed.error.issues[0]?.message ?? "Invalid event payload",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全校验:submission 必须属于当前学生
|
||||||
|
const submission = await db.query.examSubmissions.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(examSubmissions.id, parsed.data.submissionId),
|
||||||
|
eq(examSubmissions.studentId, ctx.userId),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if (!submission) {
|
||||||
|
return failState<{ id: string }>("Submission not found for current user")
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await recordProctoringEvent({
|
||||||
|
submissionId: parsed.data.submissionId,
|
||||||
|
studentId: ctx.userId,
|
||||||
|
examId: parsed.data.examId,
|
||||||
|
eventType: parsed.data.eventType,
|
||||||
|
eventDetail: parsed.data.eventDetail,
|
||||||
|
})
|
||||||
|
|
||||||
|
return successState({ id: event.id }, "Event recorded")
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return failState<{ id: string }>(error.message)
|
||||||
|
}
|
||||||
|
console.error("recordProctoringEventAction error:", error)
|
||||||
|
return failState<{ id: string }>("Failed to record proctoring event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取监考面板数据(教师/管理员使用)
|
||||||
|
* 需要 EXAM_PROCTOR 权限
|
||||||
|
*/
|
||||||
|
export async function getProctoringDashboardAction(
|
||||||
|
examId: string,
|
||||||
|
): Promise<ActionState<ProctoringDashboardData>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.EXAM_PROCTOR)
|
||||||
|
|
||||||
|
if (!examId) {
|
||||||
|
return failState<ProctoringDashboardData>("Exam ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const exam = await getExamForProctoring(examId)
|
||||||
|
if (!exam) {
|
||||||
|
return failState<ProctoringDashboardData>("Exam not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [summary, students, recentEvents] = await Promise.all([
|
||||||
|
getExamProctoringSummary(examId),
|
||||||
|
getStudentProctoringStatuses(examId),
|
||||||
|
getRecentProctoringEvents(examId, 20),
|
||||||
|
])
|
||||||
|
|
||||||
|
return successState<ProctoringDashboardData>({
|
||||||
|
summary,
|
||||||
|
students,
|
||||||
|
recentEvents,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return failState<ProctoringDashboardData>(error.message)
|
||||||
|
}
|
||||||
|
console.error("getProctoringDashboardAction error:", error)
|
||||||
|
return failState<ProctoringDashboardData>("Failed to load proctoring dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/modules/proctoring/components/anti-cheat-monitor.tsx
Normal file
225
src/modules/proctoring/components/anti-cheat-monitor.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react"
|
||||||
|
import { AlertTriangle, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { recordProctoringEventAction } from "../actions"
|
||||||
|
import type { ProctoringEventType } from "../types"
|
||||||
|
import { PROCTORING_EVENT_LABELS } from "../types"
|
||||||
|
|
||||||
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟
|
||||||
|
const REPORT_THROTTLE_MS = 1500 // 同类事件最小上报间隔
|
||||||
|
|
||||||
|
type AntiCheatMonitorProps = {
|
||||||
|
examId: string
|
||||||
|
submissionId: string
|
||||||
|
/** 是否启用防作弊监控(通常 proctored 模式才启用) */
|
||||||
|
enabled: boolean
|
||||||
|
/** 是否强制全屏(proctored 模式下为 true) */
|
||||||
|
forceFullscreen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type WarningState = {
|
||||||
|
visible: boolean
|
||||||
|
message: string
|
||||||
|
eventType?: ProctoringEventType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生端防作弊监控组件
|
||||||
|
* 监听各类异常行为并上报到服务端
|
||||||
|
*/
|
||||||
|
export function AntiCheatMonitor({
|
||||||
|
examId,
|
||||||
|
submissionId,
|
||||||
|
enabled,
|
||||||
|
forceFullscreen = false,
|
||||||
|
}: AntiCheatMonitorProps) {
|
||||||
|
const [warning, setWarning] = useState<WarningState>({ visible: false, message: "" })
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const lastReportRef = useRef<Map<ProctoringEventType, number>>(new Map())
|
||||||
|
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const reportEvent = useCallback(
|
||||||
|
async (eventType: ProctoringEventType, detail?: string) => {
|
||||||
|
// 节流:同类事件在短时间内只上报一次
|
||||||
|
const now = Date.now()
|
||||||
|
const last = lastReportRef.current.get(eventType) ?? 0
|
||||||
|
if (now - last < REPORT_THROTTLE_MS) return
|
||||||
|
lastReportRef.current.set(eventType, now)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("submissionId", submissionId)
|
||||||
|
formData.set("examId", examId)
|
||||||
|
formData.set("eventType", eventType)
|
||||||
|
if (detail) formData.set("eventDetail", detail)
|
||||||
|
await recordProctoringEventAction(null, formData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to report proctoring event:", error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[examId, submissionId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const showWarning = useCallback(
|
||||||
|
(eventType: ProctoringEventType, message?: string) => {
|
||||||
|
const text = message ?? PROCTORING_EVENT_LABELS[eventType]
|
||||||
|
setWarning({ visible: true, message: text, eventType })
|
||||||
|
reportEvent(eventType, message)
|
||||||
|
},
|
||||||
|
[reportEvent],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetIdleTimer = useCallback(() => {
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
|
||||||
|
idleTimerRef.current = setTimeout(() => {
|
||||||
|
showWarning("idle_timeout", "空闲超时(5 分钟无操作)")
|
||||||
|
}, IDLE_TIMEOUT_MS)
|
||||||
|
}, [showWarning])
|
||||||
|
|
||||||
|
// 进入/退出全屏
|
||||||
|
const enterFullscreen = useCallback(() => {
|
||||||
|
const elem = document.documentElement
|
||||||
|
if (elem.requestFullscreen) {
|
||||||
|
elem.requestFullscreen().catch(() => {
|
||||||
|
// 全屏请求失败(如用户未交互),忽略
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const exitFullscreen = useCallback(() => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen().catch(() => {})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 主副作用:注册事件监听
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
showWarning("tab_switch", "切换到其他标签页或最小化窗口")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
showWarning("window_blur", "窗口失去焦点")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (e: ClipboardEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
showWarning("copy_attempt", "尝试复制内容")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
showWarning("paste_attempt", "尝试粘贴内容")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
showWarning("right_click", "尝试右键点击")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// F12 / Ctrl+Shift+I / Ctrl+Shift+J / Ctrl+U
|
||||||
|
const isDevtools =
|
||||||
|
e.key === "F12" ||
|
||||||
|
(e.ctrlKey && e.shiftKey && (e.key === "I" || e.key === "i" || e.key === "J" || e.key === "j")) ||
|
||||||
|
(e.ctrlKey && (e.key === "U" || e.key === "u"))
|
||||||
|
if (isDevtools) {
|
||||||
|
e.preventDefault()
|
||||||
|
showWarning("devtools_open", "尝试打开开发者工具")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
const fs = !!document.fullscreenElement
|
||||||
|
setIsFullscreen(fs)
|
||||||
|
if (!fs && forceFullscreen) {
|
||||||
|
showWarning("fullscreen_exit", "退出了全屏模式")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUserActivity = () => {
|
||||||
|
resetIdleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
window.addEventListener("blur", handleBlur)
|
||||||
|
document.addEventListener("copy", handleCopy)
|
||||||
|
document.addEventListener("paste", handlePaste)
|
||||||
|
document.addEventListener("contextmenu", handleContextMenu)
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
document.addEventListener("fullscreenchange", handleFullscreenChange)
|
||||||
|
document.addEventListener("mousemove", handleUserActivity)
|
||||||
|
document.addEventListener("keydown", handleUserActivity)
|
||||||
|
document.addEventListener("click", handleUserActivity)
|
||||||
|
|
||||||
|
resetIdleTimer()
|
||||||
|
|
||||||
|
if (forceFullscreen) {
|
||||||
|
enterFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
window.removeEventListener("blur", handleBlur)
|
||||||
|
document.removeEventListener("copy", handleCopy)
|
||||||
|
document.removeEventListener("paste", handlePaste)
|
||||||
|
document.removeEventListener("contextmenu", handleContextMenu)
|
||||||
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
document.removeEventListener("fullscreenchange", handleFullscreenChange)
|
||||||
|
document.removeEventListener("mousemove", handleUserActivity)
|
||||||
|
document.removeEventListener("keydown", handleUserActivity)
|
||||||
|
document.removeEventListener("click", handleUserActivity)
|
||||||
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
|
||||||
|
if (forceFullscreen) exitFullscreen()
|
||||||
|
}
|
||||||
|
}, [enabled, forceFullscreen, showWarning, resetIdleTimer, enterFullscreen, exitFullscreen])
|
||||||
|
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{forceFullscreen && !isFullscreen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 p-6">
|
||||||
|
<div className="max-w-md space-y-4 text-center">
|
||||||
|
<AlertTriangle className="mx-auto h-12 w-12 text-destructive" />
|
||||||
|
<h2 className="text-xl font-semibold">请进入全屏模式继续考试</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
监考模式要求在全屏下进行考试。点击下方按钮重新进入全屏。
|
||||||
|
</p>
|
||||||
|
<Button onClick={enterFullscreen}>进入全屏</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warning.visible && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-destructive/50 bg-destructive/10 p-4 shadow-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-destructive">监考警告</div>
|
||||||
|
<div className="text-sm text-foreground">{warning.message}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
该行为已被记录并上报监考教师
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWarning((s) => ({ ...s, visible: false }))}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="关闭警告"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
230
src/modules/proctoring/components/exam-mode-config.tsx
Normal file
230
src/modules/proctoring/components/exam-mode-config.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type Control, type FieldPath, useWatch } from "react-hook-form"
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription,
|
||||||
|
} from "@/shared/components/ui/form"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Switch } from "@/shared/components/ui/switch"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
import type { ExamMode } from "../types"
|
||||||
|
import { EXAM_MODE_LABELS } from "../types"
|
||||||
|
|
||||||
|
const EXAM_MODES: ExamMode[] = ["homework", "timed", "proctored"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 考试模式配置表单值约束
|
||||||
|
* 集成到考试创建/编辑表单时,表单值需满足此接口
|
||||||
|
*/
|
||||||
|
export interface ExamModeConfigFieldValues {
|
||||||
|
examMode: ExamMode
|
||||||
|
durationMinutes: number | null
|
||||||
|
shuffleQuestions: boolean
|
||||||
|
allowLateStart: boolean
|
||||||
|
lateStartGraceMinutes: number
|
||||||
|
antiCheatEnabled: boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExamModeConfigProps<T extends ExamModeConfigFieldValues> = {
|
||||||
|
control: Control<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||||
|
control,
|
||||||
|
}: ExamModeConfigProps<T>) {
|
||||||
|
const examMode = useWatch({ control, name: "examMode" as FieldPath<T> }) as ExamMode
|
||||||
|
const showDuration = examMode === "timed" || examMode === "proctored"
|
||||||
|
const showProctorOptions = examMode === "proctored"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>考试模式</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
选择考试模式并配置相关选项。监考模式会启用防作弊监控。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={"examMode" as FieldPath<T>}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>模式</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
value={field.value as ExamMode}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
className="grid gap-3 md:grid-cols-3"
|
||||||
|
>
|
||||||
|
{EXAM_MODES.map((mode) => (
|
||||||
|
<Label
|
||||||
|
key={mode}
|
||||||
|
htmlFor={`exam-mode-${mode}`}
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-md border p-3 has-[:checked]:border-primary has-[:checked]:bg-accent"
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
id={`exam-mode-${mode}`}
|
||||||
|
value={mode}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{EXAM_MODE_LABELS[mode]}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{mode === "homework" && "学生可在任意时间作答,无时间限制"}
|
||||||
|
{mode === "timed" && "限时作答,到时自动提交"}
|
||||||
|
{mode === "proctored" && "限时作答 + 防作弊监控 + 强制全屏"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showDuration && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={"durationMinutes" as FieldPath<T>}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>考试时长(分钟)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{examMode === "proctored"
|
||||||
|
? "监考模式下必须设置考试时长"
|
||||||
|
: "学生开始作答后,到时自动提交"}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={(field.value as number | null) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value === "" ? null : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showProctorOptions && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={"shuffleQuestions" as FieldPath<T>}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>题目乱序</FormLabel>
|
||||||
|
<FormDescription>每位学生看到的题目顺序随机</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={"antiCheatEnabled" as FieldPath<T>}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>启用防作弊监控</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
监听切屏、复制、右键、开发者工具等异常行为
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={"allowLateStart" as FieldPath<T>}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>允许迟开始</FormLabel>
|
||||||
|
<FormDescription>允许学生在考试开始后一段时间内进入</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={"lateStartGraceMinutes" as FieldPath<T>}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>迟到宽限时间(分钟)</FormLabel>
|
||||||
|
<FormDescription>超过此时间后不允许新学生进入</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={(field.value as number) ?? 0}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
286
src/modules/proctoring/components/proctoring-dashboard.tsx
Normal file
286
src/modules/proctoring/components/proctoring-dashboard.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/components/ui/table"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
|
||||||
|
import { getProctoringDashboardAction } from "../actions"
|
||||||
|
import type {
|
||||||
|
ProctoringDashboardData,
|
||||||
|
ProctoringEventType,
|
||||||
|
} from "../types"
|
||||||
|
import { PROCTORING_EVENT_LABELS, EXAM_MODE_LABELS } from "../types"
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_MS = 10_000
|
||||||
|
|
||||||
|
const formatTime = (iso: string | null): string => {
|
||||||
|
if (!iso) return "—"
|
||||||
|
return new Date(iso).toLocaleString("zh-CN", {
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
|
month: "2-digit", day: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = (status: string | null) => {
|
||||||
|
if (!status) return <Badge variant="outline">未开始</Badge>
|
||||||
|
if (status === "started") return <Badge variant="secondary">已开始</Badge>
|
||||||
|
if (status === "submitted") return <Badge>已提交</Badge>
|
||||||
|
if (status === "graded") return <Badge>已批改</Badge>
|
||||||
|
return <Badge variant="outline">{status}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: number | string
|
||||||
|
hint?: string
|
||||||
|
highlight?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value, hint, highlight }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={cn(highlight && "border-destructive/50")}>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-md bg-muted",
|
||||||
|
highlight && "bg-destructive/10 text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{label}</div>
|
||||||
|
<div className="text-xl font-semibold">{value}</div>
|
||||||
|
{hint && <div className="text-xs text-muted-foreground">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProctoringDashboardProps = {
|
||||||
|
examId: string
|
||||||
|
initialData: ProctoringDashboardData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProctoringDashboard({
|
||||||
|
examId,
|
||||||
|
initialData,
|
||||||
|
}: ProctoringDashboardProps) {
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
|
const canProctor = hasPermission(Permissions.EXAM_PROCTOR)
|
||||||
|
|
||||||
|
const [data, setData] = useState<ProctoringDashboardData | null>(initialData)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(
|
||||||
|
initialData ? new Date() : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!canProctor) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await getProctoringDashboardAction(examId)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setData(result.data)
|
||||||
|
setLastUpdated(new Date())
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh proctoring dashboard:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [examId, canProctor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canProctor) return
|
||||||
|
const timer = setInterval(refresh, REFRESH_INTERVAL_MS)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [refresh, canProctor])
|
||||||
|
|
||||||
|
if (!canProctor) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 text-center text-muted-foreground">
|
||||||
|
您没有监考权限(exam:proctor)。
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 text-center text-muted-foreground">
|
||||||
|
暂无监考数据
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, students, recentEvents } = data
|
||||||
|
const notStarted = summary.totalStudents - summary.startedStudents - summary.submittedStudents
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{summary.examTitle}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
模式:{EXAM_MODE_LABELS[summary.examMode]} · 最后更新:
|
||||||
|
{lastUpdated ? formatTime(lastUpdated.toISOString()) : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<StatCard icon={<Users className="h-4 w-4" />} label="学生总数" value={summary.totalStudents} hint={`未开始 ${notStarted}`} />
|
||||||
|
<StatCard icon={<Clock className="h-4 w-4" />} label="已开始 / 已提交" value={`${summary.startedStudents} / ${summary.submittedStudents}`} />
|
||||||
|
<StatCard icon={<ClipboardList className="h-4 w-4" />} label="异常事件总数" value={summary.totalEvents} />
|
||||||
|
<StatCard icon={<AlertTriangle className="h-4 w-4" />} label="异常学生数" value={summary.abnormalStudents} highlight={summary.abnormalStudents > 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>事件类型分布</CardTitle>
|
||||||
|
<CardDescription>各类监考事件触发次数</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(summary.eventsByType) as ProctoringEventType[]).map((type) => {
|
||||||
|
const count = summary.eventsByType[type]
|
||||||
|
if (count === 0) return null
|
||||||
|
return (
|
||||||
|
<Badge key={type} variant="destructive">
|
||||||
|
{PROCTORING_EVENT_LABELS[type]}:{count}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{summary.totalEvents === 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">暂无异常事件</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>学生监考状态</CardTitle>
|
||||||
|
<CardDescription>异常学生(事件数 ≥ 3)高亮显示</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">暂无学生提交记录</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>学生</TableHead>
|
||||||
|
<TableHead>提交状态</TableHead>
|
||||||
|
<TableHead className="text-right">事件数</TableHead>
|
||||||
|
<TableHead>最后事件</TableHead>
|
||||||
|
<TableHead>异常类型</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{students.map((s) => (
|
||||||
|
<TableRow key={s.studentId} className={cn(s.isAbnormal && "bg-destructive/5")}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{s.studentName}
|
||||||
|
{s.isAbnormal && <AlertTriangle className="ml-2 inline h-3 w-3 text-destructive" />}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{statusBadge(s.submissionStatus)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{s.eventCount > 0 ? (
|
||||||
|
<span className={cn(s.isAbnormal && "font-semibold text-destructive")}>{s.eventCount}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">0</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatTime(s.lastEventAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(Object.keys(s.eventsByType) as ProctoringEventType[])
|
||||||
|
.filter((t) => s.eventsByType[t] > 0)
|
||||||
|
.map((t) => (
|
||||||
|
<Badge key={t} variant="outline" className="text-xs">
|
||||||
|
{PROCTORING_EVENT_LABELS[t]}×{s.eventsByType[t]}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近事件</CardTitle>
|
||||||
|
<CardDescription>最新 20 条监考事件</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentEvents.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">暂无事件</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-72">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{recentEvents.map((e) => (
|
||||||
|
<li key={e.id} className="flex items-start justify-between gap-3 rounded-md border p-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{e.studentName}
|
||||||
|
<Badge variant="destructive" className="ml-2 text-xs">
|
||||||
|
{PROCTORING_EVENT_LABELS[e.eventType]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{e.eventDetail && (
|
||||||
|
<div className="text-xs text-muted-foreground">{e.eventDetail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatTime(e.occurredAt)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
388
src/modules/proctoring/data-access.ts
Normal file
388
src/modules/proctoring/data-access.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import {
|
||||||
|
exams,
|
||||||
|
examProctoringEvents,
|
||||||
|
examSubmissions,
|
||||||
|
users,
|
||||||
|
} from "@/shared/db/schema"
|
||||||
|
import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm"
|
||||||
|
import { cache } from "react"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProctoringEvent,
|
||||||
|
ProctoringEventWithDetails,
|
||||||
|
ExamProctoringSummary,
|
||||||
|
StudentProctoringStatus,
|
||||||
|
RecordProctoringEventInput,
|
||||||
|
GetProctoringEventsFilters,
|
||||||
|
ExamModeConfig,
|
||||||
|
ProctoringEventType,
|
||||||
|
ExamMode,
|
||||||
|
} from "./types"
|
||||||
|
import { ABNORMAL_EVENT_THRESHOLD } from "./types"
|
||||||
|
|
||||||
|
const ALL_EVENT_TYPES: ProctoringEventType[] = [
|
||||||
|
"tab_switch",
|
||||||
|
"window_blur",
|
||||||
|
"copy_attempt",
|
||||||
|
"paste_attempt",
|
||||||
|
"right_click",
|
||||||
|
"devtools_open",
|
||||||
|
"fullscreen_exit",
|
||||||
|
"idle_timeout",
|
||||||
|
]
|
||||||
|
|
||||||
|
const emptyEventsByType = (): Record<ProctoringEventType, number> => ({
|
||||||
|
tab_switch: 0,
|
||||||
|
window_blur: 0,
|
||||||
|
copy_attempt: 0,
|
||||||
|
paste_attempt: 0,
|
||||||
|
right_click: 0,
|
||||||
|
devtools_open: 0,
|
||||||
|
fullscreen_exit: 0,
|
||||||
|
idle_timeout: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toExamMode = (value: unknown): ExamMode => {
|
||||||
|
if (value === "homework" || value === "timed" || value === "proctored") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "homework"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一条监考事件
|
||||||
|
*/
|
||||||
|
export async function recordProctoringEvent(
|
||||||
|
input: RecordProctoringEventInput,
|
||||||
|
): Promise<ProctoringEvent> {
|
||||||
|
const eventId = createId()
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
await db.insert(examProctoringEvents).values({
|
||||||
|
id: eventId,
|
||||||
|
submissionId: input.submissionId,
|
||||||
|
studentId: input.studentId,
|
||||||
|
examId: input.examId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
eventDetail: input.eventDetail ?? null,
|
||||||
|
occurredAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: eventId,
|
||||||
|
submissionId: input.submissionId,
|
||||||
|
studentId: input.studentId,
|
||||||
|
examId: input.examId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
eventDetail: input.eventDetail ?? null,
|
||||||
|
occurredAt: now.toISOString(),
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某场考试的监考事件(含学生姓名、考试标题)
|
||||||
|
*/
|
||||||
|
export const getProctoringEvents = cache(
|
||||||
|
async (
|
||||||
|
examId: string,
|
||||||
|
filters?: GetProctoringEventsFilters,
|
||||||
|
): Promise<ProctoringEventWithDetails[]> => {
|
||||||
|
const conditions = [eq(examProctoringEvents.examId, examId)]
|
||||||
|
|
||||||
|
if (filters?.studentId) {
|
||||||
|
conditions.push(eq(examProctoringEvents.studentId, filters.studentId))
|
||||||
|
}
|
||||||
|
if (filters?.eventType) {
|
||||||
|
conditions.push(eq(examProctoringEvents.eventType, filters.eventType))
|
||||||
|
}
|
||||||
|
if (filters?.startedAt) {
|
||||||
|
conditions.push(gte(examProctoringEvents.occurredAt, new Date(filters.startedAt)))
|
||||||
|
}
|
||||||
|
if (filters?.endedAt) {
|
||||||
|
conditions.push(lte(examProctoringEvents.occurredAt, new Date(filters.endedAt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
event: examProctoringEvents,
|
||||||
|
studentName: users.name,
|
||||||
|
examTitle: exams.title,
|
||||||
|
})
|
||||||
|
.from(examProctoringEvents)
|
||||||
|
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
|
||||||
|
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.event.id,
|
||||||
|
submissionId: row.event.submissionId,
|
||||||
|
studentId: row.event.studentId,
|
||||||
|
examId: row.event.examId,
|
||||||
|
eventType: row.event.eventType as ProctoringEventType,
|
||||||
|
eventDetail: row.event.eventDetail,
|
||||||
|
occurredAt: row.event.occurredAt.toISOString(),
|
||||||
|
createdAt: row.event.createdAt.toISOString(),
|
||||||
|
studentName: row.studentName ?? "未知学生",
|
||||||
|
examTitle: row.examTitle,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某次提交的监考事件
|
||||||
|
*/
|
||||||
|
export const getProctoringEventsBySubmission = cache(
|
||||||
|
async (submissionId: string): Promise<ProctoringEvent[]> => {
|
||||||
|
const rows = await db.query.examProctoringEvents.findMany({
|
||||||
|
where: eq(examProctoringEvents.submissionId, submissionId),
|
||||||
|
orderBy: [desc(examProctoringEvents.occurredAt)],
|
||||||
|
})
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
submissionId: row.submissionId,
|
||||||
|
studentId: row.studentId,
|
||||||
|
examId: row.examId,
|
||||||
|
eventType: row.eventType as ProctoringEventType,
|
||||||
|
eventDetail: row.eventDetail,
|
||||||
|
occurredAt: row.occurredAt.toISOString(),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取考试监考摘要
|
||||||
|
*/
|
||||||
|
export const getExamProctoringSummary = cache(
|
||||||
|
async (examId: string): Promise<ExamProctoringSummary> => {
|
||||||
|
const exam = await db.query.exams.findFirst({
|
||||||
|
where: eq(exams.id, examId),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
examMode: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const examTitle = exam?.title ?? "未知考试"
|
||||||
|
const examMode = toExamMode(exam?.examMode)
|
||||||
|
|
||||||
|
// 统计提交记录
|
||||||
|
const submissions = await db.query.examSubmissions.findMany({
|
||||||
|
where: eq(examSubmissions.examId, examId),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
studentId: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalStudents = submissions.length
|
||||||
|
const startedStudents = submissions.filter(
|
||||||
|
(s) => s.status === "started",
|
||||||
|
).length
|
||||||
|
const submittedStudents = submissions.filter(
|
||||||
|
(s) => s.status === "submitted" || s.status === "graded",
|
||||||
|
).length
|
||||||
|
|
||||||
|
// 按事件类型分组统计
|
||||||
|
const eventStats = await db
|
||||||
|
.select({
|
||||||
|
eventType: examProctoringEvents.eventType,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(examProctoringEvents)
|
||||||
|
.where(eq(examProctoringEvents.examId, examId))
|
||||||
|
.groupBy(examProctoringEvents.eventType)
|
||||||
|
|
||||||
|
const eventsByType = emptyEventsByType()
|
||||||
|
let totalEvents = 0
|
||||||
|
for (const stat of eventStats) {
|
||||||
|
const type = stat.eventType as ProctoringEventType
|
||||||
|
if (eventsByType[type] !== undefined) {
|
||||||
|
eventsByType[type] = stat.count
|
||||||
|
totalEvents += stat.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计异常学生数(事件数 >= 阈值)
|
||||||
|
const studentEventCounts = await db
|
||||||
|
.select({
|
||||||
|
studentId: examProctoringEvents.studentId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(examProctoringEvents)
|
||||||
|
.where(eq(examProctoringEvents.examId, examId))
|
||||||
|
.groupBy(examProctoringEvents.studentId)
|
||||||
|
|
||||||
|
const abnormalStudents = studentEventCounts.filter(
|
||||||
|
(s) => s.count >= ABNORMAL_EVENT_THRESHOLD,
|
||||||
|
).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
examId,
|
||||||
|
examTitle,
|
||||||
|
examMode,
|
||||||
|
totalStudents,
|
||||||
|
startedStudents,
|
||||||
|
submittedStudents,
|
||||||
|
totalEvents,
|
||||||
|
abnormalStudents,
|
||||||
|
eventsByType,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有学生监考状态
|
||||||
|
*/
|
||||||
|
export const getStudentProctoringStatuses = cache(
|
||||||
|
async (examId: string): Promise<StudentProctoringStatus[]> => {
|
||||||
|
// 1. 拉取所有提交记录及学生姓名
|
||||||
|
const submissions = await db
|
||||||
|
.select({
|
||||||
|
submission: examSubmissions,
|
||||||
|
studentName: users.name,
|
||||||
|
})
|
||||||
|
.from(examSubmissions)
|
||||||
|
.innerJoin(users, eq(users.id, examSubmissions.studentId))
|
||||||
|
.where(eq(examSubmissions.examId, examId))
|
||||||
|
|
||||||
|
if (submissions.length === 0) return []
|
||||||
|
|
||||||
|
const studentIds = submissions.map((s) => s.submission.studentId)
|
||||||
|
|
||||||
|
// 2. 拉取这些提交的事件,按学生聚合
|
||||||
|
const eventRows = await db
|
||||||
|
.select({
|
||||||
|
studentId: examProctoringEvents.studentId,
|
||||||
|
eventType: examProctoringEvents.eventType,
|
||||||
|
occurredAt: examProctoringEvents.occurredAt,
|
||||||
|
})
|
||||||
|
.from(examProctoringEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(examProctoringEvents.examId, examId),
|
||||||
|
inArray(examProctoringEvents.studentId, studentIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||||
|
|
||||||
|
// 3. 按学生聚合
|
||||||
|
const statsByStudent = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
count: number
|
||||||
|
lastEventAt: Date | null
|
||||||
|
byType: Record<ProctoringEventType, number>
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const row of eventRows) {
|
||||||
|
const sid = row.studentId
|
||||||
|
const type = row.eventType as ProctoringEventType
|
||||||
|
const existing = statsByStudent.get(sid) ?? {
|
||||||
|
count: 0,
|
||||||
|
lastEventAt: null,
|
||||||
|
byType: emptyEventsByType(),
|
||||||
|
}
|
||||||
|
existing.count += 1
|
||||||
|
if (existing.byType[type] !== undefined) {
|
||||||
|
existing.byType[type] += 1
|
||||||
|
}
|
||||||
|
if (!existing.lastEventAt || row.occurredAt > existing.lastEventAt) {
|
||||||
|
existing.lastEventAt = row.occurredAt
|
||||||
|
}
|
||||||
|
statsByStudent.set(sid, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
return submissions.map((row) => {
|
||||||
|
const studentId = row.submission.studentId
|
||||||
|
const stats = statsByStudent.get(studentId)
|
||||||
|
return {
|
||||||
|
studentId,
|
||||||
|
studentName: row.studentName ?? "未知学生",
|
||||||
|
submissionId: row.submission.id,
|
||||||
|
submissionStatus: (row.submission.status ?? null) as StudentProctoringStatus["submissionStatus"],
|
||||||
|
eventCount: stats?.count ?? 0,
|
||||||
|
lastEventAt: stats?.lastEventAt ? stats.lastEventAt.toISOString() : null,
|
||||||
|
isAbnormal: (stats?.count ?? 0) >= ABNORMAL_EVENT_THRESHOLD,
|
||||||
|
eventsByType: stats?.byType ?? emptyEventsByType(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取考试信息(含监考模式相关字段)
|
||||||
|
*/
|
||||||
|
export const getExamForProctoring = cache(
|
||||||
|
async (examId: string): Promise<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
examMode: ExamMode
|
||||||
|
config: ExamModeConfig
|
||||||
|
} | null> => {
|
||||||
|
const exam = await db.query.exams.findFirst({
|
||||||
|
where: eq(exams.id, examId),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!exam) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: exam.id,
|
||||||
|
title: exam.title,
|
||||||
|
examMode: toExamMode(exam.examMode),
|
||||||
|
config: {
|
||||||
|
examMode: toExamMode(exam.examMode),
|
||||||
|
durationMinutes: exam.durationMinutes ?? null,
|
||||||
|
shuffleQuestions: exam.shuffleQuestions ?? false,
|
||||||
|
allowLateStart: exam.allowLateStart ?? false,
|
||||||
|
lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0,
|
||||||
|
antiCheatEnabled: exam.antiCheatEnabled ?? false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近 N 条监考事件(用于面板实时展示)
|
||||||
|
*/
|
||||||
|
export const getRecentProctoringEvents = cache(
|
||||||
|
async (examId: string, limit = 20): Promise<ProctoringEventWithDetails[]> => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
event: examProctoringEvents,
|
||||||
|
studentName: users.name,
|
||||||
|
examTitle: exams.title,
|
||||||
|
})
|
||||||
|
.from(examProctoringEvents)
|
||||||
|
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
|
||||||
|
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
|
||||||
|
.where(eq(examProctoringEvents.examId, examId))
|
||||||
|
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.event.id,
|
||||||
|
submissionId: row.event.submissionId,
|
||||||
|
studentId: row.event.studentId,
|
||||||
|
examId: row.event.examId,
|
||||||
|
eventType: row.event.eventType as ProctoringEventType,
|
||||||
|
eventDetail: row.event.eventDetail,
|
||||||
|
occurredAt: row.event.occurredAt.toISOString(),
|
||||||
|
createdAt: row.event.createdAt.toISOString(),
|
||||||
|
studentName: row.studentName ?? "未知学生",
|
||||||
|
examTitle: row.examTitle,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export { ALL_EVENT_TYPES }
|
||||||
136
src/modules/proctoring/types.ts
Normal file
136
src/modules/proctoring/types.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// 监考模块类型定义
|
||||||
|
|
||||||
|
export type ProctoringEventType =
|
||||||
|
| "tab_switch"
|
||||||
|
| "window_blur"
|
||||||
|
| "copy_attempt"
|
||||||
|
| "paste_attempt"
|
||||||
|
| "right_click"
|
||||||
|
| "devtools_open"
|
||||||
|
| "fullscreen_exit"
|
||||||
|
| "idle_timeout"
|
||||||
|
|
||||||
|
export type ExamMode = "homework" | "timed" | "proctored"
|
||||||
|
|
||||||
|
export type SubmissionStatus = "started" | "submitted" | "graded"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监考事件(原始记录)
|
||||||
|
*/
|
||||||
|
export interface ProctoringEvent {
|
||||||
|
id: string
|
||||||
|
submissionId: string
|
||||||
|
studentId: string
|
||||||
|
examId: string
|
||||||
|
eventType: ProctoringEventType
|
||||||
|
eventDetail?: string | null
|
||||||
|
occurredAt: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监考事件(含学生姓名、考试标题等关联信息)
|
||||||
|
*/
|
||||||
|
export interface ProctoringEventWithDetails extends ProctoringEvent {
|
||||||
|
studentName: string
|
||||||
|
examTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监考事件输入参数
|
||||||
|
*/
|
||||||
|
export interface RecordProctoringEventInput {
|
||||||
|
submissionId: string
|
||||||
|
studentId: string
|
||||||
|
examId: string
|
||||||
|
eventType: ProctoringEventType
|
||||||
|
eventDetail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监考事件查询过滤条件
|
||||||
|
*/
|
||||||
|
export interface GetProctoringEventsFilters {
|
||||||
|
studentId?: string
|
||||||
|
eventType?: ProctoringEventType
|
||||||
|
startedAt?: string
|
||||||
|
endedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 考试监考摘要
|
||||||
|
*/
|
||||||
|
export interface ExamProctoringSummary {
|
||||||
|
examId: string
|
||||||
|
examTitle: string
|
||||||
|
examMode: ExamMode
|
||||||
|
totalStudents: number
|
||||||
|
startedStudents: number
|
||||||
|
submittedStudents: number
|
||||||
|
totalEvents: number
|
||||||
|
abnormalStudents: number
|
||||||
|
eventsByType: Record<ProctoringEventType, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生监考状态
|
||||||
|
*/
|
||||||
|
export interface StudentProctoringStatus {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
submissionId: string | null
|
||||||
|
submissionStatus: SubmissionStatus | null
|
||||||
|
eventCount: number
|
||||||
|
lastEventAt: string | null
|
||||||
|
isAbnormal: boolean
|
||||||
|
eventsByType: Record<ProctoringEventType, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监考面板数据(合并摘要与学生状态)
|
||||||
|
*/
|
||||||
|
export interface ProctoringDashboardData {
|
||||||
|
summary: ExamProctoringSummary
|
||||||
|
students: StudentProctoringStatus[]
|
||||||
|
recentEvents: ProctoringEventWithDetails[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 考试模式配置(用于考试创建/编辑表单)
|
||||||
|
*/
|
||||||
|
export interface ExamModeConfig {
|
||||||
|
examMode: ExamMode
|
||||||
|
durationMinutes: number | null
|
||||||
|
shuffleQuestions: boolean
|
||||||
|
allowLateStart: boolean
|
||||||
|
lateStartGraceMinutes: number
|
||||||
|
antiCheatEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监考事件类型显示标签
|
||||||
|
*/
|
||||||
|
export const PROCTORING_EVENT_LABELS: Record<ProctoringEventType, string> = {
|
||||||
|
tab_switch: "切换标签页",
|
||||||
|
window_blur: "窗口失焦",
|
||||||
|
copy_attempt: "复制操作",
|
||||||
|
paste_attempt: "粘贴操作",
|
||||||
|
right_click: "右键点击",
|
||||||
|
devtools_open: "开发者工具",
|
||||||
|
fullscreen_exit: "退出全屏",
|
||||||
|
idle_timeout: "空闲超时",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 考试模式显示标签
|
||||||
|
*/
|
||||||
|
export const EXAM_MODE_LABELS: Record<ExamMode, string> = {
|
||||||
|
homework: "作业模式",
|
||||||
|
timed: "限时模式",
|
||||||
|
proctored: "监考模式",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异常事件阈值:超过该值视为异常学生
|
||||||
|
*/
|
||||||
|
export const ABNORMAL_EVENT_THRESHOLD = 3
|
||||||
@@ -418,6 +418,14 @@ export const classSchedule = mysqlTable("class_schedule", {
|
|||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- P2: Exam Proctoring (考试监考) ---
|
||||||
|
|
||||||
|
export const examModeEnum = mysqlEnum("exam_mode", ["homework", "timed", "proctored"]);
|
||||||
|
export const proctoringEventTypeEnum = mysqlEnum("event_type", [
|
||||||
|
"tab_switch", "window_blur", "copy_attempt", "paste_attempt",
|
||||||
|
"right_click", "devtools_open", "fullscreen_exit", "idle_timeout"
|
||||||
|
]);
|
||||||
|
|
||||||
export const exams = mysqlTable("exams", {
|
export const exams = mysqlTable("exams", {
|
||||||
id: id("id").primaryKey(),
|
id: id("id").primaryKey(),
|
||||||
title: varchar("title", { length: 255 }).notNull(),
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
@@ -444,7 +452,15 @@ export const exams = mysqlTable("exams", {
|
|||||||
|
|
||||||
startTime: timestamp("start_time"),
|
startTime: timestamp("start_time"),
|
||||||
endTime: timestamp("end_time"),
|
endTime: timestamp("end_time"),
|
||||||
|
|
||||||
|
// P2: Online exam mode + proctoring settings
|
||||||
|
examMode: examModeEnum.default("homework"),
|
||||||
|
durationMinutes: int("duration_minutes"),
|
||||||
|
shuffleQuestions: boolean("shuffle_questions").default(false),
|
||||||
|
allowLateStart: boolean("allow_late_start").default(false),
|
||||||
|
lateStartGraceMinutes: int("late_start_grace_minutes").default(0),
|
||||||
|
antiCheatEnabled: boolean("anti_cheat_enabled").default(false),
|
||||||
|
|
||||||
// Status: draft, published, ongoing, finished
|
// Status: draft, published, ongoing, finished
|
||||||
status: varchar("status", { length: 50 }).default("draft"),
|
status: varchar("status", { length: 50 }).default("draft"),
|
||||||
|
|
||||||
@@ -1083,3 +1099,115 @@ export const scheduleChanges = mysqlTable("schedule_changes", {
|
|||||||
requestedByIdx: index("schedule_changes_requested_by_idx").on(table.requestedBy),
|
requestedByIdx: index("schedule_changes_requested_by_idx").on(table.requestedBy),
|
||||||
originalScheduleIdx: index("schedule_changes_original_schedule_idx").on(table.originalScheduleId),
|
originalScheduleIdx: index("schedule_changes_original_schedule_idx").on(table.originalScheduleId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- P2: Elective Course Management (选课管理) ---
|
||||||
|
|
||||||
|
export const electiveCourseStatusEnum = mysqlEnum("status", ["draft", "open", "closed", "cancelled"]);
|
||||||
|
export const electiveSelectionModeEnum = mysqlEnum("selection_mode", ["fcfs", "lottery"]);
|
||||||
|
export const courseSelectionStatusEnum = mysqlEnum("selection_status", ["selected", "enrolled", "waitlist", "dropped", "rejected"]);
|
||||||
|
|
||||||
|
export const electiveCourses = mysqlTable("elective_courses", {
|
||||||
|
id: id("id").primaryKey(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id, { onDelete: "set null" }),
|
||||||
|
teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id, { onDelete: "set null" }),
|
||||||
|
description: text("description"),
|
||||||
|
capacity: int("capacity").default(30).notNull(),
|
||||||
|
enrolledCount: int("enrolled_count").default(0).notNull(),
|
||||||
|
classroom: varchar("classroom", { length: 100 }),
|
||||||
|
schedule: varchar("schedule", { length: 255 }),
|
||||||
|
startDate: date("start_date"),
|
||||||
|
endDate: date("end_date"),
|
||||||
|
selectionStartAt: datetime("selection_start_at"),
|
||||||
|
selectionEndAt: datetime("selection_end_at"),
|
||||||
|
status: electiveCourseStatusEnum.default("draft").notNull(),
|
||||||
|
selectionMode: electiveSelectionModeEnum.default("fcfs").notNull(),
|
||||||
|
credit: decimal("credit", { precision: 3, scale: 1 }).default("1.0"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
teacherIdx: index("elective_courses_teacher_idx").on(table.teacherId),
|
||||||
|
subjectIdx: index("elective_courses_subject_idx").on(table.subjectId),
|
||||||
|
gradeIdx: index("elective_courses_grade_idx").on(table.gradeId),
|
||||||
|
statusIdx: index("elective_courses_status_idx").on(table.status),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const courseSelections = mysqlTable("course_selections", {
|
||||||
|
id: id("id").primaryKey(),
|
||||||
|
courseId: varchar("course_id", { length: 128 }).notNull().references(() => electiveCourses.id, { onDelete: "cascade" }),
|
||||||
|
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
status: courseSelectionStatusEnum.default("selected").notNull(),
|
||||||
|
priority: int("priority").default(1),
|
||||||
|
selectedAt: timestamp("selected_at").defaultNow().notNull(),
|
||||||
|
enrolledAt: timestamp("enrolled_at"),
|
||||||
|
droppedAt: timestamp("dropped_at"),
|
||||||
|
lotteryRank: int("lottery_rank"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
courseStudentPk: primaryKey({ columns: [table.courseId, table.studentId] }),
|
||||||
|
courseIdx: index("course_selections_course_idx").on(table.courseId),
|
||||||
|
studentIdx: index("course_selections_student_idx").on(table.studentId),
|
||||||
|
statusIdx: index("course_selections_status_idx").on(table.status),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- P2: Exam Proctoring (考试监考) ---
|
||||||
|
|
||||||
|
export const examProctoringEvents = mysqlTable("exam_proctoring_events", {
|
||||||
|
id: id("id").primaryKey(),
|
||||||
|
submissionId: varchar("submission_id", { length: 128 }).notNull().references(() => examSubmissions.id, { onDelete: "cascade" }),
|
||||||
|
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }),
|
||||||
|
eventType: proctoringEventTypeEnum.notNull(),
|
||||||
|
eventDetail: text("event_detail"),
|
||||||
|
occurredAt: timestamp("occurred_at").defaultNow().notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
submissionIdx: index("proctoring_submission_idx").on(table.submissionId),
|
||||||
|
studentIdx: index("proctoring_student_idx").on(table.studentId),
|
||||||
|
examIdx: index("proctoring_exam_idx").on(table.examId),
|
||||||
|
eventTypeIdx: index("proctoring_event_type_idx").on(table.eventType),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- P2: Learning Diagnostic (学情诊断报告) ---
|
||||||
|
|
||||||
|
export const knowledgePointMastery = mysqlTable("knowledge_point_mastery", {
|
||||||
|
id: id("id").primaryKey(),
|
||||||
|
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull().references(() => knowledgePoints.id, { onDelete: "cascade" }),
|
||||||
|
masteryLevel: decimal("mastery_level", { precision: 5, scale: 2 }).default("0").notNull(),
|
||||||
|
totalQuestions: int("total_questions").default(0).notNull(),
|
||||||
|
correctQuestions: int("correct_questions").default(0).notNull(),
|
||||||
|
lastAssessedAt: timestamp("last_assessed_at").defaultNow().notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
studentKpPk: primaryKey({ columns: [table.studentId, table.knowledgePointId] }),
|
||||||
|
studentIdx: index("mastery_student_idx").on(table.studentId),
|
||||||
|
kpIdx: index("mastery_kp_idx").on(table.knowledgePointId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const diagnosticReportStatusEnum = mysqlEnum("report_status", ["draft", "published", "archived"]);
|
||||||
|
export const diagnosticReportTypeEnum = mysqlEnum("report_type", ["individual", "class", "grade"]);
|
||||||
|
|
||||||
|
export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports", {
|
||||||
|
id: id("id").primaryKey(),
|
||||||
|
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
|
||||||
|
reportType: diagnosticReportTypeEnum.default("individual").notNull(),
|
||||||
|
period: varchar("period", { length: 50 }),
|
||||||
|
summary: text("summary"),
|
||||||
|
strengths: json("strengths"),
|
||||||
|
weaknesses: json("weaknesses"),
|
||||||
|
recommendations: json("recommendations"),
|
||||||
|
overallScore: decimal("overall_score", { precision: 5, scale: 2 }),
|
||||||
|
status: diagnosticReportStatusEnum.default("draft").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
studentIdx: index("diagnostic_student_idx").on(table.studentId),
|
||||||
|
generatedByIdx: index("diagnostic_generated_by_idx").on(table.generatedBy),
|
||||||
|
statusIdx: index("diagnostic_status_idx").on(table.status),
|
||||||
|
reportTypeIdx: index("diagnostic_report_type_idx").on(table.reportType),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
|||||||
Permissions.MESSAGE_DELETE,
|
Permissions.MESSAGE_DELETE,
|
||||||
Permissions.SCHEDULE_AUTO,
|
Permissions.SCHEDULE_AUTO,
|
||||||
Permissions.SCHEDULE_ADJUST,
|
Permissions.SCHEDULE_ADJUST,
|
||||||
|
Permissions.ELECTIVE_MANAGE,
|
||||||
|
Permissions.ELECTIVE_READ,
|
||||||
|
Permissions.EXAM_PROCTOR,
|
||||||
|
Permissions.EXAM_PROCTOR_READ,
|
||||||
|
Permissions.DIAGNOSTIC_MANAGE,
|
||||||
|
Permissions.DIAGNOSTIC_READ,
|
||||||
],
|
],
|
||||||
teacher: [
|
teacher: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
@@ -78,6 +84,12 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
|||||||
Permissions.MESSAGE_SEND,
|
Permissions.MESSAGE_SEND,
|
||||||
Permissions.MESSAGE_READ,
|
Permissions.MESSAGE_READ,
|
||||||
Permissions.MESSAGE_DELETE,
|
Permissions.MESSAGE_DELETE,
|
||||||
|
Permissions.ELECTIVE_MANAGE,
|
||||||
|
Permissions.ELECTIVE_READ,
|
||||||
|
Permissions.EXAM_PROCTOR,
|
||||||
|
Permissions.EXAM_PROCTOR_READ,
|
||||||
|
Permissions.DIAGNOSTIC_MANAGE,
|
||||||
|
Permissions.DIAGNOSTIC_READ,
|
||||||
],
|
],
|
||||||
student: [
|
student: [
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
@@ -92,6 +104,9 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
|||||||
Permissions.ATTENDANCE_READ,
|
Permissions.ATTENDANCE_READ,
|
||||||
Permissions.MESSAGE_READ,
|
Permissions.MESSAGE_READ,
|
||||||
Permissions.MESSAGE_DELETE,
|
Permissions.MESSAGE_DELETE,
|
||||||
|
Permissions.ELECTIVE_SELECT,
|
||||||
|
Permissions.ELECTIVE_READ,
|
||||||
|
Permissions.DIAGNOSTIC_READ,
|
||||||
],
|
],
|
||||||
parent: [
|
parent: [
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
@@ -135,6 +150,10 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
|||||||
Permissions.MESSAGE_SEND,
|
Permissions.MESSAGE_SEND,
|
||||||
Permissions.MESSAGE_READ,
|
Permissions.MESSAGE_READ,
|
||||||
Permissions.MESSAGE_DELETE,
|
Permissions.MESSAGE_DELETE,
|
||||||
|
Permissions.ELECTIVE_READ,
|
||||||
|
Permissions.EXAM_PROCTOR_READ,
|
||||||
|
Permissions.DIAGNOSTIC_MANAGE,
|
||||||
|
Permissions.DIAGNOSTIC_READ,
|
||||||
],
|
],
|
||||||
teaching_head: [
|
teaching_head: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
@@ -163,6 +182,9 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
|||||||
Permissions.MESSAGE_SEND,
|
Permissions.MESSAGE_SEND,
|
||||||
Permissions.MESSAGE_READ,
|
Permissions.MESSAGE_READ,
|
||||||
Permissions.MESSAGE_DELETE,
|
Permissions.MESSAGE_DELETE,
|
||||||
|
Permissions.ELECTIVE_READ,
|
||||||
|
Permissions.EXAM_PROCTOR_READ,
|
||||||
|
Permissions.DIAGNOSTIC_READ,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ export const Permissions = {
|
|||||||
// Scheduling (排课与调课)
|
// Scheduling (排课与调课)
|
||||||
SCHEDULE_AUTO: "schedule:auto",
|
SCHEDULE_AUTO: "schedule:auto",
|
||||||
SCHEDULE_ADJUST: "schedule:adjust",
|
SCHEDULE_ADJUST: "schedule:adjust",
|
||||||
|
|
||||||
|
// P2: Elective Course (选课管理)
|
||||||
|
ELECTIVE_MANAGE: "elective:manage",
|
||||||
|
ELECTIVE_READ: "elective:read",
|
||||||
|
ELECTIVE_SELECT: "elective:select",
|
||||||
|
|
||||||
|
// P2: Exam Proctoring (考试监考)
|
||||||
|
EXAM_PROCTOR: "exam:proctor",
|
||||||
|
EXAM_PROCTOR_READ: "exam:proctor_read",
|
||||||
|
|
||||||
|
// P2: Learning Diagnostic (学情诊断)
|
||||||
|
DIAGNOSTIC_MANAGE: "diagnostic:manage",
|
||||||
|
DIAGNOSTIC_READ: "diagnostic:read",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||||
|
|||||||
Reference in New Issue
Block a user