feat(exams,homework,proctoring): 长期问题修复与竞品差距补齐

P1-1 跨模块直查消除:
- homework/data-access-classes.ts 移除对 exams/subjects 表的 JOIN 直查
- 改为调用 exams/data-access.getExamSubjectIdMap + school/data-access.getSubjectNameMapByIds
- school/data-access.ts 新增 getSubjectNameMapByIds 批量科目名称映射函数

P1-2 as 断言消除(exam-mode-config.tsx):
- 移除全部 10 处 as 类型断言
- 改用 useFormContext 替代 Control prop,避免 Control<T> 不变型问题
- exam-form.tsx 调用方简化为 <ExamModeConfig />(已集成到考试表单)

P1-3 as 断言消除(proctoring-dashboard.tsx):
- 用类型守卫函数 isProctoringEventType + toProctoringEventTypes
  替代 Object.keys(...) as ProctoringEventType[] 断言

P0-竞品倒计时(对标智学网/猿题库):
- 新增 hooks/use-exam-countdown.ts 考试倒计时 Hook
- homework-take-view.tsx 集成限时/监考模式倒计时显示与到时自动提交
- data-access.ts 的 getStudentHomeworkTakeData 新增 examModeConfig + startedAt 字段
- types.ts 扩展 StudentHomeworkTakeData 类型
- i18n 补充 timedExam/timeRemaining/timeUpAutoSubmit 翻译键

架构文档同步:
- 004/005 更新 homework/proctoring/school/exams 模块导出与依赖关系
- 005 新增 homework.hooks.useExamCountdown 与 school.dataAccess.getSubjectNameMapByIds
- 005 依赖矩阵 homework→school 补充 getSubjectNameMapByIds

验证:tsc --noEmit 零错误,eslint 零错误(3 个预存 warning 无关)
This commit is contained in:
SpecialX
2026-06-23 09:34:24 +08:00
parent 2c0f81391b
commit 036a2f2839
12 changed files with 915 additions and 136 deletions

View File

