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:
@@ -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<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 参数)
|
||||
- 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` 调用 `<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` 为唯一规范路径
|
||||
- ✅ 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`
|
||||
|
||||
@@ -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<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": [
|
||||
@@ -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<string>",
|
||||
"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<string>",
|
||||
"purpose": "将备份码列表 bcrypt 哈希为 JSON 数组字符串(v3 新增)"
|
||||
},
|
||||
{
|
||||
"name": "verifyBackupCode",
|
||||
"file": "lib/totp.ts",
|
||||
"signature": "(input: string, storedHashedJson: string) => Promise<number>",
|
||||
"purpose": "校验备份码,返回匹配索引或 -1(v3 新增)"
|
||||
},
|
||||
{
|
||||
"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>>",
|
||||
"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<ActionState<number>>",
|
||||
"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<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",
|
||||
"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<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": [
|
||||
@@ -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<ActionState<string>>",
|
||||
"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<PaginatedResult<Notification>>",
|
||||
"signature": "(userId: string, params?: { page?, pageSize?, unreadOnly?, unarchivedOnly?, priority? }) => Promise<PaginatedResult<Notification>>",
|
||||
"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<string>",
|
||||
"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<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",
|
||||
"signature": "(userId: string) => Promise<ChannelRecipient>",
|
||||
@@ -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<Buffer>",
|
||||
"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<T> 不变型问题,移除全部 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<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": [
|
||||
@@ -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<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 + 起止分钟数;无法解析返回 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<Buffer>",
|
||||
"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<Buffer>",
|
||||
"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": [
|
||||
|
||||
@@ -233,7 +233,7 @@ export function ExamForm() {
|
||||
queuedPreviewTaskCount={queuedPreviewTaskCount}
|
||||
/>
|
||||
)}
|
||||
<ExamModeConfig<ExamFormValues> control={form.control} />
|
||||
<ExamModeConfig />
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -222,14 +255,44 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
</div>
|
||||
|
||||
{!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">
|
||||
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
|
||||
</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">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<HomeworkAssignmentWithSubject[]> => {
|
||||
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<HomeworkAssignmentSubjectRow[]> => {
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
122
src/modules/homework/hooks/use-exam-countdown.ts
Normal file
122
src/modules/homework/hooks/use-exam-countdown.ts
Normal file
@@ -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<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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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<T extends ExamModeConfigFieldValues> = {
|
||||
control: Control<T>
|
||||
}
|
||||
|
||||
export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
control,
|
||||
}: ExamModeConfigProps<T>) {
|
||||
/**
|
||||
* 考试模式配置卡片(homework / timed / proctored)。
|
||||
*
|
||||
* P1-2 修复:移除泛型与所有 as 断言。
|
||||
* 原方案通过 control: Control<ExamModeConfigFieldValues> 接收控制对象,
|
||||
* 但 Control<T> 在 react-hook-form 中为不变型(invariant),
|
||||
* Control<ExamFormValues> 无法赋值给 Control<ExamModeConfigFieldValues>。
|
||||
* 改为使用 useFormContext 从 FormProvider 读取表单上下文,
|
||||
* 避免在调用方使用 as 断言适配 Control 类型。
|
||||
*
|
||||
* 使用方需确保:
|
||||
* 1. 外层已通过 <Form {...form}> / <FormProvider {...form}> 提供上下文
|
||||
* 2. 表单值类型结构包含 ExamModeConfigFieldValues 的全部字段
|
||||
*/
|
||||
export function ExamModeConfig() {
|
||||
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 showProctorOptions = examMode === "proctored"
|
||||
|
||||
@@ -61,13 +76,13 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
<CardContent className="space-y-6">
|
||||
<FormField
|
||||
control={control}
|
||||
name={"examMode" as FieldPath<T>}
|
||||
name="examMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("proctoring.mode.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
value={field.value as ExamMode}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="grid gap-3 md:grid-cols-3"
|
||||
>
|
||||
@@ -102,7 +117,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
{showDuration && (
|
||||
<FormField
|
||||
control={control}
|
||||
name={"durationMinutes" as FieldPath<T>}
|
||||
name="durationMinutes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
@@ -117,12 +132,15 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={(field.value as number | null) ?? ""}
|
||||
onChange={(e) =>
|
||||
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<T extends ExamModeConfigFieldValues>({
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name={"shuffleQuestions" as FieldPath<T>}
|
||||
name="shuffleQuestions"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-0.5">
|
||||
@@ -148,7 +166,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value as boolean}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-label={t("proctoring.config.shuffleQuestions")}
|
||||
/>
|
||||
@@ -159,7 +177,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={"antiCheatEnabled" as FieldPath<T>}
|
||||
name="antiCheatEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-0.5">
|
||||
@@ -168,7 +186,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value as boolean}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-label={t("proctoring.config.antiCheat")}
|
||||
/>
|
||||
@@ -179,7 +197,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={"allowLateStart" as FieldPath<T>}
|
||||
name="allowLateStart"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-0.5">
|
||||
@@ -188,7 +206,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value as boolean}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-label={t("proctoring.config.allowLateStart")}
|
||||
/>
|
||||
@@ -199,7 +217,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={"lateStartGraceMinutes" as FieldPath<T>}
|
||||
name="lateStartGraceMinutes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
@@ -210,8 +228,11 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={(field.value as number) ?? 0}
|
||||
onChange={(e) => 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}
|
||||
|
||||
@@ -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<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 => {
|
||||
if (!iso) return "—"
|
||||
return formatDateTime(iso)
|
||||
@@ -176,7 +197,7 @@ export function ProctoringDashboard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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]
|
||||
if (count === 0) return null
|
||||
return (
|
||||
@@ -229,7 +250,7 @@ export function ProctoringDashboard({
|
||||
<TableCell className="text-muted-foreground">{formatTime(s.lastEventAt)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(Object.keys(s.eventsByType) as ProctoringEventType[])
|
||||
{toProctoringEventTypes(Object.keys(s.eventsByType))
|
||||
.filter((t) => s.eventsByType[t] > 0)
|
||||
.map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -197,6 +197,9 @@
|
||||
"readyDescription": "点击上方\"开始作答\"按钮。点击\"保存答案\"将保存您的答案。",
|
||||
"startNow": "立即开始",
|
||||
"back": "返回",
|
||||
"timedExam": "限时考试:{{minutes}} 分钟",
|
||||
"timeRemaining": "剩余时间",
|
||||
"timeUpAutoSubmit": "时间到,正在自动提交...",
|
||||
"confirmSubmit": "确认提交",
|
||||
"confirmSubmitDescription": "所有题目已作答。提交后答案不可修改,确定要提交吗?",
|
||||
"unansweredWarning": "您有 {{count}} 道题未作答。提交后答案不可修改,确定要提交吗?",
|
||||
|
||||
Reference in New Issue
Block a user