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:
SpecialX
2026-06-17 19:12:51 +08:00
parent baf8f679bf
commit b86255f0ea
46 changed files with 13234 additions and 80 deletions

View File

@@ -38,4 +38,9 @@
- 每次修改后运行 `npm run lint``npx tsc --noEmit` 确保零错误
- Server Action 必须使用 `requirePermission()` 进行权限校验
- 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()`
- 单文件不超过 300 行
- 单文件行数遵循企业级规范:
- 配置文件、常量文件、类型定义文件:无限制
- React 组件:建议 ≤ 500 行(复杂表单/大型表格可放宽至 800 行)
- Server Actions / Data Access 模块:建议 ≤ 800 行
- 超过建议行数时应考虑拆分(如 data-access 拆分为多个按职责划分的文件)
- 硬性上限:任何文件不超过 1000 行,超过必须拆分

View File

@@ -218,7 +218,7 @@
#### `Permissions` (常量对象)
- 文件:`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, 前端组件
#### `ROLE_PERMISSIONS` (常量对象)
@@ -228,6 +228,7 @@
- 考勤权限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 含 `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)
#### `db` (Drizzle 实例)
@@ -399,6 +400,8 @@
| `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 |
| `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 角色菜单包含 "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 角色菜单包含 "Diagnostic" 项icon: Stethoscope, href: /teacher/diagnostic, permission: Permissions.DIAGNOSTIC_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 角色菜单包含 "Diagnostic" 项icon: Stethoscope, href: /student/diagnostic, permission: Permissions.DIAGNOSTIC_READ
- parent 角色菜单包含 "Dashboard" 项icon: LayoutDashboard, href: /parent/dashboard无 permission 字段,仅需登录)
- parent 角色菜单包含 "Grades" 项icon: GraduationCap, href: /parent/grades, permission: Permissions.GRADE_RECORD_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_READteaching_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: idstatus → 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+knowledgePointIdonDuplicateKeyUpdate upsert |
| `learningDiagnosticReports` | 学情诊断报告reportType: individual/class/gradestatus: 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 必须包含 classIdclass_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 默认 30selectionMode 默认 fcfscredit 默认 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** | db,schema,permissions,login-logger | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **school** | db,auth-guard,types,audit-logger | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-dropdown | - | - |
| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **users** | db,auth-guard(requireAuth,requirePermission),types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **audit** | db,auth-guard.requirePermission,types.permissions | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **announcements** | db,auth-guard,types | auth | - | - | - | - | - | - | data-access.getGrades | - | - | - | - | - | - | - | - | - | - | - | - |
| **files** | db,auth-guard(requireAuth,requirePermission),types,lib/file-storage | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **grades** | db,auth-guard,types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **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 | - | - | - | - | - |
| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **scheduling** | db,auth-guard(requirePermission,getAuthContext),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| ↓ 使用 → | 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** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **auth** | db,schema,permissions,login-logger | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **school** | db,auth-guard,types,audit-logger | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-dropdown | - | - | - | - |
| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **users** | db,auth-guard(requireAuth,requirePermission),types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **audit** | db,auth-guard.requirePermission,types.permissions | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **announcements** | db,auth-guard,types | auth | - | - | - | - | - | - | data-access.getGrades | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **files** | db,auth-guard(requireAuth,requirePermission),types,lib/file-storage | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **grades** | db,auth-guard,types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **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 | - | - | - | - | - | - |
| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - |
| **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`
6. 在 homework/actions.ts 中作为 `creatorId` 写入 `homeworkAssignments`
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`
1.`exams/actions.ts``createExamAction` 产生,通过 CUID2 生成,写入 `exams`
@@ -2634,13 +2909,13 @@
6.`auth-guard.ts` 中通过 `classSubjectTeachers` 查询教师关联的 classIds构建 `DataScope.class_taught`
### `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` 等)
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 无排课权限)
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 无排课权限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
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)` 在客户端组件中条件渲染
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`
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)` 二次校验家长拥有该子女
8. 传递到 `attendance/data-access.getAttendanceRecords({ scope })` 进行行级过滤class_taught 按教师班级过滤children 按子女过滤class_members 仅查自己all 查全部)
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/[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.tsx10 秒轮询刷新) |
| `/teacher/exams/grading` | ExamGradingList | server | exam:read | 考试批改列表 |
| `/teacher/exams/grading/[submissionId]` | ExamGradingView | client | exam:read | 考试批改页面 |
| `/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 tabSecurity tab 含 PasswordChangeFormNotifications tab 含 NotificationPreferencesFormdataAccess: messaging/notification-preferences.getNotificationPreferences |
| `/settings/security` | SecuritySettingsPage | server | auth_required | 安全设置独立页面PasswordChangeForm + 安全提示权限requireAuth() |
### diagnostic/* 路由
| 路由 | 组件 | 类型 | 权限 | 说明 |
|------|------|------|------|------|
| `/teacher/diagnostic` | ReportList | client | diagnostic:read | 学情诊断报告列表reportType/status 过滤器[URL searchParams]dataAccess: diagnostic/data-access-reports.getDiagnosticReportsactions: publishReportAction, deleteReportAction[DIAGNOSTIC_MANAGE]权限requirePermission(DIAGNOSTIC_READ)DataScope.class_members 仅查看自己报告) |
| `/teacher/diagnostic/student/[studentId]` | StudentDiagnosticView | client | diagnostic:read | 学生学情诊断视图(概览卡片+雷达图+强项/弱项+生成报告[DIAGNOSTIC_MANAGE]+最新报告dataAccess: getStudentMasterySummary, getKnowledgePointStats[班级平均对比], getDiagnosticReportsactions: generateStudentReportAction权限getAuthContext + DataScope 二次校验class_members 仅自己children 仅子女) |
| `/teacher/diagnostic/class/[classId]` | ClassDiagnosticView | client | diagnostic:read | 班级学情诊断视图(概览+知识点热力图+排名表+需重点关注学生+生成班级报告[DIAGNOSTIC_MANAGE]dataAccess: getClassMasterySummaryactions: generateClassReportAction权限getAuthContext + DataScope 校验class_taught 必须包含 classIdclass_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: createElectiveCourseActiondataAccess: getSubjectOptions权限requirePermission(ELECTIVE_MANAGE) |
| `/admin/elective/[id]/edit` | ElectiveCourseForm (edit) | client | elective:manage | 编辑选修课程actions: updateElectiveCourseActiondataAccess: 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, getStudentSelectionsactions: selectCourseAction, dropCourseAction权限requirePermission(ELECTIVE_SELECT) |
### API 路由(含速率限制)
| 路由 | 方法 | 限流规则 | 说明 |

View File

@@ -65,15 +65,20 @@
"MESSAGE_READ": "message:read",
"MESSAGE_DELETE": "message:delete",
"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": {
"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"],
"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"],
"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"],
"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","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","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"],
"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"],
"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"]
"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","DIAGNOSTIC_READ","ELECTIVE_READ"]
},
"dataScopeTypes": {
"all": "管理员:无过滤",
@@ -419,7 +424,11 @@
"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"]},
"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+knowledgePointIdonDuplicateKeyUpdate upsert"},
"learningDiagnosticReports": {"fields": ["id","studentId","generatedBy","reportType","period","summary","strengths","weaknesses","recommendations","overallScore","status","createdAt","updatedAt"], "usedBy": ["diagnostic"], "description": "学情诊断报告reportType: individual/class/gradestatus: 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/cancelledselectionMode: fcfs/lottery"},
"courseSelections": {"fields": ["id","courseId","studentId","status","priority","selectedAt","enrolledAt","droppedAt","lotteryRank","createdAt","updatedAt"], "usedBy": ["elective"], "description": "选课记录(复合主键 courseId+studentIdstatus: selected/enrolled/waitlist/dropped/rejected"}
}
},
"auth": {
@@ -909,7 +918,7 @@
{"name": "NavItem", "type": "type", "definition": "{ title, href, icon?, permission? }", "usedBy": ["NAV_CONFIG", "AppSidebar"]}
],
"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": "冲突检测视图(班级选择器 + 检测按钮 + 冲突结果列表)" }
]
}
},
"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=draftstudentId 存生成者 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 按 teacherIdgrade_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/waitlistlottery 模式 selected", "usedBy": ["actions.selectCourseAction"]},
{"name": "dropCourse", "file": "data-access-operations.ts", "signature": "(courseId: string, studentId: string) => Promise<void>", "purpose": "学生退课status=droppedFCFS 模式自动递补 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 默认 30selectionMode 默认 fcfscredit 默认 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": {
@@ -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"]}},
"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"]}},
"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": {
"userId": {
@@ -1469,7 +1629,10 @@
"homework/actions.ts → 作为 creatorId 写入 homeworkAssignments 表",
"classes/data-access.ts → getTeacherClasses(teacherId), getGradeManagedClasses(userId)",
"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": {
@@ -1501,15 +1664,15 @@
]
},
"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": [
"shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射admin/teacher 拥有全部 FILE_* 及 GRADE_RECORD_MANAGE/READstudent/parent/grade_head/teaching_head 拥有 GRADE_RECORD_READadmin/teacher 拥有 ATTENDANCE_MANAGE+ATTENDANCE_READstudent/parent/grade_head/teaching_head 拥有 ATTENDANCE_READadmin/teacher/parent/grade_head/teaching_head 拥有 MESSAGE_SEND/READ/DELETEstudent 拥有 MESSAGE_READ/DELETE 但无 MESSAGE_SENDadmin 拥有 SCHEDULE_AUTO+SCHEDULE_ADJUSTteacher 无排课权限)",
"shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射admin/teacher 拥有全部 FILE_* 及 GRADE_RECORD_MANAGE/READstudent/parent/grade_head/teaching_head 拥有 GRADE_RECORD_READadmin/teacher 拥有 ATTENDANCE_MANAGE+ATTENDANCE_READstudent/parent/grade_head/teaching_head 拥有 ATTENDANCE_READadmin/teacher/parent/grade_head/teaching_head 拥有 MESSAGE_SEND/READ/DELETEstudent 拥有 MESSAGE_READ/DELETE 但无 MESSAGE_SENDadmin 拥有 SCHEDULE_AUTO+SCHEDULE_ADJUSTteacher 无排课权限admin/teacher 拥有 ELECTIVE_MANAGE+ELECTIVE_READstudent 拥有 ELECTIVE_SELECT+ELECTIVE_READgrade_head/teaching_head 拥有 ELECTIVE_READ",
"auth.ts JWT callback → resolvePermissions(roleNames) → token.permissions",
"proxy.ts middleware → token.permissions → 路由权限检查",
"auth-guard.ts requirePermission(permission) → Server Action权限断言如 /api/files/[id] DELETE 使用 FILE_DELETEgrades/actions.ts 使用 GRADE_RECORD_MANAGE/READmessaging/actions.ts 使用 MESSAGE_SEND/READ/DELETEattendance/actions.ts 使用 ATTENDANCE_MANAGE/READscheduling/actions.ts 使用 SCHEDULE_AUTO/SCHEDULE_ADJUST",
"auth-guard.ts requirePermission(permission) → Server Action权限断言如 /api/files/[id] DELETE 使用 FILE_DELETEgrades/actions.ts 使用 GRADE_RECORD_MANAGE/READmessaging/actions.ts 使用 MESSAGE_SEND/READ/DELETEattendance/actions.ts 使用 ATTENDANCE_MANAGE/READscheduling/actions.ts 使用 SCHEDULE_AUTO/SCHEDULE_ADJUSTelective/actions.ts 使用 ELECTIVE_MANAGE/READ/SELECT",
"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 写消息/删除按钮可见性)",
"layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤Grades 菜单项使用 GRADE_RECORD_READMessages 菜单项使用 MESSAGE_READAttendance 菜单项 teacher 使用 ATTENDANCE_MANAGEstudent/parent 使用 ATTENDANCE_READScheduling 菜单项 admin 使用 SCHEDULE_ADJUST/SCHEDULE_AUTOteacher Schedule Changes 使用 SCHEDULE_ADJUST"
"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_READMessages 菜单项使用 MESSAGE_READAttendance 菜单项 teacher 使用 ATTENDANCE_MANAGEstudent/parent 使用 ATTENDANCE_READScheduling 菜单项 admin 使用 SCHEDULE_ADJUST/SCHEDULE_AUTOteacher Schedule Changes 使用 SCHEDULE_ADJUSTElectives 菜单项 admin/teacher 使用 ELECTIVE_MANAGEstudent 使用 ELECTIVE_SELECT"
]
},
"dataScope": {
@@ -1522,7 +1685,9 @@
"exams/actions.ts update/delete → scope.type !== 'all' 时校验资源归属",
"grades/data-access.getGradeRecords({ scope }) → 行级过滤class_taught 限制所教班级class_members 限制学生本人children 限制子女)",
"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/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/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/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/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/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 必须包含 classIdclass_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/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/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/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/grade/classes": {"component": "GradeClassesClient", "type": "client", "module": "classes", "permission": "grade:manage"},

View File

@@ -2,6 +2,52 @@
## 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 修复
- `src/proxy.ts` 导出函数从 `middleware` 重命名为 `proxy`Next.js 16 要求)
- 修复 `getToken()` 在 edge 运行时缺少 `secret` 导致的 `MissingSecret` 错误

115
drizzle/0001_heavy_sage.sql Normal file
View 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`);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1781676504560,
"tag": "0000_perfect_pestilence",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1781679978738,
"tag": "0001_heavy_sage",
"breakpoints": true
}
]
}