@@ -583,13 +583,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数** **导出函数**
- Actions`createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction` / `batchAutoGradeSubmissionsAction`V3-7 新增批量自动批改HOMEWORK_GRADE 权限+非管理员仅可批改自己创建的作业)(✅ P1-2 已修复actions 层不再直接访问 DB全部下沉到 data-access/data-access-write - Actions`createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction` / `batchAutoGradeSubmissionsAction`V3-7 新增批量自动批改HOMEWORK_GRADE 权限+非管理员仅可批改自己创建的作业)(✅ P1-2 已修复actions 层不再直接访问 DB全部下沉到 data-access/data-access-write
- Data-access`getHomeworkAssignments` / `getHomeworkAssignmentById` / `getHomeworkSubmissions` / `getStudentHomeworkAssignments` / `getStudentHomeworkTakeData` / `getHomeworkAssignmentReviewList` / `getHomeworkSubmissionDetails` / `getDemoStudentUser`(已迁移至 users 模块 `getCurrentStudentUser`,此处为 re-export 向后兼容)/ `isRecord` / `toQuestionContent` / `getAssignmentMaxScoreById`(后三者供 stats-service 使用)/ `getHomeworkAssignmentsByExamId`V3-8 新增:按考试 ID 查作业+目标/提交/批改计数)/ `getGradedSubmissionsByExamId`V3-8 新增:按考试 ID 查已批改提交,按学生去重)/ `getStudentSubmissionResult`V3-9 新增:查学生指定作业最新提交,用于结果页)/ `getStudentExamResults`V3-11 新增:查学生考试结果列表,供家长端展示) - Data-access`getHomeworkAssignments` / `getHomeworkAssignmentById` / `getHomeworkSubmissions` / `getStudentHomeworkAssignments` / `getStudentHomeworkTakeData` / `getHomeworkAssignmentReviewList` / `getHomeworkSubmissionDetails` / `getDemoStudentUser`(已迁移至 users 模块 `getCurrentStudentUser`,此处为 re-export 向后兼容)/ `isRecord` / `toQuestionContent` / `getAssignmentMaxScoreById`(后三者供 stats-service 使用)/ `getHomeworkAssignmentsByExamId`V3-8 新增:按考试 ID 查作业+目标/提交/批改计数)/ `getGradedSubmissionsByExamId`V3-8 新增:按考试 ID 查已批改提交,按学生去重)/ `getStudentSubmissionResult`V3-9 新增:查学生指定作业最新提交,用于结果页)/ `getStudentExamResults`V3-11 新增:查学生考试结果列表,供家长端展示)
- Data-access-classes`getAssignmentIdsForStudents` / `getHomeworkAssignmentsWithSubject` / `getHomeworkAssignmentsByIds` / `getAssignmentTargetCounts` / `getHomeworkSubmissionsForStudents` / `getPublishedHomeworkAssignmentsWithSubject` / `getHomeworkSubmissionsForAssignments`P0-7 新增,供 classes 模块跨模块调用,封装 homework/exams 表查询 - Data-access-classes`getAssignmentIdsForStudents` / `getHomeworkAssignmentsWithSubject` / `getHomeworkAssignmentsByIds` / `getAssignmentTargetCounts` / `getHomeworkSubmissionsForStudents` / `getPublishedHomeworkAssignmentsWithSubject` / `getHomeworkSubmissionsForAssignments`P0-7 新增,供 classes 模块跨模块调用;✅ P1-1 已修复:内部不再直查 exams/subjects 表,改为调用 `exams/data-access.getExamSubjectIdMap` + `school/data-access.getSubjectNameMapByIds`
- Data-access-write11 个写操作函数P1-2 新增 10 个从 actions 下沉 + V3-7 新增 `batchAutoGradeSubmissions` - Data-access-write11 个写操作函数P1-2 新增 10 个从 actions 下沉 + V3-7 新增 `batchAutoGradeSubmissions`
- Stats-service`getTeacherGradeTrends` / `getHomeworkAssignmentAnalytics` / `getStudentDashboardGrades`(从 data-access.ts re-export 以保持向后兼容) - Stats-service`getTeacherGradeTrends` / `getHomeworkAssignmentAnalytics` / `getStudentDashboardGrades`(从 data-access.ts re-export 以保持向后兼容)
- ComponentsV3-7/V3-9 新增):`HomeworkBatchGradingView`(批量批改视图:勾选+一键批改+toast 反馈)/ `HomeworkSubmissionResult`(提交后即时反馈:分数汇总+对错分布+错题预览) - ComponentsV3-7/V3-9 新增):`HomeworkBatchGradingView`(批量批改视图:勾选+一键批改+toast 反馈)/ `HomeworkSubmissionResult`(提交后即时反馈:分数汇总+对错分布+错题预览)
**依赖关系** **依赖关系**
- 依赖:`shared/*``@/auth``exams`(✅ P1-1 已修复:通过 exams data-access.getExamIdsByGradeIds/getExamSubjectIdMap/getExamWithQuestionsForHomework`classes`(✅ P1-1 已修复:通过 classes data-access.getStudentIdsByClassId 等 7 个函数)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions`users`(✅ P1-1 已修复:通过 users data-access.getUserWithRole/getUserNamesByIds - 依赖:`shared/*``@/auth``exams`(✅ P1-1 已修复:通过 exams data-access.getExamIdsByGradeIds/getExamSubjectIdMap/getExamWithQuestionsForHomework/getExamForProctoringCrossModule)、`classes`(✅ P1-1 已修复:通过 classes data-access.getStudentIdsByClassId 等 7 个函数)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getSubjectNameMapByIds)、`users`(✅ P1-1 已修复:通过 users data-access.getUserWithRole/getUserNamesByIds
- 被依赖:`dashboard`(通过 data-access合理`parent`(通过 data-access合理V3-11 新增 `getStudentExamResults` 供 parent 调用)、`classes`(✅ P0-7 已修复classes 通过 `homework/data-access-classes` 获取作业数据,不再反向直查 homework/exams 表)、`exams`V3-8 新增:`exams/stats-service.getExamAnalytics` 调用 `getHomeworkAssignmentsByExamId` / `getGradedSubmissionsByExamId`,合理跨模块调用) - 被依赖:`dashboard`(通过 data-access合理`parent`(通过 data-access合理V3-11 新增 `getStudentExamResults` 供 parent 调用)、`classes`(✅ P0-7 已修复classes 通过 `homework/data-access-classes` 获取作业数据,不再反向直查 homework/exams 表)、`exams`V3-8 新增:`exams/stats-service.getExamAnalytics` 调用 `getHomeworkAssignmentsByExamId` / `getGradedSubmissionsByExamId`,合理跨模块调用)
**已知问题** **已知问题**
@@ -597,7 +597,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ P0 已解决:`getStudentDashboardGrades` 排名计算逻辑迁移至 `stats-service.ts` - ✅ P0 已解决:`getStudentDashboardGrades` 排名计算逻辑迁移至 `stats-service.ts`
- ✅ P0 已解决:`getHomeworkAssignmentAnalytics` 错误率统计逻辑迁移至 `stats-service.ts` - ✅ P0 已解决:`getHomeworkAssignmentAnalytics` 错误率统计逻辑迁移至 `stats-service.ts`
- ✅ P0-7 已修复:新增 `data-access-classes.ts`,将 classes 模块对 homework/exams 表的直查封装为 homework 模块的导出函数,恢复三层架构 - ✅ P0-7 已修复:新增 `data-access-classes.ts`,将 classes 模块对 homework/exams 表的直查封装为 homework 模块的导出函数,恢复三层架构
- ✅ P1-1 已修复:~~5 处直查 `exams` 表~~ 改为调用 `exams/data-access.getExamIdsByGradeIds` / `getExamSubjectIdMap` / `getExamWithQuestionsForHomework` - ✅ P1-1 已修复:~~5 处直查 `exams` 表~~ 改为调用 `exams/data-access.getExamIdsByGradeIds` / `getExamSubjectIdMap` / `getExamWithQuestionsForHomework``data-access-classes.ts` 内部 JOIN exams+subjects 也改为调用 `exams/data-access.getExamSubjectIdMap` + `school/data-access.getSubjectNameMapByIds`,彻底消除跨模块直查
- ✅ P0-竞品已修复:新增 `hooks/use-exam-countdown.ts` 倒计时 Hook对标智学网/猿题库),`homework-take-view.tsx` 集成限时/监考模式倒计时显示与到时自动提交;`data-access.ts``getStudentHomeworkTakeData` 新增 `examModeConfig` + `startedAt` 字段供客户端计算截止时间
- ✅ P1-2 已修复:~~`actions.ts` 多处直接 DB 操作(`createHomeworkAssignmentAction` 157 行)~~ DB 操作已下沉到 `data-access-write.ts`actions.ts 现 239 行 - ✅ P1-2 已修复:~~`actions.ts` 多处直接 DB 操作(`createHomeworkAssignmentAction` 157 行)~~ DB 操作已下沉到 `data-access-write.ts`actions.ts 现 239 行
- ✅ V3-7新增 `batchAutoGradeSubmissionsAction` + `batchAutoGradeSubmissions` + `HomeworkBatchGradingView`,提交列表页接入批量批改 - ✅ V3-7新增 `batchAutoGradeSubmissionsAction` + `batchAutoGradeSubmissions` + `HomeworkBatchGradingView`,提交列表页接入批量批改
- ✅ V3-8新增 `getHomeworkAssignmentsByExamId` + `getGradedSubmissionsByExamId`,供 exams 模块跨模块调用 - ✅ V3-8新增 `getHomeworkAssignmentsByExamId` + `getGradedSubmissionsByExamId`,供 exams 模块跨模块调用
@@ -610,14 +611,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|------|------|------| |------|------|------|
| `data-access.ts` | 598+ | 作业 CRUD + 学生视角 + 批改(含 re-export stats 函数V3-8/V3-9/V3-11 新增 4 个查询函数) | | `data-access.ts` | 598+ | 作业 CRUD + 学生视角 + 批改(含 re-export stats 函数V3-8/V3-9/V3-11 新增 4 个查询函数) |
| `data-access-write.ts` | 285+ | 作业写操作P1-2 新增 10 个写函数从 actions 下沉V3-7 新增 `batchAutoGradeSubmissions` | | `data-access-write.ts` | 285+ | 作业写操作P1-2 新增 10 个写函数从 actions 下沉V3-7 新增 `batchAutoGradeSubmissions` |
| `data-access-classes.ts` | 232 | 跨模块查询封装P0-7 新增,供 classes 模块调用,封装 homework/exams 表查询 | | `data-access-classes.ts` | 240+ | 跨模块查询封装P0-7 新增;✅ P1-1 已修复:内部通过 exams/school data-access 获取考试科目信息,不再直查 exams/subjects 表 |
| `stats-service.ts` | 425 | 统计分析(教师趋势/作业分析/学生仪表盘成绩) | | `stats-service.ts` | 425 | 统计分析(教师趋势/作业分析/学生仪表盘成绩) |
| `actions.ts` | 239+ | 6 个 Server ActionP1-2 已修复,无直接 DB 操作V3-7 新增 `batchAutoGradeSubmissionsAction` | | `actions.ts` | 239+ | 6 个 Server ActionP1-2 已修复,无直接 DB 操作V3-7 新增 `batchAutoGradeSubmissionsAction` |
| `types.ts` | 186 | 类型定义 | | `types.ts` | 186 | 类型定义 |
| `schema.ts` | 29 | Zod 校验 | | `schema.ts` | 29 | Zod 校验 |
| `components/homework-batch-grading-view.tsx` | - | V3-7 新增批量批改视图use client | | `components/homework-batch-grading-view.tsx` | - | V3-7 新增批量批改视图use client |
| `components/homework-submission-result.tsx` | - | V3-9 新增:提交后即时反馈页 | | `components/homework-submission-result.tsx` | - | V3-9 新增:提交后即时反馈页 |
| `components/homework-take-view.tsx` | - | V3-9/V3-12 增强:提交后跳转结果页+移动端触摸优化 | | `components/homework-take-view.tsx` | - | V3-9/V3-12 增强:提交后跳转结果页+移动端触摸优化;✅ P0-竞品:集成限时/监考模式倒计时useExamCountdown+ 到时自动提交 |
| `hooks/use-exam-countdown.ts` | 122 | P0-竞品新增:考试倒计时 Hook每秒更新、紧急状态高亮、到时回调自动提交 |
--- ---
@@ -723,12 +725,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。 **职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
**导出函数** **导出函数**
- Actions`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction` / `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限) - Actions`getGradeRecordsAction` / `createGradeRecordAction`v4-P1-6 增强:成绩录入后通知学生和家长,调用 `notifyGradeEntered`/ `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction`v4-P1-12 增强:新增可选 `studentId` 参数,支持按学生导出,家长视角调用 `exportStudentGradeRecordsToExcel`,校验 studentId 属于家长子女)/ `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`v4-P1-6 增强:批量成绩录入后通知学生和家长)/ `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限)
- Data-access`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUDupsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总) - Data-access`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUDupsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)
- Types✅ v3-P2 新增):`SchoolWideGradeSummaryItem`全校汇总按年级聚合项gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount/ `SchoolWideGradeSummary`全校汇总grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record<string, string>, timestamp: number },位于 data-access.ts - Types✅ v3-P2 新增):`SchoolWideGradeSummaryItem`全校汇总按年级聚合项gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount/ `SchoolWideGradeSummary`全校汇总grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record<string, string>, timestamp: number },位于 data-access.ts
- Lib✅ P1-2 新增,✅ P3 更新签名,✅ P3-26 拆分):`toNumber` / `normalize`(位于 `lib/grade-utils.ts``buildScopeClassFilter(scope, currentUserId?)`P3-26 从 grade-utils.ts 迁移至 `lib/scope-filter.ts`P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数) - Lib✅ P1-2 新增,✅ P3 更新签名,✅ P3-26 拆分):`toNumber` / `normalize`(位于 `lib/grade-utils.ts``buildScopeClassFilter(scope, currentUserId?)`P3-26 从 grade-utils.ts 迁移至 `lib/scope-filter.ts`P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数)
- Stats-service✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O统计逻辑可独立测试 - Stats-service✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O统计逻辑可独立测试
- ComponentsP1-5 新增):`WidgetBoundary`Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格 - Export✅ v4-P1-12 新增):`exportGradeRecordsToExcel` / `exportClassGradeReportToExcel` / `exportStudentGradeRecordsToExcel`v4-P1-12 新增:导出单个学生成绩单家长视角,仅含成绩明细 +统计不含班级数据scope 为 children 自动按 studentId 过滤)/ `formatDateForFile`(已迁移至 shared/lib/utils
- Components✅ P1-5 新增):`WidgetBoundary`Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格)/ `ScoreCell`(✅ v4-P1-7 新增:成绩单元格组件,根据得分率着色——红<60%/黄60-84%/绿≥85%,使用语义化 Tailwind 类名避免动态拼接fullScore<=0 时不着色)
**依赖关系** **依赖关系**
- 依赖:`shared/*``@/auth``classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds - 依赖:`shared/*``@/auth``classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds
@@ -767,30 +770,36 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ v3-P2 改进2026-06-23新增 `getSchoolWideGradeSummary` data-access 函数 + `SchoolWideSummaryCard` 组件 + `SchoolWideGradeSummary`/`SchoolWideGradeSummaryItem` 类型,管理员全校成绩汇总视图(按年级聚合平均分/及格率/优秀率/学生数/班级数加权平均计算全校汇总admin/school/grades/insights/page.tsx 顶部新增 SchoolWideSummaryCard - ✅ v3-P2 改进2026-06-23新增 `getSchoolWideGradeSummary` data-access 函数 + `SchoolWideSummaryCard` 组件 + `SchoolWideGradeSummary`/`SchoolWideGradeSummaryItem` 类型,管理员全校成绩汇总视图(按年级聚合平均分/及格率/优秀率/学生数/班级数加权平均计算全校汇总admin/school/grades/insights/page.tsx 顶部新增 SchoolWideSummaryCard
- ✅ v3-P2 改进2026-06-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard - ✅ v3-P2 改进2026-06-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard
- ✅ v3-P3-4 改进2026-06-23GradeTrendCard 新增日期范围选择器(全部/近7天/近30天/近90天通过 nuqs `trendRange` URL 参数持久化useEffect 中计算截止时间戳避免渲染阶段调用 Date.now() - ✅ v3-P3-4 改进2026-06-23GradeTrendCard 新增日期范围选择器(全部/近7天/近30天/近90天通过 nuqs `trendRange` URL 参数持久化useEffect 中计算截止时间戳避免渲染阶段调用 Date.now()
- ✅ v4-P3-4 改进2026-06-23GradeDistributionChart 色盲友好双重编码——每个分数段使用不同 SVG pattern条纹/点状/交叉线/反向条纹/网格)+ 颜色,`SimpleBarChart` 新增 `defs` prop 支持自定义 SVG 图案
- ✅ P3 修复2026-06-23~~`lib/grade-utils.ts` 72 行超 40 行工具函数上限~~ P3-26 将 `buildScopeClassFilter` 迁移至 `lib/scope-filter.ts`grade-utils.ts 仅保留 toNumber/normalize20 行) - ✅ P3 修复2026-06-23~~`lib/grade-utils.ts` 72 行超 40 行工具函数上限~~ P3-26 将 `buildScopeClassFilter` 迁移至 `lib/scope-filter.ts`grade-utils.ts 仅保留 toNumber/normalize20 行)
- ✅ P3 修复2026-06-23~~`stats-service.ts` createDefaultBuckets 不必要导出~~ P3-10 移除 export 关键字,改为内部函数 - ✅ P3 修复2026-06-23~~`stats-service.ts` createDefaultBuckets 不必要导出~~ P3-10 移除 export 关键字,改为内部函数
- ✅ P3 修复2026-06-23~~`stats-service.ts` buildGradeTrendPoints 使用 as 断言~~ P3-24 新增 isGradeTrendType 类型守卫函数替代 as 断言 - ✅ P3 修复2026-06-23~~`stats-service.ts` buildGradeTrendPoints 使用 as 断言~~ P3-24 新增 isGradeTrendType 类型守卫函数替代 as 断言
- ✅ P3 修复2026-06-23~~`export.ts` 局部 avg 函数与 stats-service.computeAverageScore 重复~~ P3-6 删除局部 avg复用 computeAverageScore - ✅ P3 修复2026-06-23~~`export.ts` 局部 avg 函数与 stats-service.computeAverageScore 重复~~ P3-6 删除局部 avg复用 computeAverageScore
- ✅ P3 修复2026-06-23~~`export.ts` TYPE_LABELS 硬编码中文~~ P3-7 改用 next-intl getTranslations新增 export.sheets/columns/metrics i18n 键zh-CN/en 同步) - ✅ P3 修复2026-06-23~~`export.ts` TYPE_LABELS 硬编码中文~~ P3-7 改用 next-intl getTranslations新增 export.sheets/columns/metrics i18n 键zh-CN/en 同步)
- ✅ v4-P1-6 改进2026-06-23`createGradeRecordAction` / `batchCreateGradeRecordsAction` 成绩录入后通知学生和家长(调用 `notifyGradeEntered`,内部使用 `parent/data-access.getParentIdsByStudentIds` 批量查询家长 ID通知失败不阻断成绩录入
- ✅ v4-P1-7 改进2026-06-23新增 `ScoreCell` 组件components/score-cell.tsx根据得分率着色红<60%/黄60-84%/绿≥85%),使用语义化 Tailwind 类名避免动态拼接
- ✅ v4-P1-10 改进2026-06-23`grade-record-list.tsx` 使用 `ScoreCell` 替代纯文本分数展示 + 表格 `overflow-x-auto` 水平滚动
- ✅ v4-P1-12 改进2026-06-23`exportGradesAction` 新增可选 `studentId` 参数,支持按学生导出(家长视角);新增 `exportStudentGradeRecordsToExcel` 导出函数(仅含成绩明细 + 个人统计不含班级数据parent/grades/page.tsx 传 studentId 到 ParentExportButtonParentExportButton 接入 exportGradesAction
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `actions.ts` | 396+ | 18 个 Server Action含 Zod 校验,含 v2-P1-5 安全修复assertClassInScope + 行级 scope 校验P3 修复handleActionError + safeJsonParse + scope 传递 + DB 层分页v3-P2 新增saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction | | `actions.ts` | 631+ | 18 个 Server Action含 Zod 校验,含 v2-P1-5 安全修复assertClassInScope + 行级 scope 校验P3 修复handleActionError + safeJsonParse + scope 传递 + DB 层分页v3-P2 新增saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftActionv4-P1-6createGradeRecordAction/batchCreateGradeRecordsAction 新增通知v4-P1-12exportGradesAction 新增 studentId 参数 |
| `actions-analytics.ts` | 170 | 5 个分析 Action含 Zod 校验P3 修复handleActionError + assertClassInScope 校验) | | `actions-analytics.ts` | 170 | 5 个分析 Action含 Zod 校验P3 修复handleActionError + assertClassInScope 校验) |
| `data-access.ts` | 428+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复recorderName 批量查询P3 修复PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名v3-P2 新增saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口) | | `data-access.ts` | 428+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复recorderName 批量查询P3 修复PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名v3-P2 新增saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口) |
| `data-access-analytics.ts` | 200+ | 趋势/对比分析P3 修复getClassComparison 应用 buildScopeClassFilterv3-P2 新增getExamOptionsForGrades/getSchoolWideGradeSummarygetGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) | | `data-access-analytics.ts` | 200+ | 趋势/对比分析P3 修复getClassComparison 应用 buildScopeClassFilterv3-P2 新增getExamOptionsForGrades/getSchoolWideGradeSummarygetGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) |
| `data-access-ranking.ts` | 83 | 排名查询P3 修复getRankingTrend 接受 scope 参数 + class_taught 校验) | | `data-access-ranking.ts` | 83 | 排名查询P3 修复getRankingTrend 接受 scope 参数 + class_taught 校验) |
| `stats-service.ts` | 285 | 统计计算纯函数P1-1 新增8 个纯函数 + 2 个常量 + 2 个接口P3-10createDefaultBuckets 改为内部函数P3-24buildGradeTrendPoints 使用 isGradeTrendType 类型守卫替代 as 断言) | | `stats-service.ts` | 285 | 统计计算纯函数P1-1 新增8 个纯函数 + 2 个常量 + 2 个接口P3-10createDefaultBuckets 改为内部函数P3-24buildGradeTrendPoints 使用 isGradeTrendType 类型守卫替代 as 断言) |
| `export.ts` | 209 | Excel 导出v2-P1-5 修复:传递 currentUserId 到 data-accessP3 修复:适配 PaginatedGradeRecords 结构 + 传递 scopeP3-6复用 stats-service.computeAverageScore 替代局部 avgP3-7硬编码中文改用 next-intl getTranslations | | `export.ts` | 290+ | Excel 导出v2-P1-5 修复:传递 currentUserId 到 data-accessP3 修复:适配 PaginatedGradeRecords 结构 + 传递 scopeP3-6复用 stats-service.computeAverageScore 替代局部 avgP3-7硬编码中文改用 next-intl getTranslationsv4-P1-12 新增exportStudentGradeRecordsToExcel 家长视角单学生导出 |
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schemaP3 修复score .max(1000) + records .max(500) + 补全查询字段v3-P2 新增grade_drafts 表定义第 1444-1469 行) | | `schema.ts` | 113+ | Zod 校验(含 12 个查询 schemaP3 修复score .max(1000) + records .max(500) + 补全查询字段v3-P2 新增grade_drafts 表定义第 1444-1469 行) |
| `lib/grade-utils.ts` | 20 | 公共工具函数toNumber/normalizeP3-26buildScopeClassFilter 迁移至 scope-filter.ts | | `lib/grade-utils.ts` | 20 | 公共工具函数toNumber/normalizeP3-26buildScopeClassFilter 迁移至 scope-filter.ts |
| `lib/scope-filter.ts` | 56 | DB 行级权限过滤buildScopeClassFilterP3-26 从 grade-utils.ts 迁移v2-P2-2 修复:改用 classes data-access 子查询P3 修复:新增 currentUserId 参数) | | `lib/scope-filter.ts` | 56 | DB 行级权限过滤buildScopeClassFilterP3-26 从 grade-utils.ts 迁移v2-P2-2 修复:改用 classes data-access 子查询P3 修复:新增 currentUserId 参数) |
| `types.ts` | 168+ | 类型定义v3-P2 新增SchoolWideGradeSummaryItem/SchoolWideGradeSummary | | `types.ts` | 168+ | 类型定义v3-P2 新增SchoolWideGradeSummaryItem/SchoolWideGradeSummary |
| `components/widget-boundary.tsx` | 136 | Widget 边界组件P1-5 新增v2-P1-1 已在 3 个页面应用) | | `components/widget-boundary.tsx` | 136 | Widget 边界组件P1-5 新增v2-P1-1 已在 3 个页面应用) |
| `components/school-wide-summary-card.tsx` | - | v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格) | | `components/school-wide-summary-card.tsx` | - | v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格) |
| `components/score-cell.tsx` | 41 | v4-P1-7 新增:成绩单元格组件,根据得分率着色(红<60%/黄60-84%/绿≥85%),使用语义化 Tailwind 类名 |
| `components/grade-trend-card.tsx` | 69 | 趋势卡片v2-P2-9 修复a11yv2-P1-4i18nP3 修复NaN 日期检查 + fullScore > 0 守卫) | | `components/grade-trend-card.tsx` | 69 | 趋势卡片v2-P2-9 修复a11yv2-P1-4i18nP3 修复NaN 日期检查 + fullScore > 0 守卫) |
| `components/grade-record-list.tsx` | 125 | 成绩记录列表v2-P1-4i18nP3 修复safeActionCall 包装删除操作) | | `components/grade-record-list.tsx` | 125 | 成绩记录列表v2-P1-4i18nP3 修复safeActionCall 包装删除操作v4-P1-7使用 ScoreCellv4-P1-10overflow-x-auto |
| `components/grade-distribution-chart.tsx` | 100 | 分数分布图v2-P1-4i18n | | `components/grade-distribution-chart.tsx` | 100 | 分数分布图v2-P1-4i18n |
| `components/subject-comparison-chart.tsx` | 62 | 科目对比图v2-P1-4i18n | | `components/subject-comparison-chart.tsx` | 62 | 科目对比图v2-P1-4i18n |
| `components/class-comparison-chart.tsx` | 58 | 班级对比图v2-P1-4i18n | | `components/class-comparison-chart.tsx` | 58 | 班级对比图v2-P1-4i18n |
@@ -858,7 +867,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数** **导出函数**
- Actions`createSchoolAction` / `updateSchoolAction` / `deleteSchoolAction` / `createAcademicYearAction` / `updateAcademicYearAction` / `deleteAcademicYearAction` / `createDepartmentAction` / `updateDepartmentAction` / `deleteDepartmentAction` / `createGradeAction` / `updateGradeAction` / `deleteGradeAction` / `promoteGradesAction`(编排层:权限校验 + Zod 校验 + 调用 data-access + revalidatePath + after(logAudit)`promoteGradesAction` 年级升级,审计日志 `grade.promote` - Actions`createSchoolAction` / `updateSchoolAction` / `deleteSchoolAction` / `createAcademicYearAction` / `updateAcademicYearAction` / `deleteAcademicYearAction` / `createDepartmentAction` / `updateDepartmentAction` / `deleteDepartmentAction` / `createGradeAction` / `updateGradeAction` / `deleteGradeAction` / `promoteGradesAction`(编排层:权限校验 + Zod 校验 + 调用 data-access + revalidatePath + after(logAudit)`promoteGradesAction` 年级升级,审计日志 `grade.promote`
- Data-access只读查询`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getOrgTree`+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`+ `promoteGrades(schoolId)` 年级升级order +1 + 名称升级,辅助函数 `promoteGradeName` - Data-access只读查询`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getOrgTree` / `getSubjectOptions` / `getGradeOptions` / `getSubjectNameMapByIds`P1-1 新增:批量科目名称映射,供 homework/data-access-classes 调用)+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`+ `promoteGrades(schoolId)` 年级升级order +1 + 名称升级,辅助函数 `promoteGradeName`
**依赖关系** **依赖关系**
- 依赖:`shared/*``@/auth``users`(⚠️ `getStaffOptions` 直查 users/roles可接受 - 依赖:`shared/*``@/auth``users`(⚠️ `getStaffOptions` 直查 users/roles可接受
@@ -946,18 +955,24 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:考勤记录管理 + 统计分析 + 规则配置。 **职责**:考勤记录管理 + 统计分析 + 规则配置。
**导出函数** **导出函数**
- Actions10 个):`recordAttendanceAction` / `batchRecordAttendanceAction` / `updateAttendanceAction` / `deleteAttendanceAction` / `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `saveAttendanceRulesAction` / `getAttendanceRulesAction` - Actions10 个):`recordAttendanceAction` / `batchRecordAttendanceAction` / `updateAttendanceAction` / `deleteAttendanceAction` / `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `saveAttendanceRulesAction` / `getAttendanceRulesAction`(✅ P2-25 个写 Action 错误消息改用 `getTranslations("attendance")` 国际化)
- Data-access`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`管理员考勤总览页统计概览,基于 `getAttendanceRecords` 聚合/ `upsertAttendanceRules` / `getAttendanceRules` - Data-access`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`✅ P2-1 已修复:改用 SQL `COUNT()` + `SUM(CASE WHEN ...)` 聚合查询,不再依赖 `getAttendanceRecords` 分页结果/ `upsertAttendanceRules` / `getAttendanceRules`
- Data-access-stats`getStudentAttendanceSummary` / `getClassAttendanceStats` / `computeStats`(⚠️ 未导出,无法单测) - Data-access-stats`getStudentAttendanceSummary`(✅ P2-6 已修复stats 改用 SQL 聚合查询recentRecords 增加 `recentLimit` 参数默认 20不再加载全部记录/ `getClassAttendanceStats` / `computeStats`
- Components`AttendanceSheet`(批量点名表单)/ `AttendanceRecordList`(记录列表 + 删除)/ `AttendanceFilters`URL 同步筛选器)/ `AttendanceStatsCard`(单卡片统计)/ `AttendanceStatsCards`(管理员 6 卡片总览)/ `AttendanceStatsClassSelector`(班级筛选 ChipNav/ `AttendanceRulesForm`(规则配置表单)/ `StudentAttendanceView`(学生/家长只读视图 - Import-export`exportAttendanceRecordsToExcel`(✅ P2-11 新增Sheet1 考勤明细 + Sheet2 统计汇总,列头 i18n 化
- Components`AttendanceSheet`(批量点名表单)/ `AttendanceRecordList`(记录列表 + 删除)/ `AttendanceFilters`URL 同步筛选器)/ `AttendanceStatsCard`(单卡片统计)/ `AttendanceStatsCards`(管理员 6 卡片总览)/ `AttendanceStatsClassSelector`(班级筛选 ChipNav/ `AttendanceRulesForm`(规则配置表单)/ `StudentAttendanceView`(学生/家长只读视图)/ `AttendancePageLayout`(✅ P2-3 新增页面布局骨架admin/teacher 考勤页复用)
**依赖关系** **依赖关系**
- 依赖:`shared/*``@/auth``classes`(⚠️ P1-1 未修复:`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表) - 依赖:`shared/*``@/auth``classes`(⚠️ P1-1 未修复:`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表)`next-intl`(✅ P2-2Server Action 错误消息 i18n
- 被依赖:`parent`(⚠️ 跨模块 UI 类型依赖3 个 parent 组件直接 import `@/modules/attendance/types` - 被依赖:`parent`(⚠️ 跨模块 UI 类型依赖3 个 parent 组件直接 import `@/modules/attendance/types``app/api/export`(✅ P2-11通过 `exportAttendanceRecordsToExcel`
**已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md` **已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md`
- P0`getAttendanceStats` 统计失真——调用 `getAttendanceRecords`(默认 pageSize=20后对 `items` 聚合,仅基于前 20 条记录计算总览数据 - P2-1 已修复`getAttendanceStats` 改用 SQL 聚合查询,消除分页截断导致统计失真
- P0`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表(架构图此前声称已修复,实际未修复) - P2-2 已修复5 个写 Action 错误消息改用 `getTranslations("attendance")` 国际化
- ✅ P2-3 已修复:新增 `AttendancePageLayout` 组件admin/teacher 考勤页复用布局
- ✅ P2-5 已修复:`parent-attendance-calendar.tsx` 增加键盘导航ARIA grid 模式,方向键/Home/End
- ✅ P2-6 已修复:`getStudentAttendanceSummary` stats 改用 SQL 聚合 + recentRecords 分页限制
- ✅ P2-11 已修复:新增 `export.ts`,考勤记录/统计可导出 Excel
- ❌ P0`getClassStudentsForAttendance` 仍直查 `classEnrollments`
- ❌ P06 个读 Action 无调用方(页面绕过 Action 直接调用 data-access违反三层架构 - ❌ P06 个读 Action 无调用方(页面绕过 Action 直接调用 data-access违反三层架构
- ❌ P0update/delete Action 缺资源归属校验(教师 A 可修改/删除教师 B 的记录) - ❌ P0update/delete Action 缺资源归属校验(教师 A 可修改/删除教师 B 的记录)
- ❌ P0i18n 完全缺失(`ATTENDANCE_STATUS_LABELS` 硬编码英文,组件中硬编码中文) - ❌ P0i18n 完全缺失(`ATTENDANCE_STATUS_LABELS` 硬编码英文,组件中硬编码中文)
@@ -973,9 +988,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `actions.ts` | 271 | 10 个 Server Action含权限校验、Zod 校验) | | `actions.ts` | 271 | 10 个 Server Action含权限校验、Zod 校验P2-2错误消息 i18n 化 |
| `data-access.ts` | 309 | 考勤 CRUD + 班级学生查询 + 规则 upsert + 总览统计 | | `data-access.ts` | 340 | 考勤 CRUD + 班级学生查询 + 规则 upsert + 总览统计P2-1`getAttendanceStats` 改 SQL 聚合) |
| `data-access-stats.ts` | 145 | 学生/班级考勤汇总(拆分范例,`computeStats` 未导出 | | `data-access-stats.ts` | 180 | 学生/班级考勤汇总(P2-6stats 改 SQL 聚合 + recentRecords 分页 |
| `export.ts` | 90 | Excel 导出P2-11 新增:考勤明细 + 统计汇总双 Sheet |
| `schema.ts` | 43 | Zod 校验5 个 schema | | `schema.ts` | 43 | Zod 校验5 个 schema |
| `types.ts` | 103 | 类型定义 + 状态标签/颜色常量(硬编码英文) | | `types.ts` | 103 | 类型定义 + 状态标签/颜色常量(硬编码英文) |
| `components/attendance-sheet.tsx` | 353 | 批量点名表单(键盘快捷键、状态按钮组) | | `components/attendance-sheet.tsx` | 353 | 批量点名表单(键盘快捷键、状态按钮组) |
@@ -986,6 +1002,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `components/attendance-stats-class-selector.tsx` | 27 | 班级筛选 ChipNav | | `components/attendance-stats-class-selector.tsx` | 27 | 班级筛选 ChipNav |
| `components/attendance-rules-form.tsx` | 148 | 考勤规则配置表单 | | `components/attendance-rules-form.tsx` | 148 | 考勤规则配置表单 |
| `components/student-attendance-view.tsx` | 104 | 学生/家长视图(统计 + 最近记录) | | `components/student-attendance-view.tsx` | 104 | 学生/家长视图(统计 + 最近记录) |
| `components/attendance-page-layout.tsx` | 38 | 页面布局骨架P2-3 新增header/stats/filters/children 插槽) |
--- ---
@@ -1142,13 +1159,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**多渠道通知分发SMS/Email/WeChat/InApp+ 站内通知 CRUD + 通知偏好管理 + 通知 UI 组件。 **职责**多渠道通知分发SMS/Email/WeChat/InApp+ 站内通知 CRUD + 通知偏好管理 + 通知 UI 组件。
**导出函数** **导出函数**
- Actions`sendNotificationAction` / `sendClassNotificationAction` / `getNotificationsAction` / `getUnreadNotificationCountAction` / `markNotificationAsReadAction` / `markAllNotificationsAsReadAction`(✅ P1-4 新增:后 4 个通知 CRUD Action 从 messaging 模块迁移) - Actions`sendNotificationAction` / `sendClassNotificationAction` / `getNotificationsAction` / `getUnreadNotificationCountAction` / `markNotificationAsReadAction` / `markAllNotificationsAsReadAction` / `archiveNotificationAction`(✅ P1-4 新增:后 4 个通知 CRUD Action 从 messaging 模块迁移;✅ V2-P2-13b 新增archiveNotificationAction 归档 Action
- Dispatcher`sendNotification(payload)` / `sendBatchNotifications(payloads)` - Dispatcher`sendNotification(payload)` / `sendBatchNotifications(payloads)`
- Data-access`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移) - Data-access`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增archiveNotification / unarchiveNotification 归档函数
- Preferences`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移) - Preferences`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
- Channels`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender` - Channels`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
- Components`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移) - Components`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13bNotificationList 支持优先级 Badge 显示和归档操作
- Hooks`useNotificationStream`(✅ V2-P3 新增SSE 实时推送 + 轮询降级 Hook - Hooks`useNotificationStream`(✅ V2-P3 新增SSE 实时推送 + 轮询降级 Hook
- Types`NotificationPriority`(✅ V2-P2-13b 新增:通知优先级类型 low/normal/high/urgent
**依赖关系** **依赖关系**
- 依赖:`shared/*``@/auth``classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getStudentIdsByClassId - 依赖:`shared/*``@/auth``classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getStudentIdsByClassId
@@ -1163,6 +1181,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ V2-P0-1 已修复:~~通知 i18n 键混在 messages.json 中~~ 新增独立的 `notifications.json` 命名空间zh-CN/en通知组件 `useTranslations``"messages"` 切换到 `"notifications"``src/i18n/request.ts` 新增 notifications 命名空间加载 - ✅ V2-P0-1 已修复:~~通知 i18n 键混在 messages.json 中~~ 新增独立的 `notifications.json` 命名空间zh-CN/en通知组件 `useTranslations``"messages"` 切换到 `"notifications"``src/i18n/request.ts` 新增 notifications 命名空间加载
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `notification-dropdown.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量30_000ms - ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `notification-dropdown.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量30_000ms
- ✅ V2-P3 已优化:~~30 秒轮询~~ `notification-dropdown.tsx` 改为 SSE 实时推送(`/api/notifications/stream`SSE 不可用时自动降级为轮询(调用 Server Actions间隔 30 秒) - ✅ V2-P3 已优化:~~30 秒轮询~~ `notification-dropdown.tsx` 改为 SSE 实时推送(`/api/notifications/stream`SSE 不可用时自动降级为轮询(调用 Server Actions间隔 30 秒)
- ✅ V2-P2-13b 新增通知优先级和归档功能。schema.ts `messageNotifications` 表新增 `priority`low/normal/high/urgent默认 normal`isArchived`(默认 false字段 + 2 个索引types.ts 新增 `NotificationPriority` 类型data-access.ts 新增 `archiveNotification` / `unarchiveNotification` 函数,`getNotifications` 支持归档和优先级筛选(默认仅返回未归档),`createNotification` 支持 priorityactions.ts 新增 `archiveNotificationAction`(含 trackEvent 埋点 notification.archivedNotificationList 组件支持优先级 Badge 显示和归档按钮i18n 新增 priority/actions.archive/messages.archiveFailed 翻译键
- ⚠️ P1发送日志仅 console`notification_logs` - ⚠️ P1发送日志仅 console`notification_logs`
- ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现) - ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现)
@@ -1170,20 +1189,20 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `dispatcher.ts` | 152 | 渠道选择 + 并行分发 | | `dispatcher.ts` | 152 | 渠道选择 + 并行分发 |
| `data-access.ts` | 212 | 站内通知 CRUD + 用户联系方式 + 发送日志持久化notification_logs 表P0-4 / P1-5 修复后新增通知 CRUD | | `data-access.ts` | ~250 | 站内通知 CRUD + 用户联系方式 + 发送日志持久化notification_logs 表P0-4 / P1-5 修复后新增通知 CRUD;✅ V2-P2-13b新增归档函数 + 优先级/归档筛选 |
| `preferences.ts` | 166 | 通知偏好 CRUDP0-4 / P1-5 修复后从 messaging 迁移) | | `preferences.ts` | 166 | 通知偏好 CRUDP0-4 / P1-5 修复后从 messaging 迁移) |
| `actions.ts` | ~260 | 6 个 Server Action✅ P1-4新增 4 个通知 CRUD Action | | `actions.ts` | ~300 | 7 个 Server Action✅ P1-4新增 4 个通知 CRUD Action;✅ V2-P2-13b新增 archiveNotificationAction |
| `types.ts` | 120 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型P0-4 / P1-5 修复后扩充) | | `types.ts` | ~130 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型P0-4 / P1-5 修复后扩充;✅ V2-P2-13b新增 NotificationPriority 类型 + priority/isArchived 字段 |
| `index.ts` | ~75 | 对外导出入口(✅ P1-4新增组件和 CRUD Action 导出) | | `index.ts` | ~80 | 对外导出入口(✅ P1-4新增组件和 CRUD Action 导出;✅ V2-P2-13b新增归档函数/Action/类型导出) |
| `channels/*` | 5 文件 | 4 个渠道实现 | | `channels/*` | 5 文件 | 4 个渠道实现 |
| `components/notification-list.tsx` | ~140 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件 | | `components/notification-list.tsx` | ~170 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件;✅ V2-P2-13b支持优先级 Badge 显示和归档操作 |
| `components/notification-dropdown.tsx` | ~150 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3改用 SSE 实时推送 + 轮询降级 | | `components/notification-dropdown.tsx` | ~150 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3改用 SSE 实时推送 + 轮询降级 |
| `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增SSE 实时推送 HookEventSource + 轮询降级) | | `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增SSE 实时推送 HookEventSource + 轮询降级) |
**组件清单** **组件清单**
| 组件 | 职责 | | 组件 | 职责 |
|------|------| |------|------|
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1useTranslations 命名空间从 "messages" 切换到 "notifications" | | `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b支持优先级 Badge 显示和归档操作 |
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1useTranslations 命名空间切换;✅ V2-P2-1POLL_INTERVAL_MS 常量) | | `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1useTranslations 命名空间切换;✅ V2-P2-1POLL_INTERVAL_MS 常量) |
**客户端行为** **客户端行为**
@@ -1389,7 +1408,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ P2 已修复:~~`buildHomeworkSummary``[...assignments].sort()` 不必要拷贝~~ 改为 `toSorted()` - ✅ P2 已修复:~~`buildHomeworkSummary``[...assignments].sort()` 不必要拷贝~~ 改为 `toSorted()`
- ✅ P2 已修复:~~`in7Days` 死代码~~ 已删除 - ✅ P2 已修复:~~`in7Days` 死代码~~ 已删除
- ⚠️ v4 保留:`/parent/leave` 为占位页,待后端实现请假审批流后接入 - ⚠️ v4 保留:`/parent/leave` 为占位页,待后端实现请假审批流后接入
- ⚠️ v4 保留:`ParentExportButton` 为占位,待后端实现成绩导出 Server Action 后接入 - v4-P1-5 改进2026-06-23新增 `getParentIdsByStudentIds` data-access 函数,批量查询多个学生的家长 userId去重供 diagnostic/grades 模块通知场景调用
- ✅ v4-P1-9 改进2026-06-23parent/diagnostic/page.tsx 新增错误卡片展示子女诊断查询失败原因
- ✅ v4-P1-12 改进2026-06-23`ParentExportButton` 接入 `exportGradesAction`,支持按 studentId 导出单个子女成绩parent/grades/page.tsx 传 studentId 到 ParentExportButton
- ⚠️ v4 保留:详情页 Attendance/Diagnostic Tab 为占位提示,待对应功能实现后填充 - ⚠️ v4 保留:详情页 Attendance/Diagnostic Tab 为占位提示,待对应功能实现后填充
- ✅ v3-P2 改进2026-06-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCardparent/diagnostic/page.tsx 传入 `practiceHrefBase={null}` 隐藏练习按钮 - ✅ v3-P2 改进2026-06-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCardparent/diagnostic/page.tsx 传入 `practiceHrefBase={null}` 隐藏练习按钮
- ✅ 职责单一,正确复用其他模块 data-access - ✅ 职责单一,正确复用其他模块 data-access
@@ -1404,7 +1425,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `components/parent-attendance-warning.tsx` | 89 | v4 新增:考勤异常预警 | | `components/parent-attendance-warning.tsx` | 89 | v4 新增:考勤异常预警 |
| `components/parent-attendance-rate-card.tsx` | 105 | v4 新增:考勤出勤率汇总卡片 | | `components/parent-attendance-rate-card.tsx` | 105 | v4 新增:考勤出勤率汇总卡片 |
| `components/parent-attendance-calendar.tsx` | 175 | v4 新增考勤月历视图use client | | `components/parent-attendance-calendar.tsx` | 175 | v4 新增考勤月历视图use client |
| `components/parent-export-button.tsx` | 50 | v4 新增:成绩导出按钮(占位 | | `components/parent-export-button.tsx` | 50 | v4 新增:成绩导出按钮(v4-P1-12 已接入 exportGradesAction支持按 studentId 导出单个子女成绩 |
| `components/child-card.tsx` | 148 | 子女卡片v4 增强:异常突出 + 趋势图标) | | `components/child-card.tsx` | 148 | 子女卡片v4 增强:异常突出 + 趋势图标) |
| `components/child-detail-header.tsx` | 78 | 详情页头部v4 增强:面包屑) | | `components/child-detail-header.tsx` | 78 | 详情页头部v4 增强:面包屑) |
| `components/child-detail-panel.tsx` | 200+ | 详情页 Tab 面板 + SiblingSwitcherv4 重写,集成 Homework/Grade DetailV3-11 新增 exams Tab | | `components/child-detail-panel.tsx` | 200+ | 详情页 Tab 面板 + SiblingSwitcherv4 重写,集成 Homework/Grade DetailV3-11 新增 exams Tab |
@@ -1434,17 +1455,27 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:选修课程管理 + 学生选课 + 抽签。 **职责**:选修课程管理 + 学生选课 + 抽签。
**导出函数** **导出函数**
- Actions11 个):`getElectiveCoursesAction` / `createElectiveCourseAction` / `updateElectiveCourseAction` / `deleteElectiveCourseAction` / `getStudentSelectionsAction` / `selectCourseAction` / `dropCourseAction` / `runLotteryAction` / `getAvailableCoursesForStudentAction` / `openSelectionAction` / `closeSelectionAction` - Actions11 个):`getElectiveCoursesAction` / `createElectiveCourseAction` / `updateElectiveCourseAction` / `deleteElectiveCourseAction` / `getStudentSelectionsAction` / `selectCourseAction` / `dropCourseAction` / `runLotteryAction` / `getAvailableCoursesForStudentAction` / `openSelectionAction` / `closeSelectionAction`(✅ P2-28 个写 Action 错误消息改用 `getTranslations("elective")` 国际化)
- Data-access`getElectiveCourses` / `getElectiveCourseById` / `createElectiveCourse` / `updateElectiveCourse` / `deleteElectiveCourse` / `openSelection` / `closeSelection` / `buildCourseSelect` / `mapCourseRow` / `resolveCourseDisplayNames` / `CourseCoreRow`P3 新增导出,供 data-access-selections 复用) - Data-access`getElectiveCourses` / `getElectiveCourseById` / `createElectiveCourse` / `updateElectiveCourse` / `deleteElectiveCourse` / `openSelection` / `closeSelection` / `buildCourseSelect` / `mapCourseRow` / `resolveCourseDisplayNames`(✅ P2-7使用 React `cache()` 包装 subject/grade options 请求级去重;✅ P2-8通过 `resolvers.ts` 接口抽象跨模块依赖)/ `CourseCoreRow`
- Data-access-operations`selectCourse` / `dropCourse` / `runLottery` / `buildLotteryRankCase`(⚠️ 未导出,无法单测) - Data-access-operations`selectCourse`(✅ P2-9新增 `checkScheduleConflict` 时间冲突检测;✅ P2-10新增 `checkCreditLimit` 学分上限校验)/ `dropCourse` / `runLottery` / `buildLotteryRankCase`(⚠️ 未导出,无法单测)/ `checkScheduleConflict`P2-9 新增)/ `checkCreditLimit`P2-10 新增)
- Data-access-selections`getCourseSelections` / `getStudentSelections` / `getStudentGradeId` / `getAvailableCoursesForStudent` - Data-access-selections`getCourseSelections` / `getStudentSelections` / `getStudentGradeId`(✅ P2-8通过 `resolvers.ts` StudentGradeResolver 接口抽象)/ `getAvailableCoursesForStudent` / `resolveStudentDisplayNames`(✅ P2-8通过 `resolvers.ts` CourseDisplayResolver 接口抽象)
- Components`ElectiveCourseList`(课程卡片网格 + 管理操作)/ `ElectiveCourseForm`(课程创建/编辑表单)/ `ElectiveFilters`nuqs 筛选栏)/ `StudentSelectionView`(学生选课视图 - Resolvers✅ P2-8 新增):`CourseDisplayResolver` / `StudentGradeResolver`(接口)/ `getCourseDisplayResolver` / `getStudentGradeResolver` / `setCourseDisplayResolver` / `setStudentGradeResolver` / `resetResolvers`(可注入测试 mock
- Lib 纯函数(✅ P2-9 新增):`parseSchedule`(解析时间段字符串)/ `isScheduleConflict`(判断两个时间段是否冲突)
- Import-export✅ P2-11 新增):`exportElectiveCoursesToExcel`(课程列表导出)/ `exportCourseSelectionsToExcel`(选课名单导出)
- Components`ElectiveCourseList`(课程卡片网格 + 管理操作)/ `ElectiveCourseForm`(课程创建/编辑表单)/ `ElectiveFilters`nuqs 筛选栏)/ `StudentSelectionView`(学生选课视图)/ `ElectivePageLayout`(✅ P2-4 新增页面布局骨架admin/teacher 选课页复用)
**依赖关系** **依赖关系**
- 依赖:`shared/*``@/auth``school`(✅ P3 已修复:通过 school data-access.getSubjectOptions/getGradeOptions 获取科目/年级名称,不再直查 subjects/grades 表)、`users`(✅ P3 已修复:通过 users data-access.getUserNamesByIds 获取教师姓名,不再直查 users 表)、`classes`(通过 classes data-access.getStudentActiveGradeId 获取学生年级 - 依赖:`shared/*``@/auth``school`(✅ P3 已修复:通过 school data-access.getSubjectOptions/getGradeOptions 获取科目/年级名称;✅ P2-8通过 resolvers.ts 接口抽象)、`users`(✅ P3 已修复:通过 users data-access.getUserNamesByIds;✅ P2-8通过 resolvers.ts 接口抽象)、`classes`(通过 classes data-access.getStudentActiveGradeId;✅ P2-8通过 resolvers.ts 接口抽象)、`next-intl`(✅ P2-2Server Action 错误消息 i18n
- 被依赖: - 被依赖:`app/api/export`(✅ P2-11通过 `exportElectiveCoursesToExcel` / `exportCourseSelectionsToExcel`
**已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md` **已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md`
- ✅ P2-2 已修复8 个写 Action 错误消息改用 `getTranslations("elective")` 国际化
- ✅ P2-4 已修复:新增 `ElectivePageLayout` 组件admin/teacher 选课页复用布局
- ✅ P2-7 已修复:`resolveCourseDisplayNames` 使用 React `cache()` 包装 subject/grade options 请求级去重
- ✅ P2-8 已修复:新增 `resolvers.ts`跨模块依赖users/school/classes通过接口抽象支持测试注入 mock
- ✅ P2-9 已修复:`selectCourse` 新增时间冲突检测(`parseSchedule` + `isScheduleConflict` 纯函数 + `checkScheduleConflict` 异步查询)
- ✅ P2-10 已修复:`selectCourse` 新增学分上限校验(`checkCreditLimit`MAX_CREDIT_PER_TERM=10
- ✅ P2-11 已修复:新增 `export.ts`,课程列表/选课名单可导出 Excel
- ❌ P03 个读 Action 无调用方(`getElectiveCoursesAction`/`getStudentSelectionsAction`/`getAvailableCoursesForStudentAction`),页面绕过 Action 直接调用 data-access - ❌ P03 个读 Action 无调用方(`getElectiveCoursesAction`/`getStudentSelectionsAction`/`getAvailableCoursesForStudentAction`),页面绕过 Action 直接调用 data-access
- ❌ P0update/delete/select/drop/lottery Action 缺资源归属校验(教师 A 可操作教师 B 的课程,学生可退选他人课程) - ❌ P0update/delete/select/drop/lottery Action 缺资源归属校验(教师 A 可操作教师 B 的课程,学生可退选他人课程)
- ❌ P0i18n 完全缺失4 组标签/颜色常量硬编码英文,组件中硬编码中文) - ❌ P0i18n 完全缺失4 组标签/颜色常量硬编码英文,组件中硬编码中文)
@@ -1464,16 +1495,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `actions.ts` | 304 | 11 个 Server Action | | `actions.ts` | 304 | 11 个 Server ActionP2-2错误消息 i18n 化) |
| `data-access.ts` | 250 | 课程 CRUD + scope 过滤 + 共享映射函数P3 重构:移除跨模块 join通过 school/users data-access 获取显示名称 | | `data-access.ts` | 250 | 课程 CRUD + scope 过滤 + 共享映射函数P2-7cache 包装P2-8resolvers 接口抽象 |
| `data-access-operations.ts` | 245 | 选课操作(select/drop/lotteryP3 重构:事务包裹 + FOR UPDATE 锁 + Fisher-Yates 洗牌) | | `data-access-operations.ts` | 370 | 选课操作(P2-9时间冲突检测P2-10学分上限校验P3:事务 + FOR UPDATE 锁 + Fisher-Yates 洗牌) |
| `data-access-selections.ts` | 149 | 选课记录查询 + 学生可选课程 | | `data-access-selections.ts` | 149 | 选课记录查询 + 学生可选课程P2-8resolvers 接口抽象) |
| `resolvers.ts` | 83 | 跨模块依赖接口抽象P2-8 新增CourseDisplayResolver/StudentGradeResolver + 注入/重置函数) |
| `export.ts` | 102 | Excel 导出P2-11 新增:课程列表 + 选课名单双函数) |
| `schema.ts` | 132 | Zod 校验 | | `schema.ts` | 132 | Zod 校验 |
| `types.ts` | 108 | 类型定义 + 4 组标签/颜色常量(硬编码英文) | | `types.ts` | 108 | 类型定义 + 4 组标签/颜色常量(硬编码英文) |
| `components/elective-course-list.tsx` | 233 | 课程卡片网格 + 管理操作 | | `components/elective-course-list.tsx` | 233 | 课程卡片网格 + 管理操作 |
| `components/elective-course-form.tsx` | 293 | 课程创建/编辑表单 | | `components/elective-course-form.tsx` | 293 | 课程创建/编辑表单 |
| `components/elective-filters.tsx` | 49 | nuqs 筛选栏(搜索 + 模式) | | `components/elective-filters.tsx` | 49 | nuqs 筛选栏(搜索 + 模式) |
| `components/student-selection-view.tsx` | 250 | 学生选课视图(已选 + 可选) | | `components/student-selection-view.tsx` | 250 | 学生选课视图(已选 + 可选) |
| `components/elective-page-layout.tsx` | 30 | 页面布局骨架P2-4 新增header/children 插槽) |
--- ---
@@ -1490,7 +1524,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- 被依赖:无 - 被依赖:无
**已知问题** **已知问题**
- P0`exam-mode-config.tsx` 集成到考试表单(死代码,监考功能无法启用) - P1-2 已修复`exam-mode-config.tsx` 集成到考试表单(`exam-form.tsx` 调用 `<ExamModeConfig />`),移除全部 10 处 `as` 类型断言,改用 `useFormContext` 替代 `Control` prop 避免 `Control<T>` 不变型问题
- ✅ P1-3 已修复:`proctoring-dashboard.tsx` 用类型守卫函数 `isProctoringEventType` + `toProctoringEventTypes` 替代 `Object.keys(...) as ProctoringEventType[]` 断言
- ✅ P0-6 已修复:~~事件上报存在 Server Action 与 REST API 双通道重复~~ 删除 `/api/proctoring/event` REST 路由(移至 deletes/Server Action `recordProctoringEventAction` 为唯一规范路径 - ✅ P0-6 已修复:~~事件上报存在 Server Action 与 REST API 双通道重复~~ 删除 `/api/proctoring/event` REST 路由(移至 deletes/Server Action `recordProctoringEventAction` 为唯一规范路径
- ✅ P1-1 已修复:~~跨模块直查 `exams`/`examSubmissions`/`users`~~ 改为通过 exams/users data-access 函数获取数据 - ✅ P1-1 已修复:~~跨模块直查 `exams`/`examSubmissions`/`users`~~ 改为通过 exams/users data-access 函数获取数据
- ✅ P2 已修复:`actions.ts` 不再直接 import `db``examSubmissions`submission 归属校验已下沉到 data-access`recordProctoringEventAction` 改用 `requirePermission(EXAM_SUBMIT)` 并增加 `revalidatePath` - ✅ P2 已修复:`actions.ts` 不再直接 import `db``examSubmissions`submission 归属校验已下沉到 data-access`recordProctoringEventAction` 改用 `requirePermission(EXAM_SUBMIT)` 并增加 `revalidatePath`
@@ -1503,8 +1538,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `actions.ts` | 139 | 2 个 Server Action | | `actions.ts` | 139 | 2 个 Server Action |
| `types.ts` | 136 | 类型定义 + 标签常量 + 阈值常量 | | `types.ts` | 136 | 类型定义 + 标签常量 + 阈值常量 |
| `components/anti-cheat-monitor.tsx` | - | 学生端防作弊监控 | | `components/anti-cheat-monitor.tsx` | - | 学生端防作弊监控 |
| `components/exam-mode-config.tsx` | - | 考试模式配置(**未集成** | | `components/exam-mode-config.tsx` | - | 考试模式配置(✅ P1-2 已修复:已集成到 exam-formuseFormContext 替代 Control prop无 as 断言 |
| `components/proctoring-dashboard.tsx` | - | 教师监考面板 | | `components/proctoring-dashboard.tsx` | - | 教师监考面板(✅ P1-3 已修复:类型守卫函数替代 as 断言) |
--- ---
@@ -1513,9 +1548,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:知识点掌握度查询 + 诊断报告生成。 **职责**:知识点掌握度查询 + 诊断报告生成。
**导出函数** **导出函数**
- Actions`generateStudentReportAction` / `generateClassReportAction` / `publishReportAction` / `deleteReportAction`v2-P2-3 修复:删除死代码 `getDiagnosticReportsAction` / `getDiagnosticReportByIdAction`,页面直接调用 data-access 并自行权限校验) - Actions`generateStudentReportAction` / `generateClassReportAction` / `publishReportAction`v4-P1-4 + v4-P1-5 增强:班级报告发布时通知全班学生 + 全班学生家长,个人报告通知学生本人 + 其家长,调用 `parent/data-access.getParentIdsByStudentIds` 批量查询家长 ID/ `deleteReportAction`v2-P2-3 修复:删除死代码 `getDiagnosticReportsAction` / `getDiagnosticReportByIdAction`,页面直接调用 data-access 并自行权限校验)
- Data-access`updateMasteryFromSubmission`v2-P1-8 修复累积模式v2-P2-5 修复db.transaction 包裹)/ `getStudentMastery`P3-19 修复:移除 export改为模块内部函数/ `getStudentMasterySummary`P3-18 修复getUserNamesByIds 与 getStudentMastery 并行查询)/ `getClassMasterySummary`v2-P2-4 修复totalStudents 语义 + 班级平均掌握度按学生平均)/ `getKnowledgePointStats`v2-P1-7 修复:页面先查班级再传参) - Data-access`updateMasteryFromSubmission`v2-P1-8 修复累积模式v2-P2-5 修复db.transaction 包裹)/ `getStudentMastery`P3-19 修复:移除 export改为模块内部函数/ `getStudentMasterySummary`P3-18 修复getUserNamesByIds 与 getStudentMastery 并行查询)/ `getClassMasterySummary`v2-P2-4 修复totalStudents 语义 + 班级平均掌握度按学生平均)/ `getKnowledgePointStats`v2-P1-7 修复:页面先查班级再传参)
- Data-access-reports`generateDiagnosticReport` / `generateClassDiagnosticReport`v2-P2-6 修复校验掌握度数据P3-27 修复:使用 DiagnosticReportError 结构化错误码)/ `getDiagnosticReports`P3-15 修复:支持分页 limit/offset返回 { reports, total } 结构)/ `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoizationP3-1 修复toNumber 从 grades 模块导入)/ `DiagnosticReportError`P3-27 新增:结构化错误码类) - Data-access-reports`generateDiagnosticReport` / `generateClassDiagnosticReport`v2-P2-6 修复校验掌握度数据P3-27 修复:使用 DiagnosticReportError 结构化错误码)/ `getDiagnosticReports`P3-15 修复:支持分页 limit/offset返回 { reports, total } 结构v4-P1-1 增强:新增可选 `scope?: DataScope` 参数,支持 children/class_taught 行级权限过滤class_taught 通过 `getStudentIdsByClassIds` 解析所教班级学生 ID/ `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoizationP3-1 修复toNumber 从 grades 模块导入)/ `DiagnosticReportError`P3-27 新增:结构化错误码类)
- Stats-service✅ v2-P1-6 新增):`serializeMasteryWithKp` / `computeAverageMastery` / `classifyStrengthsWeaknesses`P3-16 修复:弱项阈值从 <60 改为 <80消除 60-79 盲区)/ `buildStudentMasterySummary` / `aggregateClassMastery` / `computeKpStats` / `computeClassAverageMastery` / `buildStudentsNeedingAttention` / `buildClassMasterySummary` / `buildStudentReportContent` / `buildClassReportContent` / `computeMasteryLevel` / `serializeMastery`(从 data-access / data-access-reports 抽取的纯统计函数) - Stats-service✅ v2-P1-6 新增):`serializeMasteryWithKp` / `computeAverageMastery` / `classifyStrengthsWeaknesses`P3-16 修复:弱项阈值从 <60 改为 <80消除 60-79 盲区)/ `buildStudentMasterySummary` / `aggregateClassMastery` / `computeKpStats` / `computeClassAverageMastery` / `buildStudentsNeedingAttention` / `buildClassMasterySummary` / `buildStudentReportContent` / `buildClassReportContent` / `computeMasteryLevel` / `serializeMastery`(从 data-access / data-access-reports 抽取的纯统计函数)
- Schema`GenerateStudentReportSchema` / `GenerateClassReportSchema` / `PublishReportSchema` / `DeleteReportSchema`v2-P2-3 修复:删除死代码 `GetDiagnosticReportsSchema` / `GetDiagnosticReportByIdSchema` - Schema`GenerateStudentReportSchema` / `GenerateClassReportSchema` / `PublishReportSchema` / `DeleteReportSchema`v2-P2-3 修复:删除死代码 `GetDiagnosticReportsSchema` / `GetDiagnosticReportByIdSchema`
@@ -1551,18 +1586,28 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ P3-18 已修复2026-06-23~~`getStudentMasterySummary` 串行查询用户名和掌握度~~ 改为 Promise.all 并行查询 - ✅ P3-18 已修复2026-06-23~~`getStudentMasterySummary` 串行查询用户名和掌握度~~ 改为 Promise.all 并行查询
- ✅ P3-19 已修复2026-06-23~~`getStudentMastery` 使用 export 但仅内部使用~~ 移除 export改为模块内部函数 - ✅ P3-19 已修复2026-06-23~~`getStudentMastery` 使用 export 但仅内部使用~~ 移除 export改为模块内部函数
- ✅ P3-27 已修复2026-06-23~~`generateDiagnosticReport` / `generateClassDiagnosticReport``throw new Error("...")` 直接暴露给用户~~ 改为使用 `DiagnosticReportError` 结构化错误码(继承 BusinessError - ✅ P3-27 已修复2026-06-23~~`generateDiagnosticReport` / `generateClassDiagnosticReport``throw new Error("...")` 直接暴露给用户~~ 改为使用 `DiagnosticReportError` 结构化错误码(继承 BusinessError
- ✅ P3-21 已修复2026-06-23`ClassDiagnosticView` View 按钮已有描述性 aria-label`classDiagnostic.viewAriaLabel`,含学生名)
- ✅ P3-22 已修复2026-06-23`StudentDiagnosticView` Practice 按钮添加 aria-label`studentDiagnostic.practiceAriaLabel`,含知识点名)
- ✅ v4-P3-6 已修复2026-06-23`MasteryRadarChart` 雷达图添加 `tabIndex={0}` 键盘可达性 + `focus-visible` 焦点样式(原有 `role="img"` + `aria-label` 保留)
- ✅ v4-P1-1 改进2026-06-23`getDiagnosticReports` 新增可选 `scope?: DataScope` 参数,支持 children/class_taught 行级权限过滤teacher/diagnostic/page.tsx 传入 dataScopestudent/diagnostic/page.tsx 传 status:"published" + dataScopeparent/diagnostic/page.tsx 传 status:"published" + dataScope + 错误卡片
- ✅ v4-P1-3 改进2026-06-23`student-diagnostic-view.tsx` 移除草稿回退逻辑(不再展示 draft 状态报告)
- ✅ v4-P1-4 + v4-P1-5 改进2026-06-23`publishReportAction` 班级报告发布时通知全班学生 + 全班学生家长(通过 `getStudentIdsByClassId` 获取全班学生,通过 `parent/data-access.getParentIdsByStudentIds` 获取家长 ID个人报告通知学生本人 + 其家长
- ✅ v4-P1-8 + v4-P1-11 改进2026-06-23`class-diagnostic-view.tsx` 热力图添加图例 + 表格 `overflow-x-auto` 水平滚动
- ✅ v4-P1-9 改进2026-06-23parent/diagnostic/page.tsx 新增错误卡片Error Card展示子女诊断查询失败原因
- ✅ v4-P1 数据库变更2026-06-23`learningDiagnosticReports` 表新增 `classId` 字段varchar(128), references classes.id, onDelete: set null+ `diagnostic_class_idx` 索引;`DiagnosticReport` 接口新增 `classId: string | null` 字段(班级报告关联班级 ID个人报告为 null
- ✅ v4-P1 师生关系校验2026-06-23teacher/diagnostic/student/[studentId]/page.tsx 新增 class_taught 师生关系校验
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `data-access.ts` | 179 | 知识点掌握度查询 + 更新v2-P1-8 累积模式v2-P2-5 事务v2-P2-4 语义修正v2-P1-6 改用 stats-service 纯函数) | | `data-access.ts` | 179 | 知识点掌握度查询 + 更新v2-P1-8 累积模式v2-P2-5 事务v2-P2-4 语义修正v2-P1-6 改用 stats-service 纯函数) |
| `data-access-reports.ts` | 160 | 诊断报告 CRUDv2-P2-6 校验v2-P1-6 改用 stats-service 纯函数) | | `data-access-reports.ts` | 183+ | 诊断报告 CRUDv2-P2-6 校验v2-P1-6 改用 stats-service 纯函数v4-P1-1getDiagnosticReports 新增 scope 参数支持行级权限过滤 |
| `stats-service.ts` | 352 | 统计计算纯函数v2-P1-6 新增12 个纯函数 + 2 个常量 + 4 个接口) | | `stats-service.ts` | 352 | 统计计算纯函数v2-P1-6 新增12 个纯函数 + 2 个常量 + 4 个接口) |
| `actions.ts` | 111 | 4 个 Server Actionv2-P2-3 删除 2 个死代码读 Action | | `actions.ts` | 165+ | 4 个 Server Actionv2-P2-3 删除 2 个死代码读 Actionv4-P1-4/v4-P1-5publishReportAction 新增全班学生 + 家长通知 |
| `schema.ts` | 23 | Zod 校验4 个 schemav2-P2-3 删除 2 个死代码 schema | | `schema.ts` | 23 | Zod 校验4 个 schemav2-P2-3 删除 2 个死代码 schema |
| `types.ts` | 87 | 类型定义 | | `types.ts` | 87 | 类型定义 |
| `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图v2-P1-6 热力图 a11yv2-P1-4 i18n | | `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图v2-P1-6 热力图 a11yv2-P1-4 i18nv4-P1-8 热力图图例v4-P1-11 表格 overflow-x-auto |
| `components/student-diagnostic-view.tsx` | 225+ | 学生诊断视图v2-P1-4 i18nv3-P2 新增practiceHrefBase propnull 时隐藏练习按钮) | | `components/student-diagnostic-view.tsx` | 225+ | 学生诊断视图v2-P1-4 i18nv3-P2 新增practiceHrefBase propnull 时隐藏练习按钮v4-P1-3 移除草稿回退逻辑 |
| `components/mastery-radar-chart.tsx` | 72 | 雷达图v2-P1-4 i18n | | `components/mastery-radar-chart.tsx` | 72 | 雷达图v2-P1-4 i18n |
| `components/report-list.tsx` | 265 | 报告列表v2-P2-7 Label htmlForv2-P1-4 i18n | | `components/report-list.tsx` | 265 | 报告列表v2-P2-7 Label htmlForv2-P1-4 i18n |
@@ -1940,9 +1985,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
--- ---
## 2.29 aiAI 模块)— ✅ 新增 / V2 增强 ## 2.29 aiAI 模块)— ✅ 新增 / V2 增强 / V3 安全加固+竞品对标
**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。V2 增加流式响应、Markdown 渲染、全局助手、内容安全过滤、家长学情摘要、管理员使用统计、学生学习路径推荐。 **职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。V2 增加流式响应、Markdown 渲染、全局助手、内容安全过滤、家长学情摘要、管理员使用统计、学生学习路径推荐。V3 安全加固(原子配额/苏格拉底校验/重试机制)+ 竞品对标(知识图谱集成/苏格拉底式辅导强化)。
**架构定位** **架构定位**
- 位于 `modules/` 层,通过 `shared/lib/ai` 调用底层 AI SDK - 位于 `modules/` 层,通过 `shared/lib/ai` 调用底层 AI SDK
@@ -1989,6 +2034,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| **Safety** | `filterUserInput` | `modules/ai/services/content-safety.ts` | 输入安全过滤 — V2 新增 | | **Safety** | `filterUserInput` | `modules/ai/services/content-safety.ts` | 输入安全过滤 — V2 新增 |
| **Safety** | `filterAiOutput` | `modules/ai/services/content-safety.ts` | 输出安全过滤 — V2 新增 | | **Safety** | `filterAiOutput` | `modules/ai/services/content-safety.ts` | 输出安全过滤 — V2 新增 |
| **Safety** | `checkDailyLimit` | `modules/ai/services/content-safety.ts` | 每日交互限制 — V2 新增 | | **Safety** | `checkDailyLimit` | `modules/ai/services/content-safety.ts` | 每日交互限制 — V2 新增 |
| **Safety** | `tryConsumeDailyQuota` | `modules/ai/services/content-safety.ts` | 原子化每日限额(防 TOCTOU 竞态)— V3 新增 |
| **Safety** | `refundDailyQuota` | `modules/ai/services/content-safety.ts` | 配额回退(过滤/失败时不扣配额)— V3 新增 |
| **Safety** | `validateSocraticOutput` | `modules/ai/services/content-safety.ts` | 苏格拉底式辅导输出校验(问号结尾/连续陈述句限制)— V3 新增 |
| **Data Access** | `recordAiEvent` | `modules/ai/data-access.ts` | AI 事件记录(内存存储,生产环境替换为 DB— V3 新增 |
| **Data Access** | `getAiUsageStats` | `modules/ai/data-access.ts` | AI 使用统计聚合(真实聚合,替代硬编码零)— V3 新增 |
| **Prompt** | `SOCRATIC_TUTOR_SYSTEM_PROMPT` | `modules/ai/services/prompt-templates.ts` | 苏格拉底式辅导专用提示词3 级提示升级/禁止直接给答案)— V3 新增 |
| **Util** | `consumeSseStream` / `getStreamErrorKey` / `removeTrailingEmptyAssistant` | `modules/ai/hooks/stream-utils.ts` | SSE 流解析工具(从 hook 抽取)— V3 新增 |
**集成点** **集成点**
@@ -2184,7 +2236,7 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
- ✅ 各模块 data-access 暴露查询接口: - ✅ 各模块 data-access 暴露查询接口:
- `classes/data-access`~~`getClassGradeIdsByClassIds`~~ ✅ 已实现 / `getClassStudentsByClassId` / `getActiveClassStudents` / `getClassExists` / `getClassNameById` / `getClassNamesByIds` / `getActiveStudentIdsByClassId` / `getStudentActiveClassId` / `getClassesByGradeId` / `verifyTeacherOwnsClass` / `getTeacherIdForMutations` - `classes/data-access`~~`getClassGradeIdsByClassIds`~~ ✅ 已实现 / `getClassStudentsByClassId` / `getActiveClassStudents` / `getClassExists` / `getClassNameById` / `getClassNamesByIds` / `getActiveStudentIdsByClassId` / `getStudentActiveClassId` / `getClassesByGradeId` / `verifyTeacherOwnsClass` / `getTeacherIdForMutations`
- `exams/data-access`~~`getExamForHomeworkCreation`~~ ✅ 已实现(`getExamIdsByGradeIds` / `getExamSubjectIdMap` / `getExamWithQuestionsForHomework` / `getExamSubmissionWithAnswers` / `getExamForProctoringCrossModule` / `getExamSubmissionForProctoringCrossModule` / `getExamSubmissionsForExam` / `getExamTitleById` - `exams/data-access`~~`getExamForHomeworkCreation`~~ ✅ 已实现(`getExamIdsByGradeIds` / `getExamSubjectIdMap` / `getExamWithQuestionsForHomework` / `getExamSubmissionWithAnswers` / `getExamForProctoringCrossModule` / `getExamSubmissionForProctoringCrossModule` / `getExamSubmissionsForExam` / `getExamTitleById`
- `school/data-access`~~`getSubjectOptions` / `getGradeOptions`~~ ✅ 已实现 - `school/data-access`~~`getSubjectOptions` / `getGradeOptions`~~ ✅ 已实现(含 `getSubjectNameMapByIds` 批量科目名称映射P1-1 新增供 homework/data-access-classes 调用)
- `users/data-access`~~`getUserNameByIds` / `getStudentInfo`~~ ✅ 已实现(`getUserNamesByIds` / `getUserWithRole` / `getUserBasicInfo` / `getUserIdsByGradeId` / `getCurrentStudentUser` - `users/data-access`~~`getUserNameByIds` / `getStudentInfo`~~ ✅ 已实现(`getUserNamesByIds` / `getUserWithRole` / `getUserBasicInfo` / `getUserIdsByGradeId` / `getCurrentStudentUser`
- `textbooks/data-access`~~`getKnowledgePointOptions`~~ ✅ 已实现 - `textbooks/data-access`~~`getKnowledgePointOptions`~~ ✅ 已实现
- `questions/data-access`~~`insertQuestionWithRelations`~~ ✅ 已通过 `createQuestionWithRelations` 供 exams 调用 / `getKnowledgePointsForQuestions` - `questions/data-access`~~`insertQuestionWithRelations`~~ ✅ 已通过 `createQuestionWithRelations` 供 exams 调用 / `getKnowledgePointsForQuestions`

View File

@@ -2431,6 +2431,8 @@
"content", "content",
"link", "link",
"isRead", "isRead",
"priority",
"isArchived",
"createdAt" "createdAt"
], ],
"usedBy": [ "usedBy": [
@@ -2570,6 +2572,7 @@
"id", "id",
"studentId", "studentId",
"generatedBy", "generatedBy",
"classId",
"reportType", "reportType",
"period", "period",
"summary", "summary",
@@ -2581,10 +2584,17 @@
"createdAt", "createdAt",
"updatedAt" "updatedAt"
], ],
"indexes": [
"diagnostic_student_idx (studentId)",
"diagnostic_generated_by_idx (generatedBy)",
"diagnostic_status_idx (status)",
"diagnostic_report_type_idx (reportType)",
"diagnostic_class_idx (classId)"
],
"usedBy": [ "usedBy": [
"diagnostic" "diagnostic"
], ],
"description": "学情诊断报告reportType: individual/class/gradestatus: draft/published/archived" "description": "学情诊断报告reportType: individual/class/gradestatus: draft/published/archivedv4-P1-4 新增 classId 字段 varchar(128) references classes.id onDelete:set null用于班级报告关联班级发布时批量通知全班学生"
}, },
"electiveCourses": { "electiveCourses": {
"fields": [ "fields": [
@@ -4092,6 +4102,17 @@
"file": "homework-submission-result.tsx", "file": "homework-submission-result.tsx",
"purpose": "V3-9 新增:提交后即时反馈页。学生提交后立即看到分数汇总(总分/满分、得分率 Progress、对错分布正确/错误/部分正确/待批改)、错题预览(题目文本、学生答案、正确答案)。对标智学网/猿题库提交后反馈。" "purpose": "V3-9 新增:提交后即时反馈页。学生提交后立即看到分数汇总(总分/满分、得分率 Progress、对错分布正确/错误/部分正确/待批改)、错题预览(题目文本、学生答案、正确答案)。对标智学网/猿题库提交后反馈。"
} }
],
"hooks": [
{
"name": "useExamCountdown",
"file": "hooks/use-exam-countdown.ts",
"signature": "(options: { durationMinutes: number | null, startedAt: string | null, onExpire?: () => void, enabled?: boolean }) => ExamCountdownState | null",
"purpose": "P0-竞品新增:考试倒计时 Hook对标智学网/猿题库。每秒更新剩余时间剩余≤5分钟标记紧急状态到时触发 onExpire 回调自动提交。setInterval + setState 仅在 interval 回调中异步调用,符合 react-hooks/purity 与 set-state-in-effect 规则。",
"usedBy": [
"homework/components/homework-take-view.HomeworkTakeView"
]
}
] ]
} }
}, },
@@ -6291,6 +6312,15 @@
"usedBy": [ "usedBy": [
"classes/actions.createTeacherClassAction" "classes/actions.createTeacherClassAction"
] ]
},
{
"name": "getSubjectNameMapByIds",
"signature": "(subjectIds: string[]) => Promise<Map<string, string | null>>",
"purpose": "按 ID 批量获取科目名称映射跨模块接口P1-1 新增供 homework/data-access-classes 调用,替代直查 subjects 表)",
"usedBy": [
"homework/data-access-classes.getHomeworkAssignmentsWithSubject",
"homework/data-access-classes.getPublishedHomeworkAssignmentsWithSubject"
]
} }
], ],
"schema": [ "schema": [
@@ -7595,6 +7625,74 @@
"SettingsView" "SettingsView"
] ]
} }
],
"lib": [
{
"name": "parseUserAgent",
"file": "lib/security-utils.ts",
"signature": "(ua: string | null) => { device: string; browser: string }",
"purpose": "解析 User-Agent 字符串为设备类型 + 浏览器名称v2 抽取的纯函数,便于单测)"
},
{
"name": "formatRelativeTime",
"file": "lib/security-utils.ts",
"signature": "(iso: string, locale: string) => string",
"purpose": "将 ISO 时间戳格式化为相对时间v2 抽取的纯函数)"
},
{
"name": "generateTotpSecret",
"file": "lib/totp.ts",
"signature": "() => string",
"purpose": "生成 TOTP base32 密钥v3 新增,基于 otplib v13"
},
{
"name": "buildOtpAuthUrl",
"file": "lib/totp.ts",
"signature": "(params: { serviceName: string; accountName: string; secret: string }) => string",
"purpose": "构建 otpauth:// URL 用于二维码扫描v3 新增)"
},
{
"name": "generateQrCodeDataUrl",
"file": "lib/totp.ts",
"signature": "(otpAuthUrl: string) => Promise<string>",
"purpose": "将 otpauth URL 转换为 QR 码 Data URLbase64 PNGv3 新增)"
},
{
"name": "verifyTotpCode",
"file": "lib/totp.ts",
"signature": "(token: string, secret: string) => boolean",
"purpose": "校验 TOTP 一次性码±30s 窗口容差v3 新增)"
},
{
"name": "generateBackupCodes",
"file": "lib/totp.ts",
"signature": "() => string[]",
"purpose": "生成 10 个 8 位备份码去除易混淆字符v3 新增)"
},
{
"name": "hashBackupCodes",
"file": "lib/totp.ts",
"signature": "(codes: string[]) => Promise<string>",
"purpose": "将备份码列表 bcrypt 哈希为 JSON 数组字符串v3 新增)"
},
{
"name": "verifyBackupCode",
"file": "lib/totp.ts",
"signature": "(input: string, storedHashedJson: string) => Promise<number>",
"purpose": "校验备份码,返回匹配索引或 -1v3 新增)"
},
{
"name": "consumeBackupCode",
"file": "lib/totp.ts",
"signature": "(storedHashedJson: string, usedIndex: number) => Promise<string>",
"purpose": "从哈希列表中移除已使用的备份码v3 新增)"
},
{
"name": "countRemainingBackupCodes",
"file": "lib/totp.ts",
"signature": "(storedHashedJson: string) => number",
"purpose": "统计剩余备份码数量v3 新增)"
}
] ]
} }
}, },
@@ -9038,6 +9136,14 @@
"signature": "(prevState, formData) => Promise<ActionState<string>>", "signature": "(prevState, formData) => Promise<ActionState<string>>",
"file": "actions.ts", "file": "actions.ts",
"permission": "GRADE_RECORD_MANAGE", "permission": "GRADE_RECORD_MANAGE",
"purpose": "创建单条成绩记录。v4-P1-6 增强:成绩录入后通知学生和家长(调用 notifyGradeEntered内部使用 parent/data-access.getParentIdsByStudentIds 批量查询家长 ID通知失败不阻断录入v3-P1-5录入后更新诊断掌握度updateMasteryFromExamScore",
"deps": [
"requirePermission",
"data-access.createGradeRecord",
"diagnostic/data-access.updateMasteryFromExamScore",
"grades/lib/notify-grade-entered.notifyGradeEntered",
"revalidatePath"
],
"usedBy": [ "usedBy": [
"grades/components/grade-record-form" "grades/components/grade-record-form"
] ]
@@ -9047,6 +9153,15 @@
"signature": "(prevState, formData) => Promise<ActionState<number>>", "signature": "(prevState, formData) => Promise<ActionState<number>>",
"file": "actions.ts", "file": "actions.ts",
"permission": "GRADE_RECORD_MANAGE", "permission": "GRADE_RECORD_MANAGE",
"purpose": "批量创建成绩记录db.transaction 原子操作。v4-P1-6 增强:批量录入后通知所有相关学生和家长(调用 notifyGradeEnteredv3-P1-5录入后更新诊断掌握度v3-P2-3返回创建的记录 ID 列表供前端撤销",
"deps": [
"requirePermission",
"data-access.batchCreateGradeRecords",
"diagnostic/data-access.updateMasteryFromExamScore",
"grades/lib/notify-grade-entered.notifyGradeEntered",
"shared/lib/action-utils.safeJsonParse",
"revalidatePath"
],
"usedBy": [ "usedBy": [
"grades/components/batch-grade-entry" "grades/components/batch-grade-entry"
] ]
@@ -9117,18 +9232,21 @@
}, },
{ {
"name": "exportGradesAction", "name": "exportGradesAction",
"signature": "(params: { classId: string; subjectId?: string; examId?: string; reportType?: \"detail\" | \"class\" }) => Promise<ActionState<{ buffer: string; filename: string }>>", "signature": "(params: { classId?: string; studentId?: string; subjectId?: string; examId?: string; reportType?: \"detail\" | \"class\" }) => Promise<ActionState<{ buffer: string; filename: string }>>",
"file": "actions.ts", "file": "actions.ts",
"permission": "GRADE_RECORD_READ", "permission": "GRADE_RECORD_READ",
"purpose": "导出成绩到 Exceldetail=成绩明细+统计汇总class=班级多科目横向对比总表),返回 base64 buffer", "purpose": "导出成绩到 Exceldetail=成绩明细+统计汇总class=班级多科目横向对比总表),返回 base64 buffer。v4-P1-12 增强:新增可选 studentId 参数支持按学生导出(家长视角)——当提供 studentId 且 scope 为 children 时校验该学生属于家长子女,调用 exportStudentGradeRecordsToExcel 导出单学生成绩单",
"deps": [ "deps": [
"requirePermission", "requirePermission",
"export.exportGradeRecordsToExcel", "export.exportGradeRecordsToExcel",
"export.exportClassGradeReportToExcel", "export.exportClassGradeReportToExcel",
"export.formatDateForFile" "export.exportStudentGradeRecordsToExcel",
"export.formatDateForFile",
"actions.assertClassInScope"
], ],
"usedBy": [ "usedBy": [
"grades/components/export-button.tsx" "grades/components/export-button.tsx",
"parent/components/parent-export-button.tsx"
] ]
}, },
{ {
@@ -9874,6 +9992,18 @@
"usedBy": [ "usedBy": [
"admin/school/grades/insights/page.tsx" "admin/school/grades/insights/page.tsx"
] ]
},
{
"name": "ScoreCell",
"file": "components/score-cell.tsx",
"purpose": "v4-P1-7 新增:成绩单元格组件,根据得分率着色——红<60%(不及格 text-red-600/黄60-84%(及格未优秀 text-yellow-600/绿≥85%(优秀 text-green-600使用语义化 Tailwind 类名避免动态拼接fullScore<=0 时不着色(避免除零)",
"props": "{ score: number; fullScore: number; className?: string }",
"deps": [
"shared/lib/utils.cn"
],
"usedBy": [
"grades/components/grade-record-list.tsx"
]
} }
] ]
} }
@@ -10385,6 +10515,22 @@
"usedBy": [ "usedBy": [
"parent/children/[studentId]/page.tsx" "parent/children/[studentId]/page.tsx"
] ]
},
{
"name": "getParentIdsByStudentIds",
"signature": "(studentIds: string[]) => Promise<string[]>",
"file": "data-access.ts",
"purpose": "v4-P1-5 新增:批量查询多个学生的家长 userId 列表,用于通知场景——报告/成绩发布时同步通知所有相关家长。返回去重后的 parentId 数组,使用 react.cache 包装实现请求级 memoization空数组直接返回 []",
"deps": [
"shared.db",
"shared.db.schema.parentStudentRelations",
"drizzle-orm.inArray",
"react.cache"
],
"usedBy": [
"diagnostic/actions.publishReportAction",
"grades/lib/notify-grade-entered.notifyGradeEntered"
]
} }
], ],
"types": [ "types": [
@@ -10914,7 +11060,7 @@
"name": "Notification", "name": "Notification",
"type": "type", "type": "type",
"file": "types.ts", "file": "types.ts",
"definition": "{ id, userId, type: NotificationType, title, content: string | null, link: string | null, isRead, createdAt }", "definition": "{ id, userId, type: NotificationType, title, content: string | null, link: string | null, isRead, priority: NotificationPriority, isArchived, createdAt }",
"usedBy": [ "usedBy": [
"notification-dropdown", "notification-dropdown",
"notification-list" "notification-list"
@@ -10961,7 +11107,7 @@
"name": "CreateNotificationInput", "name": "CreateNotificationInput",
"type": "type", "type": "type",
"file": "types.ts", "file": "types.ts",
"definition": "{ userId, type: NotificationType, title, content?, link? }", "definition": "{ userId, type: NotificationType, title, content?, link?, priority? }",
"usedBy": [ "usedBy": [
"createNotification" "createNotification"
] ]
@@ -11134,6 +11280,22 @@
"notification-dropdown.tsx", "notification-dropdown.tsx",
"notification-list.tsx" "notification-list.tsx"
] ]
},
{
"name": "archiveNotificationAction",
"permission": "MESSAGE_READ",
"signature": "(notificationId: string) => Promise<ActionState<string>>",
"purpose": "V2-P2-13b 新增:将单条通知归档(归档后不在默认列表显示);含 trackEvent 埋点 notification.archived",
"deps": [
"requirePermission",
"schema.NotificationIdSchema",
"data-access.archiveNotification",
"trackEvent",
"revalidatePath"
],
"usedBy": [
"notification-list.tsx"
]
} }
], ],
"dispatcher": [ "dispatcher": [
@@ -11173,9 +11335,9 @@
"dataAccess": [ "dataAccess": [
{ {
"name": "getNotifications", "name": "getNotifications",
"signature": "(userId: string, params?: { page?, pageSize?, unreadOnly? }) => Promise<PaginatedResult<Notification>>", "signature": "(userId: string, params?: { page?, pageSize?, unreadOnly?, unarchivedOnly?, priority? }) => Promise<PaginatedResult<Notification>>",
"file": "data-access.ts", "file": "data-access.ts",
"purpose": "获取用户站内通知列表(分页,支持 unreadOnly 过滤P0-4 / P1-5 修复后从 messaging 迁移)", "purpose": "获取用户站内通知列表(分页,支持 unreadOnly/unarchivedOnly/priority 过滤P0-4 / P1-5 修复后从 messaging 迁移V2-P2-13b 新增归档和优先级筛选,默认仅返回未归档",
"deps": [ "deps": [
"shared.db", "shared.db",
"shared.db.schema.messageNotifications", "shared.db.schema.messageNotifications",
@@ -11190,7 +11352,7 @@
"name": "createNotification", "name": "createNotification",
"signature": "(input: CreateNotificationInput) => Promise<string>", "signature": "(input: CreateNotificationInput) => Promise<string>",
"file": "data-access.ts", "file": "data-access.ts",
"purpose": "创建站内通知记录(写入 message_notifications 表P0-4 / P1-5 修复后从 messaging 迁移)", "purpose": "创建站内通知记录(写入 message_notifications 表P0-4 / P1-5 修复后从 messaging 迁移V2-P2-13b 支持 priority 可选参数,默认 normal",
"deps": [ "deps": [
"shared.db", "shared.db",
"shared.db.schema.messageNotifications", "shared.db.schema.messageNotifications",
@@ -11240,6 +11402,32 @@
"待扩展" "待扩展"
] ]
}, },
{
"name": "archiveNotification",
"signature": "(id: string, userId: string) => Promise<void>",
"file": "data-access.ts",
"purpose": "V2-P2-13b 新增将单条通知归档isArchived 置 true归档后不在默认列表显示",
"deps": [
"shared.db",
"shared.db.schema.messageNotifications"
],
"usedBy": [
"archiveNotificationAction"
]
},
{
"name": "unarchiveNotification",
"signature": "(id: string, userId: string) => Promise<void>",
"file": "data-access.ts",
"purpose": "V2-P2-13b 新增取消归档通知isArchived 置 false",
"deps": [
"shared.db",
"shared.db.schema.messageNotifications"
],
"usedBy": [
"待扩展"
]
},
{ {
"name": "getUserContactInfo", "name": "getUserContactInfo",
"signature": "(userId: string) => Promise<ChannelRecipient>", "signature": "(userId: string) => Promise<ChannelRecipient>",
@@ -11422,11 +11610,22 @@
"messaging (via re-export)" "messaging (via re-export)"
] ]
}, },
{
"name": "NotificationPriority",
"type": "type",
"file": "types.ts",
"definition": "'low' | 'normal' | 'high' | 'urgent'",
"usedBy": [
"data-access",
"components.notification-list",
"messaging (via re-export)"
]
},
{ {
"name": "Notification", "name": "Notification",
"type": "interface", "type": "interface",
"file": "types.ts", "file": "types.ts",
"definition": "{ id, userId, type: NotificationType, title, content: string | null, link: string | null, isRead, createdAt }", "definition": "{ id, userId, type: NotificationType, title, content: string | null, link: string | null, isRead, priority: NotificationPriority, isArchived, createdAt }",
"usedBy": [ "usedBy": [
"data-access.getNotifications", "data-access.getNotifications",
"messaging (via re-export)" "messaging (via re-export)"
@@ -11462,7 +11661,7 @@
"name": "GetNotificationsParams", "name": "GetNotificationsParams",
"type": "interface", "type": "interface",
"file": "types.ts", "file": "types.ts",
"definition": "{ page?, pageSize?, unreadOnly? }", "definition": "{ page?, pageSize?, unreadOnly?, unarchivedOnly?, priority? }",
"usedBy": [ "usedBy": [
"data-access.getNotifications", "data-access.getNotifications",
"messaging (via re-export)" "messaging (via re-export)"
@@ -11472,7 +11671,7 @@
"name": "CreateNotificationInput", "name": "CreateNotificationInput",
"type": "interface", "type": "interface",
"file": "types.ts", "file": "types.ts",
"definition": "{ userId, type: NotificationType, title, content?, link? }", "definition": "{ userId, type: NotificationType, title, content?, link?, priority? }",
"usedBy": [ "usedBy": [
"data-access.createNotification", "data-access.createNotification",
"channels.in-app-channel", "channels.in-app-channel",
@@ -12028,6 +12227,35 @@
"usedBy": [ "usedBy": [
"teacher/attendance/stats/page.tsx" "teacher/attendance/stats/page.tsx"
] ]
},
{
"name": "AttendancePageLayout",
"file": "components/attendance-page-layout.tsx",
"purpose": "考勤页面布局骨架P2-3 新增:抽取 admin/teacher 考勤页重复的 header/stats/filters/children 布局结构,统一 spacing 与响应式)",
"deps": [
"shared/lib/utils.cn"
],
"usedBy": [
"admin/attendance/page.tsx",
"teacher/attendance/page.tsx"
]
}
],
"importExport": [
{
"name": "exportAttendanceRecordsToExcel",
"signature": "(params: { scope: DataScope; currentUserId?: string; classId?: string; status?: string; date?: string }) => Promise<Buffer>",
"file": "export.ts",
"purpose": "导出考勤记录到 ExcelP2-11 新增Sheet1 考勤明细—学生/班级/日期/状态/备注/记录人/创建时间Sheet2 统计汇总—总记录数/到场/缺勤/迟到/早退/请假/出勤率;列头使用 next-intl getTranslations 国际化)",
"deps": [
"shared.lib.excel.exportToExcel",
"data-access.getAttendanceRecords",
"data-access.getAttendanceStats",
"next-intl/server.getTranslations"
],
"usedBy": [
"app/api/export/route.ts"
]
} }
] ]
} }
@@ -12768,7 +12996,7 @@
{ {
"name": "ProctoringDashboard", "name": "ProctoringDashboard",
"file": "components/proctoring-dashboard.tsx", "file": "components/proctoring-dashboard.tsx",
"purpose": "教师监考面板实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制)" "purpose": "教师监考面板实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制;✅ P1-3 已修复:用类型守卫函数 isProctoringEventType + toProctoringEventTypes 替代 Object.keys(...) as ProctoringEventType[] 断言"
}, },
{ {
"name": "AntiCheatMonitor", "name": "AntiCheatMonitor",
@@ -12778,7 +13006,7 @@
{ {
"name": "ExamModeConfig", "name": "ExamModeConfig",
"file": "components/exam-mode-config.tsx", "file": "components/exam-mode-config.tsx",
"purpose": "考试模式配置(react-hook-form Controller作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项)" "purpose": "考试模式配置(✅ P1-2 已修复:已集成到 exam-form.tsxuseFormContext 替代 Control prop 避免 Control<T> 不变型问题,移除全部 10 处 as 类型断言;作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项)"
} }
] ]
} }
@@ -13162,7 +13390,7 @@
"name": "DiagnosticReport", "name": "DiagnosticReport",
"type": "interface", "type": "interface",
"file": "types.ts", "file": "types.ts",
"definition": "{ id, studentId, generatedBy, reportType, period, summary, strengths[], weaknesses[], recommendations[], overallScore, status, createdAt, updatedAt }", "definition": "{ id, studentId, classId(v4-P1-4 新增: 班级报告关联班级 ID个人报告为 null), generatedBy, reportType, period, summary, strengths[], weaknesses[], recommendations[], overallScore, status, createdAt, updatedAt }",
"usedBy": [ "usedBy": [
"data-access-reports", "data-access-reports",
"types.DiagnosticReportWithDetails" "types.DiagnosticReportWithDetails"
@@ -13616,6 +13844,35 @@
"usedBy": [ "usedBy": [
"actions.dropCourseAction" "actions.dropCourseAction"
] ]
},
{
"name": "checkScheduleConflict",
"file": "data-access-operations.ts",
"signature": "(tx: Tx, studentId: string, courseId: string) => Promise<boolean>",
"purpose": "查询学生已选课程时间段,与新课程时间段逐一调用 isScheduleConflict 判断冲突P2-9 新增选课时间冲突检测selectCourse 调用)",
"deps": [
"shared.db",
"shared.db.schema.courseSelections",
"shared.db.schema.electiveCourses",
"lib.isScheduleConflict"
],
"usedBy": [
"data-access-operations.selectCourse"
]
},
{
"name": "checkCreditLimit",
"file": "data-access-operations.ts",
"signature": "(tx: Tx, studentId: string, courseId: string) => Promise<{ exceeded: boolean; current: number; max: number }>",
"purpose": "查询学生本学期已选课程学分总和,加上新课程学分后判断是否超过 MAX_CREDIT_PER_TERM(10)P2-10 新增学分上限校验selectCourse 调用)",
"deps": [
"shared.db",
"shared.db.schema.courseSelections",
"shared.db.schema.electiveCourses"
],
"usedBy": [
"data-access-operations.selectCourse"
]
} }
], ],
"types": [ "types": [
@@ -13781,6 +14038,129 @@
"actions.dropCourseAction", "actions.dropCourseAction",
"shared/components/ui/*" "shared/components/ui/*"
] ]
},
{
"name": "ElectivePageLayout",
"file": "components/elective-page-layout.tsx",
"purpose": "选课页面布局骨架P2-4 新增:抽取 admin/teacher 选课页重复的 header/children 布局结构,统一 spacing",
"deps": [
"shared/lib/utils.cn"
],
"usedBy": [
"admin/elective/page.tsx",
"teacher/elective/page.tsx"
]
}
],
"resolvers": [
{
"name": "CourseDisplayResolver",
"type": "interface",
"file": "resolvers.ts",
"definition": "{ getUserNamesByIds(ids: string[]): Promise<Map<string,{name:string|null}>>; getSubjectOptions(): Promise<Array<{id,name}>>; getGradeOptions(): Promise<Array<{id,name}>> }",
"purpose": "课程显示名解析接口P2-8 新增:抽象跨模块依赖 users/school data-access便于测试注入 mock",
"usedBy": [
"data-access.resolveCourseDisplayNames",
"data-access-selections.resolveStudentDisplayNames"
]
},
{
"name": "StudentGradeResolver",
"type": "interface",
"file": "resolvers.ts",
"definition": "{ getStudentActiveGradeId(studentId: string): Promise<string|null> }",
"purpose": "学生年级解析接口P2-8 新增:抽象跨模块依赖 classes data-access便于测试注入 mock",
"usedBy": [
"data-access-selections.getStudentGradeId"
]
},
{
"name": "getCourseDisplayResolver",
"signature": "() => CourseDisplayResolver",
"file": "resolvers.ts",
"purpose": "获取当前课程显示名解析器(默认委托 users/school data-access测试可通过 setCourseDisplayResolver 注入 mock",
"usedBy": [
"data-access.resolveCourseDisplayNames",
"data-access-selections.resolveStudentDisplayNames"
]
},
{
"name": "getStudentGradeResolver",
"signature": "() => StudentGradeResolver",
"file": "resolvers.ts",
"purpose": "获取当前学生年级解析器(默认委托 classes data-access测试可通过 setStudentGradeResolver 注入 mock",
"usedBy": [
"data-access-selections.getStudentGradeId"
]
},
{
"name": "setCourseDisplayResolver",
"signature": "(resolver: CourseDisplayResolver) => void",
"file": "resolvers.ts",
"purpose": "注入自定义课程显示名解析器(仅测试用,生产代码勿调)"
},
{
"name": "setStudentGradeResolver",
"signature": "(resolver: StudentGradeResolver) => void",
"file": "resolvers.ts",
"purpose": "注入自定义学生年级解析器(仅测试用,生产代码勿调)"
},
{
"name": "resetResolvers",
"signature": "() => void",
"file": "resolvers.ts",
"purpose": "重置解析器为默认实现(测试 afterEach 清理用)"
}
],
"lib": [
{
"name": "parseSchedule",
"signature": "(schedule: string) => { day: number; startMinutes: number; endMinutes: number } | null",
"file": "data-access-operations.ts",
"purpose": "纯函数:解析时间段字符串(支持 '周一 14:00-15:30' / 'Mon 14:00-15:30' 两种格式),返回星期 0-6 + 起止分钟数;无法解析返回 nullP2-9 新增)",
"usedBy": [
"data-access-operations.isScheduleConflict",
"data-access-operations.checkScheduleConflict"
]
},
{
"name": "isScheduleConflict",
"signature": "(a: string, b: string) => boolean",
"file": "data-access-operations.ts",
"purpose": "纯函数:判断两个时间段是否冲突(同一天且时间区间重叠);任一无法解析返回 falseP2-9 新增,可独立单测)",
"usedBy": [
"data-access-operations.checkScheduleConflict"
]
}
],
"importExport": [
{
"name": "exportElectiveCoursesToExcel",
"signature": "(params: { status?: string; teacherId?: string }) => Promise<Buffer>",
"file": "export.ts",
"purpose": "导出选修课程列表到 ExcelP2-11 新增:课程名/学科/教师/年级/容量/已选/状态/模式/学分/时间段;列头使用 next-intl getTranslations 国际化)",
"deps": [
"shared.lib.excel.exportToExcel",
"data-access.getElectiveCourses",
"next-intl/server.getTranslations"
],
"usedBy": [
"app/api/export/route.ts"
]
},
{
"name": "exportCourseSelectionsToExcel",
"signature": "(params: { courseId: string }) => Promise<Buffer>",
"file": "export.ts",
"purpose": "导出课程选课名单到 ExcelP2-11 新增:学生名/状态/优先级/选课时间/录取时间;列头使用 next-intl getTranslations 国际化)",
"deps": [
"shared.lib.excel.exportToExcel",
"data-access-selections.getCourseSelections",
"next-intl/server.getTranslations"
],
"usedBy": [
"app/api/export/route.ts"
]
} }
] ]
} }
@@ -14833,7 +15213,7 @@
}, },
"messageNotifications": { "messageNotifications": {
"owner": "notifications", "owner": "notifications",
"description": "消息通知" "description": "消息通知(type/title/content/link/isRead/priority/isArchived/createdAtV2-P2-13b 新增 priority 和 isArchived 字段)"
}, },
"notificationLogs": { "notificationLogs": {
"owner": "notifications", "owner": "notifications",
@@ -14911,7 +15291,7 @@
}, },
"learningDiagnosticReports": { "learningDiagnosticReports": {
"owner": "diagnostic", "owner": "diagnostic",
"description": "学情诊断报告(individual/class/grade)" "description": "学情诊断报告(individual/class/gradev4-P1-4 新增 classId 字段关联班级)"
} }
} }
}, },
@@ -15194,7 +15574,8 @@
"data-access.getClassTeacherById" "data-access.getClassTeacherById"
], ],
"school": [ "school": [
"data-access.getSubjectOptions" "data-access.getSubjectOptions",
"data-access.getSubjectNameMapByIds"
], ],
"users": [ "users": [
"data-access.getUserWithRole", "data-access.getUserWithRole",
@@ -15400,7 +15781,7 @@
"data-access.deleteFileAttachment" "data-access.deleteFileAttachment"
] ]
}, },
"note": "组件层通过 SettingsService 接口注入解耦,不直接 import messaging/actions页面层 app/(dashboard)/settings/page.tsx 负责注入 users/actions + messaging/actions 实现。P0-3/P2-8/P2-9/P2-10/P2-11 已修复AdminSettingsView 接入真实数据层system_settings 表、头像上传、2FA/登录历史、通知测试按钮、语言切换集成。v2 已增强:2FA 开关改为禁用状态避免虚假安全感、通知测试接入真实发送、头像上传清理旧文件、会话远程登出、AdminSettingsView/通知偏好表单 dirty 检测、currentDeviceLabel 标记当前会话、文件名长度校验、2FA 查询 N+1 优化、新增 30 个单元测试。" "note": "组件层通过 SettingsService 接口注入解耦,不直接 import messaging/actions页面层 app/(dashboard)/settings/page.tsx 负责注入 users/actions + messaging/actions 实现。P0-3/P2-8/P2-9/P2-10/P2-11 已修复AdminSettingsView 接入真实数据层system_settings 表、头像上传、2FA/登录历史、通知测试按钮、语言切换集成。v2 已增强通知测试接入真实发送、头像上传清理旧文件、会话远程登出、AdminSettingsView/通知偏好表单 dirty 检测、currentDeviceLabel 标记当前会话、文件名长度校验、2FA 查询 N+1 优化。v3 已增强:完整 TOTP 2FA 流程otplib v13 + qrcode— 启用(QR码扫描+验证码校验+10个备份码 bcrypt 哈希存储)/关闭(需 TOTP 或备份码确认)/重新生成备份码;登录流程接入 2FApreflightTwoFactorAction 预检 + auth.ts authorize 校验);新增 29 个 TOTP 单元测试(总计 59 个。依赖新增otplib、qrcode。"
}, },
"users": { "users": {
"dependsOn": [ "dependsOn": [
@@ -16314,7 +16695,19 @@
"from": "elective", "from": "elective",
"to": "school", "to": "school",
"type": "data-access", "type": "data-access",
"description": "关联年级(grades)/科目(subjects)" "description": "关联年级(grades)/科目(subjects)P2-8通过 resolvers.ts CourseDisplayResolver 接口抽象,默认实现委托 school data-access"
},
{
"from": "elective",
"to": "users",
"type": "data-access",
"description": "查询教师/学生显示名P2-8通过 resolvers.ts CourseDisplayResolver 接口抽象,默认实现委托 users data-access.getUserNamesByIds"
},
{
"from": "elective",
"to": "classes",
"type": "data-access",
"description": "查询学生所在年级P2-8通过 resolvers.ts StudentGradeResolver 接口抽象,默认实现委托 classes data-access.getStudentActiveGradeId"
}, },
{ {
"from": "attendance", "from": "attendance",
@@ -17908,11 +18301,11 @@
"methods": [ "methods": [
"POST" "POST"
], ],
"handler": "Excel 导出grades/users/attendance", "handler": "Excel 导出grades/users/attendance/electiveCourses/courseSelections/audit/login/dataChange",
"auth": "requireAuth", "auth": "requireAuth",
"module": "shared.lib.excel + users/grades", "module": "shared.lib.excel + users/grades/attendance/elective/audit",
"validation": "JSON body { type, params }", "validation": "JSON body { type, params }",
"description": "按 type 分发exportGradeRecordsToExcel/exportUsersToExcel返回 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 二进制流" "description": "按 type 分发grades→exportGradeRecordsToExcel, users→exportUsersToExcel, attendance→exportAttendanceRecordsToExcel(需 ATTENDANCE_READ), electiveCourses→exportElectiveCoursesToExcel(需 ELECTIVE_READ), courseSelections→exportCourseSelectionsToExcel(需 ELECTIVE_MANAGE), audit/login/dataChange→audit/actions 导出(需 AUDIT_LOG_READ)。返回 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 二进制流"
}, },
"/api/import": { "/api/import": {
"methods": [ "methods": [

View File

@@ -233,7 +233,7 @@ export function ExamForm() {
queuedPreviewTaskCount={queuedPreviewTaskCount} queuedPreviewTaskCount={queuedPreviewTaskCount}
/> />
)} )}
<ExamModeConfig<ExamFormValues> control={form.control} /> <ExamModeConfig />
</form> </form>
</Form> </Form>

View File

@@ -21,7 +21,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog" } from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2 } from "lucide-react" import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils" import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types" import type { StudentHomeworkTakeData } from "../types"
@@ -29,6 +29,7 @@ import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomework
import { QuestionRenderer } from "./question-renderer" import { QuestionRenderer } from "./question-renderer"
import { parseSavedAnswer } from "../lib/question-content-utils" import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save" import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
import { useExamCountdown } from "../hooks/use-exam-countdown"
type HomeworkTakeViewProps = { type HomeworkTakeViewProps = {
assignmentId: string assignmentId: string
@@ -195,6 +196,38 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
return false return false
}).length }).length
// P0-竞品修复:限时/监考模式倒计时
const examModeConfig = initialData.examModeConfig
const isTimedExam = canEdit
&& examModeConfig !== null
&& (examModeConfig.examMode === "timed" || examModeConfig.examMode === "proctored")
&& examModeConfig.durationMinutes !== null
&& examModeConfig.durationMinutes > 0
&& initialData.submission?.startedAt !== null
&& initialData.submission?.startedAt !== undefined
const countdown = useExamCountdown({
durationMinutes: examModeConfig?.durationMinutes ?? null,
startedAt: initialData.submission?.startedAt ?? null,
enabled: isTimedExam,
onExpire: () => {
// 到时自动提交(仅触发一次)
if (submissionStatus === "started" && submissionId) {
toast.warning(t("homework.take.timeUpAutoSubmit"))
void handleSubmit()
}
},
})
const formatCountdown = (s: { hours: number; minutes: number; seconds: number } | null): string => {
if (!s) return ""
const parts: string[] = []
if (s.hours > 0) parts.push(`${s.hours}h`)
parts.push(`${s.minutes.toString().padStart(2, "0")}m`)
parts.push(`${s.seconds.toString().padStart(2, "0")}s`)
return parts.join(" ")
}
return ( return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12"> <div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card"> <div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
@@ -222,14 +255,44 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div> </div>
{!canEdit ? ( {!canEdit ? (
<div className="flex items-center gap-3">
{isTimedExam && examModeConfig && (
<div className="flex items-center gap-1.5 rounded-md border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-300">
<Timer className="h-3.5 w-3.5" />
<span className="font-medium">
{t("homework.take.timedExam", { minutes: examModeConfig.durationMinutes ?? 0 })}
</span>
</div>
)}
<Button onClick={handleStart} disabled={isBusy} size="sm"> <Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")} {isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
</Button> </Button>
</div>
) : ( ) : (
<div className="flex items-center gap-3">
{countdown && (
<div
className={cn(
"flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-semibold tabular-nums",
countdown.isExpired
? "border-destructive bg-destructive/10 text-destructive"
: countdown.isUrgent
? "border-destructive bg-destructive/5 text-destructive animate-pulse"
: "border-muted-foreground/20 bg-muted/50 text-foreground"
)}
role="timer"
aria-live="polite"
aria-label={t("homework.take.timeRemaining")}
>
<Timer className="h-4 w-4" />
<span>{formatCountdown(countdown)}</span>
</div>
)}
<Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm"> <Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" /> <CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")} {isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
</Button> </Button>
</div>
)} )}
</div> </div>

View File

@@ -5,12 +5,12 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
exams,
homeworkAssignmentTargets, homeworkAssignmentTargets,
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
subjects,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { getExamSubjectIdMap } from "@/modules/exams/data-access"
import { getSubjectNameMapByIds } from "@/modules/school/data-access"
/** /**
* This file exposes homework data needed by the classes module. * This file exposes homework data needed by the classes module.
@@ -19,6 +19,9 @@ import {
* *
* All functions return plain data records; callers are responsible for * All functions return plain data records; callers are responsible for
* any further aggregation/statistics. * any further aggregation/statistics.
*
* P1-1 修复:不再直接 import exams/subjects 表,改通过 exams/school
* 模块的 data-access 跨模块函数获取科目信息。
*/ */
export type HomeworkAssignmentWithSubject = { export type HomeworkAssignmentWithSubject = {
@@ -79,6 +82,12 @@ export const getAssignmentIdsForStudents = cache(
/** /**
* Returns homework assignments joined with subject info (via source exam), * Returns homework assignments joined with subject info (via source exam),
* optionally filtered by subject IDs. Used by class-level homework insights. * optionally filtered by subject IDs. Used by class-level homework insights.
*
* P1-1 修复:不再 JOIN exams/subjects 表,改为:
* 1. 查 homeworkAssignments含 sourceExamId
* 2. 通过 exams data-access 批量获取 examId→subjectId 映射
* 3. 通过 school data-access 批量获取 subjectId→name 映射
* 4. 在内存中合并与过滤
*/ */
export const getHomeworkAssignmentsWithSubject = cache( export const getHomeworkAssignmentsWithSubject = cache(
async (params: { async (params: {
@@ -87,11 +96,9 @@ export const getHomeworkAssignmentsWithSubject = cache(
limit?: number limit?: number
}): Promise<HomeworkAssignmentWithSubject[]> => { }): Promise<HomeworkAssignmentWithSubject[]> => {
if (params.assignmentIds.length === 0) return [] if (params.assignmentIds.length === 0) return []
const conditions = [inArray(homeworkAssignments.id, params.assignmentIds)]
if (params.subjectIdFilter && params.subjectIdFilter.length > 0) {
conditions.push(inArray(exams.subjectId, params.subjectIdFilter))
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
// Step 1: 查 homeworkAssignments含 sourceExamId
const rows = await db const rows = await db
.select({ .select({
id: homeworkAssignments.id, id: homeworkAssignments.id,
@@ -99,16 +106,50 @@ export const getHomeworkAssignmentsWithSubject = cache(
status: homeworkAssignments.status, status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt, createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt, dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId, sourceExamId: homeworkAssignments.sourceExamId,
subjectName: subjects.name,
}) })
.from(homeworkAssignments) .from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id)) .where(inArray(homeworkAssignments.id, params.assignmentIds))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(...conditions))
.orderBy(desc(homeworkAssignments.createdAt)) .orderBy(desc(homeworkAssignments.createdAt))
.limit(limit) .limit(limit)
if (rows.length === 0) return []
// Step 2: 通过 exams data-access 批量获取 examId→subjectId
const examIds = rows
.map((r) => r.sourceExamId)
.filter((id): id is string => id !== null && id.length > 0)
const examSubjectMap = await getExamSubjectIdMap(examIds)
// Step 3: 通过 school data-access 批量获取 subjectId→name
const subjectIds = Array.from(examSubjectMap.values()).filter(
(id): id is string => id !== null && id.length > 0
)
const subjectNameMap = await getSubjectNameMapByIds(subjectIds)
// Step 4: 在内存中合并与过滤
const subjectIdFilterSet = params.subjectIdFilter && params.subjectIdFilter.length > 0
? new Set(params.subjectIdFilter)
: null
return rows return rows
.map((r) => {
const subjectId = r.sourceExamId ? (examSubjectMap.get(r.sourceExamId) ?? null) : null
const subjectName = subjectId ? (subjectNameMap.get(subjectId) ?? null) : null
return {
id: r.id,
title: r.title,
status: r.status,
createdAt: r.createdAt,
dueAt: r.dueAt,
subjectId,
subjectName,
}
})
.filter((item) => {
if (!subjectIdFilterSet) return true
return item.subjectId !== null && subjectIdFilterSet.has(item.subjectId)
})
} }
) )
@@ -197,20 +238,21 @@ export const getHomeworkSubmissionsForStudents = cache(
/** /**
* Returns published homework assignments joined with subject info (via source exam). * Returns published homework assignments joined with subject info (via source exam).
* Used by student subject score aggregation. * Used by student subject score aggregation.
*
* P1-1 修复:不再 JOIN exams/subjects 表,改用跨模块 data-access。
*/ */
export const getPublishedHomeworkAssignmentsWithSubject = cache( export const getPublishedHomeworkAssignmentsWithSubject = cache(
async (params: { assignmentIds: string[] }): Promise<HomeworkAssignmentSubjectRow[]> => { async (params: { assignmentIds: string[] }): Promise<HomeworkAssignmentSubjectRow[]> => {
if (params.assignmentIds.length === 0) return [] if (params.assignmentIds.length === 0) return []
// Step 1: 查 published homeworkAssignments含 sourceExamId
const rows = await db const rows = await db
.select({ .select({
id: homeworkAssignments.id, id: homeworkAssignments.id,
createdAt: homeworkAssignments.createdAt, createdAt: homeworkAssignments.createdAt,
subjectId: exams.subjectId, sourceExamId: homeworkAssignments.sourceExamId,
subjectName: subjects.name,
}) })
.from(homeworkAssignments) .from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where( .where(
and( and(
inArray(homeworkAssignments.id, params.assignmentIds), inArray(homeworkAssignments.id, params.assignmentIds),
@@ -218,7 +260,32 @@ export const getPublishedHomeworkAssignmentsWithSubject = cache(
) )
) )
.orderBy(desc(homeworkAssignments.createdAt)) .orderBy(desc(homeworkAssignments.createdAt))
return rows
if (rows.length === 0) return []
// Step 2: 通过 exams data-access 批量获取 examId→subjectId
const examIds = rows
.map((r) => r.sourceExamId)
.filter((id): id is string => id !== null && id.length > 0)
const examSubjectMap = await getExamSubjectIdMap(examIds)
// Step 3: 通过 school data-access 批量获取 subjectId→name
const subjectIds = Array.from(examSubjectMap.values()).filter(
(id): id is string => id !== null && id.length > 0
)
const subjectNameMap = await getSubjectNameMapByIds(subjectIds)
// Step 4: 在内存中合并
return rows.map((r) => {
const subjectId = r.sourceExamId ? (examSubjectMap.get(r.sourceExamId) ?? null) : null
const subjectName = subjectId ? (subjectNameMap.get(subjectId) ?? null) : null
return {
id: r.id,
createdAt: r.createdAt,
subjectId,
subjectName,
}
})
} }
) )

View File

@@ -12,7 +12,7 @@ import {
homeworkSubmissions, homeworkSubmissions,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access" import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { getExamIdsByGradeIds, getExamSubjectIdMap } from "@/modules/exams/data-access" import { getExamIdsByGradeIds, getExamSubjectIdMap, getExamForProctoringCrossModule } from "@/modules/exams/data-access"
import { getSubjectOptions } from "@/modules/school/data-access" import { getSubjectOptions } from "@/modules/school/data-access"
import type { import type {
@@ -935,6 +935,24 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
} }
} }
// P0-竞品修复:获取考试模式配置(仅当作业关联考试时)
let examModeConfig: StudentHomeworkTakeData["examModeConfig"] = null
if (assignment.sourceExamId) {
const examConfig = await getExamForProctoringCrossModule(assignment.sourceExamId)
if (examConfig) {
examModeConfig = {
examMode: (examConfig.examMode === "timed" || examConfig.examMode === "proctored" || examConfig.examMode === "homework")
? examConfig.examMode
: "homework",
durationMinutes: examConfig.durationMinutes,
shuffleQuestions: examConfig.shuffleQuestions ?? false,
allowLateStart: examConfig.allowLateStart ?? false,
lateStartGraceMinutes: examConfig.lateStartGraceMinutes ?? 0,
antiCheatEnabled: examConfig.antiCheatEnabled ?? false,
}
}
}
return { return {
assignment: { assignment: {
id: assignment.id, id: assignment.id,
@@ -946,6 +964,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null, lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts, maxAttempts: assignment.maxAttempts,
}, },
examModeConfig,
submission: latestSubmission submission: latestSubmission
? { ? {
id: latestSubmission.id, id: latestSubmission.id,
@@ -953,6 +972,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
attemptNo: latestSubmission.attemptNo, attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null, submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null, score: latestSubmission.score ?? null,
startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null,
} }
: null, : null,
questions: assignmentQuestions.map((aq) => { questions: assignmentQuestions.map((aq) => {

View File

@@ -0,0 +1,122 @@
"use client"
import { useEffect, useRef, useState } from "react"
/**
* P0-竞品修复:考试倒计时 hook。
*
* 对标智学网/猿题库的限时考试功能:
* - 学生开始作答后,根据 durationMinutes 计算截止时间
* - 每秒更新剩余时间
* - 剩余时间 ≤ 0 时触发 onExpire 回调(自动提交)
* - 剩余时间 ≤ 5 分钟时标记为紧急状态(红色高亮)
*
* 设计要点:
* - 使用 ref 存储 onExpire 回调避免闭包陷阱
* - 使用 setInterval 每秒更新 stateDate.now 仅在 interval 回调中调用,
* 不在 render 阶段调用,符合 react-hooks/purity 规则)
* - setState 仅在 interval 回调中异步调用,不在 effect 体内同步执行
* - 服务端时间偏差由调用方传入 startedAt服务端 ISO 时间)缓解
*/
export interface ExamCountdownState {
/** 剩余毫秒数(≤ 0 表示已到时) */
remainingMs: number
/** 剩余小时数 */
hours: number
/** 剩余分钟数0-59 */
minutes: number
/** 剩余秒数0-59 */
seconds: number
/** 是否已到时 */
isExpired: boolean
/** 是否进入紧急状态(≤ 5 分钟) */
isUrgent: boolean
}
interface UseExamCountdownOptions {
/** 考试时长分钟null 表示无限制 */
durationMinutes: number | null
/** 提交记录创建时间ISO 字符串),用于计算截止时间 */
startedAt: string | null
/** 到时回调(仅触发一次) */
onExpire?: () => void
/** 是否启用(默认 true */
enabled?: boolean
}
const URGENT_THRESHOLD_MS = 5 * 60 * 1000 // 5 分钟
const TICK_INTERVAL_MS = 1000
const computeState = (remainingMs: number): ExamCountdownState => {
const clamped = Math.max(0, remainingMs)
const totalSeconds = Math.floor(clamped / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return {
remainingMs: clamped,
hours,
minutes,
seconds,
isExpired: remainingMs <= 0,
isUrgent: remainingMs > 0 && remainingMs <= URGENT_THRESHOLD_MS,
}
}
const isStartTimeValid = (startedAt: string | null): boolean =>
startedAt !== null && !Number.isNaN(new Date(startedAt).getTime())
export function useExamCountdown({
durationMinutes,
startedAt,
onExpire,
enabled = true,
}: UseExamCountdownOptions): ExamCountdownState | null {
const [state, setState] = useState<ExamCountdownState | null>(null)
const onExpireRef = useRef(onExpire)
const expiredRef = useRef(false)
// 保持 onExpire 回调最新,避免闭包陷阱
useEffect(() => {
onExpireRef.current = onExpire
}, [onExpire])
// 配置有效性(派生计算,无需 setState
const isConfigValid =
enabled &&
durationMinutes !== null &&
durationMinutes > 0 &&
isStartTimeValid(startedAt)
// 启动每秒定时器Date.now() 与 setState 均在 interval 回调中异步调用,
// 不在 effect 体内同步执行,符合 react-hooks/set-state-in-effect 与 purity 规则
useEffect(() => {
if (!isConfigValid || !startedAt || durationMinutes === null) {
return
}
const startTime = new Date(startedAt).getTime()
const deadline = startTime + durationMinutes * 60 * 1000
expiredRef.current = false
const update = () => {
const remaining = deadline - Date.now()
setState(computeState(remaining))
if (remaining <= 0 && !expiredRef.current) {
expiredRef.current = true
onExpireRef.current?.()
}
}
const timer = setInterval(update, TICK_INTERVAL_MS)
return () => clearInterval(timer)
}, [isConfigValid, startedAt, durationMinutes])
// 配置无效时不显示倒计时state 旧值由 isConfigValid 守卫拦截)
if (!isConfigValid) {
return null
}
return state
}

View File

@@ -161,12 +161,26 @@ export type StudentHomeworkTakeData = {
lateDueAt: string | null lateDueAt: string | null
maxAttempts: number maxAttempts: number
} }
/**
* 考试模式配置(仅当作业关联考试时存在)。
* P0-竞品修复:限时/监考模式需在答题页展示倒计时并到时自动提交。
*/
examModeConfig: {
examMode: "homework" | "timed" | "proctored"
durationMinutes: number | null
shuffleQuestions: boolean
allowLateStart: boolean
lateStartGraceMinutes: number
antiCheatEnabled: boolean
} | null
submission: { submission: {
id: string id: string
status: HomeworkSubmissionStatus status: HomeworkSubmissionStatus
attemptNo: number attemptNo: number
submittedAt: string | null submittedAt: string | null
score: number | null score: number | null
/** 提交记录创建时间(用于计算限时考试的剩余时间) */
startedAt: string | null
} | null } | null
questions: StudentHomeworkTakeQuestion[] questions: StudentHomeworkTakeQuestion[]
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { type Control, type FieldPath, useWatch } from "react-hook-form" import { useFormContext, useWatch } from "react-hook-form"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { import {
FormField, FormField,
@@ -37,18 +37,33 @@ export interface ExamModeConfigFieldValues {
allowLateStart: boolean allowLateStart: boolean
lateStartGraceMinutes: number lateStartGraceMinutes: number
antiCheatEnabled: boolean antiCheatEnabled: boolean
[key: string]: unknown
} }
type ExamModeConfigProps<T extends ExamModeConfigFieldValues> = { /**
control: Control<T> * 考试模式配置卡片homework / timed / proctored
} *
* P1-2 修复:移除泛型与所有 as 断言。
export function ExamModeConfig<T extends ExamModeConfigFieldValues>({ * 原方案通过 control: Control<ExamModeConfigFieldValues> 接收控制对象,
control, * 但 Control<T> 在 react-hook-form 中为不变型invariant
}: ExamModeConfigProps<T>) { * Control<ExamFormValues> 无法赋值给 Control<ExamModeConfigFieldValues>。
* 改为使用 useFormContext 从 FormProvider 读取表单上下文,
* 避免在调用方使用 as 断言适配 Control 类型。
*
* 使用方需确保:
* 1. 外层已通过 <Form {...form}> / <FormProvider {...form}> 提供上下文
* 2. 表单值类型结构包含 ExamModeConfigFieldValues 的全部字段
*/
export function ExamModeConfig() {
const t = useTranslations("examHomework") const t = useTranslations("examHomework")
const examMode = useWatch({ control, name: "examMode" as FieldPath<T> }) as ExamMode const form = useFormContext<ExamModeConfigFieldValues>()
// useWatch 必须在条件返回之前无条件调用Rules of Hooks
// form?.control 为 undefined 时 useWatch 会回退到 useFormContext
const examMode = useWatch<ExamModeConfigFieldValues, "examMode">({
control: form?.control,
name: "examMode",
})
if (!form) return null
const { control } = form
const showDuration = examMode === "timed" || examMode === "proctored" const showDuration = examMode === "timed" || examMode === "proctored"
const showProctorOptions = examMode === "proctored" const showProctorOptions = examMode === "proctored"
@@ -61,13 +76,13 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<FormField <FormField
control={control} control={control}
name={"examMode" as FieldPath<T>} name="examMode"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("proctoring.mode.title")}</FormLabel> <FormLabel>{t("proctoring.mode.title")}</FormLabel>
<FormControl> <FormControl>
<RadioGroup <RadioGroup
value={field.value as ExamMode} value={field.value}
onValueChange={field.onChange} onValueChange={field.onChange}
className="grid gap-3 md:grid-cols-3" className="grid gap-3 md:grid-cols-3"
> >
@@ -102,7 +117,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
{showDuration && ( {showDuration && (
<FormField <FormField
control={control} control={control}
name={"durationMinutes" as FieldPath<T>} name="durationMinutes"
render={({ field }) => ( render={({ field }) => (
<FormItem className="space-y-2"> <FormItem className="space-y-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -117,12 +132,15 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<Input <Input
type="number" type="number"
min={1} min={1}
value={(field.value as number | null) ?? ""} value={field.value ?? ""}
onChange={(e) => onChange={(e) => {
field.onChange( if (e.target.value === "") {
e.target.value === "" ? null : Number(e.target.value), field.onChange(null)
) return
} }
const n = Number(e.target.value)
field.onChange(Number.isFinite(n) ? n : null)
}}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
ref={field.ref} ref={field.ref}
@@ -139,7 +157,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<> <>
<FormField <FormField
control={control} control={control}
name={"shuffleQuestions" as FieldPath<T>} name="shuffleQuestions"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3"> <FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -148,7 +166,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
</div> </div>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value as boolean} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
aria-label={t("proctoring.config.shuffleQuestions")} aria-label={t("proctoring.config.shuffleQuestions")}
/> />
@@ -159,7 +177,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<FormField <FormField
control={control} control={control}
name={"antiCheatEnabled" as FieldPath<T>} name="antiCheatEnabled"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3"> <FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -168,7 +186,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
</div> </div>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value as boolean} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
aria-label={t("proctoring.config.antiCheat")} aria-label={t("proctoring.config.antiCheat")}
/> />
@@ -179,7 +197,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<FormField <FormField
control={control} control={control}
name={"allowLateStart" as FieldPath<T>} name="allowLateStart"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3"> <FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -188,7 +206,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
</div> </div>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value as boolean} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
aria-label={t("proctoring.config.allowLateStart")} aria-label={t("proctoring.config.allowLateStart")}
/> />
@@ -199,7 +217,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<FormField <FormField
control={control} control={control}
name={"lateStartGraceMinutes" as FieldPath<T>} name="lateStartGraceMinutes"
render={({ field }) => ( render={({ field }) => (
<FormItem className="space-y-2"> <FormItem className="space-y-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -210,8 +228,11 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<Input <Input
type="number" type="number"
min={0} min={0}
value={(field.value as number) ?? 0} value={field.value ?? 0}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => {
const n = Number(e.target.value)
field.onChange(Number.isFinite(n) ? n : 0)
}}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
ref={field.ref} ref={field.ref}

View File

@@ -40,6 +40,27 @@ import { PROCTORING_EVENT_LABELS, EXAM_MODE_LABELS } from "../types"
const REFRESH_INTERVAL_MS = 10_000 const REFRESH_INTERVAL_MS = 10_000
/**
* P1-3 修复:类型守卫替代 `as ProctoringEventType[]` 断言。
* `Object.keys()` 返回 `string[]`,需过滤为合法的 ProctoringEventType。
*/
const PROCTORING_EVENT_TYPE_SET: ReadonlySet<string> = new Set([
"tab_switch",
"window_blur",
"copy_attempt",
"paste_attempt",
"right_click",
"devtools_open",
"fullscreen_exit",
"idle_timeout",
])
const isProctoringEventType = (v: string): v is ProctoringEventType =>
PROCTORING_EVENT_TYPE_SET.has(v)
const toProctoringEventTypes = (keys: string[]): ProctoringEventType[] =>
keys.filter(isProctoringEventType)
const formatTime = (iso: string | null): string => { const formatTime = (iso: string | null): string => {
if (!iso) return "—" if (!iso) return "—"
return formatDateTime(iso) return formatDateTime(iso)
@@ -176,7 +197,7 @@ export function ProctoringDashboard({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(Object.keys(summary.eventsByType) as ProctoringEventType[]).map((type) => { {toProctoringEventTypes(Object.keys(summary.eventsByType)).map((type) => {
const count = summary.eventsByType[type] const count = summary.eventsByType[type]
if (count === 0) return null if (count === 0) return null
return ( return (
@@ -229,7 +250,7 @@ export function ProctoringDashboard({
<TableCell className="text-muted-foreground">{formatTime(s.lastEventAt)}</TableCell> <TableCell className="text-muted-foreground">{formatTime(s.lastEventAt)}</TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{(Object.keys(s.eventsByType) as ProctoringEventType[]) {toProctoringEventTypes(Object.keys(s.eventsByType))
.filter((t) => s.eventsByType[t] > 0) .filter((t) => s.eventsByType[t] > 0)
.map((t) => ( .map((t) => (
<Badge key={t} variant="outline" className="text-xs"> <Badge key={t} variant="outline" className="text-xs">

View File

@@ -197,6 +197,9 @@
"readyDescription": "Click the \"Start Assignment\" button above to begin. Your answers will be saved when you click \"Save Answer\".", "readyDescription": "Click the \"Start Assignment\" button above to begin. Your answers will be saved when you click \"Save Answer\".",
"startNow": "Start Now", "startNow": "Start Now",
"back": "Back", "back": "Back",
"timedExam": "Timed exam: {{minutes}} minutes",
"timeRemaining": "Time remaining",
"timeUpAutoSubmit": "Time is up, auto-submitting...",
"confirmSubmit": "Confirm Submission", "confirmSubmit": "Confirm Submission",
"confirmSubmitDescription": "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?", "confirmSubmitDescription": "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?",
"unansweredWarning": "You have {{count}} unanswered question(s). Submitted answers cannot be changed. Are you sure you want to submit?", "unansweredWarning": "You have {{count}} unanswered question(s). Submitted answers cannot be changed. Are you sure you want to submit?",

View File

@@ -197,6 +197,9 @@
"readyDescription": "点击上方\"开始作答\"按钮。点击\"保存答案\"将保存您的答案。", "readyDescription": "点击上方\"开始作答\"按钮。点击\"保存答案\"将保存您的答案。",
"startNow": "立即开始", "startNow": "立即开始",
"back": "返回", "back": "返回",
"timedExam": "限时考试:{{minutes}} 分钟",
"timeRemaining": "剩余时间",
"timeUpAutoSubmit": "时间到,正在自动提交...",
"confirmSubmit": "确认提交", "confirmSubmit": "确认提交",
"confirmSubmitDescription": "所有题目已作答。提交后答案不可修改,确定要提交吗?", "confirmSubmitDescription": "所有题目已作答。提交后答案不可修改,确定要提交吗?",
"unansweredWarning": "您有 {{count}} 道题未作答。提交后答案不可修改,确定要提交吗?", "unansweredWarning": "您有 {{count}} 道题未作答。提交后答案不可修改,确定要提交吗?",