diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 77e0c5d..68fafbf 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -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) - 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-write:11 个写操作函数(P1-2 新增 10 个从 actions 下沉 + V3-7 新增 `batchAutoGradeSubmissions`) - Stats-service:`getTeacherGradeTrends` / `getHomeworkAssignmentAnalytics` / `getStudentDashboardGrades`(从 data-access.ts re-export 以保持向后兼容) - Components(V3-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`,合理跨模块调用) **已知问题**: @@ -597,7 +597,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P0 已解决:`getStudentDashboardGrades` 排名计算逻辑迁移至 `stats-service.ts` - ✅ P0 已解决:`getHomeworkAssignmentAnalytics` 错误率统计逻辑迁移至 `stats-service.ts` - ✅ 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 行 - ✅ V3-7:新增 `batchAutoGradeSubmissionsAction` + `batchAutoGradeSubmissions` + `HomeworkBatchGradingView`,提交列表页接入批量批改 - ✅ 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-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 | 统计分析(教师趋势/作业分析/学生仪表盘成绩) | | `actions.ts` | 239+ | 6 个 Server Action(P1-2 已修复,无直接 DB 操作;V3-7 新增 `batchAutoGradeSubmissionsAction`) | | `types.ts` | 186 | 类型定义 | | `schema.ts` | 29 | Zod 校验 | | `components/homework-batch-grading-view.tsx` | - | V3-7 新增:批量批改视图(use client) | | `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 新增:成绩录入草稿 CRUD,upsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总) - Types(✅ v3-P2 新增):`SchoolWideGradeSummaryItem`(全校汇总按年级聚合项:gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount)/ `SchoolWideGradeSummary`(全校汇总:grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record, 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 参数) - Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试) -- Components(✅ P1-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) @@ -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):parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard - ✅ v3-P3-4 改进(2026-06-23):GradeTrendCard 新增日期范围选择器(全部/近7天/近30天/近90天),通过 nuqs `trendRange` URL 参数持久化,useEffect 中计算截止时间戳避免渲染阶段调用 Date.now() +- ✅ v4-P3-4 改进(2026-06-23):GradeDistributionChart 色盲友好双重编码——每个分数段使用不同 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/normalize(20 行) - ✅ 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):~~`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 同步) +- ✅ 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 到 ParentExportButton;ParentExportButton 接入 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/deleteGradeDraftAction;v4-P1-6:createGradeRecordAction/batchCreateGradeRecordsAction 新增通知;v4-P1-12:exportGradesAction 新增 studentId 参数) | | `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-analytics.ts` | 200+ | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter;v3-P2 新增:getExamOptionsForGrades/getSchoolWideGradeSummary;getGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) | | `data-access-ranking.ts` | 83 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) | | `stats-service.ts` | 285 | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口;P3-10:createDefaultBuckets 改为内部函数;P3-24:buildGradeTrendPoints 使用 isGradeTrendType 类型守卫替代 as 断言) | -| `export.ts` | 209 | Excel 导出(v2-P1-5 修复:传递 currentUserId 到 data-access;P3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope;P3-6:复用 stats-service.computeAverageScore 替代局部 avg;P3-7:硬编码中文改用 next-intl getTranslations) | +| `export.ts` | 290+ | Excel 导出(v2-P1-5 修复:传递 currentUserId 到 data-access;P3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope;P3-6:复用 stats-service.computeAverageScore 替代局部 avg;P3-7:硬编码中文改用 next-intl getTranslations;v4-P1-12 新增:exportStudentGradeRecordsToExcel 家长视角单学生导出) | | `schema.ts` | 113+ | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段;v3-P2 新增:grade_drafts 表定义第 1444-1469 行) | | `lib/grade-utils.ts` | 20 | 公共工具函数(toNumber/normalize;P3-26:buildScopeClassFilter 迁移至 scope-filter.ts) | | `lib/scope-filter.ts` | 56 | DB 行级权限过滤(buildScopeClassFilter;P3-26 从 grade-utils.ts 迁移;v2-P2-2 修复:改用 classes data-access 子查询;P3 修复:新增 currentUserId 参数) | | `types.ts` | 168+ | 类型定义(v3-P2 新增:SchoolWideGradeSummaryItem/SchoolWideGradeSummary) | | `components/widget-boundary.tsx` | 136 | Widget 边界组件(P1-5 新增,v2-P1-1 已在 3 个页面应用) | | `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 修复:a11y;v2-P1-4:i18n;P3 修复:NaN 日期检查 + fullScore > 0 守卫) | -| `components/grade-record-list.tsx` | 125 | 成绩记录列表(v2-P1-4:i18n;P3 修复:safeActionCall 包装删除操作) | +| `components/grade-record-list.tsx` | 125 | 成绩记录列表(v2-P1-4:i18n;P3 修复:safeActionCall 包装删除操作;v4-P1-7:使用 ScoreCell;v4-P1-10:overflow-x-auto) | | `components/grade-distribution-chart.tsx` | 100 | 分数分布图(v2-P1-4:i18n) | | `components/subject-comparison-chart.tsx` | 62 | 科目对比图(v2-P1-4:i18n) | | `components/class-comparison-chart.tsx` | 58 | 班级对比图(v2-P1-4:i18n) | @@ -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`) -- 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,可接受) @@ -946,18 +955,24 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:考勤记录管理 + 统计分析 + 规则配置。 **导出函数**: -- Actions(10 个):`recordAttendanceAction` / `batchRecordAttendanceAction` / `updateAttendanceAction` / `deleteAttendanceAction` / `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `saveAttendanceRulesAction` / `getAttendanceRulesAction` -- Data-access:`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`(管理员考勤总览页统计概览,基于 `getAttendanceRecords` 聚合)/ `upsertAttendanceRules` / `getAttendanceRules` -- Data-access-stats:`getStudentAttendanceSummary` / `getClassAttendanceStats` / `computeStats`(⚠️ 未导出,无法单测) -- Components:`AttendanceSheet`(批量点名表单)/ `AttendanceRecordList`(记录列表 + 删除)/ `AttendanceFilters`(URL 同步筛选器)/ `AttendanceStatsCard`(单卡片统计)/ `AttendanceStatsCards`(管理员 6 卡片总览)/ `AttendanceStatsClassSelector`(班级筛选 ChipNav)/ `AttendanceRulesForm`(规则配置表单)/ `StudentAttendanceView`(学生/家长只读视图) +- Actions(10 个):`recordAttendanceAction` / `batchRecordAttendanceAction` / `updateAttendanceAction` / `deleteAttendanceAction` / `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `saveAttendanceRulesAction` / `getAttendanceRulesAction`(✅ P2-2:5 个写 Action 错误消息改用 `getTranslations("attendance")` 国际化) +- Data-access:`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`(✅ P2-1 已修复:改用 SQL `COUNT()` + `SUM(CASE WHEN ...)` 聚合查询,不再依赖 `getAttendanceRecords` 分页结果)/ `upsertAttendanceRules` / `getAttendanceRules` +- Data-access-stats:`getStudentAttendanceSummary`(✅ P2-6 已修复:stats 改用 SQL 聚合查询,recentRecords 增加 `recentLimit` 参数默认 20,不再加载全部记录)/ `getClassAttendanceStats` / `computeStats` +- 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` 表) -- 被依赖:`parent`(⚠️ 跨模块 UI 类型依赖:3 个 parent 组件直接 import `@/modules/attendance/types`) +- 依赖:`shared/*`、`@/auth`、`classes`(⚠️ P1-1 未修复:`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表)、`next-intl`(✅ P2-2:Server Action 错误消息 i18n) +- 被依赖:`parent`(⚠️ 跨模块 UI 类型依赖:3 个 parent 组件直接 import `@/modules/attendance/types`)、`app/api/export`(✅ P2-11:通过 `exportAttendanceRecordsToExcel`) **已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md`): -- ❌ P0:`getAttendanceStats` 统计失真——调用 `getAttendanceRecords`(默认 pageSize=20)后对 `items` 聚合,仅基于前 20 条记录计算总览数据 -- ❌ P0:`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表(架构图此前声称已修复,实际未修复) +- ✅ P2-1 已修复:`getAttendanceStats` 改用 SQL 聚合查询,消除分页截断导致统计失真 +- ✅ 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` 表 - ❌ P0:6 个读 Action 无调用方(页面绕过 Action 直接调用 data-access),违反三层架构 - ❌ P0:update/delete Action 缺资源归属校验(教师 A 可修改/删除教师 B 的记录) - ❌ P0:i18n 完全缺失(`ATTENDANCE_STATUS_LABELS` 硬编码英文,组件中硬编码中文) @@ -973,9 +988,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `actions.ts` | 271 | 10 个 Server Action(含权限校验、Zod 校验) | -| `data-access.ts` | 309 | 考勤 CRUD + 班级学生查询 + 规则 upsert + 总览统计 | -| `data-access-stats.ts` | 145 | 学生/班级考勤汇总(拆分范例,`computeStats` 未导出) | +| `actions.ts` | 271 | 10 个 Server Action(含权限校验、Zod 校验;P2-2:错误消息 i18n 化) | +| `data-access.ts` | 340 | 考勤 CRUD + 班级学生查询 + 规则 upsert + 总览统计(P2-1:`getAttendanceStats` 改 SQL 聚合) | +| `data-access-stats.ts` | 180 | 学生/班级考勤汇总(P2-6:stats 改 SQL 聚合 + recentRecords 分页) | +| `export.ts` | 90 | Excel 导出(P2-11 新增:考勤明细 + 统计汇总双 Sheet) | | `schema.ts` | 43 | Zod 校验(5 个 schema) | | `types.ts` | 103 | 类型定义 + 状态标签/颜色常量(硬编码英文) | | `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-rules-form.tsx` | 148 | 考勤规则配置表单 | | `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 组件。 **导出函数**: -- 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)` -- 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 迁移) - Channels:`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender` -- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移) +- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13b:NotificationList 支持优先级 Badge 显示和归档操作) - 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) @@ -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-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-P2-13b 新增:通知优先级和归档功能。schema.ts `messageNotifications` 表新增 `priority`(low/normal/high/urgent,默认 normal)和 `isArchived`(默认 false)字段 + 2 个索引;types.ts 新增 `NotificationPriority` 类型;data-access.ts 新增 `archiveNotification` / `unarchiveNotification` 函数,`getNotifications` 支持归档和优先级筛选(默认仅返回未归档),`createNotification` 支持 priority;actions.ts 新增 `archiveNotificationAction`(含 trackEvent 埋点 notification.archived);NotificationList 组件支持优先级 Badge 显示和归档按钮;i18n 新增 priority/actions.archive/messages.archiveFailed 翻译键 - ⚠️ P1:发送日志仅 console,无 `notification_logs` 表 - ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现) @@ -1170,20 +1189,20 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | 文件 | 行数 | 职责 | |------|------|------| | `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 | 通知偏好 CRUD(P0-4 / P1-5 修复后从 messaging 迁移) | -| `actions.ts` | ~260 | 6 个 Server Action(✅ P1-4:新增 4 个通知 CRUD Action) | -| `types.ts` | 120 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型(P0-4 / P1-5 修复后扩充) | -| `index.ts` | ~75 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出) | +| `actions.ts` | ~300 | 7 个 Server Action(✅ P1-4:新增 4 个通知 CRUD Action;✅ V2-P2-13b:新增 archiveNotificationAction) | +| `types.ts` | ~130 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型(P0-4 / P1-5 修复后扩充;✅ V2-P2-13b:新增 NotificationPriority 类型 + priority/isArchived 字段) | +| `index.ts` | ~80 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出;✅ V2-P2-13b:新增归档函数/Action/类型导出) | | `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 实时推送 + 轮询降级 | | `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增:SSE 实时推送 Hook(EventSource + 轮询降级) | **组件清单**: | 组件 | 职责 | |------|------| -| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications") | +| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b:支持优先级 Badge 显示和归档操作) | | `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3:改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量) | **客户端行为**: @@ -1389,7 +1408,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P2 已修复:~~`buildHomeworkSummary` 中 `[...assignments].sort()` 不必要拷贝~~ 改为 `toSorted()` - ✅ P2 已修复:~~`in7Days` 死代码~~ 已删除 - ⚠️ v4 保留:`/parent/leave` 为占位页,待后端实现请假审批流后接入 -- ⚠️ v4 保留:`ParentExportButton` 为占位,待后端实现成绩导出 Server Action 后接入 +- ✅ v4-P1-5 改进(2026-06-23):新增 `getParentIdsByStudentIds` data-access 函数,批量查询多个学生的家长 userId(去重),供 diagnostic/grades 模块通知场景调用 +- ✅ v4-P1-9 改进(2026-06-23):parent/diagnostic/page.tsx 新增错误卡片展示子女诊断查询失败原因 +- ✅ v4-P1-12 改进(2026-06-23):`ParentExportButton` 接入 `exportGradesAction`,支持按 studentId 导出单个子女成绩;parent/grades/page.tsx 传 studentId 到 ParentExportButton - ⚠️ v4 保留:详情页 Attendance/Diagnostic Tab 为占位提示,待对应功能实现后填充 - ✅ v3-P2 改进(2026-06-23):parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard;parent/diagnostic/page.tsx 传入 `practiceHrefBase={null}` 隐藏练习按钮 - ✅ 职责单一,正确复用其他模块 data-access @@ -1404,7 +1425,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `components/parent-attendance-warning.tsx` | 89 | v4 新增:考勤异常预警 | | `components/parent-attendance-rate-card.tsx` | 105 | v4 新增:考勤出勤率汇总卡片 | | `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-detail-header.tsx` | 78 | 详情页头部(v4 增强:面包屑) | | `components/child-detail-panel.tsx` | 200+ | 详情页 Tab 面板 + SiblingSwitcher(v4 重写,集成 Homework/Grade Detail;V3-11 新增 exams Tab) | @@ -1434,17 +1455,27 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:选修课程管理 + 学生选课 + 抽签。 **导出函数**: -- Actions(11 个):`getElectiveCoursesAction` / `createElectiveCourseAction` / `updateElectiveCourseAction` / `deleteElectiveCourseAction` / `getStudentSelectionsAction` / `selectCourseAction` / `dropCourseAction` / `runLotteryAction` / `getAvailableCoursesForStudentAction` / `openSelectionAction` / `closeSelectionAction` -- Data-access:`getElectiveCourses` / `getElectiveCourseById` / `createElectiveCourse` / `updateElectiveCourse` / `deleteElectiveCourse` / `openSelection` / `closeSelection` / `buildCourseSelect` / `mapCourseRow` / `resolveCourseDisplayNames` / `CourseCoreRow`(P3 新增导出,供 data-access-selections 复用) -- Data-access-operations:`selectCourse` / `dropCourse` / `runLottery` / `buildLotteryRankCase`(⚠️ 未导出,无法单测) -- Data-access-selections:`getCourseSelections` / `getStudentSelections` / `getStudentGradeId` / `getAvailableCoursesForStudent` -- Components:`ElectiveCourseList`(课程卡片网格 + 管理操作)/ `ElectiveCourseForm`(课程创建/编辑表单)/ `ElectiveFilters`(nuqs 筛选栏)/ `StudentSelectionView`(学生选课视图) +- Actions(11 个):`getElectiveCoursesAction` / `createElectiveCourseAction` / `updateElectiveCourseAction` / `deleteElectiveCourseAction` / `getStudentSelectionsAction` / `selectCourseAction` / `dropCourseAction` / `runLotteryAction` / `getAvailableCoursesForStudentAction` / `openSelectionAction` / `closeSelectionAction`(✅ P2-2:8 个写 Action 错误消息改用 `getTranslations("elective")` 国际化) +- 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`(✅ P2-9:新增 `checkScheduleConflict` 时间冲突检测;✅ P2-10:新增 `checkCreditLimit` 学分上限校验)/ `dropCourse` / `runLottery` / `buildLotteryRankCase`(⚠️ 未导出,无法单测)/ `checkScheduleConflict`(P2-9 新增)/ `checkCreditLimit`(P2-10 新增) +- Data-access-selections:`getCourseSelections` / `getStudentSelections` / `getStudentGradeId`(✅ P2-8:通过 `resolvers.ts` StudentGradeResolver 接口抽象)/ `getAvailableCoursesForStudent` / `resolveStudentDisplayNames`(✅ P2-8:通过 `resolvers.ts` CourseDisplayResolver 接口抽象) +- 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-2:Server Action 错误消息 i18n) +- 被依赖:`app/api/export`(✅ P2-11:通过 `exportElectiveCoursesToExcel` / `exportCourseSelectionsToExcel`) **已知问题**(详见 `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 - ❌ P0:3 个读 Action 无调用方(`getElectiveCoursesAction`/`getStudentSelectionsAction`/`getAvailableCoursesForStudentAction`),页面绕过 Action 直接调用 data-access - ❌ P0:update/delete/select/drop/lottery Action 缺资源归属校验(教师 A 可操作教师 B 的课程,学生可退选他人课程) - ❌ P0:i18n 完全缺失(4 组标签/颜色常量硬编码英文,组件中硬编码中文) @@ -1464,16 +1495,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `actions.ts` | 304 | 11 个 Server Action | -| `data-access.ts` | 250 | 课程 CRUD + scope 过滤 + 共享映射函数(P3 重构:移除跨模块 join,通过 school/users data-access 获取显示名称) | -| `data-access-operations.ts` | 245 | 选课操作(select/drop/lottery,P3 重构:事务包裹 + FOR UPDATE 锁 + Fisher-Yates 洗牌) | -| `data-access-selections.ts` | 149 | 选课记录查询 + 学生可选课程 | +| `actions.ts` | 304 | 11 个 Server Action(P2-2:错误消息 i18n 化) | +| `data-access.ts` | 250 | 课程 CRUD + scope 过滤 + 共享映射函数(P2-7:cache 包装;P2-8:resolvers 接口抽象) | +| `data-access-operations.ts` | 370 | 选课操作(P2-9:时间冲突检测;P2-10:学分上限校验;P3:事务 + FOR UPDATE 锁 + Fisher-Yates 洗牌) | +| `data-access-selections.ts` | 149 | 选课记录查询 + 学生可选课程(P2-8:resolvers 接口抽象) | +| `resolvers.ts` | 83 | 跨模块依赖接口抽象(P2-8 新增:CourseDisplayResolver/StudentGradeResolver + 注入/重置函数) | +| `export.ts` | 102 | Excel 导出(P2-11 新增:课程列表 + 选课名单双函数) | | `schema.ts` | 132 | Zod 校验 | | `types.ts` | 108 | 类型定义 + 4 组标签/颜色常量(硬编码英文) | | `components/elective-course-list.tsx` | 233 | 课程卡片网格 + 管理操作 | | `components/elective-course-form.tsx` | 293 | 课程创建/编辑表单 | | `components/elective-filters.tsx` | 49 | nuqs 筛选栏(搜索 + 模式) | | `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` 调用 ``),移除全部 10 处 `as` 类型断言,改用 `useFormContext` 替代 `Control` prop 避免 `Control` 不变型问题 +- ✅ 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` 为唯一规范路径 - ✅ P1-1 已修复:~~跨模块直查 `exams`/`examSubmissions`/`users`~~ 改为通过 exams/users data-access 函数获取数据 - ✅ 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 | | `types.ts` | 136 | 类型定义 + 标签常量 + 阈值常量 | | `components/anti-cheat-monitor.tsx` | - | 学生端防作弊监控 | -| `components/exam-mode-config.tsx` | - | 考试模式配置(**未集成**) | -| `components/proctoring-dashboard.tsx` | - | 教师监考面板 | +| `components/exam-mode-config.tsx` | - | 考试模式配置(✅ P1-2 已修复:已集成到 exam-form,useFormContext 替代 Control prop,无 as 断言) | +| `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-reports:`generateDiagnosticReport` / `generateClassDiagnosticReport`(v2-P2-6 修复:校验掌握度数据;P3-27 修复:使用 DiagnosticReportError 结构化错误码)/ `getDiagnosticReports`(P3-15 修复:支持分页 limit/offset,返回 { reports, total } 结构)/ `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoization;P3-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()` 包装实现请求级 memoization;P3-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 抽取的纯统计函数) - 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-19 已修复(2026-06-23):~~`getStudentMastery` 使用 export 但仅内部使用~~ 移除 export,改为模块内部函数 - ✅ 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 传入 dataScope;student/diagnostic/page.tsx 传 status:"published" + dataScope;parent/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-23):parent/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-23):teacher/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-reports.ts` | 160 | 诊断报告 CRUD(v2-P2-6 校验;v2-P1-6 改用 stats-service 纯函数) | +| `data-access-reports.ts` | 183+ | 诊断报告 CRUD(v2-P2-6 校验;v2-P1-6 改用 stats-service 纯函数;v4-P1-1:getDiagnosticReports 新增 scope 参数支持行级权限过滤) | | `stats-service.ts` | 352 | 统计计算纯函数(v2-P1-6 新增:12 个纯函数 + 2 个常量 + 4 个接口) | -| `actions.ts` | 111 | 4 个 Server Action(v2-P2-3 删除 2 个死代码读 Action) | +| `actions.ts` | 165+ | 4 个 Server Action(v2-P2-3 删除 2 个死代码读 Action;v4-P1-4/v4-P1-5:publishReportAction 新增全班学生 + 家长通知) | | `schema.ts` | 23 | Zod 校验(4 个 schema,v2-P2-3 删除 2 个死代码 schema) | | `types.ts` | 87 | 类型定义 | -| `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图(v2-P1-6 热力图 a11y;v2-P1-4 i18n) | -| `components/student-diagnostic-view.tsx` | 225+ | 学生诊断视图(v2-P1-4 i18n;v3-P2 新增:practiceHrefBase prop,null 时隐藏练习按钮) | +| `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图(v2-P1-6 热力图 a11y;v2-P1-4 i18n;v4-P1-8 热力图图例;v4-P1-11 表格 overflow-x-auto) | +| `components/student-diagnostic-view.tsx` | 225+ | 学生诊断视图(v2-P1-4 i18n;v3-P2 新增:practiceHrefBase prop,null 时隐藏练习按钮;v4-P1-3 移除草稿回退逻辑) | | `components/mastery-radar-chart.tsx` | 72 | 雷达图(v2-P1-4 i18n) | | `components/report-list.tsx` | 265 | 报告列表(v2-P2-7 Label htmlFor;v2-P1-4 i18n) | @@ -1940,9 +1985,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" --- -## 2.29 ai(AI 模块)— ✅ 新增 / V2 增强 +## 2.29 ai(AI 模块)— ✅ 新增 / V2 增强 / V3 安全加固+竞品对标 -**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。V2 增加流式响应、Markdown 渲染、全局助手、内容安全过滤、家长学情摘要、管理员使用统计、学生学习路径推荐。 +**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。V2 增加流式响应、Markdown 渲染、全局助手、内容安全过滤、家长学情摘要、管理员使用统计、学生学习路径推荐。V3 安全加固(原子配额/苏格拉底校验/重试机制)+ 竞品对标(知识图谱集成/苏格拉底式辅导强化)。 **架构定位**: - 位于 `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** | `filterAiOutput` | `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 暴露查询接口: - `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`) - - `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`) - `textbooks/data-access`:~~`getKnowledgePointOptions`~~ ✅ 已实现 - `questions/data-access`:~~`insertQuestionWithRelations`~~ ✅ 已通过 `createQuestionWithRelations` 供 exams 调用 / `getKnowledgePointsForQuestions` diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 1d105fb..e42ef01 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -2431,6 +2431,8 @@ "content", "link", "isRead", + "priority", + "isArchived", "createdAt" ], "usedBy": [ @@ -2570,6 +2572,7 @@ "id", "studentId", "generatedBy", + "classId", "reportType", "period", "summary", @@ -2581,10 +2584,17 @@ "createdAt", "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": [ "diagnostic" ], - "description": "学情诊断报告(reportType: individual/class/grade;status: draft/published/archived)" + "description": "学情诊断报告(reportType: individual/class/grade;status: draft/published/archived;v4-P1-4 新增 classId 字段 varchar(128) references classes.id onDelete:set null,用于班级报告关联班级,发布时批量通知全班学生)" }, "electiveCourses": { "fields": [ @@ -4092,6 +4102,17 @@ "file": "homework-submission-result.tsx", "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": [ "classes/actions.createTeacherClassAction" ] + }, + { + "name": "getSubjectNameMapByIds", + "signature": "(subjectIds: string[]) => Promise>", + "purpose": "按 ID 批量获取科目名称映射(跨模块接口,P1-1 新增供 homework/data-access-classes 调用,替代直查 subjects 表)", + "usedBy": [ + "homework/data-access-classes.getHomeworkAssignmentsWithSubject", + "homework/data-access-classes.getPublishedHomeworkAssignmentsWithSubject" + ] } ], "schema": [ @@ -7595,6 +7625,74 @@ "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", + "purpose": "将 otpauth URL 转换为 QR 码 Data URL(base64 PNG)(v3 新增)" + }, + { + "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", + "purpose": "将备份码列表 bcrypt 哈希为 JSON 数组字符串(v3 新增)" + }, + { + "name": "verifyBackupCode", + "file": "lib/totp.ts", + "signature": "(input: string, storedHashedJson: string) => Promise", + "purpose": "校验备份码,返回匹配索引或 -1(v3 新增)" + }, + { + "name": "consumeBackupCode", + "file": "lib/totp.ts", + "signature": "(storedHashedJson: string, usedIndex: number) => Promise", + "purpose": "从哈希列表中移除已使用的备份码(v3 新增)" + }, + { + "name": "countRemainingBackupCodes", + "file": "lib/totp.ts", + "signature": "(storedHashedJson: string) => number", + "purpose": "统计剩余备份码数量(v3 新增)" + } ] } }, @@ -9038,6 +9136,14 @@ "signature": "(prevState, formData) => Promise>", "file": "actions.ts", "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": [ "grades/components/grade-record-form" ] @@ -9047,6 +9153,15 @@ "signature": "(prevState, formData) => Promise>", "file": "actions.ts", "permission": "GRADE_RECORD_MANAGE", + "purpose": "批量创建成绩记录(db.transaction 原子操作)。v4-P1-6 增强:批量录入后通知所有相关学生和家长(调用 notifyGradeEntered);v3-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": [ "grades/components/batch-grade-entry" ] @@ -9117,18 +9232,21 @@ }, { "name": "exportGradesAction", - "signature": "(params: { classId: string; subjectId?: string; examId?: string; reportType?: \"detail\" | \"class\" }) => Promise>", + "signature": "(params: { classId?: string; studentId?: string; subjectId?: string; examId?: string; reportType?: \"detail\" | \"class\" }) => Promise>", "file": "actions.ts", "permission": "GRADE_RECORD_READ", - "purpose": "导出成绩到 Excel(detail=成绩明细+统计汇总,class=班级多科目横向对比总表),返回 base64 buffer", + "purpose": "导出成绩到 Excel(detail=成绩明细+统计汇总,class=班级多科目横向对比总表),返回 base64 buffer。v4-P1-12 增强:新增可选 studentId 参数支持按学生导出(家长视角)——当提供 studentId 且 scope 为 children 时校验该学生属于家长子女,调用 exportStudentGradeRecordsToExcel 导出单学生成绩单", "deps": [ "requirePermission", "export.exportGradeRecordsToExcel", "export.exportClassGradeReportToExcel", - "export.formatDateForFile" + "export.exportStudentGradeRecordsToExcel", + "export.formatDateForFile", + "actions.assertClassInScope" ], "usedBy": [ - "grades/components/export-button.tsx" + "grades/components/export-button.tsx", + "parent/components/parent-export-button.tsx" ] }, { @@ -9874,6 +9992,18 @@ "usedBy": [ "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": [ "parent/children/[studentId]/page.tsx" ] + }, + { + "name": "getParentIdsByStudentIds", + "signature": "(studentIds: string[]) => Promise", + "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": [ @@ -10914,7 +11060,7 @@ "name": "Notification", "type": "type", "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": [ "notification-dropdown", "notification-list" @@ -10961,7 +11107,7 @@ "name": "CreateNotificationInput", "type": "type", "file": "types.ts", - "definition": "{ userId, type: NotificationType, title, content?, link? }", + "definition": "{ userId, type: NotificationType, title, content?, link?, priority? }", "usedBy": [ "createNotification" ] @@ -11134,6 +11280,22 @@ "notification-dropdown.tsx", "notification-list.tsx" ] + }, + { + "name": "archiveNotificationAction", + "permission": "MESSAGE_READ", + "signature": "(notificationId: string) => Promise>", + "purpose": "V2-P2-13b 新增:将单条通知归档(归档后不在默认列表显示);含 trackEvent 埋点 notification.archived", + "deps": [ + "requirePermission", + "schema.NotificationIdSchema", + "data-access.archiveNotification", + "trackEvent", + "revalidatePath" + ], + "usedBy": [ + "notification-list.tsx" + ] } ], "dispatcher": [ @@ -11173,9 +11335,9 @@ "dataAccess": [ { "name": "getNotifications", - "signature": "(userId: string, params?: { page?, pageSize?, unreadOnly? }) => Promise>", + "signature": "(userId: string, params?: { page?, pageSize?, unreadOnly?, unarchivedOnly?, priority? }) => Promise>", "file": "data-access.ts", - "purpose": "获取用户站内通知列表(分页,支持 unreadOnly 过滤;P0-4 / P1-5 修复后从 messaging 迁移)", + "purpose": "获取用户站内通知列表(分页,支持 unreadOnly/unarchivedOnly/priority 过滤;P0-4 / P1-5 修复后从 messaging 迁移;V2-P2-13b 新增归档和优先级筛选,默认仅返回未归档)", "deps": [ "shared.db", "shared.db.schema.messageNotifications", @@ -11190,7 +11352,7 @@ "name": "createNotification", "signature": "(input: CreateNotificationInput) => Promise", "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": [ "shared.db", "shared.db.schema.messageNotifications", @@ -11240,6 +11402,32 @@ "待扩展" ] }, + { + "name": "archiveNotification", + "signature": "(id: string, userId: string) => Promise", + "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", + "file": "data-access.ts", + "purpose": "V2-P2-13b 新增:取消归档通知(isArchived 置 false)", + "deps": [ + "shared.db", + "shared.db.schema.messageNotifications" + ], + "usedBy": [ + "待扩展" + ] + }, { "name": "getUserContactInfo", "signature": "(userId: string) => Promise", @@ -11422,11 +11610,22 @@ "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", "type": "interface", "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": [ "data-access.getNotifications", "messaging (via re-export)" @@ -11462,7 +11661,7 @@ "name": "GetNotificationsParams", "type": "interface", "file": "types.ts", - "definition": "{ page?, pageSize?, unreadOnly? }", + "definition": "{ page?, pageSize?, unreadOnly?, unarchivedOnly?, priority? }", "usedBy": [ "data-access.getNotifications", "messaging (via re-export)" @@ -11472,7 +11671,7 @@ "name": "CreateNotificationInput", "type": "interface", "file": "types.ts", - "definition": "{ userId, type: NotificationType, title, content?, link? }", + "definition": "{ userId, type: NotificationType, title, content?, link?, priority? }", "usedBy": [ "data-access.createNotification", "channels.in-app-channel", @@ -12028,6 +12227,35 @@ "usedBy": [ "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", + "file": "export.ts", + "purpose": "导出考勤记录到 Excel(P2-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", "file": "components/proctoring-dashboard.tsx", - "purpose": "教师监考面板(实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制)" + "purpose": "教师监考面板(实时学生状态、异常事件统计、异常学生高亮、10 秒轮询、usePermission 权限控制;✅ P1-3 已修复:用类型守卫函数 isProctoringEventType + toProctoringEventTypes 替代 Object.keys(...) as ProctoringEventType[] 断言)" }, { "name": "AntiCheatMonitor", @@ -12778,7 +13006,7 @@ { "name": "ExamModeConfig", "file": "components/exam-mode-config.tsx", - "purpose": "考试模式配置(react-hook-form Controller,作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项)" + "purpose": "考试模式配置(✅ P1-2 已修复:已集成到 exam-form.tsx,useFormContext 替代 Control prop 避免 Control 不变型问题,移除全部 10 处 as 类型断言;作业/限时/监考模式选择,限时设置时长,监考设置防作弊选项)" } ] } @@ -13162,7 +13390,7 @@ "name": "DiagnosticReport", "type": "interface", "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": [ "data-access-reports", "types.DiagnosticReportWithDetails" @@ -13616,6 +13844,35 @@ "usedBy": [ "actions.dropCourseAction" ] + }, + { + "name": "checkScheduleConflict", + "file": "data-access-operations.ts", + "signature": "(tx: Tx, studentId: string, courseId: string) => Promise", + "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": [ @@ -13781,6 +14038,129 @@ "actions.dropCourseAction", "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>; getSubjectOptions(): Promise>; getGradeOptions(): Promise> }", + "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 }", + "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 + 起止分钟数;无法解析返回 null(P2-9 新增)", + "usedBy": [ + "data-access-operations.isScheduleConflict", + "data-access-operations.checkScheduleConflict" + ] + }, + { + "name": "isScheduleConflict", + "signature": "(a: string, b: string) => boolean", + "file": "data-access-operations.ts", + "purpose": "纯函数:判断两个时间段是否冲突(同一天且时间区间重叠);任一无法解析返回 false(P2-9 新增,可独立单测)", + "usedBy": [ + "data-access-operations.checkScheduleConflict" + ] + } + ], + "importExport": [ + { + "name": "exportElectiveCoursesToExcel", + "signature": "(params: { status?: string; teacherId?: string }) => Promise", + "file": "export.ts", + "purpose": "导出选修课程列表到 Excel(P2-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", + "file": "export.ts", + "purpose": "导出课程选课名单到 Excel(P2-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": { "owner": "notifications", - "description": "消息通知" + "description": "消息通知(type/title/content/link/isRead/priority/isArchived/createdAt;V2-P2-13b 新增 priority 和 isArchived 字段)" }, "notificationLogs": { "owner": "notifications", @@ -14911,7 +15291,7 @@ }, "learningDiagnosticReports": { "owner": "diagnostic", - "description": "学情诊断报告(individual/class/grade)" + "description": "学情诊断报告(individual/class/grade;v4-P1-4 新增 classId 字段关联班级)" } } }, @@ -15194,7 +15574,8 @@ "data-access.getClassTeacherById" ], "school": [ - "data-access.getSubjectOptions" + "data-access.getSubjectOptions", + "data-access.getSubjectNameMapByIds" ], "users": [ "data-access.getUserWithRole", @@ -15400,7 +15781,7 @@ "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 或备份码确认)/重新生成备份码;登录流程接入 2FA(preflightTwoFactorAction 预检 + auth.ts authorize 校验);新增 29 个 TOTP 单元测试(总计 59 个)。依赖新增:otplib、qrcode。" }, "users": { "dependsOn": [ @@ -16314,7 +16695,19 @@ "from": "elective", "to": "school", "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", @@ -17908,11 +18301,11 @@ "methods": [ "POST" ], - "handler": "Excel 导出(grades/users/attendance)", + "handler": "Excel 导出(grades/users/attendance/electiveCourses/courseSelections/audit/login/dataChange)", "auth": "requireAuth", - "module": "shared.lib.excel + users/grades", + "module": "shared.lib.excel + users/grades/attendance/elective/audit", "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": { "methods": [ diff --git a/src/modules/exams/components/exam-form.tsx b/src/modules/exams/components/exam-form.tsx index 42e5f62..6008841 100644 --- a/src/modules/exams/components/exam-form.tsx +++ b/src/modules/exams/components/exam-form.tsx @@ -233,7 +233,7 @@ export function ExamForm() { queuedPreviewTaskCount={queuedPreviewTaskCount} /> )} - control={form.control} /> + diff --git a/src/modules/homework/components/homework-take-view.tsx b/src/modules/homework/components/homework-take-view.tsx index 3107207..229e074 100644 --- a/src/modules/homework/components/homework-take-view.tsx +++ b/src/modules/homework/components/homework-take-view.tsx @@ -21,7 +21,7 @@ import { AlertDialogHeader, AlertDialogTitle, } 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 type { StudentHomeworkTakeData } from "../types" @@ -29,6 +29,7 @@ import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomework import { QuestionRenderer } from "./question-renderer" import { parseSavedAnswer } from "../lib/question-content-utils" import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save" +import { useExamCountdown } from "../hooks/use-exam-countdown" type HomeworkTakeViewProps = { assignmentId: string @@ -195,6 +196,38 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView return false }).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 (
@@ -222,14 +255,44 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{!canEdit ? ( - +
+ {isTimedExam && examModeConfig && ( +
+ + + {t("homework.take.timedExam", { minutes: examModeConfig.durationMinutes ?? 0 })} + +
+ )} + +
) : ( - +
+ {countdown && ( +
+ + {formatCountdown(countdown)} +
+ )} + +
)}
diff --git a/src/modules/homework/data-access-classes.ts b/src/modules/homework/data-access-classes.ts index 3ca5e3a..d1dd131 100644 --- a/src/modules/homework/data-access-classes.ts +++ b/src/modules/homework/data-access-classes.ts @@ -5,12 +5,12 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm" import { db } from "@/shared/db" import { - exams, homeworkAssignmentTargets, homeworkAssignments, homeworkSubmissions, - subjects, } 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. @@ -19,6 +19,9 @@ import { * * All functions return plain data records; callers are responsible for * any further aggregation/statistics. + * + * P1-1 修复:不再直接 import exams/subjects 表,改通过 exams/school + * 模块的 data-access 跨模块函数获取科目信息。 */ export type HomeworkAssignmentWithSubject = { @@ -79,6 +82,12 @@ export const getAssignmentIdsForStudents = cache( /** * Returns homework assignments joined with subject info (via source exam), * 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( async (params: { @@ -87,11 +96,9 @@ export const getHomeworkAssignmentsWithSubject = cache( limit?: number }): Promise => { 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 + + // Step 1: 查 homeworkAssignments(含 sourceExamId) const rows = await db .select({ id: homeworkAssignments.id, @@ -99,16 +106,50 @@ export const getHomeworkAssignmentsWithSubject = cache( status: homeworkAssignments.status, createdAt: homeworkAssignments.createdAt, dueAt: homeworkAssignments.dueAt, - subjectId: exams.subjectId, - subjectName: subjects.name, + sourceExamId: homeworkAssignments.sourceExamId, }) .from(homeworkAssignments) - .innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id)) - .leftJoin(subjects, eq(exams.subjectId, subjects.id)) - .where(and(...conditions)) + .where(inArray(homeworkAssignments.id, params.assignmentIds)) .orderBy(desc(homeworkAssignments.createdAt)) .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 + .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). * Used by student subject score aggregation. + * + * P1-1 修复:不再 JOIN exams/subjects 表,改用跨模块 data-access。 */ export const getPublishedHomeworkAssignmentsWithSubject = cache( async (params: { assignmentIds: string[] }): Promise => { if (params.assignmentIds.length === 0) return [] + + // Step 1: 查 published homeworkAssignments(含 sourceExamId) const rows = await db .select({ id: homeworkAssignments.id, createdAt: homeworkAssignments.createdAt, - subjectId: exams.subjectId, - subjectName: subjects.name, + sourceExamId: homeworkAssignments.sourceExamId, }) .from(homeworkAssignments) - .innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id)) - .leftJoin(subjects, eq(exams.subjectId, subjects.id)) .where( and( inArray(homeworkAssignments.id, params.assignmentIds), @@ -218,7 +260,32 @@ export const getPublishedHomeworkAssignmentsWithSubject = cache( ) ) .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, + } + }) } ) diff --git a/src/modules/homework/data-access.ts b/src/modules/homework/data-access.ts index 90ae3b4..615178f 100644 --- a/src/modules/homework/data-access.ts +++ b/src/modules/homework/data-access.ts @@ -12,7 +12,7 @@ import { homeworkSubmissions, } from "@/shared/db/schema" 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 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 { assignment: { id: assignment.id, @@ -946,6 +964,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null, maxAttempts: assignment.maxAttempts, }, + examModeConfig, submission: latestSubmission ? { id: latestSubmission.id, @@ -953,6 +972,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu attemptNo: latestSubmission.attemptNo, submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null, score: latestSubmission.score ?? null, + startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null, } : null, questions: assignmentQuestions.map((aq) => { diff --git a/src/modules/homework/hooks/use-exam-countdown.ts b/src/modules/homework/hooks/use-exam-countdown.ts new file mode 100644 index 0000000..8f8e9fa --- /dev/null +++ b/src/modules/homework/hooks/use-exam-countdown.ts @@ -0,0 +1,122 @@ +"use client" + +import { useEffect, useRef, useState } from "react" + +/** + * P0-竞品修复:考试倒计时 hook。 + * + * 对标智学网/猿题库的限时考试功能: + * - 学生开始作答后,根据 durationMinutes 计算截止时间 + * - 每秒更新剩余时间 + * - 剩余时间 ≤ 0 时触发 onExpire 回调(自动提交) + * - 剩余时间 ≤ 5 分钟时标记为紧急状态(红色高亮) + * + * 设计要点: + * - 使用 ref 存储 onExpire 回调避免闭包陷阱 + * - 使用 setInterval 每秒更新 state(Date.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(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 +} diff --git a/src/modules/homework/types.ts b/src/modules/homework/types.ts index 4556f38..94f2792 100644 --- a/src/modules/homework/types.ts +++ b/src/modules/homework/types.ts @@ -161,12 +161,26 @@ export type StudentHomeworkTakeData = { lateDueAt: string | null maxAttempts: number } + /** + * 考试模式配置(仅当作业关联考试时存在)。 + * P0-竞品修复:限时/监考模式需在答题页展示倒计时并到时自动提交。 + */ + examModeConfig: { + examMode: "homework" | "timed" | "proctored" + durationMinutes: number | null + shuffleQuestions: boolean + allowLateStart: boolean + lateStartGraceMinutes: number + antiCheatEnabled: boolean + } | null submission: { id: string status: HomeworkSubmissionStatus attemptNo: number submittedAt: string | null score: number | null + /** 提交记录创建时间(用于计算限时考试的剩余时间) */ + startedAt: string | null } | null questions: StudentHomeworkTakeQuestion[] } diff --git a/src/modules/proctoring/components/exam-mode-config.tsx b/src/modules/proctoring/components/exam-mode-config.tsx index c7924fd..712ea42 100644 --- a/src/modules/proctoring/components/exam-mode-config.tsx +++ b/src/modules/proctoring/components/exam-mode-config.tsx @@ -1,6 +1,6 @@ "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 { FormField, @@ -37,18 +37,33 @@ export interface ExamModeConfigFieldValues { allowLateStart: boolean lateStartGraceMinutes: number antiCheatEnabled: boolean - [key: string]: unknown } -type ExamModeConfigProps = { - control: Control -} - -export function ExamModeConfig({ - control, -}: ExamModeConfigProps) { +/** + * 考试模式配置卡片(homework / timed / proctored)。 + * + * P1-2 修复:移除泛型与所有 as 断言。 + * 原方案通过 control: Control 接收控制对象, + * 但 Control 在 react-hook-form 中为不变型(invariant), + * Control 无法赋值给 Control。 + * 改为使用 useFormContext 从 FormProvider 读取表单上下文, + * 避免在调用方使用 as 断言适配 Control 类型。 + * + * 使用方需确保: + * 1. 外层已通过
/ 提供上下文 + * 2. 表单值类型结构包含 ExamModeConfigFieldValues 的全部字段 + */ +export function ExamModeConfig() { const t = useTranslations("examHomework") - const examMode = useWatch({ control, name: "examMode" as FieldPath }) as ExamMode + const form = useFormContext() + // useWatch 必须在条件返回之前无条件调用(Rules of Hooks) + // form?.control 为 undefined 时 useWatch 会回退到 useFormContext + const examMode = useWatch({ + control: form?.control, + name: "examMode", + }) + if (!form) return null + const { control } = form const showDuration = examMode === "timed" || examMode === "proctored" const showProctorOptions = examMode === "proctored" @@ -61,13 +76,13 @@ export function ExamModeConfig({ } + name="examMode" render={({ field }) => ( {t("proctoring.mode.title")} @@ -102,7 +117,7 @@ export function ExamModeConfig({ {showDuration && ( } + name="durationMinutes" render={({ field }) => (
@@ -117,12 +132,15 @@ export function ExamModeConfig({ - field.onChange( - e.target.value === "" ? null : Number(e.target.value), - ) - } + value={field.value ?? ""} + onChange={(e) => { + if (e.target.value === "") { + field.onChange(null) + return + } + const n = Number(e.target.value) + field.onChange(Number.isFinite(n) ? n : null) + }} onBlur={field.onBlur} name={field.name} ref={field.ref} @@ -139,7 +157,7 @@ export function ExamModeConfig({ <> } + name="shuffleQuestions" render={({ field }) => (
@@ -148,7 +166,7 @@ export function ExamModeConfig({
@@ -159,7 +177,7 @@ export function ExamModeConfig({ } + name="antiCheatEnabled" render={({ field }) => (
@@ -168,7 +186,7 @@ export function ExamModeConfig({
@@ -179,7 +197,7 @@ export function ExamModeConfig({ } + name="allowLateStart" render={({ field }) => (
@@ -188,7 +206,7 @@ export function ExamModeConfig({
@@ -199,7 +217,7 @@ export function ExamModeConfig({ } + name="lateStartGraceMinutes" render={({ field }) => (
@@ -210,8 +228,11 @@ export function ExamModeConfig({ field.onChange(Number(e.target.value))} + value={field.value ?? 0} + onChange={(e) => { + const n = Number(e.target.value) + field.onChange(Number.isFinite(n) ? n : 0) + }} onBlur={field.onBlur} name={field.name} ref={field.ref} diff --git a/src/modules/proctoring/components/proctoring-dashboard.tsx b/src/modules/proctoring/components/proctoring-dashboard.tsx index 179b0be..ae8ef4a 100644 --- a/src/modules/proctoring/components/proctoring-dashboard.tsx +++ b/src/modules/proctoring/components/proctoring-dashboard.tsx @@ -40,6 +40,27 @@ import { PROCTORING_EVENT_LABELS, EXAM_MODE_LABELS } from "../types" const REFRESH_INTERVAL_MS = 10_000 +/** + * P1-3 修复:类型守卫替代 `as ProctoringEventType[]` 断言。 + * `Object.keys()` 返回 `string[]`,需过滤为合法的 ProctoringEventType。 + */ +const PROCTORING_EVENT_TYPE_SET: ReadonlySet = 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 => { if (!iso) return "—" return formatDateTime(iso) @@ -176,7 +197,7 @@ export function ProctoringDashboard({
- {(Object.keys(summary.eventsByType) as ProctoringEventType[]).map((type) => { + {toProctoringEventTypes(Object.keys(summary.eventsByType)).map((type) => { const count = summary.eventsByType[type] if (count === 0) return null return ( @@ -229,7 +250,7 @@ export function ProctoringDashboard({ {formatTime(s.lastEventAt)}
- {(Object.keys(s.eventsByType) as ProctoringEventType[]) + {toProctoringEventTypes(Object.keys(s.eventsByType)) .filter((t) => s.eventsByType[t] > 0) .map((t) => ( diff --git a/src/shared/i18n/messages/en/exam-homework.json b/src/shared/i18n/messages/en/exam-homework.json index fe58899..e65234f 100644 --- a/src/shared/i18n/messages/en/exam-homework.json +++ b/src/shared/i18n/messages/en/exam-homework.json @@ -197,6 +197,9 @@ "readyDescription": "Click the \"Start Assignment\" button above to begin. Your answers will be saved when you click \"Save Answer\".", "startNow": "Start Now", "back": "Back", + "timedExam": "Timed exam: {{minutes}} minutes", + "timeRemaining": "Time remaining", + "timeUpAutoSubmit": "Time is up, auto-submitting...", "confirmSubmit": "Confirm Submission", "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?", diff --git a/src/shared/i18n/messages/zh-CN/exam-homework.json b/src/shared/i18n/messages/zh-CN/exam-homework.json index f8b3c54..66620d8 100644 --- a/src/shared/i18n/messages/zh-CN/exam-homework.json +++ b/src/shared/i18n/messages/zh-CN/exam-homework.json @@ -197,6 +197,9 @@ "readyDescription": "点击上方\"开始作答\"按钮。点击\"保存答案\"将保存您的答案。", "startNow": "立即开始", "back": "返回", + "timedExam": "限时考试:{{minutes}} 分钟", + "timeRemaining": "剩余时间", + "timeUpAutoSubmit": "时间到,正在自动提交...", "confirmSubmit": "确认提交", "confirmSubmitDescription": "所有题目已作答。提交后答案不可修改,确定要提交吗?", "unansweredWarning": "您有 {{count}} 道题未作答。提交后答案不可修改,确定要提交吗?",