diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 30735a0..ab2215c 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -542,9 +542,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - Data-access:`getExams` / `getExamById` / `persistExamDraft` / `persistAiGeneratedExamDraft` / `buildExamDescription` / `resolveSubjectGradeNames` / `getExamCreatorId` / `updateExamWithQuestions` / `deleteExamById` / `duplicateExam` / `getExamPreview` / `getExamSubjects` / `getExamGrades`(后 7 个为 P1-2 新增) - AI Pipeline:`generateAiCreateDraftFromSource` / `generateAiPreviewData` / `regenerateAiQuestionByInstruction` - Utils:`normalizeStructure`(v3 新增:将持久化的 `exam.structure` unknown JSON 运行时校验并归一化为类型安全的 `ExamNode[]`,类型守卫模式无 `as` 断言,从 `teacher/exams/[id]/build/page.tsx` 提取) +- Stats-service(V3-8 新增):`getExamAnalytics`(cache 包装,聚合考试所有作业的已批改提交,计算平均分/及格率/分数段分布/逐题错误率与难度等级,对标智学网考试分析)+ `ExamAnalyticsSummary` 类型 +- Components(V3-8 新增):`ExamAnalyticsDashboard`(考试分析仪表盘:汇总卡片+分数段分布+逐题分析表) **依赖关系**: -- 依赖:`shared/*`、`@/auth`、`questions`(✅ P0-1 已修复:通过 data-access.createQuestionWithRelations)、`classes`(✅ P0-2 已修复:通过 data-access.getClassGradeIdsByClassIds)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions) +- 依赖:`shared/*`、`@/auth`、`questions`(✅ P0-1 已修复:通过 data-access.createQuestionWithRelations)、`classes`(✅ P0-2 已修复:通过 data-access.getClassGradeIdsByClassIds)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`homework`(V3-8 新增:stats-service 通过 `homework/data-access.getHomeworkAssignmentsByExamId` / `getGradedSubmissionsByExamId` 获取作业与提交数据,合理跨模块调用) - 被依赖:`homework`(通过 sourceExamId 外键,合理)、`dashboard`(通过 data-access,P0-4 已修复)、`proctoring`(✅ P1-1 已修复:通过 exams data-access)、`diagnostic`(✅ P1-1 已修复:通过 exams data-access) **已知问题**: @@ -554,6 +556,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P1-2 已修复:~~`actions.ts` 832 行(超 800 建议),多处直接 DB 操作~~ DB 操作已下沉到 data-access,actions.ts 现 691 行 - ⚠️ P1:`ai-pipeline.ts` 857 行(超 800 建议),混合 4 类职责 - ✅ P2 已修复:`ai-pipeline.ts` 中 3 处非空断言清理(`draft.sections!.forEach` → 安全守卫、`aiParsed.sections!.flatMap` → `?? []`、`aiParsed.sections!.map` → `?? []`) +- ✅ V3-5/V3-8/V3-12:`exam-actions.tsx` 增强:V3-5 角色化菜单(按角色显示不同操作项)、V3-8 新增 analytics 菜单项(BarChart3 图标,跳转 `/teacher/exams/[id]/analytics`)、V3-12 移动端触摸目标尺寸优化 **文件清单**: | 文件 | 行数 | 职责 | @@ -561,10 +564,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `actions.ts` | 691 | 10 个 Server Action(P1-2 已修复,无直接 DB 操作) | | `ai-pipeline.ts` | 857 | AI 出题管线(超限) | | `data-access.ts` | 473 | 考试 CRUD(含 P1-2 新增 7 个写/查询函数,P0-1/P0-2 已修复:通过 questions/classes data-access 跨模块通信) | +| `stats-service.ts` | - | V3-8 新增:考试分析数据聚合(`getExamAnalytics` + `ExamAnalyticsSummary` 类型) | | `types.ts` | 31 | 类型定义 | | `hooks/use-exam-preview.ts` | 295 | 预览 Hook | | `utils/normalize-structure.ts` | 57 | v3 新增:exam.structure 运行时校验与归一化(从 build/page.tsx 提取) | -| `components/*` | 18 文件 | 考试表单/组卷/预览组件 | +| `components/exam-analytics-dashboard.tsx` | - | V3-8 新增:考试分析仪表盘组件 | +| `components/exam-actions.tsx` | - | V3-5/V3-8/V3-12 增强:角色化菜单+analytics 链接+移动端触摸优化 | +| `components/*` | 19 文件 | 考试表单/组卷/预览/分析组件 | --- @@ -573,15 +579,16 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:作业全生命周期(创建/发布/作答/批改/分析)。 **导出函数**: -- Actions:`createHomeworkAssignmentAction` / `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction` / `gradeHomeworkSubmissionAction`(✅ 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 使用) +- 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-write:10 个写操作函数(P1-2 新增,从 actions 下沉) +- 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) -- 被依赖:`dashboard`(通过 data-access,合理)、`parent`(通过 data-access,合理)、`classes`(✅ P0-7 已修复:classes 通过 `homework/data-access-classes` 获取作业数据,不再反向直查 homework/exams 表) +- 被依赖:`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`,合理跨模块调用) **已知问题**: - ✅ P0 已解决:`data-access.ts` 已拆分至 598 行(原 1038 行超 1000 硬上限),统计函数迁移至 `stats-service.ts` @@ -590,17 +597,24 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P0-7 已修复:新增 `data-access-classes.ts`,将 classes 模块对 homework/exams 表的直查封装为 homework 模块的导出函数,恢复三层架构 - ✅ P1-1 已修复:~~5 处直查 `exams` 表~~ 改为调用 `exams/data-access.getExamIdsByGradeIds` / `getExamSubjectIdMap` / `getExamWithQuestionsForHomework` - ✅ 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 模块跨模块调用 +- ✅ V3-9:新增 `getStudentSubmissionResult` + `HomeworkSubmissionResult` + 路由 `/student/learning/assignments/[assignmentId]/result`,`homework-take-view.tsx` 提交后跳转结果页 +- ✅ V3-12:`homework-take-view.tsx` 移动端触摸目标尺寸优化 **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `data-access.ts` | 598 | 作业 CRUD + 学生视角 + 批改(含 re-export stats 函数) | -| `data-access-write.ts` | 285 | 作业写操作(P1-2 新增,10 个写函数从 actions 下沉) | +| `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 表查询) | | `stats-service.ts` | 425 | 统计分析(教师趋势/作业分析/学生仪表盘成绩) | -| `actions.ts` | 239 | 5 个 Server Action(P1-2 已修复,无直接 DB 操作) | +| `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 增强:提交后跳转结果页+移动端触摸优化 | --- @@ -1284,8 +1298,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:家长视角的子女数据聚合与展示。 **导出函数**: -- Data-access:`getChildren` / `getChildBasicInfo` / `getChildDashboardData` / `getParentDashboardData` / `verifyParentChildRelation` / `getChildNameList`(✅ v4 新增:用于详情页头部多子女切换器,一次批量查询避免 N+1) -- Components:`ParentDashboard` / `ChildCard` / `ChildDetailHeader` / `ChildDetailPanel` / `SiblingSwitcher` / `ChildHomeworkSummary` / `ChildHomeworkDetail`(v4 新增)/ `ChildGradeSummary` / `ChildGradeDetail`(v4 新增)/ `ChildScheduleCard` / `ParentChildrenDataPage` / `ParentNoChildrenPage` / `ParentAttentionBanner`(v4 新增)/ `ParentAttendanceWarning`(v4 新增)/ `ParentAttendanceRateCard`(v4 新增)/ `ParentAttendanceCalendar`(v4 新增)/ `ParentExportButton`(v4 新增) +- Data-access:`getChildren` / `getChildBasicInfo` / `getChildDashboardData`(V3-11 增强:并行调用 `homework/data-access.getStudentExamResults` 获取考试结果)/ `getParentDashboardData` / `verifyParentChildRelation` / `getChildNameList`(✅ v4 新增:用于详情页头部多子女切换器,一次批量查询避免 N+1) +- Types(V3-11 新增):`ChildExamResultItem`(单条考试结果:submissionId/examId/examTitle/assignmentId/assignmentTitle/score/maxScore/submittedAt/status);`ChildDashboardData` 扩展 `examResults: ChildExamResultItem[]` 字段 +- Components:`ParentDashboard` / `ChildCard` / `ChildDetailHeader` / `ChildDetailPanel`(V3-11 增强:新增 exams Tab)/ `SiblingSwitcher` / `ChildHomeworkSummary` / `ChildHomeworkDetail`(v4 新增)/ `ChildGradeSummary` / `ChildGradeDetail`(v4 新增)/ `ChildScheduleCard` / `ChildExamDetail`(V3-11 新增:子女考试详情视图,汇总卡片+考试列表)/ `ParentChildrenDataPage` / `ParentNoChildrenPage` / `ParentAttentionBanner`(v4 新增)/ `ParentAttendanceWarning`(v4 新增)/ `ParentAttendanceRateCard`(v4 新增)/ `ParentAttendanceCalendar`(v4 新增)/ `ParentExportButton`(v4 新增) **v4 修复(产品/UX 维度)**: - ✅ FEAT-G01:新增 `/parent/leave` 请假申请占位页(含 loading.tsx) @@ -1324,8 +1339,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ MOBILE-P03:移动端子女卡片改为水平滑动 Carousel(snap-x) - ✅ MOBILE-P04:作业/成绩列表项 `min-h-[44px]` 触摸区域 +**V3-11 修复(家长端考试详情)**: +- ✅ V3-11:`parent/types.ts` 新增 `ChildExamResultItem` 类型;`ChildDashboardData` 扩展 `examResults` 字段 +- ✅ V3-11:`parent/data-access.ts` `getChildDashboardData` 并行调用 `homework/data-access.getStudentExamResults` 获取子女考试结果(加入现有 `Promise.all`) +- ✅ V3-11:新增组件 `ChildExamDetail`(考试成绩汇总卡片:已参加考试数/平均分/最高分 + 考试成绩列表:考试标题/分数/得分率 Progress/提交时间) +- ✅ V3-11:`child-detail-panel.tsx` 新增 exams Tab(对标智学网家长端考试详情) + **依赖关系**: -- 依赖:`shared/*`、`@/auth`、`classes`(合理)、`homework`(合理)、`grades`(合理)、`users`(合理)、`school`(合理)、`attendance`(v4 新增:考勤页复用 `StudentAttendanceView`;⚠️ 跨模块 UI 类型依赖:3 个组件直接 import `@/modules/attendance/types`) +- 依赖:`shared/*`、`@/auth`、`classes`(合理)、`homework`(合理;V3-11 新增 `getStudentExamResults` 调用)、`grades`(合理)、`users`(合理)、`school`(合理)、`attendance`(v4 新增:考勤页复用 `StudentAttendanceView`;⚠️ 跨模块 UI 类型依赖:3 个组件直接 import `@/modules/attendance/types`) - 被依赖:无 **已知问题**: @@ -1346,8 +1367,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `data-access.ts` | 243 | 子女关系 + 仪表盘数据聚合 + 关系校验 + 子女姓名列表(v4 新增 `getChildNameList` + `buildWeeklySchedule`) | -| `types.ts` | 79 | 类型定义(含 JSDoc,v4 新增 `ChildWeeklyScheduleItem`) | +| `data-access.ts` | 243+ | 子女关系 + 仪表盘数据聚合 + 关系校验 + 子女姓名列表(v4 新增 `getChildNameList` + `buildWeeklySchedule`;V3-11 `getChildDashboardData` 并行调用 `getStudentExamResults`) | +| `types.ts` | 79+ | 类型定义(含 JSDoc,v4 新增 `ChildWeeklyScheduleItem`;V3-11 新增 `ChildExamResultItem` + `ChildDashboardData.examResults`) | | `components/parent-dashboard.tsx` | 110 | 仪表盘(v4 重构:待办横幅 + 宫格快捷入口 + 移动端水平滑动) | | `components/parent-attention-banner.tsx` | 128 | v4 新增:待办事项/异常聚合横幅(作业项直接跳转详情页 homework tab) | | `components/parent-attendance-warning.tsx` | 89 | v4 新增:考勤异常预警 | @@ -1356,11 +1377,12 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `components/parent-export-button.tsx` | 50 | v4 新增:成绩导出按钮(占位) | | `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) | +| `components/child-detail-panel.tsx` | 200+ | 详情页 Tab 面板 + SiblingSwitcher(v4 重写,集成 Homework/Grade Detail;V3-11 新增 exams Tab) | | `components/child-homework-summary.tsx` | 147 | 作业摘要(v4 增强:科目标识 + 触摸区域 + pts 单位) | | `components/child-homework-detail.tsx` | 145 | v4 新增:作业详情视图(完整作业信息) | | `components/child-grade-summary.tsx` | 159 | 成绩趋势(v4 增强:趋势图标 + aria-label) | | `components/child-grade-detail.tsx` | 165 | v4 新增:成绩详情视图(按科目分组分析) | +| `components/child-exam-detail.tsx` | - | V3-11 新增:子女考试详情视图(汇总卡片+考试列表,对标智学网家长端) | | `components/child-schedule-card.tsx` | 119 | 课表卡片(v4 增强:周课表视图) | | `components/parent-children-data-page.tsx` | 92 | 共享数据页(v4 增强:headerExtra) | diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index fc85dda..ace18eb 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5,7 +5,7 @@ "generatedAt": "2026-06-17", "formatVersion": "1.1", "rule": "每次文件修改后须同步更新本文件", - "lastUpdate": "新增 error-book(错题本)模块:(1) DB schema 新增 2 表 errorBookItems(18列/4索引/2外键)+ errorBookReviews(8列/2索引/2外键),含 SM-2 间隔重复字段(nextReviewAt/reviewInterval/reviewCount/correctStreak/masteryLevel)。(2) 新增 3 权限点 ERROR_BOOK_READ/ERROR_BOOK_MANAGE/ERROR_BOOK_ANALYTICS_READ,分配给 6 角色(student: READ+MANAGE, parent: READ, teacher/admin/grade_head/teaching_head: ANALYTICS_READ)。(3) 模块结构:actions.ts(9 个 Server Actions)+ data-access.ts(16 个函数,含 SM-2 算法实现 calculateNewInterval/calculateNewMastery/deriveStatus/calculateNextReviewAt)+ schema.ts(4 个 Zod schema)+ types.ts(6 个类型)+ 9 个组件。(4) 自动采集:collectFromExamSubmission + collectFromHomeworkSubmission 从考试/作业提交自动收录错题(去重)。(5) 跨模块查询:getStudentErrorBookSummaries/getTopWrongQuestionsByStudentIds/getKnowledgePointWeakness/getSubjectErrorDistribution 供教师/家长/管理员视图使用。(6) 4 个角色页面:student(统计/筛选/列表/手动添加/详情复习)、teacher(班级概览/薄弱知识点/学科分布/高频错题)、parent(子女错题统计/薄弱知识点/高频错题)、admin(全校错题分析)。(7) DataScope 行级权限:student=owned, parent=children, teacher=class_taught, admin=all, grade_head/teaching_head=grade_managed。(8) 导航:6 角色均添加错题分析导航项(BookX 图标)。(9) i18n:zh-CN/en 双语翻译文件。前序:exam-homework-audit-v2 全量修复已同步:(1) P0-3 ExamModeConfig 全链路集成:formSchema 扩展 6 字段(examMode/durationMinutes/shuffleQuestions/allowLateStart/lateStartGraceMinutes/antiCheatEnabled)+superRefine 校验,exam-form onSubmit 追加 FormData,actions.ts 新增 parseExamModeConfig 解析,data-access persistExamDraft/persistAiGeneratedExamDraft 写入 DB。(2) P1-6 类型断言清理:exam-form.tsx 用 Resolver 替代 as any;exam-actions.tsx 用 RawStructureNode 类型守卫替代 as unknown as Question;homework-take-view.tsx 用类型收窄替代 as unknown[];homework-grading-view.tsx 用 getOptions+类型守卫替代 as ChoiceOption[];homework/data-access.ts 移除 as unknown。(3) P1-7 ai-pipeline.ts(857行) 拆分为 ai-pipeline/ 目录(parse.ts/request.ts/structure.ts/index.ts)。(4) P1-8 getHomeworkSubmissionDetails 相邻记录查询优化为 LIMIT 1 双查询。(5) P2-9 useDebouncedAutoSave hook 集成到 homework-take-view:3秒debounce+localStorage离线缓存+网络恢复重试+UI状态指示器(idle/saving/saved/error)+提交前flush。(6) P2-12 a11y:exam-columns 难度色条加 role=img+aria-label(i18n);homework-take 题目导航按钮加 aria-pressed+title。(7) P2-13 ExamHomeworkRoleConfig:shared/config/exam-homework-role-config.ts(6角色×11功能特性+并集合并函数)+shared/hooks/use-exam-homework-features.ts。(8) 6.1 ExamHomeworkServicePort:shared/services/exam-homework-port.ts(接口定义+ServiceProvider单例注册器)。(9) 6.5 单测:question-content-utils.test.ts(52测试覆盖14纯函数+applyAutoGrades)+exam-homework-role-config.test.ts(11测试覆盖角色合并)。(10) 6.7 trackExamEvent:track-event.ts 扩展17个exam/homework事件+trackExamEvent便捷函数。前序:teacher_bug_v4 P1-3+P2-1 修复已同步:(1) P1-3 空状态 CTA 优化:empty-state.tsx 默认按钮 variant 从 default 改为 outline,新增 variant prop;button.tsx 导出 ButtonProps 类型;返回路径统一为 ghost+ArrowLeft+文字标签模式(textbooks/[id]、grades/analytics、homework/assignments/[id]、course-plans/[id]、lesson-plans/new);course-plan-detail 中 raw 改为 。(2) P2-1 日期格式本地化+中英文统一:formatLongDate 默认 locale 从 en-US 改为 zh-CN,weekday 从 long 改为 short;teacher 导航项全部中文化(仪表盘/教材/考试/作业/成绩/题库/班级管理/课程计划/我的备课/考勤/调课申请/学情诊断/选修课/年级管理/公告/消息);app-sidebar Collapse 按钮改为「收起」;5 个详情页返回按钮文案中文化;course-plan-detail 组件全量中文化(状态标签/表头/按钮/空状态/删除对话框/toast);grades/analytics 页面标题与描述中文化。前序:Announcements 公告模块修复已同步:(1) getAnnouncements 新增 audience 受众过滤参数(school 全可见 / grade 按年级 / class 按班级),使用 or+and 组合条件;(2) 用户端列表页 /announcements 传入 audience(根据 ctx.dataScope 解析 gradeId/classId,admin 不过滤);(3) 新增用户端公告详情页 /announcements/[id](只读模式 canManage=false,requirePermission ANNOUNCEMENT_READ);(4) 用户端列表页传递 detailHrefBuilder;(5) 管理端列表页 /admin/announcements 增加 getAdminClasses 调用,传递 classes 给 AdminAnnouncementsView;(6) 发布公告触发通知:publishAnnouncementAction/createAnnouncementAction(直接发布)/updateAnnouncementAction(状态变 published) 调用 sendBatchNotifications,根据公告类型查询目标用户(school=全部/grade=按年级/class=学生+教师),新增 users/data-access.getAllUserIds 函数;(7) 新增 loading.tsx 骨架屏(用户端 + 管理端)。前序:Profile/Settings 模块修复已同步:(1) 新增 profile/settings/settings/security 的 loading.tsx + error.tsx(参考 admin 模式);(2) settings/page.tsx 增加 parent 角色分支,新增 ParentSettingsView 组件(backHref 指向 /parent/dashboard);(3) SettingsView 集成 AiProviderSettingsCard(新增 AI 标签页,条件渲染需 AI_CONFIGURE 权限);(4) profile/page.tsx 添加 Avatar 头像展示(从 userProfile.image 获取,无头像显示首字母 fallback);(5) SettingsView Tab URL 持久化(useSearchParams 读取 tab 参数,router.push 更新 URL,Suspense 包装);(6) SettingsView 登出按钮 AlertDialog 二次确认;(7) password-change-form 修复任意值 Tailwind 类([&>div]:bg-red-500 改为 Progress 组件新增 indicatorClassName prop + 标准颜色类);(8) profile/page.tsx 保持 requireAuth(页面仅查看,编辑在 settings 页面有权限校验)。前序:第二轮共享组件抽取重构已同步:P0-1 ConfirmDeleteDialog(5 处 AlertDialog 删除确认块抽取);P0-2 Pagination(3 处审计表格分页块抽取);P0-3 EmptyTableRow(3 处审计表格空行抽取);P1-1 StatusBadge + typeColors 共享(9+ 处状态徽章抽取,修复 StudentHomeworkProgressStatus 在 3 个文件中颜色不一致 bug,统一 audit/grades/homework/questions 状态映射到模块 types.ts);P1-2 TextField/SelectField/TextareaField 表单字段抽取(profile-settings-form 6+1、exam-basic-info-form 4+3、ai-provider-settings-card 4+1、create-question-dialog 2+1 共 26 处 FormField 重复抽取);P1-3 统一 formatDate/formatDateTime/formatLongDate(8 处 toLocaleDateString/toLocaleString 抽取);P1-4 useActionQuery + useActionMutation Hook 抽取(schools-view 3 处 mutation 示范重构,create-question-dialog 1 处 query 重构,潜在影响 50+ 文件)。新增 shared 层 7 个 UI 组件 + 2 个 Hooks + 2 个工具函数。前序:P0-b/P1-a/P1-b/P1-c/P2-a/P2-b/P3-a/P3-b/P3-c/P3-d 共享组件抽取重构已同步:新增 shared 层 UI 组件(StatCard/StatItem/ChipNav/PageHeader/FilterBar+FilterSearchInput+FilterResetButton)、图表组件(ChartCardShell/TrendLineChart/SimpleBarChart/ComparisonRadarChart)、课表组件(ScheduleList+ScheduleListItem)、题库组件(QuestionBankFilters)、工具函数(downloadBase64File/downloadBlob/getInitials/formatDateForFile);新增 settings 模块 SettingsView 组件;删除 messaging/notification-preferences.ts(P0-b 通知模块去重,re-export shim 已移除,消费方改为直接从 notifications/preferences 导入);P2-6/P2-7/P2-8/P2-11/P2-17/P2-18 已修复:proxy.ts 改用 Permissions 常量替代硬编码字符串;useA11yId 文件已不存在(use-aria-live.ts 已在 hooks/ 目录);schema.ts 分节编号重新编号为连续 1-24,消除 8b/14b/乱序问题;announcements 死代码 void wasPublished 已不存在;layout/app-sidebar.tsx 改用 hasRole() 判断角色,不再用权限反推角色;scheduling/actions.ts 移除末尾 re-export data-access,4 个页面改为从 data-access 直接导入;P1-1 已修复:所有跨模块直查已改为通过对方 data-access 接口(homework/grades/parent/diagnostic/elective/proctoring/notifications/scheduling/classes 模块);P0-2 已修复:shared/lib ↔ auth 循环依赖已解决,新增 shared/lib/session.ts 单一入口;P1-6 已修复:http-utils.ts 统一 IP/UA 提取;P0-1/P0-2/P0-3/P0-4/P0-5/P0-7/P0-8 已修复;P1-2 已修复:actions 层 DB 操作下沉到 data-access;P2-2/P2-3/P2-12/P2-20 已修复" + "lastUpdate": "V3 增量更新(exam-homework-port 智学网对标功能):(V3-7) 批量批改:homework/data-access-write.ts 新增 batchAutoGradeSubmissions(对多份提交一键自动批改客观题,复用 autoGradeSubmission 逻辑,主观题保持原分数),homework/actions.ts 新增 batchAutoGradeSubmissionsAction(HOMEWORK_GRADE 权限+非管理员仅可批改自己创建的作业+revalidatePath 刷新),新增组件 HomeworkBatchGradingView(提交列表页勾选+一键批改+toast 反馈),/teacher/homework/assignments/[id]/submissions 页面接入。(V3-8) 考试分析:exams/stats-service.ts 新增 getExamAnalytics(cache 包装,聚合考试所有作业的已批改提交,计算平均分/及格率/分数段分布/逐题错误率与难度等级)+ ExamAnalyticsSummary 类型,新增组件 ExamAnalyticsDashboard(汇总卡片+分数段分布+逐题分析表),新增路由 /teacher/exams/[id]/analytics,exam-actions.tsx 新增 analytics 菜单项(BarChart3 图标);homework/data-access.ts 新增 getHomeworkAssignmentsByExamId(按考试 ID 查作业+目标/提交/批改计数)+ getGradedSubmissionsByExamId(按考试 ID 查已批改提交,按学生去重保留最新)。(V3-9) 提交后反馈:homework/data-access.ts 新增 getStudentSubmissionResult(查学生指定作业最新已提交/已批改 submission),新增组件 HomeworkSubmissionResult(分数汇总+对错分布+错题预览),新增路由 /student/learning/assignments/[assignmentId]/result,homework-take-view.tsx 提交后 router.push 跳转结果页。(V3-11) 家长端考试详情:homework/data-access.ts 新增 getStudentExamResults(查学生已批改的考试关联作业提交,按 examId 去重,limit 50);parent/types.ts 新增 ChildExamResultItem 类型 + ChildDashboardData 扩展 examResults 字段;parent/data-access.ts getChildDashboardData 并行调用 getStudentExamResults;新增组件 ChildExamDetail(汇总卡片+考试列表),child-detail-panel.tsx 新增 exams Tab。(V3-12) 移动端触摸优化:exam-actions.tsx 与 homework-take-view.tsx 调整触摸目标尺寸。i18n:zh-CN/en exam-homework.json 新增 V3-7/V3-8/V3-9 翻译键。修复:exam-homework-port.ts 类型导入;instrumentation.ts adapter 函数;trend-line-chart.tsx 数据类型允许 undefined。前序:新增 error-book(错题本)模块:(1) DB schema 新增 2 表 errorBookItems(18列/4索引/2外键)+ errorBookReviews(8列/2索引/2外键),含 SM-2 间隔重复字段(nextReviewAt/reviewInterval/reviewCount/correctStreak/masteryLevel)。(2) 新增 3 权限点 ERROR_BOOK_READ/ERROR_BOOK_MANAGE/ERROR_BOOK_ANALYTICS_READ,分配给 6 角色(student: READ+MANAGE, parent: READ, teacher/admin/grade_head/teaching_head: ANALYTICS_READ)。(3) 模块结构:actions.ts(9 个 Server Actions)+ data-access.ts(16 个函数,含 SM-2 算法实现 calculateNewInterval/calculateNewMastery/deriveStatus/calculateNextReviewAt)+ schema.ts(4 个 Zod schema)+ types.ts(6 个类型)+ 9 个组件。(4) 自动采集:collectFromExamSubmission + collectFromHomeworkSubmission 从考试/作业提交自动收录错题(去重)。(5) 跨模块查询:getStudentErrorBookSummaries/getTopWrongQuestionsByStudentIds/getKnowledgePointWeakness/getSubjectErrorDistribution 供教师/家长/管理员视图使用。(6) 4 个角色页面:student(统计/筛选/列表/手动添加/详情复习)、teacher(班级概览/薄弱知识点/学科分布/高频错题)、parent(子女错题统计/薄弱知识点/高频错题)、admin(全校错题分析)。(7) DataScope 行级权限:student=owned, parent=children, teacher=class_taught, admin=all, grade_head/teaching_head=grade_managed。(8) 导航:6 角色均添加错题分析导航项(BookX 图标)。(9) i18n:zh-CN/en 双语翻译文件。前序:exam-homework-audit-v2 全量修复已同步:(1) P0-3 ExamModeConfig 全链路集成:formSchema 扩展 6 字段(examMode/durationMinutes/shuffleQuestions/allowLateStart/lateStartGraceMinutes/antiCheatEnabled)+superRefine 校验,exam-form onSubmit 追加 FormData,actions.ts 新增 parseExamModeConfig 解析,data-access persistExamDraft/persistAiGeneratedExamDraft 写入 DB。(2) P1-6 类型断言清理:exam-form.tsx 用 Resolver 替代 as any;exam-actions.tsx 用 RawStructureNode 类型守卫替代 as unknown as Question;homework-take-view.tsx 用类型收窄替代 as unknown[];homework-grading-view.tsx 用 getOptions+类型守卫替代 as ChoiceOption[];homework/data-access.ts 移除 as unknown。(3) P1-7 ai-pipeline.ts(857行) 拆分为 ai-pipeline/ 目录(parse.ts/request.ts/structure.ts/index.ts)。(4) P1-8 getHomeworkSubmissionDetails 相邻记录查询优化为 LIMIT 1 双查询。(5) P2-9 useDebouncedAutoSave hook 集成到 homework-take-view:3秒debounce+localStorage离线缓存+网络恢复重试+UI状态指示器(idle/saving/saved/error)+提交前flush。(6) P2-12 a11y:exam-columns 难度色条加 role=img+aria-label(i18n);homework-take 题目导航按钮加 aria-pressed+title。(7) P2-13 ExamHomeworkRoleConfig:shared/config/exam-homework-role-config.ts(6角色×11功能特性+并集合并函数)+shared/hooks/use-exam-homework-features.ts。(8) 6.1 ExamHomeworkServicePort:shared/services/exam-homework-port.ts(接口定义+ServiceProvider单例注册器)。(9) 6.5 单测:question-content-utils.test.ts(52测试覆盖14纯函数+applyAutoGrades)+exam-homework-role-config.test.ts(11测试覆盖角色合并)。(10) 6.7 trackExamEvent:track-event.ts 扩展17个exam/homework事件+trackExamEvent便捷函数。前序:teacher_bug_v4 P1-3+P2-1 修复已同步:(1) P1-3 空状态 CTA 优化:empty-state.tsx 默认按钮 variant 从 default 改为 outline,新增 variant prop;button.tsx 导出 ButtonProps 类型;返回路径统一为 ghost+ArrowLeft+文字标签模式(textbooks/[id]、grades/analytics、homework/assignments/[id]、course-plans/[id]、lesson-plans/new);course-plan-detail 中 raw 改为 。(2) P2-1 日期格式本地化+中英文统一:formatLongDate 默认 locale 从 en-US 改为 zh-CN,weekday 从 long 改为 short;teacher 导航项全部中文化(仪表盘/教材/考试/作业/成绩/题库/班级管理/课程计划/我的备课/考勤/调课申请/学情诊断/选修课/年级管理/公告/消息);app-sidebar Collapse 按钮改为「收起」;5 个详情页返回按钮文案中文化;course-plan-detail 组件全量中文化(状态标签/表头/按钮/空状态/删除对话框/toast);grades/analytics 页面标题与描述中文化。前序:Announcements 公告模块修复已同步:(1) getAnnouncements 新增 audience 受众过滤参数(school 全可见 / grade 按年级 / class 按班级),使用 or+and 组合条件;(2) 用户端列表页 /announcements 传入 audience(根据 ctx.dataScope 解析 gradeId/classId,admin 不过滤);(3) 新增用户端公告详情页 /announcements/[id](只读模式 canManage=false,requirePermission ANNOUNCEMENT_READ);(4) 用户端列表页传递 detailHrefBuilder;(5) 管理端列表页 /admin/announcements 增加 getAdminClasses 调用,传递 classes 给 AdminAnnouncementsView;(6) 发布公告触发通知:publishAnnouncementAction/createAnnouncementAction(直接发布)/updateAnnouncementAction(状态变 published) 调用 sendBatchNotifications,根据公告类型查询目标用户(school=全部/grade=按年级/class=学生+教师),新增 users/data-access.getAllUserIds 函数;(7) 新增 loading.tsx 骨架屏(用户端 + 管理端)。前序:Profile/Settings 模块修复已同步:(1) 新增 profile/settings/settings/security 的 loading.tsx + error.tsx(参考 admin 模式);(2) settings/page.tsx 增加 parent 角色分支,新增 ParentSettingsView 组件(backHref 指向 /parent/dashboard);(3) SettingsView 集成 AiProviderSettingsCard(新增 AI 标签页,条件渲染需 AI_CONFIGURE 权限);(4) profile/page.tsx 添加 Avatar 头像展示(从 userProfile.image 获取,无头像显示首字母 fallback);(5) SettingsView Tab URL 持久化(useSearchParams 读取 tab 参数,router.push 更新 URL,Suspense 包装);(6) SettingsView 登出按钮 AlertDialog 二次确认;(7) password-change-form 修复任意值 Tailwind 类([&>div]:bg-red-500 改为 Progress 组件新增 indicatorClassName prop + 标准颜色类);(8) profile/page.tsx 保持 requireAuth(页面仅查看,编辑在 settings 页面有权限校验)。前序:第二轮共享组件抽取重构已同步:P0-1 ConfirmDeleteDialog(5 处 AlertDialog 删除确认块抽取);P0-2 Pagination(3 处审计表格分页块抽取);P0-3 EmptyTableRow(3 处审计表格空行抽取);P1-1 StatusBadge + typeColors 共享(9+ 处状态徽章抽取,修复 StudentHomeworkProgressStatus 在 3 个文件中颜色不一致 bug,统一 audit/grades/homework/questions 状态映射到模块 types.ts);P1-2 TextField/SelectField/TextareaField 表单字段抽取(profile-settings-form 6+1、exam-basic-info-form 4+3、ai-provider-settings-card 4+1、create-question-dialog 2+1 共 26 处 FormField 重复抽取);P1-3 统一 formatDate/formatDateTime/formatLongDate(8 处 toLocaleDateString/toLocaleString 抽取);P1-4 useActionQuery + useActionMutation Hook 抽取(schools-view 3 处 mutation 示范重构,create-question-dialog 1 处 query 重构,潜在影响 50+ 文件)。新增 shared 层 7 个 UI 组件 + 2 个 Hooks + 2 个工具函数。前序:P0-b/P1-a/P1-b/P1-c/P2-a/P2-b/P3-a/P3-b/P3-c/P3-d 共享组件抽取重构已同步:新增 shared 层 UI 组件(StatCard/StatItem/ChipNav/PageHeader/FilterBar+FilterSearchInput+FilterResetButton)、图表组件(ChartCardShell/TrendLineChart/SimpleBarChart/ComparisonRadarChart)、课表组件(ScheduleList+ScheduleListItem)、题库组件(QuestionBankFilters)、工具函数(downloadBase64File/downloadBlob/getInitials/formatDateForFile);新增 settings 模块 SettingsView 组件;删除 messaging/notification-preferences.ts(P0-b 通知模块去重,re-export shim 已移除,消费方改为直接从 notifications/preferences 导入);P2-6/P2-7/P2-8/P2-11/P2-17/P2-18 已修复:proxy.ts 改用 Permissions 常量替代硬编码字符串;useA11yId 文件已不存在(use-aria-live.ts 已在 hooks/ 目录);schema.ts 分节编号重新编号为连续 1-24,消除 8b/14b/乱序问题;announcements 死代码 void wasPublished 已不存在;layout/app-sidebar.tsx 改用 hasRole() 判断角色,不再用权限反推角色;scheduling/actions.ts 移除末尾 re-export data-access,4 个页面改为从 data-access 直接导入;P1-1 已修复:所有跨模块直查已改为通过对方 data-access 接口(homework/grades/parent/diagnostic/elective/proctoring/notifications/scheduling/classes 模块);P0-2 已修复:shared/lib ↔ auth 循环依赖已解决,新增 shared/lib/session.ts 单一入口;P1-6 已修复:http-utils.ts 统一 IP/UA 提取;P0-1/P0-2/P0-3/P0-4/P0-5/P0-7/P0-8 已修复;P1-2 已修复:actions 层 DB 操作下沉到 data-access;P2-2/P2-3/P2-12/P2-20 已修复" }, "architectureOverview": { "layers": [ @@ -3247,6 +3247,14 @@ "name": "QuestionSubQuestionsEditor", "file": "question-sub-questions-editor.tsx", "purpose": "子题目编辑器" + }, + { + "name": "ExamAnalyticsDashboard", + "file": "exam-analytics-dashboard.tsx", + "purpose": "V3-8 新增:考试分析仪表盘(汇总卡片:应考人数/已批改份数/平均分/及格率 + 分数段分布 + 逐题分析表:错误率/难度等级),对标智学网考试分析功能", + "types": [ + "ExamAnalyticsSummary" + ] } ], "hooks": [ @@ -3275,6 +3283,37 @@ "teacher/exams/[id]/build/page.tsx" ] } + ], + "statsService": [ + { + "name": "getExamAnalytics", + "file": "stats-service.ts", + "type": "function", + "signature": "(examId: string) => Promise", + "purpose": "V3-8 新增:考试分析数据聚合(cache 包装)。聚合该考试所有作业的已批改提交数据,计算平均分、及格率、分数段分布、逐题错误率与难度等级(easy/medium/hard)。对标智学网考试分析功能。", + "deps": [ + "shared.db", + "shared.db.schema.exams", + "shared.db.schema.examQuestions", + "homework/data-access.getHomeworkAssignmentsByExamId", + "homework/data-access.getGradedSubmissionsByExamId", + "homework/lib/question-content-utils.getQuestionText", + "react.cache" + ], + "usedBy": [ + "teacher/exams/[id]/analytics/page.tsx" + ] + }, + { + "name": "ExamAnalyticsSummary", + "file": "stats-service.ts", + "type": "interface", + "definition": "{ examId, examTitle, totalScore, assignmentCount, totalStudents, submittedCount, gradedCount, averageScore, maxScore, passRate, scoreDistribution: Array<{ range, count }>, questions: Array<{ questionId, questionType, questionText, maxScore, errorCount, errorRate, difficulty: 'easy'|'medium'|'hard' }> }", + "usedBy": [ + "getExamAnalytics", + "exams/components/exam-analytics-dashboard.tsx" + ] + } ] } }, @@ -3348,6 +3387,23 @@ "usedBy": [ "homework-grading-view.tsx" ] + }, + { + "name": "batchAutoGradeSubmissionsAction", + "permission": "HOMEWORK_GRADE", + "signature": "(prevState: ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }> | null, formData: FormData) => Promise>", + "purpose": "V3-7 新增:批量自动批改提交。教师在提交列表页勾选多份提交后一键自动批改所有客观题(选择题/判断题),主观题保持原分数。非管理员仅可批改自己创建的作业提交(通过 getHomeworkSubmissionForGrading 校验 creatorId===ctx.userId)。", + "deps": [ + "requirePermission", + "shared/lib/parse.safeJsonParse", + "data-access-write.getHomeworkSubmissionForGrading", + "data-access-write.batchAutoGradeSubmissions", + "shared/lib/track-event.trackExamEvent", + "revalidatePath" + ], + "usedBy": [ + "homework-batch-grading-view.tsx" + ] } ], "dataAccess": [ @@ -3411,6 +3467,66 @@ "usedBy": [ "homework-take-view.tsx" ] + }, + { + "name": "getHomeworkAssignmentsByExamId", + "signature": "(examId: string) => Promise>", + "purpose": "V3-8 新增:按考试 ID 查询关联作业列表(含目标/已提交/已批改学生计数,3 个聚合查询并行执行)。供 exams/stats-service.getExamAnalytics 跨模块调用。", + "deps": [ + "shared.db", + "shared.db.schema.homeworkAssignments", + "shared.db.schema.homeworkAssignmentTargets", + "shared.db.schema.homeworkSubmissions", + "drizzle-orm.eq/inArray/and/count/sql", + "react.cache" + ], + "usedBy": [ + "exams/stats-service.getExamAnalytics" + ] + }, + { + "name": "getGradedSubmissionsByExamId", + "signature": "(examId: string) => Promise }>>", + "purpose": "V3-8 新增:按考试 ID 查询所有已批改提交(按学生去重,保留最新一条;含答案列表)。供 exams/stats-service.getExamAnalytics 跨模块调用。", + "deps": [ + "shared.db", + "shared.db.schema.homeworkAssignments", + "shared.db.schema.homeworkSubmissions", + "drizzle-orm.eq/inArray/and", + "react.cache" + ], + "usedBy": [ + "exams/stats-service.getExamAnalytics" + ] + }, + { + "name": "getStudentSubmissionResult", + "signature": "(assignmentId: string, studentId: string) => Promise", + "purpose": "V3-9 新增:获取学生在指定作业的最新提交结果(用于提交后反馈页)。查找学生最近一次已提交/已批改的 submission,返回完整详情含答案(复用 getHomeworkSubmissionDetails)。", + "deps": [ + "shared.db", + "shared.db.schema.homeworkSubmissions", + "drizzle-orm.eq/and/inArray/desc", + "data-access.getHomeworkSubmissionDetails", + "react.cache" + ], + "usedBy": [ + "student/learning/assignments/[assignmentId]/result/page.tsx" + ] + }, + { + "name": "getStudentExamResults", + "signature": "(studentId: string) => Promise>", + "purpose": "V3-11 新增:获取学生的考试结果列表(供家长端展示)。查找学生所有已批改的、关联到考试的作业提交,按 examId 去重,limit 50。供 parent/data-access.getChildDashboardData 跨模块调用。", + "deps": [ + "shared.db", + "shared.db.schema.homeworkSubmissions", + "drizzle-orm.eq/and/desc", + "react.cache" + ], + "usedBy": [ + "parent/data-access.getChildDashboardData" + ] } ], "dataAccessClasses": [ @@ -3527,6 +3643,22 @@ "usedBy": [ "gradeHomeworkSubmissionAction" ] + }, + { + "name": "batchAutoGradeSubmissions", + "file": "data-access-write.ts", + "signature": "(submissionIds: string[]) => Promise>", + "purpose": "V3-7 新增:对多个提交执行自动批改(复用 autoGradeSubmission 逻辑)。仅批改客观题(选择题/判断题),主观题保持原分数。返回每个提交的批改结果摘要。", + "deps": [ + "shared.db", + "shared.db.schema.homeworkSubmissions", + "shared.db.schema.homeworkAnswers", + "shared.db.schema.homeworkAssignmentQuestions", + "drizzle-orm.eq" + ], + "usedBy": [ + "batchAutoGradeSubmissionsAction" + ] } ], "statsService": [ @@ -3818,6 +3950,16 @@ "name": "HomeworkReviewView", "file": "student-homework-review-view.tsx", "purpose": "学生作业复习视图" + }, + { + "name": "HomeworkBatchGradingView", + "file": "homework-batch-grading-view.tsx", + "purpose": "V3-7 新增:批量批改视图(use client)。教师在提交列表页可勾选多份提交,一键自动批改所有客观题。含全选/反选、 selectable 过滤(仅 status='submitted' 可选)、useTransition 异步提交、toast 反馈、revalidate 刷新。对标智学网批量批改功能。" + }, + { + "name": "HomeworkSubmissionResult", + "file": "homework-submission-result.tsx", + "purpose": "V3-9 新增:提交后即时反馈页。学生提交后立即看到分数汇总(总分/满分、得分率 Progress)、对错分布(正确/错误/部分正确/待批改)、错题预览(题目文本、学生答案、正确答案)。对标智学网/猿题库提交后反馈。" } ] } @@ -9687,6 +9829,7 @@ "classes/data-access.getStudentSchedule", "homework/data-access.getStudentHomeworkAssignments", "homework/data-access.getStudentDashboardGrades", + "homework/data-access.getStudentExamResults", "grades/data-access.getStudentGradeSummary", "react.cache" ], @@ -9792,13 +9935,23 @@ "name": "ChildDashboardData", "type": "type", "file": "types.ts", - "definition": "{ basicInfo: ChildBasicInfo, enrolledClasses: StudentEnrolledClass[], todaySchedule: ChildScheduleItem[], weeklySchedule: ChildWeeklyScheduleItem[], homeworkSummary: ChildHomeworkSummary, gradeTrend: StudentDashboardGradeProps, gradeSummary: StudentGradeSummary | null }", + "definition": "{ basicInfo: ChildBasicInfo, enrolledClasses: StudentEnrolledClass[], todaySchedule: ChildScheduleItem[], weeklySchedule: ChildWeeklyScheduleItem[], homeworkSummary: ChildHomeworkSummary, gradeTrend: StudentDashboardGradeProps, gradeSummary: StudentGradeSummary | null, examResults: ChildExamResultItem[] }", "usedBy": [ "getChildDashboardData", "ParentDashboardData.children", "所有 child-* 组件" ] }, + { + "name": "ChildExamResultItem", + "type": "type", + "file": "types.ts", + "definition": "{ submissionId, examId, examTitle, assignmentId, assignmentTitle, score, maxScore, submittedAt: string | null, status: string }", + "usedBy": [ + "ChildDashboardData.examResults", + "child-exam-detail.tsx" + ] + }, { "name": "ParentDashboardData", "type": "type", @@ -9895,6 +10048,11 @@ "name": "ParentNoChildrenPage", "file": "components/parent-children-data-page.tsx", "purpose": "dataScope 为空时的统一空状态页面" + }, + { + "name": "ChildExamDetail", + "file": "components/child-exam-detail.tsx", + "purpose": "V3-11 新增:家长端子女考试详情视图。展示考试成绩汇总卡片(已参加考试数、平均分、最高分)、考试成绩列表(考试标题、分数、得分率 Progress、提交时间)。对标智学网家长端考试详情。集成于 child-detail-panel.tsx 的 exams Tab。" } ] } @@ -16312,6 +16470,16 @@ "module": "exams", "permission": "exam:update" }, + "/teacher/exams/[id]/analytics": { + "component": "ExamAnalyticsDashboard", + "type": "server", + "module": "exams", + "dataAccess": [ + "exams/stats-service.getExamAnalytics" + ], + "permission": "exam:read", + "description": "V3-8 新增:考试分析仪表盘页面(汇总卡片:应考人数/已批改份数/平均分/及格率 + 分数段分布 + 逐题分析表:错误率/难度等级);权限:requirePermission(EXAM_READ)" + }, "/teacher/exams/grading": { "component": "重定向", "type": "server", @@ -16593,6 +16761,17 @@ ], "permission": "homework:submit" }, + "/student/learning/assignments/[assignmentId]/result": { + "component": "HomeworkSubmissionResult", + "type": "server", + "module": "homework", + "dataAccess": [ + "users/data-access.getCurrentStudentUser", + "homework/data-access.getStudentSubmissionResult" + ], + "permission": "homework:submit", + "description": "V3-9 新增:学生提交后即时反馈页(分数汇总+对错分布+错题预览);homework-take-view.tsx 提交后 router.push 跳转至此页;权限:requirePermission(HOMEWORK_SUBMIT)" + }, "/student/learning/courses": { "component": "StudentCoursesView", "type": "server", diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/page.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/page.tsx new file mode 100644 index 0000000..697e003 --- /dev/null +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/page.tsx @@ -0,0 +1,35 @@ +import type { JSX } from "react" +import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" +import { getHomeworkAssignmentById, getStudentSubmissionResult } from "@/modules/homework/data-access" +import { HomeworkSubmissionResult } from "@/modules/homework/components/homework-submission-result" +import { getSession } from "@/shared/lib/session" + +export const dynamic = "force-dynamic" + +export default async function HomeworkResultPage({ params }: { params: Promise<{ assignmentId: string }> }): Promise { + const { assignmentId } = await params + const t = await getTranslations("examHomework") + const session = await getSession() + const studentId = session?.user?.id + + if (!studentId) return notFound() + + const [assignment, submission] = await Promise.all([ + getHomeworkAssignmentById(assignmentId), + getStudentSubmissionResult(assignmentId, studentId), + ]) + + if (!assignment) return notFound() + if (!submission) return notFound() + + return ( +
+
+

{t("homework.result.title")}

+

{assignment.title}

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx new file mode 100644 index 0000000..42fe26e --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx @@ -0,0 +1,57 @@ +import type { JSX } from "react" +import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" +import Link from "next/link" +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { BarChart3, ArrowLeft } from "lucide-react" +import { getExamById } from "@/modules/exams/data-access" +import { getExamAnalytics } from "@/modules/exams/stats-service" +import { ExamAnalyticsDashboard } from "@/modules/exams/components/exam-analytics-dashboard" + +export const dynamic = "force-dynamic" + +export default async function ExamAnalyticsPage({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + const t = await getTranslations("examHomework") + + const [exam, analytics] = await Promise.all([ + getExamById(id), + getExamAnalytics(id), + ]) + + if (!exam) return notFound() + + return ( +
+
+
+
+ +

{t("exam.analytics.title")}

+
+

{exam.title}

+

{t("exam.analytics.description")}

+
+
+ +
+
+ + {analytics && analytics.gradedCount > 0 ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx index 735359b..6b03c52 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx @@ -2,18 +2,9 @@ import type { JSX } from "react" import Link from "next/link" import { notFound } from "next/navigation" import { getTranslations } from "next-intl/server" -import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/shared/components/ui/table" -import { formatDate } from "@/shared/lib/utils" import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access" +import { HomeworkBatchGradingView } from "@/modules/homework/components/homework-batch-grading-view" export const dynamic = "force-dynamic" @@ -52,39 +43,7 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa -
- - - - {t("homework.grade.student")} - {t("homework.grade.status")} - {t("homework.grade.submitted")} - {t("homework.grade.score")} - {t("homework.grade.action")} - - - - {submissions.map((s) => ( - - {s.studentName} - - - {s.status} - - {s.isLate ? {t("homework.grade.late")} : null} - - {s.submittedAt ? formatDate(s.submittedAt) : "-"} - {typeof s.score === "number" ? s.score : "-"} - - - {t("homework.grade.title")} - - - - ))} - -
-
+ ) } diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..bf4a051 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,80 @@ +/** + * Next.js Instrumentation 钩子 + * + * 在应用启动时执行一次性初始化操作。 + * 文档:https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation + * + * V3-3: 注册 ExamHomeworkServicePort 实现 + * 将 modules 层的 data-access 函数注入到 shared 层的 ServicePort, + * 使 app 层可以通过 EXAM_HOMEWORK_SERVICE_PROVIDER.get() 调用, + * 而不直接依赖 modules 内部实现。 + */ + +import { registerExamHomeworkService } from "@/shared/services/exam-homework-port" +import { getExamById, getExams, getExamCreatorId, getExamTitleById } from "@/modules/exams/data-access" +import { + getHomeworkAssignmentById, + getHomeworkAssignments, + getAssignmentMaxScoreById, +} from "@/modules/homework/data-access" +import { getExamWithQuestionsForHomework } from "@/modules/exams/data-access" +import type { DataScope } from "@/shared/types/permissions" +import type { Exam } from "@/modules/exams/types" +import type { HomeworkAssignmentListItem } from "@/modules/homework/types" + +/** + * 适配器:将 getExamById 的返回值补全为 Exam 类型 + * data-access 返回的对象包含 questions 数组但缺少 questionCount 字段 + */ +const adaptExam = (raw: Awaited>): Exam | null => { + if (!raw) return null + return { + ...raw, + questionCount: raw.questions?.length ?? 0, + } +} + +/** + * 适配器:将 getHomeworkAssignmentById 的返回值补全为 HomeworkAssignmentListItem 类型 + * data-access 返回的对象缺少 averageScore 和 overdueCount 字段 + */ +const adaptAssignment = (raw: Awaited>): HomeworkAssignmentListItem | null => { + if (!raw) return null + return { + id: raw.id, + sourceExamId: raw.sourceExamId, + sourceExamTitle: raw.sourceExamTitle, + title: raw.title, + status: raw.status, + availableAt: raw.availableAt, + dueAt: raw.dueAt, + allowLate: raw.allowLate, + lateDueAt: raw.lateDueAt, + maxAttempts: raw.maxAttempts, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + targetCount: raw.targetCount, + submittedCount: raw.submittedCount, + gradedCount: raw.gradedCount, + averageScore: null, + overdueCount: 0, + } +} + +export async function register(): Promise { + registerExamHomeworkService({ + // 考试 + getExamById: async (id: string, scope?: DataScope) => adaptExam(await getExamById(id, scope)), + getExams, + getExamCreatorId, + getExamTitleById, + + // 作业 + getHomeworkAssignmentById: async (id: string, scope?: DataScope) => adaptAssignment(await getHomeworkAssignmentById(id, scope)), + getHomeworkAssignments, + getAssignmentMaxScoreByIds: getAssignmentMaxScoreById, + + // 跨模块 + getExamWithQuestionsForHomework, + }) +} diff --git a/src/modules/exams/components/exam-actions.tsx b/src/modules/exams/components/exam-actions.tsx index ea74497..4e4e63d 100644 --- a/src/modules/exams/components/exam-actions.tsx +++ b/src/modules/exams/components/exam-actions.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react" +import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy, BarChart3 } from "lucide-react" import { toast } from "sonner" import { Button } from "@/shared/components/ui/button" @@ -36,6 +36,8 @@ import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreview import { Exam } from "../types" import { ExamPaperPreview } from "./assembly/exam-paper-preview" import type { ExamNode } from "./assembly/selected-question-list" +import { createId } from "@paralleldrive/cuid2" +import { useExamHomeworkFeatures } from "@/shared/hooks/use-exam-homework-features" // Raw structure node shape returned from the DB before hydration type RawStructureNode = { @@ -65,6 +67,7 @@ interface ExamActionsProps { export function ExamActions({ exam }: ExamActionsProps) { const router = useRouter() const t = useTranslations("examHomework") + const features = useExamHomeworkFeatures() const [showViewDialog, setShowViewDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isWorking, setIsWorking] = useState(false) @@ -83,7 +86,8 @@ export function ExamActions({ exam }: ExamActionsProps) { return nodes.map((node) => { if (node.type === "question") { return { - id: node.id ?? node.questionId ?? "", + // Avoid empty-string fallback that could cause React key collisions + id: node.id ?? node.questionId ?? createId(), type: "question" as const, questionId: node.questionId, score: node.score, @@ -92,7 +96,7 @@ export function ExamActions({ exam }: ExamActionsProps) { } if (node.type === "group") { return { - id: node.id ?? "", + id: node.id ?? createId(), type: "group" as const, title: node.title, score: node.score, @@ -101,7 +105,7 @@ export function ExamActions({ exam }: ExamActionsProps) { } // Unknown node type: treat as group with no children to avoid runtime crash return { - id: node.id ?? "", + id: node.id ?? createId(), type: "group" as const, title: node.title, children: [], @@ -124,8 +128,13 @@ export function ExamActions({ exam }: ExamActionsProps) { } const copyId = () => { - navigator.clipboard.writeText(exam.id) - toast.success(t("exam.actions.idCopied")) + try { + void navigator.clipboard.writeText(exam.id) + toast.success(t("exam.actions.idCopied")) + } catch (error) { + console.error("[ExamActions]", error instanceof Error ? error.message : String(error)) + toast.error(t("exam.actions.idCopied")) + } } const setStatus = async (status: Exam["status"]) => { @@ -194,7 +203,7 @@ export function ExamActions({ exam }: ExamActionsProps) { - @@ -217,42 +226,64 @@ export function ExamActions({ exam }: ExamActionsProps) { {t("exam.actions.copyId")} - router.push(`/teacher/exams/${exam.id}/build`)}> - {t("exam.actions.edit")} - - router.push(`/teacher/exams/${exam.id}/build`)}> - {t("exam.actions.build")} - - - - {t("exam.actions.duplicate")} - - - setStatus("published")} - disabled={isWorking || exam.status === "published"} - > - {t("exam.actions.publish")} - - setStatus("draft")} - disabled={isWorking || exam.status === "draft"} - > - {t("exam.actions.moveToDraft")} - - setStatus("archived")} - disabled={isWorking || exam.status === "archived"} - > - {t("exam.actions.archive")} - - setShowDeleteDialog(true)} - disabled={isWorking} - > - {t("exam.actions.delete")} - + {features.canBuild && ( + router.push(`/teacher/exams/${exam.id}/build`)}> + {t("exam.actions.edit")} + + )} + {features.canBuild && ( + router.push(`/teacher/exams/${exam.id}/build`)}> + {t("exam.actions.build")} + + )} + {features.canViewStats && ( + router.push(`/teacher/exams/${exam.id}/analytics`)}> + {t("exam.analytics.viewAnalytics")} + + )} + {features.canCreate && ( + <> + + + {t("exam.actions.duplicate")} + + + )} + {features.canPublish && ( + <> + + setStatus("published")} + disabled={isWorking || exam.status === "published"} + > + {t("exam.actions.publish")} + + setStatus("draft")} + disabled={isWorking || exam.status === "draft"} + > + {t("exam.actions.moveToDraft")} + + setStatus("archived")} + disabled={isWorking || exam.status === "archived"} + > + {t("exam.actions.archive")} + + + )} + {features.canManage && ( + <> + + setShowDeleteDialog(true)} + disabled={isWorking} + > + {t("exam.actions.delete")} + + + )} diff --git a/src/modules/exams/components/exam-analytics-dashboard.tsx b/src/modules/exams/components/exam-analytics-dashboard.tsx new file mode 100644 index 0000000..2dd1117 --- /dev/null +++ b/src/modules/exams/components/exam-analytics-dashboard.tsx @@ -0,0 +1,226 @@ +import type { JSX } from "react" +import { useTranslations } from "next-intl" +import { Users, CheckCircle2, TrendingUp, Award, AlertTriangle } from "lucide-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { Progress } from "@/shared/components/ui/progress" +import type { ExamAnalyticsSummary } from "../stats-service" + +interface ExamAnalyticsDashboardProps { + analytics: ExamAnalyticsSummary +} + +/** + * V3-8: 考试分析仪表盘 + * + * 对标智学网考试分析功能,展示: + * - 汇总卡片(应考人数、已批改份数、平均分、及格率) + * - 分数段分布 + * - 逐题分析(错误率、难度等级) + */ +export function ExamAnalyticsDashboard({ analytics }: ExamAnalyticsDashboardProps): JSX.Element { + const t = useTranslations("examHomework") + + const difficultyVariant = (difficulty: "easy" | "medium" | "hard") => { + if (difficulty === "easy") return "default" as const + if (difficulty === "medium") return "secondary" as const + return "destructive" as const + } + + const difficultyLabel = (difficulty: "easy" | "medium" | "hard") => { + if (difficulty === "easy") return t("exam.analytics.difficultyEasy") + if (difficulty === "medium") return t("exam.analytics.difficultyMedium") + return t("exam.analytics.difficultyHard") + } + + return ( +
+ {/* Summary Cards */} +
+ + + + {t("exam.analytics.totalStudents")} + + + + +
{analytics.totalStudents}
+

+ {t("exam.analytics.submitted")}: {analytics.submittedCount} / {analytics.totalStudents} +

+
+
+ + + + + {t("exam.analytics.gradedCount")} + + + + +
{analytics.gradedCount}
+

+ {t("exam.analytics.assignmentCount")}: {analytics.assignmentCount} +

+
+
+ + + + + {t("exam.analytics.averageScore")} + + + + +
+ {analytics.averageScore} + + {" / "}{analytics.maxScore} + +
+
+
+ + + + + {t("exam.analytics.passRate")} + + + + +
+ {(analytics.passRate * 100).toFixed(1)}% +
+
+
+
+ + {/* Score Distribution */} + + + {t("exam.analytics.scoreDistribution")} + {t("exam.analytics.scoreDistributionDesc")} + + +
+ {analytics.scoreDistribution.map((item) => { + const maxCount = Math.max(...analytics.scoreDistribution.map((d) => d.count), 1) + const percentage = (item.count / maxCount) * 100 + return ( +
+ {item.range} + + {item.count} +
+ ) + })} +
+
+
+ + {/* Per-Question Analysis */} + + + {t("exam.analytics.questionAnalysis")} + {t("exam.analytics.questionAnalysisDesc")} + + + + + + # + {t("exam.analytics.questionType")} + {t("exam.analytics.questionText")} + {t("exam.analytics.maxScore")} + {t("exam.analytics.errorCount")} + {t("exam.analytics.errorRate")} + {t("exam.analytics.difficulty")} + + + + {analytics.questions.map((q, index) => ( + + {index + 1} + + + {q.questionType} + + + + {q.questionText} + + {q.maxScore} + {q.errorCount} + +
+ + + {(q.errorRate * 100).toFixed(0)}% + +
+
+ + + {difficultyLabel(q.difficulty)} + + +
+ ))} +
+
+
+
+ + {/* High Error Rate Warning */} + {analytics.questions.filter((q) => q.errorRate >= 0.7).length > 0 && ( + + + + + {t("exam.analytics.highErrorWarning")} + + + +

+ {t("exam.analytics.highErrorWarningDesc")} +

+
+ {analytics.questions + .filter((q) => q.errorRate >= 0.7) + .map((q, index) => ( +
+ #{index + 1} + {q.questionText} + + {(q.errorRate * 100).toFixed(0)}% + +
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/src/modules/exams/stats-service.ts b/src/modules/exams/stats-service.ts new file mode 100644 index 0000000..8be9c6b --- /dev/null +++ b/src/modules/exams/stats-service.ts @@ -0,0 +1,159 @@ +import "server-only" + +import { cache } from "react" + +import { db } from "@/shared/db" +import { exams, examQuestions } from "@/shared/db/schema" +import { eq } from "drizzle-orm" +import { + getHomeworkAssignmentsByExamId, + getGradedSubmissionsByExamId, +} from "@/modules/homework/data-access" +import { getQuestionText } from "@/modules/homework/lib/question-content-utils" + +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null + +const parseExamMeta = (description: string | null): Record => { + if (!description) return {} + try { + const parsed: unknown = JSON.parse(description) + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +const getNumber = (obj: Record, key: string): number | undefined => { + const v = obj[key] + return typeof v === "number" ? v : undefined +} + +/** + * V3-8: 考试分析数据类型 + */ +export interface ExamAnalyticsSummary { + examId: string + examTitle: string + totalScore: number + assignmentCount: number + totalStudents: number + submittedCount: number + gradedCount: number + averageScore: number + maxScore: number + passRate: number + scoreDistribution: Array<{ range: string; count: number }> + questions: Array<{ + questionId: string + questionType: string + questionText: string + maxScore: number + errorCount: number + errorRate: number + difficulty: "easy" | "medium" | "hard" + }> +} + +/** + * V3-8: 获取考试分析数据 + * + * 对标智学网考试分析功能,聚合该考试所有作业的已批改提交数据, + * 计算:平均分、及格率、分数段分布、逐题错误率与难度。 + */ +export const getExamAnalytics = cache(async (examId: string): Promise => { + const exam = await db.query.exams.findFirst({ + where: eq(exams.id, examId), + columns: { id: true, title: true, description: true }, + }) + + if (!exam) return null + + const meta = parseExamMeta(exam.description) + const examTotalScore = getNumber(meta, "totalScore") ?? 100 + + const [assignments, gradedSubmissions, examQuestionsList] = await Promise.all([ + getHomeworkAssignmentsByExamId(examId), + getGradedSubmissionsByExamId(examId), + db.query.examQuestions.findMany({ + where: eq(examQuestions.examId, examId), + with: { question: true }, + orderBy: (eqRel, { asc }) => [asc(eqRel.order)], + }), + ]) + + const totalStudents = assignments.reduce((sum, a) => sum + a.targetCount, 0) + const submittedCount = assignments.reduce((sum, a) => sum + a.submittedCount, 0) + const gradedCount = gradedSubmissions.length + + // Calculate max score from exam questions + const maxScore = examQuestionsList.reduce((sum, eq) => sum + (eq.score ?? 0), 0) + + // Average score and pass rate + const scores = gradedSubmissions.map((s) => s.score) + const averageScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0 + const passThreshold = maxScore * 0.6 + const passCount = scores.filter((s) => s >= passThreshold).length + const passRate = scores.length > 0 ? passCount / scores.length : 0 + + // Score distribution (0-59, 60-69, 70-79, 80-89, 90-100) + const ranges = [ + { range: "0-59%", min: 0, max: 0.59 }, + { range: "60-69%", min: 0.6, max: 0.69 }, + { range: "70-79%", min: 0.7, max: 0.79 }, + { range: "80-89%", min: 0.8, max: 0.89 }, + { range: "90-100%", min: 0.9, max: 1.0 }, + ] + const scoreDistribution = ranges.map((r) => { + const count = scores.filter((s) => { + const pct = maxScore > 0 ? s / maxScore : 0 + return pct >= r.min && pct <= r.max + }).length + return { range: r.range, count } + }) + + // Per-question error rate and difficulty + const questions: ExamAnalyticsSummary["questions"] = examQuestionsList.map((eq) => { + const questionId = eq.questionId + const maxScore = eq.score ?? 0 + let errorCount = 0 + let totalAttempted = 0 + + for (const sub of gradedSubmissions) { + const ans = sub.answers.find((a) => a.questionId === questionId) + if (!ans) continue + totalAttempted += 1 + if (ans.score < maxScore) { + errorCount += 1 + } + } + + const errorRate = totalAttempted > 0 ? errorCount / totalAttempted : 0 + const difficulty: "easy" | "medium" | "hard" = + errorRate < 0.3 ? "easy" : errorRate < 0.7 ? "medium" : "hard" + + return { + questionId, + questionType: eq.question.type, + questionText: getQuestionText(eq.question.content) || "(无题目文本)", + maxScore, + errorCount, + errorRate, + difficulty, + } + }) + + return { + examId: exam.id, + examTitle: exam.title, + totalScore: examTotalScore, + assignmentCount: assignments.length, + totalStudents, + submittedCount, + gradedCount, + averageScore: Math.round(averageScore * 100) / 100, + maxScore, + passRate: Math.round(passRate * 100) / 100, + scoreDistribution, + questions, + } +}) diff --git a/src/modules/homework/actions.ts b/src/modules/homework/actions.ts index 03ad5b7..57f1f02 100644 --- a/src/modules/homework/actions.ts +++ b/src/modules/homework/actions.ts @@ -3,9 +3,11 @@ import { revalidatePath } from "next/cache" import { createId } from "@paralleldrive/cuid2" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" +import { handleActionError, safeJsonParse, safeParseDate } from "@/shared/lib/action-utils" +import { trackExamEvent } from "@/shared/lib/track-event" import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema" import { @@ -20,6 +22,7 @@ import { markHomeworkSubmitted, saveHomeworkAnswer, startHomeworkSubmission, + batchAutoGradeSubmissions, } from "./data-access-write" const parseStudentIds = (raw: string): string[] => { @@ -77,7 +80,11 @@ export async function createHomeworkAssignmentAction( let exam: Awaited> = null if (!isQuickAssignment) { - const examData = await getExamWithQuestionsForHomework(input.sourceExamId!) + const sourceExamId = input.sourceExamId + if (!sourceExamId) { + return { success: false, message: "sourceExamId is required for exam mode" } + } + const examData = await getExamWithQuestionsForHomework(sourceExamId) if (!examData) return { success: false, message: "Exam not found" } exam = examData } @@ -116,9 +123,9 @@ export async function createHomeworkAssignmentAction( } const assignmentId = createId() - const availableAt = input.availableAt ? new Date(input.availableAt) : null - const dueAt = input.dueAt ? new Date(input.dueAt) : null - const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null + const availableAt = input.availableAt ? safeParseDate(input.availableAt, "可用时间") : null + const dueAt = input.dueAt ? safeParseDate(input.dueAt, "截止时间") : null + const lateDueAt = input.lateDueAt ? safeParseDate(input.lateDueAt, "迟交截止时间") : null await createHomeworkAssignment({ assignmentId, @@ -141,13 +148,21 @@ export async function createHomeworkAssignmentAction( revalidatePath("/teacher/homework/assignments") revalidatePath("/teacher/homework/submissions") + // V3-4: 埋点监控 + await trackExamEvent("homework.created", { + userId: ctx.userId, + targetId: assignmentId, + properties: { + sourceExamId: input.sourceExamId ?? null, + classId: input.classId, + targetStudentCount: targetStudentIds.length, + published: publish, + }, + }) + return { success: true, message: "Assignment created", data: assignmentId } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -169,11 +184,7 @@ export async function startHomeworkSubmissionAction( return { success: true, message: "Started", data: result.submissionId } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -194,17 +205,16 @@ export async function saveHomeworkAnswerAction( if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" } if (submission.status !== "started") return { success: false, message: "Submission is locked" } - const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null + const payload = + typeof answerJson === "string" && answerJson.length > 0 + ? safeJsonParse(answerJson, "答案数据格式无效") + : null await saveHomeworkAnswer(submissionId, questionId, payload) return { success: true, message: "Saved", data: submissionId } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -232,18 +242,30 @@ export async function submitHomeworkAction( const isLate = Boolean(dueAt && now > dueAt) - await markHomeworkSubmitted(submissionId, isLate) + // V3-2: 即时自动批改回写 + const { isFullyAutoGraded, totalScore } = await markHomeworkSubmitted(submissionId, isLate) revalidatePath("/teacher/homework/submissions") revalidatePath("/student/learning/assignments") - return { success: true, message: "Submitted", data: submissionId } - } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } + // V3-4: 埋点监控 + await trackExamEvent("homework.submitted", { + userId: ctx.userId, + targetId: submissionId, + properties: { + isLate, + isFullyAutoGraded, + totalScore, + }, + }) + + return { + success: true, + message: isFullyAutoGraded ? "Submitted and auto-graded" : "Submitted", + data: submissionId, } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + } catch (e) { + return handleActionError(e) } } @@ -258,7 +280,7 @@ export async function gradeHomeworkSubmissionAction( const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null const parsed = GradeHomeworkSchema.safeParse({ submissionId: formData.get("submissionId"), - answers: rawAnswers ? JSON.parse(rawAnswers) : [], + answers: rawAnswers ? safeJsonParse(rawAnswers, "批改数据格式无效") : [], }) if (!parsed.success) { @@ -294,12 +316,82 @@ export async function gradeHomeworkSubmissionAction( revalidatePath("/teacher/homework/submissions") + // V3-4: 埋点监控 + await trackExamEvent("homework.graded", { + userId: ctx.userId, + targetId: submissionId, + properties: { + answerCount: answers.length, + }, + }) + return { success: true, message: "Grading saved" } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) + } +} + +/** + * V3-7: 批量自动批改提交 + * + * 教师在提交列表页勾选多份提交后,一键自动批改所有客观题。 + * 仅批改选择题/判断题,主观题保持原分数。 + */ +export async function batchAutoGradeSubmissionsAction( + prevState: ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }> | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.HOMEWORK_GRADE) + + const rawSubmissionIds = formData.get("submissionIds") + const submissionIdsJson = typeof rawSubmissionIds === "string" ? rawSubmissionIds : "[]" + const submissionIds = safeJsonParse(submissionIdsJson, "提交 ID 列表格式无效") + + if (submissionIds.length === 0) { + return { + success: false, + message: "请至少选择一份提交", + } + } + + // 权限校验:非管理员仅可批改自己创建的作业提交 + if (ctx.dataScope.type !== "all") { + for (const submissionId of submissionIds) { + const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId) + if (!submissionForGrading) { + return { success: false, message: `提交不存在: ${submissionId}` } + } + if (submissionForGrading.creatorId !== ctx.userId) { + return { success: false, message: "只能批改自己创建的作业提交" } + } + } + } + + const results = await batchAutoGradeSubmissions(submissionIds) + const successCount = results.filter((r) => r.success).length + const failedCount = results.filter((r) => !r.success).length + const fullyGradedCount = results.filter((r) => r.success && r.isFullyAutoGraded).length + + revalidatePath("/teacher/homework/submissions") + revalidatePath("/teacher/homework/assignments") + + await trackExamEvent("homework.graded", { + userId: ctx.userId, + targetId: submissionIds[0] ?? "", + properties: { + batchCount: submissionIds.length, + successCount, + fullyGradedCount, + }, + }) + + return { + success: true, + message: `批量批改完成:成功 ${successCount} 份,失败 ${failedCount} 份,其中 ${fullyGradedCount} 份已全自动批改`, + data: { successCount, failedCount, fullyGradedCount }, + } + } catch (e) { + return handleActionError(e) } } diff --git a/src/modules/homework/components/homework-batch-grading-view.tsx b/src/modules/homework/components/homework-batch-grading-view.tsx new file mode 100644 index 0000000..7dd0b1a --- /dev/null +++ b/src/modules/homework/components/homework-batch-grading-view.tsx @@ -0,0 +1,168 @@ +"use client" + +import { useState, useTransition } from "react" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { Zap } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { Badge } from "@/shared/components/ui/badge" +import { Checkbox } from "@/shared/components/ui/checkbox" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import { batchAutoGradeSubmissionsAction } from "../actions" +import type { HomeworkSubmissionListItem } from "../types" + +interface HomeworkBatchGradingViewProps { + submissions: HomeworkSubmissionListItem[] +} + +/** + * V3-7: 批量批改视图 + * + * 教师在提交列表页可勾选多份提交,一键自动批改所有客观题。 + * 对标智学网的批量批改功能。 + */ +export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingViewProps) { + const t = useTranslations("examHomework") + const router = useRouter() + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [isPending, startTransition] = useTransition() + + const selectableSubmissions = submissions.filter( + (s) => s.status === "submitted" + ) + + const allSelectableSelected = + selectableSubmissions.length > 0 && + selectableSubmissions.every((s) => selectedIds.has(s.id)) + + const toggleSelectAll = () => { + if (allSelectableSelected) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(selectableSubmissions.map((s) => s.id))) + } + } + + const toggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const handleBatchAutoGrade = () => { + if (selectedIds.size === 0) { + toast.error(t("homework.grade.batchSelectAtLeastOne")) + return + } + + startTransition(async () => { + const formData = new FormData() + formData.set("submissionIds", JSON.stringify(Array.from(selectedIds))) + const result = await batchAutoGradeSubmissionsAction(null, formData) + if (result.success) { + toast.success(result.message) + setSelectedIds(new Set()) + router.refresh() + } else { + toast.error(result.message || t("homework.grade.batchFailed")) + } + }) + } + + return ( +
+ {selectedIds.size > 0 && ( +
+ + {t("homework.grade.batchSelected", { count: selectedIds.size })} + + +
+ )} + +
+ + + + + + + {t("homework.grade.student")} + {t("homework.grade.status")} + {t("homework.grade.submitted")} + {t("homework.grade.score")} + {t("homework.grade.action")} + + + + {submissions.map((s) => { + const isSelectable = s.status === "submitted" + const isSelected = selectedIds.has(s.id) + return ( + + + {isSelectable ? ( + toggleSelect(s.id)} + aria-label={t("homework.grade.selectRow")} + /> + ) : ( + + )} + + {s.studentName} + + + {s.status} + + {s.isLate ? {t("homework.grade.late")} : null} + + {s.submittedAt ? formatDate(s.submittedAt) : "-"} + {typeof s.score === "number" ? s.score : "-"} + + + {t("homework.grade.title")} + + + + ) + })} + +
+
+
+ ) +} diff --git a/src/modules/homework/components/homework-submission-result.tsx b/src/modules/homework/components/homework-submission-result.tsx new file mode 100644 index 0000000..59d4c0d --- /dev/null +++ b/src/modules/homework/components/homework-submission-result.tsx @@ -0,0 +1,234 @@ +import type { JSX } from "react" +import Link from "next/link" +import { useTranslations } from "next-intl" +import { CheckCircle2, XCircle, AlertCircle, Award, ArrowLeft, BookOpen } from "lucide-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Progress } from "@/shared/components/ui/progress" +import { + formatStudentAnswer, + getCorrectnessState, + getQuestionText, + getTextCorrectAnswers, + getChoiceCorrectIds, + getJudgmentCorrectAnswer, +} from "../lib/question-content-utils" +import type { HomeworkSubmissionDetails } from "../types" + +interface HomeworkSubmissionResultProps { + submission: HomeworkSubmissionDetails +} + +/** + * V3-9: 提交后即时反馈页 + * + * 对标智学网/猿题库,学生提交后立即看到: + * - 分数汇总(总分/满分、得分率) + * - 对错分布(正确/错误/部分正确/待批改) + * - 错题预览(题目文本、学生答案、正确答案) + */ +export function HomeworkSubmissionResult({ submission }: HomeworkSubmissionResultProps): JSX.Element { + const t = useTranslations("examHomework") + + const maxScore = submission.answers.reduce((sum, a) => sum + a.maxScore, 0) + const totalScore = submission.totalScore ?? 0 + const scorePercentage = maxScore > 0 ? (totalScore / maxScore) * 100 : 0 + + const stats = submission.answers.reduce( + (acc, a) => { + const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore }) + if (state === "correct") acc.correct += 1 + else if (state === "incorrect") acc.incorrect += 1 + else if (state === "partial") acc.partial += 1 + else acc.ungraded += 1 + return acc + }, + { correct: 0, incorrect: 0, partial: 0, ungraded: 0 } + ) + + const wrongAnswers = submission.answers.filter((a) => { + const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore }) + return state === "incorrect" || state === "partial" + }) + + const formatCorrectAnswer = (questionType: string, content: unknown): string => { + if (questionType === "single_choice" || questionType === "multiple_choice") { + const ids = getChoiceCorrectIds(content) + return ids.length > 0 ? ids.join(", ") : "—" + } + if (questionType === "judgment") { + const ans = getJudgmentCorrectAnswer(content) + if (ans === null) return "—" + return ans ? t("homework.review.correctAnswerTrue") : t("homework.review.correctAnswerFalse") + } + if (questionType === "text") { + const answers = getTextCorrectAnswers(content) + return answers.length > 0 ? answers.join(" / ") : "—" + } + return "—" + } + + return ( +
+ {/* Score Summary */} + + +
+ +
+ + {totalScore} + + {" / "}{maxScore} + + + + {t("homework.result.scoreRate")}: {scorePercentage.toFixed(1)}% + +
+ + + {submission.status === "graded" ? ( +

+ {t("homework.result.fullyGraded")} +

+ ) : ( +

+ {t("homework.result.partiallyGraded")} +

+ )} +
+
+ + {/* Stats Grid */} +
+ + + +
+

{stats.correct}

+

{t("homework.result.correctCount")}

+
+
+
+ + + +
+

{stats.incorrect}

+

{t("homework.result.incorrectCount")}

+
+
+
+ + + +
+

{stats.partial}

+

{t("homework.result.partialCount")}

+
+
+
+ + + +
+

{stats.ungraded}

+

{t("homework.result.pendingCount")}

+
+
+
+
+ + {/* Wrong Answers Preview */} + {wrongAnswers.length > 0 && ( + + + {t("homework.result.wrongAnswersTitle")} + {t("homework.result.wrongAnswersDesc")} + + +
+ {wrongAnswers.map((a, index) => { + const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore }) + return ( +
+
+ #{index + 1} + + {state === "incorrect" + ? t("homework.grade.incorrect") + : t("homework.grade.partial")} + + + {a.questionType} + + + {a.score ?? 0} / {a.maxScore} + +
+

+ {getQuestionText(a.questionContent) || t("homework.grade.noQuestionText")} +

+
+
+

+ {t("homework.review.yourAnswer")} +

+

+ {formatStudentAnswer(a.studentAnswer) || "—"} +

+
+
+

+ {t("homework.review.correctAnswer")} +

+

+ {formatCorrectAnswer(a.questionType, a.questionContent)} +

+
+
+ {a.feedback && ( +
+

+ {t("homework.review.teacherFeedback")} +

+

{a.feedback}

+
+ )} +
+ ) + })} +
+
+
+ )} + + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/src/modules/homework/components/homework-take-view.tsx b/src/modules/homework/components/homework-take-view.tsx index 5d14de0..3107207 100644 --- a/src/modules/homework/components/homework-take-view.tsx +++ b/src/modules/homework/components/homework-take-view.tsx @@ -125,18 +125,23 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView const handleStart = async () => { setIsBusy(true) - const fd = new FormData() - fd.set("assignmentId", assignmentId) - const res = await startHomeworkSubmissionAction(null, fd) - if (res.success && res.data) { - setSubmissionId(res.data) - setSubmissionStatus("started") - toast.success(t("homework.take.startSuccess")) - router.refresh() - } else { - toast.error(res.message || t("homework.take.startFailed")) + try { + const fd = new FormData() + fd.set("assignmentId", assignmentId) + const res = await startHomeworkSubmissionAction(null, fd) + if (res.success && res.data) { + setSubmissionId(res.data) + setSubmissionStatus("started") + toast.success(t("homework.take.startSuccess")) + router.refresh() + } else { + toast.error(res.message || t("homework.take.startFailed")) + } + } catch { + toast.error(t("homework.take.startFailed")) + } finally { + setIsBusy(false) } - setIsBusy(false) } const handleSaveQuestion = async (questionId: string) => { @@ -156,22 +161,29 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView const handleSubmit = async () => { if (!submissionId) return setIsBusy(true) - // P2-9: 提交前 flush 自动保存队列,确保所有答案已落库 - await autoSave.flush() + try { + // P2-9: 提交前 flush 自动保存队列,确保所有答案已落库 + // flush 失败应中止提交,避免丢失未保存的答案 + await autoSave.flush() - const submitFd = new FormData() - submitFd.set("submissionId", submissionId) - const submitRes = await submitHomeworkAction(null, submitFd) - if (submitRes.success) { - // 提交成功后清除离线缓存 - clearOfflineCache(offlineStorageKey) - toast.success(t("homework.take.submitSuccess")) - setSubmissionStatus("submitted") - router.push("/student/learning/assignments") - } else { - toast.error(submitRes.message || t("homework.take.submitFailed")) + const submitFd = new FormData() + submitFd.set("submissionId", submissionId) + const submitRes = await submitHomeworkAction(null, submitFd) + if (submitRes.success) { + // 提交成功后清除离线缓存 + clearOfflineCache(offlineStorageKey) + toast.success(t("homework.take.submitSuccess")) + setSubmissionStatus("submitted") + // V3-9: 提交后跳转到结果页,展示即时反馈 + router.push(`/student/learning/assignments/${assignmentId}/result`) + } else { + toast.error(submitRes.message || t("homework.take.submitFailed")) + } + } catch { + toast.error(t("homework.take.submitFailed")) + } finally { + setIsBusy(false) } - setIsBusy(false) } // 统计未作答题目数 @@ -378,7 +390,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }) }} className={cn( - "h-8 w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", + "h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input" )} aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })} diff --git a/src/modules/homework/data-access-write.ts b/src/modules/homework/data-access-write.ts index 9a3bf79..48ae8a5 100644 --- a/src/modules/homework/data-access-write.ts +++ b/src/modules/homework/data-access-write.ts @@ -21,6 +21,10 @@ import { type ExamWithQuestionsForHomework, } from "@/modules/exams/data-access" import type { DataScope } from "@/shared/types/permissions" +import { + autoGradeSubmission, + type AutoGradableAnswer, +} from "./lib/question-content-utils" // ---- Types ---- @@ -267,12 +271,84 @@ export const saveHomeworkAnswer = async ( export const markHomeworkSubmitted = async ( submissionId: string, isLate: boolean -): Promise => { +): Promise<{ isFullyAutoGraded: boolean; totalScore: number }> => { const now = new Date() - await db - .update(homeworkSubmissions) - .set({ status: "submitted", submittedAt: now, isLate, updatedAt: now }) - .where(eq(homeworkSubmissions.id, submissionId)) + + // V3-2: 即时自动批改回写 + // 1. 获取提交的所有答案 + 题目元数据 + // 2. 调用 autoGradeSubmission 计算分数 + // 3. 回写答案分数 + 提交状态 + 总分 + const submission = await db.query.homeworkSubmissions.findFirst({ + where: eq(homeworkSubmissions.id, submissionId), + columns: { id: true, assignmentId: true }, + }) + + if (!submission) { + throw new Error(`Submission not found: ${submissionId}`) + } + + const [answers, assignmentQuestions] = await Promise.all([ + db.query.homeworkAnswers.findMany({ + where: eq(homeworkAnswers.submissionId, submissionId), + columns: { id: true, questionId: true, answerContent: true, score: true, feedback: true }, + }), + db.query.homeworkAssignmentQuestions.findMany({ + where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId), + with: { question: { columns: { type: true, content: true } } }, + columns: { questionId: true, score: true, order: true }, + }), + ]) + + // 构建 AutoGradableAnswer 数组 + const questionMetaMap = new Map(assignmentQuestions.map((aq) => [aq.questionId, aq])) + const autoGradableInputs: AutoGradableAnswer[] = answers.map((ans) => { + const meta = questionMetaMap.get(ans.questionId) + return { + id: ans.id, + questionId: ans.questionId, + questionType: meta?.question.type ?? "text", + questionContent: meta?.question.content ?? null, + maxScore: meta?.score ?? 0, + studentAnswer: ans.answerContent, + score: ans.score, + feedback: ans.feedback, + order: meta?.order ?? 0, + } + }) + + // 执行自动批改 + const { answers: gradedAnswers, isFullyAutoGraded } = autoGradeSubmission(autoGradableInputs) + const totalScore = gradedAnswers.reduce((sum, a) => sum + a.score, 0) + + // 回写 DB:答案分数 + 提交状态 + await db.transaction(async (tx) => { + // 批量更新答案分数 + for (const ans of gradedAnswers) { + await tx + .update(homeworkAnswers) + .set({ score: ans.score, updatedAt: now }) + .where( + and( + eq(homeworkAnswers.id, ans.id), + eq(homeworkAnswers.submissionId, submissionId) + ) + ) + } + + // 更新提交状态:全部可自动判分 → graded;含主观题 → submitted + await tx + .update(homeworkSubmissions) + .set({ + status: isFullyAutoGraded ? "graded" : "submitted", + submittedAt: now, + isLate, + score: totalScore, + updatedAt: now, + }) + .where(eq(homeworkSubmissions.id, submissionId)) + }) + + return { isFullyAutoGraded, totalScore } } export const gradeHomeworkAnswers = async ( @@ -282,10 +358,17 @@ export const gradeHomeworkAnswers = async ( await db.transaction(async (tx) => { let totalScore = 0 for (const ans of answers) { + // 关键安全约束:WHERE 子句同时匹配 answer.id 和 submissionId, + // 防止恶意客户端篡改 answer ID 批改其他 submission 的答案 await tx .update(homeworkAnswers) .set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() }) - .where(eq(homeworkAnswers.id, ans.id)) + .where( + and( + eq(homeworkAnswers.id, ans.id), + eq(homeworkAnswers.submissionId, submissionId) + ) + ) totalScore += ans.score } @@ -295,3 +378,122 @@ export const gradeHomeworkAnswers = async ( .where(eq(homeworkSubmissions.id, submissionId)) }) } + +/** + * V3-7: 批量自动批改提交 + * + * 对多个提交执行自动批改(复用 autoGradeSubmission 逻辑)。 + * 仅批改客观题(选择题/判断题),主观题保持原分数。 + * + * @param submissionIds 要批量批改的提交 ID 列表 + * @returns 每个提交的批改结果摘要 + */ +export const batchAutoGradeSubmissions = async ( + submissionIds: string[] +): Promise> => { + const now = new Date() + const results: Array<{ + submissionId: string + success: boolean + isFullyAutoGraded: boolean + totalScore: number + message?: string + }> = [] + + for (const submissionId of submissionIds) { + try { + const submission = await db.query.homeworkSubmissions.findFirst({ + where: eq(homeworkSubmissions.id, submissionId), + columns: { id: true, assignmentId: true }, + }) + + if (!submission) { + results.push({ + submissionId, + success: false, + isFullyAutoGraded: false, + totalScore: 0, + message: "Submission not found", + }) + continue + } + + const [answers, assignmentQuestions] = await Promise.all([ + db.query.homeworkAnswers.findMany({ + where: eq(homeworkAnswers.submissionId, submissionId), + columns: { id: true, questionId: true, answerContent: true, score: true, feedback: true }, + }), + db.query.homeworkAssignmentQuestions.findMany({ + where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId), + with: { question: { columns: { type: true, content: true } } }, + columns: { questionId: true, score: true, order: true }, + }), + ]) + + const questionMetaMap = new Map(assignmentQuestions.map((aq) => [aq.questionId, aq])) + const autoGradableInputs: AutoGradableAnswer[] = answers.map((ans) => { + const meta = questionMetaMap.get(ans.questionId) + return { + id: ans.id, + questionId: ans.questionId, + questionType: meta?.question.type ?? "text", + questionContent: meta?.question.content ?? null, + maxScore: meta?.score ?? 0, + studentAnswer: ans.answerContent, + score: ans.score, + feedback: ans.feedback, + order: meta?.order ?? 0, + } + }) + + const { answers: gradedAnswers, isFullyAutoGraded } = autoGradeSubmission(autoGradableInputs) + const totalScore = gradedAnswers.reduce((sum, a) => sum + a.score, 0) + + await db.transaction(async (tx) => { + for (const ans of gradedAnswers) { + await tx + .update(homeworkAnswers) + .set({ score: ans.score, updatedAt: now }) + .where( + and( + eq(homeworkAnswers.id, ans.id), + eq(homeworkAnswers.submissionId, submissionId) + ) + ) + } + + await tx + .update(homeworkSubmissions) + .set({ + status: isFullyAutoGraded ? "graded" : "submitted", + score: totalScore, + updatedAt: now, + }) + .where(eq(homeworkSubmissions.id, submissionId)) + }) + + results.push({ + submissionId, + success: true, + isFullyAutoGraded, + totalScore, + }) + } catch (error) { + results.push({ + submissionId, + success: false, + isFullyAutoGraded: false, + totalScore: 0, + message: error instanceof Error ? error.message : "Unknown error", + }) + } + } + + return results +} diff --git a/src/modules/homework/data-access.ts b/src/modules/homework/data-access.ts index 2a6e949..90ae3b4 100644 --- a/src/modules/homework/data-access.ts +++ b/src/modules/homework/data-access.ts @@ -282,44 +282,44 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: const assignmentIds = assignments.map((a) => a.id) - const targetCountRows = await db - .select({ - assignmentId: homeworkAssignmentTargets.assignmentId, - targetCount: sql`COUNT(*)`, - }) - .from(homeworkAssignmentTargets) - .where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds)) - .groupBy(homeworkAssignmentTargets.assignmentId) + const [targetCountRows, submittedCountRows, gradedCountRows] = await Promise.all([ + db + .select({ + assignmentId: homeworkAssignmentTargets.assignmentId, + targetCount: sql`COUNT(*)`, + }) + .from(homeworkAssignmentTargets) + .where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds)) + .groupBy(homeworkAssignmentTargets.assignmentId), + db + .select({ + assignmentId: homeworkSubmissions.assignmentId, + submittedCount: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})`, + }) + .from(homeworkSubmissions) + .where( + and( + inArray(homeworkSubmissions.assignmentId, assignmentIds), + inArray(homeworkSubmissions.status, ["submitted", "graded"]) + ) + ) + .groupBy(homeworkSubmissions.assignmentId), + db + .select({ + assignmentId: homeworkSubmissions.assignmentId, + gradedCount: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})`, + }) + .from(homeworkSubmissions) + .where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded"))) + .groupBy(homeworkSubmissions.assignmentId), + ]) const targetCountByAssignmentId = new Map() for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0)) - const submittedCountRows = await db - .select({ - assignmentId: homeworkSubmissions.assignmentId, - submittedCount: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})`, - }) - .from(homeworkSubmissions) - .where( - and( - inArray(homeworkSubmissions.assignmentId, assignmentIds), - inArray(homeworkSubmissions.status, ["submitted", "graded"]) - ) - ) - .groupBy(homeworkSubmissions.assignmentId) - const submittedCountByAssignmentId = new Map() for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0)) - const gradedCountRows = await db - .select({ - assignmentId: homeworkSubmissions.assignmentId, - gradedCount: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})`, - }) - .from(homeworkSubmissions) - .where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded"))) - .groupBy(homeworkSubmissions.assignmentId) - const gradedCountByAssignmentId = new Map() for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0)) @@ -452,27 +452,26 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc } } - const [targetsRow] = await db - .select({ c: count() }) - .from(homeworkAssignmentTargets) - .where(eq(homeworkAssignmentTargets.assignmentId, id)) - - const [submissionsRow] = await db - .select({ c: count() }) - .from(homeworkSubmissions) - .where(eq(homeworkSubmissions.assignmentId, id)) - - const [submittedRow] = await db - .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) - .from(homeworkSubmissions) - .where( - and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"])) - ) - - const [gradedRow] = await db - .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) - .from(homeworkSubmissions) - .where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded"))) + const [targetsRows, submissionsRows, submittedRows, gradedRows] = await Promise.all([ + db + .select({ c: count() }) + .from(homeworkAssignmentTargets) + .where(eq(homeworkAssignmentTargets.assignmentId, id)), + db + .select({ c: count() }) + .from(homeworkSubmissions) + .where(eq(homeworkSubmissions.assignmentId, id)), + db + .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) + .from(homeworkSubmissions) + .where( + and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"])) + ), + db + .select({ c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) + .from(homeworkSubmissions) + .where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded"))), + ]) return { id: assignment.id, @@ -487,15 +486,137 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc allowLate: assignment.allowLate, lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null, maxAttempts: assignment.maxAttempts, - targetCount: targetsRow?.c ?? 0, - submissionCount: submissionsRow?.c ?? 0, - submittedCount: submittedRow?.c ?? 0, - gradedCount: gradedRow?.c ?? 0, + targetCount: targetsRows[0]?.c ?? 0, + submissionCount: submissionsRows[0]?.c ?? 0, + submittedCount: submittedRows[0]?.c ?? 0, + gradedCount: gradedRows[0]?.c ?? 0, createdAt: assignment.createdAt.toISOString(), updatedAt: assignment.updatedAt.toISOString(), } }) +/** + * V3-8: 获取关联到指定考试的所有作业(跨模块读接口) + * + * 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。 + */ +export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise> => { + const assignments = await db.query.homeworkAssignments.findMany({ + where: eq(homeworkAssignments.sourceExamId, examId), + columns: { id: true, title: true, status: true, dueAt: true }, + }) + + if (assignments.length === 0) return [] + + const assignmentIds = assignments.map((a) => a.id) + + const [targetsRows, submittedRows, gradedRows] = await Promise.all([ + db + .select({ assignmentId: homeworkAssignmentTargets.assignmentId, c: count() }) + .from(homeworkAssignmentTargets) + .where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds)) + .groupBy(homeworkAssignmentTargets.assignmentId), + db + .select({ assignmentId: homeworkSubmissions.assignmentId, c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) + .from(homeworkSubmissions) + .where( + and( + inArray(homeworkSubmissions.assignmentId, assignmentIds), + inArray(homeworkSubmissions.status, ["submitted", "graded"]) + ) + ) + .groupBy(homeworkSubmissions.assignmentId), + db + .select({ assignmentId: homeworkSubmissions.assignmentId, c: sql`COUNT(DISTINCT ${homeworkSubmissions.studentId})` }) + .from(homeworkSubmissions) + .where( + and( + inArray(homeworkSubmissions.assignmentId, assignmentIds), + eq(homeworkSubmissions.status, "graded") + ) + ) + .groupBy(homeworkSubmissions.assignmentId), + ]) + + const targetMap = new Map(targetsRows.map((r) => [r.assignmentId, Number(r.c)])) + const submittedMap = new Map(submittedRows.map((r) => [r.assignmentId, Number(r.c)])) + const gradedMap = new Map(gradedRows.map((r) => [r.assignmentId, Number(r.c)])) + + return assignments.map((a) => ({ + id: a.id, + title: a.title, + status: a.status, + targetCount: targetMap.get(a.id) ?? 0, + submittedCount: submittedMap.get(a.id) ?? 0, + gradedCount: gradedMap.get(a.id) ?? 0, + dueAt: a.dueAt ? a.dueAt.toISOString() : null, + })) +}) + +/** + * V3-8: 获取指定考试所有作业的已批改提交(跨模块读接口) + * + * 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。 + */ +export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise +}>> => { + const assignments = await db.query.homeworkAssignments.findMany({ + where: eq(homeworkAssignments.sourceExamId, examId), + columns: { id: true }, + }) + + if (assignments.length === 0) return [] + + const assignmentIds = assignments.map((a) => a.id) + + const submissions = await db.query.homeworkSubmissions.findMany({ + where: and( + inArray(homeworkSubmissions.assignmentId, assignmentIds), + eq(homeworkSubmissions.status, "graded") + ), + with: { + student: true, + answers: { + columns: { questionId: true, score: true, answerContent: true }, + }, + }, + orderBy: (s, { desc }) => [desc(s.updatedAt)], + }) + + // Deduplicate: keep only the latest submission per student + const latestByStudent = new Map() + for (const s of submissions) { + if (!latestByStudent.has(s.studentId)) latestByStudent.set(s.studentId, s) + } + + return Array.from(latestByStudent.values()).map((s) => ({ + submissionId: s.id, + assignmentId: s.assignmentId, + studentId: s.studentId, + studentName: s.student.name || "Unknown", + score: s.score ?? 0, + answers: s.answers.map((a) => ({ + questionId: a.questionId, + score: a.score ?? 0, + answerContent: a.answerContent, + })), + })) +}) + export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise => { const submission = await db.query.homeworkSubmissions.findFirst({ where: eq(homeworkSubmissions.id, submissionId), @@ -507,17 +628,18 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string): if (!submission) return null - const answers = await db.query.homeworkAnswers.findMany({ - where: eq(homeworkAnswers.submissionId, submissionId), - with: { - question: true, - }, - }) - - const assignmentQ = await db.query.homeworkAssignmentQuestions.findMany({ - where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId), - orderBy: [desc(homeworkAssignmentQuestions.order)], - }) + const [answers, assignmentQ] = await Promise.all([ + db.query.homeworkAnswers.findMany({ + where: eq(homeworkAnswers.submissionId, submissionId), + with: { + question: true, + }, + }), + db.query.homeworkAssignmentQuestions.findMany({ + where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId), + orderBy: [desc(homeworkAssignmentQuestions.order)], + }), + ]) const answersWithDetails = answers .map((ans) => { @@ -579,6 +701,89 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string): } }) +/** + * V3-9: 获取学生在指定作业的最新提交结果(用于提交后反馈页) + * + * 查找学生最近一次已提交/已批改的 submission,返回完整详情含答案。 + */ +export const getStudentSubmissionResult = cache(async ( + assignmentId: string, + studentId: string +): Promise => { + const latestSubmission = await db.query.homeworkSubmissions.findFirst({ + where: and( + eq(homeworkSubmissions.assignmentId, assignmentId), + eq(homeworkSubmissions.studentId, studentId), + inArray(homeworkSubmissions.status, ["submitted", "graded"]) + ), + orderBy: [desc(homeworkSubmissions.updatedAt)], + columns: { id: true }, + }) + + if (!latestSubmission) return null + + return getHomeworkSubmissionDetails(latestSubmission.id) +}) + +/** + * V3-11: 获取学生的考试结果列表(供家长端展示) + * + * 查找学生所有已批改的、关联到考试的作业提交, + * 返回考试标题、科目、分数、提交时间等。 + */ +export const getStudentExamResults = cache(async (studentId: string): Promise> => { + const submissions = await db.query.homeworkSubmissions.findMany({ + where: and( + eq(homeworkSubmissions.studentId, studentId), + eq(homeworkSubmissions.status, "graded") + ), + with: { + assignment: { + with: { sourceExam: true }, + }, + }, + orderBy: [desc(homeworkSubmissions.updatedAt)], + limit: 50, + }) + + // Filter to only exam-linked submissions, deduplicate by examId + const latestByExamId = new Map() + for (const s of submissions) { + const examId = s.assignment.sourceExamId + if (!examId) continue + if (!latestByExamId.has(examId)) latestByExamId.set(examId, s) + } + + const examIds = Array.from(latestByExamId.keys()) + if (examIds.length === 0) return [] + + // Get max scores for each assignment + const assignmentIds = Array.from(latestByExamId.values()).map((s) => s.assignmentId) + const maxScoreMap = await getAssignmentMaxScoreById(assignmentIds) + + return Array.from(latestByExamId.entries()).map(([examId, s]) => ({ + submissionId: s.id, + examId, + examTitle: s.assignment.sourceExam?.title ?? s.assignment.title, + assignmentId: s.assignmentId, + assignmentTitle: s.assignment.title, + score: s.score ?? 0, + maxScore: maxScoreMap.get(s.assignmentId) ?? 0, + submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null, + status: s.status ?? "graded", + })) +}) + const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => { if (v === "started") return "in_progress" if (v === "submitted") return "submitted" diff --git a/src/modules/parent/components/child-detail-panel.tsx b/src/modules/parent/components/child-detail-panel.tsx index 7ad7abb..115e5ae 100644 --- a/src/modules/parent/components/child-detail-panel.tsx +++ b/src/modules/parent/components/child-detail-panel.tsx @@ -1,26 +1,207 @@ +"use client" + +import { useMemo, useState } from "react" +import { + BarChart3, + CalendarDays, + ClipboardList, + GraduationCap, + Mail, + Stethoscope, +} from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" +import { cn } from "@/shared/lib/utils" +import type { ChildDashboardData } from "@/modules/parent/types" +import { ChildGradeDetail } from "./child-grade-detail" import { ChildGradeSummary } from "./child-grade-summary" +import { ChildHomeworkDetail } from "./child-homework-detail" import { ChildHomeworkSummary } from "./child-homework-summary" import { ChildScheduleCard } from "./child-schedule-card" -import type { ChildDashboardData } from "@/modules/parent/types" +import { ChildExamDetail } from "./child-exam-detail" -export function ChildDetailPanel({ child }: { child: ChildDashboardData }) { - const { basicInfo, todaySchedule, homeworkSummary, gradeTrend } = child +export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "schedule" | "attendance" | "diagnostic" + +const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"] + +const isTab = (v: string | undefined | null): v is ChildDetailTab => + typeof v === "string" && (VALID_TABS as string[]).includes(v) + +const resolveTab = (v: string | undefined | null): ChildDetailTab => + isTab(v) ? v : "overview" + +export function ChildDetailPanel({ + child, + initialTab, + siblingSwitcher, +}: { + child: ChildDashboardData + initialTab?: string + siblingSwitcher?: React.ReactNode +}) { + const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child const childName = basicInfo.name ?? "Child" + const [tab, setTab] = useState(resolveTab(initialTab)) + + const tabs = useMemo( + () => [ + { id: "overview" as const, label: "Overview", icon: ClipboardList }, + { id: "homework" as const, label: "Homework", icon: ClipboardList }, + { id: "grades" as const, label: "Grades", icon: BarChart3 }, + { id: "exams" as const, label: "Exams", icon: GraduationCap }, + { id: "schedule" as const, label: "Schedule", icon: CalendarDays }, + { id: "attendance" as const, label: "Attendance", icon: CalendarDays }, + { id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope }, + ], + [], + ) + return (
-
-
- setTab(v as ChildDetailTab)} className="w-full"> +
+ + {tabs.map((t) => ( + + + {t.label} + + ))} + +
+ + +
+
+ + +
+
+ +
+
+
+ + + - -
-
- -
+ + + +
+ +
+

+ Subject Analysis +

+ +
+
+
+ + + + + + + + + + +
+

+ Attendance details are available on the{" "} + + Attendance page + + . +

+
+
+ + +
+

+ Diagnostic reports will be available here once published by the school. +

+
+
+ + + +
+ ) +} + +/** 紧凑的子女切换器(用于详情页头部)。 */ +export function SiblingSwitcher({ + current, + siblings, +}: { + current: { id: string; name: string | null } + siblings: Array<{ id: string; name: string | null }> +}) { + if (siblings.length <= 1) return null + + return ( +
+ Switch child +
+ {siblings.map((s) => { + const isActive = s.id === current.id + const label = s.name ?? "Child" + return ( + + {label} + + ) + })}
) diff --git a/src/modules/parent/components/child-exam-detail.tsx b/src/modules/parent/components/child-exam-detail.tsx new file mode 100644 index 0000000..0cc6894 --- /dev/null +++ b/src/modules/parent/components/child-exam-detail.tsx @@ -0,0 +1,153 @@ +import type { JSX } from "react" +import Link from "next/link" +import { GraduationCap, TrendingUp, Award, BookOpen } from "lucide-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { Progress } from "@/shared/components/ui/progress" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { formatDate } from "@/shared/lib/utils" + +export interface ChildExamResultItem { + submissionId: string + examId: string + examTitle: string + assignmentId: string + assignmentTitle: string + score: number + maxScore: number + submittedAt: string | null + status: string +} + +interface ChildExamDetailProps { + examResults: ChildExamResultItem[] + childId: string + childName: string +} + +/** + * V3-11: 家长端子女考试详情视图 + * + * 对标智学网家长端,展示: + * - 考试成绩汇总卡片(已参加考试数、平均分、最高分) + * - 考试成绩列表(考试标题、分数、得分率、提交时间) + * - 成绩趋势可视化 + */ +export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element { + const hasResults = examResults.length > 0 + + const examCount = examResults.length + const averageScore = hasResults + ? examResults.reduce((sum, r) => { + const rate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0 + return sum + rate + }, 0) / examCount + : 0 + const bestScore = hasResults + ? Math.max(...examResults.map((r) => (r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0))) + : 0 + + return ( +
+ {/* Summary Cards */} +
+ + +
+ +
+
+

{examCount}

+

Exams Taken

+
+
+
+ + +
+ +
+
+

{averageScore.toFixed(1)}%

+

Average Score

+
+
+
+ + +
+ +
+
+

{bestScore.toFixed(1)}%

+

Best Score

+
+
+
+
+ + {/* Exam Results List */} + + + + + {childName}'s Exam Results + + Recent exam scores and performance trends + + + {!hasResults ? ( + + ) : ( +
+ {examResults.map((r) => { + const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0 + const isPass = scoreRate >= 60 + return ( + +
+
+
{r.examTitle}
+ + {isPass ? "Pass" : "Below 60%"} + +
+
+ {r.submittedAt ? ( + {formatDate(r.submittedAt)} + ) : null} + + + {r.score} / {r.maxScore} + +
+ +
+
+ {scoreRate.toFixed(0)}% +
+ + ) + })} +
+ )} +
+
+
+ ) +} diff --git a/src/modules/parent/data-access.ts b/src/modules/parent/data-access.ts index 2f1ccfc..762da85 100644 --- a/src/modules/parent/data-access.ts +++ b/src/modules/parent/data-access.ts @@ -13,6 +13,7 @@ import { import { getStudentDashboardGrades, getStudentHomeworkAssignments, + getStudentExamResults, } from "@/modules/homework/data-access" import { getStudentGradeSummary } from "@/modules/grades/data-access" import { getGradeNameById } from "@/modules/school/data-access" @@ -22,6 +23,7 @@ import type { ChildDashboardData, ChildHomeworkSummaryData, ChildScheduleItem, + ChildWeeklyScheduleItem, ParentChildRelation, ParentDashboardData, } from "./types" @@ -174,26 +176,50 @@ const buildTodaySchedule = ( .sort((a, b) => a.startTime.localeCompare(b.startTime)) } +const buildWeeklySchedule = ( + schedule: Awaited>, +): ChildWeeklyScheduleItem[] => { + return schedule + .map((s) => ({ + id: s.id, + classId: s.classId, + className: s.className, + course: s.course, + startTime: s.startTime, + endTime: s.endTime, + location: s.location ?? null, + weekday: s.weekday, + })) + .sort((a, b) => + a.weekday === b.weekday + ? a.startTime.localeCompare(b.startTime) + : a.weekday - b.weekday, + ) +} + export const getChildDashboardData = cache( async (studentId: string, relation: string | null = null): Promise => { const basicInfo = await getChildBasicInfo(studentId, relation) if (!basicInfo) return null - const [enrolledClasses, schedule, assignments, gradeTrend, gradeSummary] = await Promise.all([ + const [enrolledClasses, schedule, assignments, gradeTrend, gradeSummary, examResults] = await Promise.all([ getStudentClasses(studentId), getStudentSchedule(studentId), getStudentHomeworkAssignments(studentId), getStudentDashboardGrades(studentId), getStudentGradeSummary(studentId), + getStudentExamResults(studentId), ]) return { basicInfo, enrolledClasses, todaySchedule: buildTodaySchedule(schedule), + weeklySchedule: buildWeeklySchedule(schedule), homeworkSummary: buildHomeworkSummary(assignments), gradeTrend, gradeSummary, + examResults, } }, ) @@ -224,3 +250,20 @@ export const getParentDashboardData = cache( } }, ) + +/** + * 获取家长所有子女的轻量列表(id + name),用于详情页头部多子女切换器。 + * 一次批量查询,避免 N+1。 + */ +export const getChildNameList = cache( + async (parentId: string): Promise> => { + const relations = await getChildren(parentId) + if (relations.length === 0) return [] + + const nameMap = await getUserNamesByIds(relations.map((r) => r.studentId)) + return relations.map((r) => ({ + id: r.studentId, + name: nameMap.get(r.studentId)?.name ?? null, + })) + }, +) diff --git a/src/modules/parent/types.ts b/src/modules/parent/types.ts index 140b798..3e29674 100644 --- a/src/modules/parent/types.ts +++ b/src/modules/parent/types.ts @@ -66,6 +66,21 @@ export type ChildDashboardData = { /** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */ gradeTrend: StudentDashboardGradeProps gradeSummary: StudentGradeSummary | null + /** V3-11: 考试结果列表(已批改的考试关联作业提交) */ + examResults: ChildExamResultItem[] +} + +/** V3-11: 单条考试结果(家长端展示用) */ +export type ChildExamResultItem = { + submissionId: string + examId: string + examTitle: string + assignmentId: string + assignmentTitle: string + score: number + maxScore: number + submittedAt: string | null + status: string } /** 家长仪表盘聚合数据(家长姓名 + 所有子女数据)。 */ diff --git a/src/shared/components/charts/trend-line-chart.tsx b/src/shared/components/charts/trend-line-chart.tsx index a77d8fe..d5c8687 100644 --- a/src/shared/components/charts/trend-line-chart.tsx +++ b/src/shared/components/charts/trend-line-chart.tsx @@ -41,7 +41,7 @@ export interface TrendLineSeries { interface TrendLineChartProps { /** 图表数据 */ - data: Array> + data: Array> /** 折线系列配置(支持单条或多条) */ series: TrendLineSeries[] /** X 轴数据字段名(默认 "title") */ diff --git a/src/shared/i18n/messages/en/exam-homework.json b/src/shared/i18n/messages/en/exam-homework.json index 55f3539..fe58899 100644 --- a/src/shared/i18n/messages/en/exam-homework.json +++ b/src/shared/i18n/messages/en/exam-homework.json @@ -96,6 +96,33 @@ "error": { "notFound": "Exam not found", "loadFailed": "Failed to load exam" + }, + "analytics": { + "title": "Exam Analytics", + "description": "View score distribution and per-question analysis", + "totalStudents": "Total Students", + "submitted": "Submitted", + "gradedCount": "Graded", + "assignmentCount": "Assignments", + "averageScore": "Average Score", + "passRate": "Pass Rate", + "scoreDistribution": "Score Distribution", + "scoreDistributionDesc": "Student count by percentage range", + "questionAnalysis": "Question Analysis", + "questionAnalysisDesc": "Error rate and difficulty (error rate >= 70% is hard)", + "questionType": "Type", + "questionText": "Question", + "maxScore": "Max Score", + "errorCount": "Errors", + "errorRate": "Error Rate", + "difficulty": "Difficulty", + "difficultyEasy": "Easy", + "difficultyMedium": "Medium", + "difficultyHard": "Hard", + "highErrorWarning": "High Error Rate Warning", + "highErrorWarningDesc": "The following questions have an error rate above 70% and are recommended for focused review", + "noData": "No analytics data yet. Data will be available after students submit and grading is complete.", + "viewAnalytics": "View Analytics" } }, "homework": { @@ -249,7 +276,13 @@ "nextStudent": "Next Student", "prev": "Prev", "next": "Next", - "gradesAutoSaveNote": "Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit." + "gradesAutoSaveNote": "Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.", + "batchAutoGrade": "Batch Auto-Grade", + "batchSelected": "{{count}} submissions selected", + "batchSelectAtLeastOne": "Please select at least one submission", + "batchFailed": "Batch grading failed", + "selectAll": "Select All", + "selectRow": "Select this row" }, "review": { "title": "Review", @@ -269,7 +302,23 @@ "responseSummary": "Response Summary", "description": "Description", "noDescription": "No description provided.", - "totalScore": "Total Score" + "totalScore": "Total Score", + "correctAnswerTrue": "True", + "correctAnswerFalse": "False" + }, + "result": { + "title": "Submission Result", + "scoreRate": "Score Rate", + "fullyGraded": "All questions have been graded", + "partiallyGraded": "Objective questions auto-graded. Subjective questions awaiting teacher review.", + "correctCount": "Correct", + "incorrectCount": "Incorrect", + "partialCount": "Partial", + "pendingCount": "Pending", + "wrongAnswersTitle": "Wrong Answers Preview", + "wrongAnswersDesc": "These questions need focused review", + "backToList": "Back to Assignments", + "viewErrorBook": "View Error Book" }, "status": { "draft": "Draft", diff --git a/src/shared/i18n/messages/zh-CN/exam-homework.json b/src/shared/i18n/messages/zh-CN/exam-homework.json index 44c4adc..f8b3c54 100644 --- a/src/shared/i18n/messages/zh-CN/exam-homework.json +++ b/src/shared/i18n/messages/zh-CN/exam-homework.json @@ -96,6 +96,33 @@ "error": { "notFound": "考试不存在", "loadFailed": "加载考试失败" + }, + "analytics": { + "title": "考试分析", + "description": "查看考试成绩分布与逐题分析", + "totalStudents": "应考人数", + "submitted": "已提交", + "gradedCount": "已批改", + "assignmentCount": "关联作业数", + "averageScore": "平均分", + "passRate": "及格率", + "scoreDistribution": "分数段分布", + "scoreDistributionDesc": "按百分比区间统计学生人数", + "questionAnalysis": "逐题分析", + "questionAnalysisDesc": "错误率与难度等级(错误率≥70%为难题)", + "questionType": "题型", + "questionText": "题目", + "maxScore": "满分", + "errorCount": "错误数", + "errorRate": "错误率", + "difficulty": "难度", + "difficultyEasy": "简单", + "difficultyMedium": "中等", + "difficultyHard": "困难", + "highErrorWarning": "高错误率预警", + "highErrorWarningDesc": "以下题目错误率超过 70%,建议重点讲解", + "noData": "暂无分析数据,需等待学生提交并批改后生成", + "viewAnalytics": "查看分析" } }, "homework": { @@ -249,7 +276,13 @@ "nextStudent": "下一名学生", "prev": "上一页", "next": "下一页", - "gradesAutoSaveNote": "点击提交后成绩将自动保存。学生将在您提交后立即看到成绩和反馈。" + "gradesAutoSaveNote": "点击提交后成绩将自动保存。学生将在您提交后立即看到成绩和反馈。", + "batchAutoGrade": "批量自动批改", + "batchSelected": "已选 {{count}} 份提交", + "batchSelectAtLeastOne": "请至少选择一份提交", + "batchFailed": "批量批改失败", + "selectAll": "全选", + "selectRow": "选择此行" }, "review": { "title": "复习", @@ -269,7 +302,23 @@ "responseSummary": "作答概览", "description": "描述", "noDescription": "无描述。", - "totalScore": "总分" + "totalScore": "总分", + "correctAnswerTrue": "正确", + "correctAnswerFalse": "错误" + }, + "result": { + "title": "提交结果", + "scoreRate": "得分率", + "fullyGraded": "所有题目已批改完成", + "partiallyGraded": "客观题已自动批改,主观题等待教师批改", + "correctCount": "正确", + "incorrectCount": "错误", + "partialCount": "部分正确", + "pendingCount": "待批改", + "wrongAnswersTitle": "错题预览", + "wrongAnswersDesc": "以下题目需要重点复习", + "backToList": "返回作业列表", + "viewErrorBook": "查看错题本" }, "status": { "draft": "草稿", diff --git a/src/shared/services/exam-homework-port.ts b/src/shared/services/exam-homework-port.ts index 78f2579..75e7a4a 100644 --- a/src/shared/services/exam-homework-port.ts +++ b/src/shared/services/exam-homework-port.ts @@ -18,6 +18,7 @@ import type { DataScope } from "@/shared/types/permissions" import type { Exam } from "@/modules/exams/types" import type { HomeworkAssignmentListItem } from "@/modules/homework/types" +import type { ExamWithQuestionsForHomework } from "@/modules/exams/data-access" /** * 考试/作业模块对外暴露的服务契约。 @@ -54,15 +55,7 @@ export interface ExamHomeworkServicePort { // ===== 跨模块 ===== /** 获取考试及其题目(供作业模块引用考试内容时使用) */ - getExamWithQuestionsForHomework(examId: string): Promise<{ - exam: Pick - questions: Array<{ - id: string - questionType: string - questionContent: unknown - maxScore: number - }> - } | null> + getExamWithQuestionsForHomework(examId: string): Promise } /**