View File

@@ -20,6 +20,56 @@ import { sql, eq } from "drizzle-orm";
import { hash } from "bcryptjs";
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);
// --- 8. 教材 + 章节(每科第一章第一节课)---
const chapterMap = await seedTextbooksAndChapters(subjectMap, gradeMap);
const chapterMap = await seedTextbooksAndChapters();
// --- 9. 知识点 ---
const kpMap = await seedKnowledgePoints(chapterMap);
// --- 10. 课表 ---
await seedClassSchedule(classMap, subjectMap);
await seedClassSchedule(classMap);
// --- 11. 题库 ---
const questionBanks = await seedQuestions(teacherMap, subjectMap, kpMap);
// --- 12. 试卷(语文、数学各 1 套)+ 学生答题与批改 ---
await seedExamsAndSubmissions(teacherMap, classMap, studentMap, subjectMap, questionBanks, gradeMap);
await seedExamsAndSubmissions(teacherMap, classMap, studentMap, questionBanks);
// --- 13. 作业(引用试卷)+ 学生答题与批改 ---
await seedHomework(teacherMap, classMap, studentMap, questionBanks, subjectMap);
await seedHomework(teacherMap, classMap, studentMap, questionBanks);
// --- 14. 成绩记录 ---
await seedGradeRecords(teacherMap, classMap, studentMap, subjectMap, academicYearId);
@@ -451,10 +501,7 @@ async function seedEnrollmentsAndParents(
// ============ 8. 教材 + 章节 ============
async function seedTextbooksAndChapters(
subjectMap: Record<string, string>,
gradeMap: Record<string, string>
) {
async function seedTextbooksAndChapters() {
console.log("📖 创建教材与章节...");
// 每科 1 本教材(一年级语文、一年级数学、一年级英语)
// 只实现第一章第一节课
@@ -558,8 +605,7 @@ async function seedKnowledgePoints(chapterMap: Record<string, string>) {
// ============ 10. 课表 ============
async function seedClassSchedule(
classMap: Record<string, { id: string }>,
subjectMap: Record<string, string>
classMap: Record<string, { id: string }>
) {
console.log("📅 创建课表...");
// 每班每天安排语数外,简化为周一三五各 2 节
@@ -807,13 +853,11 @@ async function seedExamsAndSubmissions(
teacherMap: Record<string, { id: string }>,
classMap: Record<string, { id: string }>,
studentMap: Record<string, { id: string; classKey: string }>,
subjectMap: Record<string, string>,
questionBanks: { chineseQuestions: any[]; mathQuestions: any[] },
gradeMap: Record<string, string>
questionBanks: SeedQuestionBank
) {
console.log("📝 创建试卷与学生答题...");
const makeGroup = (title: string, children: any[]) => ({
const makeGroup = (title: string, children: SeedQuestion[]) => ({
id: createId(),
type: "group" as const,
title,
@@ -926,12 +970,12 @@ async function seedExamsAndSubmissions(
submissionCount++;
// 答案
const buildAnswer = (q: any, idx: number) => {
const buildAnswer = (q: SeedQuestion, idx: number) => {
if (q.type === "single_choice") {
// 前 3 题选正确项,后 2 题选错误项
const correct = q.content.options.find((o: any) => o.isCorrect);
const wrong = q.content.options.find((o: any) => !o.isCorrect);
return { answer: idx < 3 ? correct.id : wrong.id };
const correct = q.content.options?.find((o) => o.isCorrect);
const wrong = q.content.options?.find((o) => !o.isCorrect);
return { answer: idx < 3 ? correct?.id : wrong?.id };
}
if (q.type === "judgment") return { answer: idx < 3 };
return { answer: idx < 3 ? "标准答案" : "学生答案" };
@@ -939,7 +983,7 @@ async function seedExamsAndSubmissions(
await db.insert(submissionAnswers).values(
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;
return {
id: createId(),
@@ -963,8 +1007,7 @@ async function seedHomework(
teacherMap: Record<string, { id: string }>,
classMap: Record<string, { id: string }>,
studentMap: Record<string, { id: string; classKey: string }>,
questionBanks: { chineseQuestions: any[]; mathQuestions: any[]; engQuestions: any[] },
subjectMap: Record<string, string>
questionBanks: SeedQuestionBank
) {
console.log("📚 创建作业...");
// 1 个语文作业(引用语文题)+ 1 个数学作业(引用数学题)
@@ -972,7 +1015,7 @@ async function seedHomework(
const dueAt = new Date(now.getTime() + 7 * 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",
title: "一年级语文第一课作业",
@@ -1039,7 +1082,6 @@ async function seedHomework(
);
// 前 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++) {
const student = targets[i];
const submissionId = createId();
@@ -1067,11 +1109,11 @@ async function seedHomework(
});
hwSubmissionCount++;
const buildAnswer = (q: any, idx: number) => {
const buildAnswer = (q: SeedQuestion, idx: number) => {
if (q.type === "single_choice") {
const correct = q.content.options.find((o: any) => o.isCorrect);
const wrong = q.content.options.find((o: any) => !o.isCorrect);
return { answer: idx < 3 ? correct.id : wrong.id };
const correct = q.content.options?.find((o) => o.isCorrect);
const wrong = q.content.options?.find((o) => !o.isCorrect);
return { answer: idx < 3 ? correct?.id : wrong?.id };
}
if (q.type === "judgment") return { answer: idx < 3 };
return { answer: idx < 3 ? "标准答案" : "学生答案" };
@@ -1079,7 +1121,7 @@ async function seedHomework(
await db.insert(homeworkAnswers).values(
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;
return {
id: createId(),
@@ -1117,7 +1159,7 @@ async function seedGradeRecords(
ENG: teacherMap.T_E1.id,
};
const records: any[] = [];
const records: SeedGradeRecord[] = [];
for (const s of g1c1Students) {
for (const [subCode, sid] of Object.entries(subjectMap)) {
records.push({
@@ -1151,7 +1193,7 @@ async function seedAttendanceRecords(
// 为一年级1班最近 5 天的考勤
const g1c1Students = Object.values(studentMap).filter(s => s.classKey === "G1C1");
const recorder = teacherMap.T_C1.id;
const records: any[] = [];
const records: SeedAttendanceRecord[] = [];
const statuses = ["present", "present", "present", "present", "late", "absent"] as const;
for (let d = 0; d < 5; d++) {

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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 },
)
}
}

View 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" }
}
}

View 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 &lt;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 (&lt;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 &lt;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>
)
}

View 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>
)
}

View 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>
)
}

View 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 (&lt;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>
)
}

View 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 存生成者 IDschema 要求 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

View 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,
}))
}

View 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
}

View 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)
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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))
}
}
}
}

View 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)
}

View 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 }

View 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>

View 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",
}

View File

@@ -16,7 +16,9 @@ import {
GraduationCap,
Mail,
CalendarCheck,
CalendarClock
CalendarClock,
Stethoscope,
BookMarked
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { Permissions } from "@/shared/types/permissions"
@@ -83,6 +85,12 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/admin/announcements",
permission: Permissions.ANNOUNCEMENT_MANAGE,
},
{
title: "Electives",
icon: BookMarked,
href: "/admin/elective",
permission: Permissions.ELECTIVE_MANAGE,
},
{
title: "Messages",
icon: Mail,
@@ -180,6 +188,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/teacher/schedule-changes",
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",
icon: Briefcase,
@@ -238,6 +258,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/student/attendance",
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",
icon: Megaphone,

View 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")
}
}

View 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>
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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 }

View 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

View File

@@ -418,6 +418,14 @@ export const classSchedule = mysqlTable("class_schedule", {
}).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", {
id: id("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
@@ -445,6 +453,14 @@ export const exams = mysqlTable("exams", {
startTime: timestamp("start_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: 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),
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),
}));

View File

@@ -47,6 +47,12 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_DELETE,
Permissions.SCHEDULE_AUTO,
Permissions.SCHEDULE_ADJUST,
Permissions.ELECTIVE_MANAGE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -78,6 +84,12 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_MANAGE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
],
student: [
Permissions.EXAM_READ,
@@ -92,6 +104,9 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_SELECT,
Permissions.ELECTIVE_READ,
Permissions.DIAGNOSTIC_READ,
],
parent: [
Permissions.EXAM_READ,
@@ -135,6 +150,10 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
],
teaching_head: [
Permissions.EXAM_CREATE,
@@ -163,6 +182,9 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_READ,
],
}

View File

@@ -80,6 +80,19 @@ export const Permissions = {
// Scheduling (排课与调课)
SCHEDULE_AUTO: "schedule:auto",
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
export type Permission = (typeof Permissions)[keyof typeof Permissions]