feat(exams,homework,parent): V3 审计深度修复 — 批量批改/考试分析/提交反馈/家长视图/移动端优化
V3-5: exam-actions.tsx 集成 useExamHomeworkFeatures hook,按角色控制菜单项可见性 V3-7: 批量批改 — 新增 batchAutoGradeSubmissions data-access + Server Action + HomeworkBatchGradingView 组件 V3-8: 考试分析仪表盘 — 新增 getExamAnalytics stats-service + ExamAnalyticsDashboard 组件 + /teacher/exams/[id]/analytics 路由 V3-9: 提交后即时反馈页 — 新增 HomeworkSubmissionResult 组件 + /student/learning/assignments/[id]/result 路由 V3-11: 家长考试详情 — 新增 ChildExamDetail 组件 + getStudentExamResults data-access + child-detail-panel exams Tab V3-12: 移动端触控优化 — 题目导航与考试操作按钮 44px 最小触控目标 修复: instrumentation.ts 适配器补全 questionCount/averageScore/overdueCount 字段 修复: exam-homework-port.ts 类型导入对齐 ExamWithQuestionsForHomework 修复: trend-line-chart.tsx 数据类型允许 undefined(classAverage 可选场景) 同步更新 004/005 架构文档
This commit is contained in:
@@ -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 新增)
|
- Data-access:`getExams` / `getExamById` / `persistExamDraft` / `persistAiGeneratedExamDraft` / `buildExamDescription` / `resolveSubjectGradeNames` / `getExamCreatorId` / `updateExamWithQuestions` / `deleteExamById` / `duplicateExam` / `getExamPreview` / `getExamSubjects` / `getExamGrades`(后 7 个为 P1-2 新增)
|
||||||
- AI Pipeline:`generateAiCreateDraftFromSource` / `generateAiPreviewData` / `regenerateAiQuestionByInstruction`
|
- AI Pipeline:`generateAiCreateDraftFromSource` / `generateAiPreviewData` / `regenerateAiQuestionByInstruction`
|
||||||
- Utils:`normalizeStructure`(v3 新增:将持久化的 `exam.structure` unknown JSON 运行时校验并归一化为类型安全的 `ExamNode[]`,类型守卫模式无 `as` 断言,从 `teacher/exams/[id]/build/page.tsx` 提取)
|
- 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)
|
- 被依赖:`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-2 已修复:~~`actions.ts` 832 行(超 800 建议),多处直接 DB 操作~~ DB 操作已下沉到 data-access,actions.ts 现 691 行
|
||||||
- ⚠️ P1:`ai-pipeline.ts` 857 行(超 800 建议),混合 4 类职责
|
- ⚠️ P1:`ai-pipeline.ts` 857 行(超 800 建议),混合 4 类职责
|
||||||
- ✅ P2 已修复:`ai-pipeline.ts` 中 3 处非空断言清理(`draft.sections!.forEach` → 安全守卫、`aiParsed.sections!.flatMap` → `?? []`、`aiParsed.sections!.map` → `?? []`)
|
- ✅ 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 操作) |
|
| `actions.ts` | 691 | 10 个 Server Action(P1-2 已修复,无直接 DB 操作) |
|
||||||
| `ai-pipeline.ts` | 857 | AI 出题管线(超限) |
|
| `ai-pipeline.ts` | 857 | AI 出题管线(超限) |
|
||||||
| `data-access.ts` | 473 | 考试 CRUD(含 P1-2 新增 7 个写/查询函数,P0-1/P0-2 已修复:通过 questions/classes data-access 跨模块通信) |
|
| `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 | 类型定义 |
|
| `types.ts` | 31 | 类型定义 |
|
||||||
| `hooks/use-exam-preview.ts` | 295 | 预览 Hook |
|
| `hooks/use-exam-preview.ts` | 295 | 预览 Hook |
|
||||||
| `utils/normalize-structure.ts` | 57 | v3 新增:exam.structure 运行时校验与归一化(从 build/page.tsx 提取) |
|
| `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)
|
- 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 使用)
|
- Data-access:`getHomeworkAssignments` / `getHomeworkAssignmentById` / `getHomeworkSubmissions` / `getStudentHomeworkAssignments` / `getStudentHomeworkTakeData` / `getHomeworkAssignmentReviewList` / `getHomeworkSubmissionDetails` / `getDemoStudentUser`(已迁移至 users 模块 `getCurrentStudentUser`,此处为 re-export 向后兼容)/ `isRecord` / `toQuestionContent` / `getAssignmentMaxScoreById`(后三者供 stats-service 使用)/ `getHomeworkAssignmentsByExamId`(V3-8 新增:按考试 ID 查作业+目标/提交/批改计数)/ `getGradedSubmissionsByExamId`(V3-8 新增:按考试 ID 查已批改提交,按学生去重)/ `getStudentSubmissionResult`(V3-9 新增:查学生指定作业最新提交,用于结果页)/ `getStudentExamResults`(V3-11 新增:查学生考试结果列表,供家长端展示)
|
||||||
- Data-access-classes:`getAssignmentIdsForStudents` / `getHomeworkAssignmentsWithSubject` / `getHomeworkAssignmentsByIds` / `getAssignmentTargetCounts` / `getHomeworkSubmissionsForStudents` / `getPublishedHomeworkAssignmentsWithSubject` / `getHomeworkSubmissionsForAssignments`(P0-7 新增,供 classes 模块跨模块调用,封装 homework/exams 表查询)
|
- Data-access-classes:`getAssignmentIdsForStudents` / `getHomeworkAssignmentsWithSubject` / `getHomeworkAssignmentsByIds` / `getAssignmentTargetCounts` / `getHomeworkSubmissionsForStudents` / `getPublishedHomeworkAssignmentsWithSubject` / `getHomeworkSubmissionsForAssignments`(P0-7 新增,供 classes 模块跨模块调用,封装 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 以保持向后兼容)
|
- Stats-service:`getTeacherGradeTrends` / `getHomeworkAssignmentAnalytics` / `getStudentDashboardGrades`(从 data-access.ts re-export 以保持向后兼容)
|
||||||
|
- Components(V3-7/V3-9 新增):`HomeworkBatchGradingView`(批量批改视图:勾选+一键批改+toast 反馈)/ `HomeworkSubmissionResult`(提交后即时反馈:分数汇总+对错分布+错题预览)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamIdsByGradeIds/getExamSubjectIdMap/getExamWithQuestionsForHomework)、`classes`(✅ P1-1 已修复:通过 classes data-access.getStudentIdsByClassId 等 7 个函数)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserWithRole/getUserNamesByIds)
|
- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamIdsByGradeIds/getExamSubjectIdMap/getExamWithQuestionsForHomework)、`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`
|
- ✅ 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 模块的导出函数,恢复三层架构
|
- ✅ P0-7 已修复:新增 `data-access-classes.ts`,将 classes 模块对 homework/exams 表的直查封装为 homework 模块的导出函数,恢复三层架构
|
||||||
- ✅ P1-1 已修复:~~5 处直查 `exams` 表~~ 改为调用 `exams/data-access.getExamIdsByGradeIds` / `getExamSubjectIdMap` / `getExamWithQuestionsForHomework`
|
- ✅ P1-1 已修复:~~5 处直查 `exams` 表~~ 改为调用 `exams/data-access.getExamIdsByGradeIds` / `getExamSubjectIdMap` / `getExamWithQuestionsForHomework`
|
||||||
- ✅ P1-2 已修复:~~`actions.ts` 多处直接 DB 操作(`createHomeworkAssignmentAction` 157 行)~~ DB 操作已下沉到 `data-access-write.ts`,actions.ts 现 239 行
|
- ✅ P1-2 已修复:~~`actions.ts` 多处直接 DB 操作(`createHomeworkAssignmentAction` 157 行)~~ DB 操作已下沉到 `data-access-write.ts`,actions.ts 现 239 行
|
||||||
|
- ✅ V3-7:新增 `batchAutoGradeSubmissionsAction` + `batchAutoGradeSubmissions` + `HomeworkBatchGradingView`,提交列表页接入批量批改
|
||||||
|
- ✅ V3-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.ts` | 598+ | 作业 CRUD + 学生视角 + 批改(含 re-export stats 函数;V3-8/V3-9/V3-11 新增 4 个查询函数) |
|
||||||
| `data-access-write.ts` | 285 | 作业写操作(P1-2 新增,10 个写函数从 actions 下沉) |
|
| `data-access-write.ts` | 285+ | 作业写操作(P1-2 新增 10 个写函数从 actions 下沉;V3-7 新增 `batchAutoGradeSubmissions`) |
|
||||||
| `data-access-classes.ts` | 232 | 跨模块查询封装(P0-7 新增,供 classes 模块调用,封装 homework/exams 表查询) |
|
| `data-access-classes.ts` | 232 | 跨模块查询封装(P0-7 新增,供 classes 模块调用,封装 homework/exams 表查询) |
|
||||||
| `stats-service.ts` | 425 | 统计分析(教师趋势/作业分析/学生仪表盘成绩) |
|
| `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 | 类型定义 |
|
| `types.ts` | 186 | 类型定义 |
|
||||||
| `schema.ts` | 29 | Zod 校验 |
|
| `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)
|
- Data-access:`getChildren` / `getChildBasicInfo` / `getChildDashboardData`(V3-11 增强:并行调用 `homework/data-access.getStudentExamResults` 获取考试结果)/ `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 新增)
|
- 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 维度)**:
|
**v4 修复(产品/UX 维度)**:
|
||||||
- ✅ FEAT-G01:新增 `/parent/leave` 请假申请占位页(含 loading.tsx)
|
- ✅ FEAT-G01:新增 `/parent/leave` 请假申请占位页(含 loading.tsx)
|
||||||
@@ -1324,8 +1339,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ MOBILE-P03:移动端子女卡片改为水平滑动 Carousel(snap-x)
|
- ✅ MOBILE-P03:移动端子女卡片改为水平滑动 Carousel(snap-x)
|
||||||
- ✅ MOBILE-P04:作业/成绩列表项 `min-h-[44px]` 触摸区域
|
- ✅ 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`) |
|
| `data-access.ts` | 243+ | 子女关系 + 仪表盘数据聚合 + 关系校验 + 子女姓名列表(v4 新增 `getChildNameList` + `buildWeeklySchedule`;V3-11 `getChildDashboardData` 并行调用 `getStudentExamResults`) |
|
||||||
| `types.ts` | 79 | 类型定义(含 JSDoc,v4 新增 `ChildWeeklyScheduleItem`) |
|
| `types.ts` | 79+ | 类型定义(含 JSDoc,v4 新增 `ChildWeeklyScheduleItem`;V3-11 新增 `ChildExamResultItem` + `ChildDashboardData.examResults`) |
|
||||||
| `components/parent-dashboard.tsx` | 110 | 仪表盘(v4 重构:待办横幅 + 宫格快捷入口 + 移动端水平滑动) |
|
| `components/parent-dashboard.tsx` | 110 | 仪表盘(v4 重构:待办横幅 + 宫格快捷入口 + 移动端水平滑动) |
|
||||||
| `components/parent-attention-banner.tsx` | 128 | v4 新增:待办事项/异常聚合横幅(作业项直接跳转详情页 homework tab) |
|
| `components/parent-attention-banner.tsx` | 128 | v4 新增:待办事项/异常聚合横幅(作业项直接跳转详情页 homework tab) |
|
||||||
| `components/parent-attendance-warning.tsx` | 89 | v4 新增:考勤异常预警 |
|
| `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/parent-export-button.tsx` | 50 | v4 新增:成绩导出按钮(占位) |
|
||||||
| `components/child-card.tsx` | 148 | 子女卡片(v4 增强:异常突出 + 趋势图标) |
|
| `components/child-card.tsx` | 148 | 子女卡片(v4 增强:异常突出 + 趋势图标) |
|
||||||
| `components/child-detail-header.tsx` | 78 | 详情页头部(v4 增强:面包屑) |
|
| `components/child-detail-header.tsx` | 78 | 详情页头部(v4 增强:面包屑) |
|
||||||
| `components/child-detail-panel.tsx` | 200 | 详情页 Tab 面板 + 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-summary.tsx` | 147 | 作业摘要(v4 增强:科目标识 + 触摸区域 + pts 单位) |
|
||||||
| `components/child-homework-detail.tsx` | 145 | v4 新增:作业详情视图(完整作业信息) |
|
| `components/child-homework-detail.tsx` | 145 | v4 新增:作业详情视图(完整作业信息) |
|
||||||
| `components/child-grade-summary.tsx` | 159 | 成绩趋势(v4 增强:趋势图标 + aria-label) |
|
| `components/child-grade-summary.tsx` | 159 | 成绩趋势(v4 增强:趋势图标 + aria-label) |
|
||||||
| `components/child-grade-detail.tsx` | 165 | v4 新增:成绩详情视图(按科目分组分析) |
|
| `components/child-grade-detail.tsx` | 165 | v4 新增:成绩详情视图(按科目分组分析) |
|
||||||
|
| `components/child-exam-detail.tsx` | - | V3-11 新增:子女考试详情视图(汇总卡片+考试列表,对标智学网家长端) |
|
||||||
| `components/child-schedule-card.tsx` | 119 | 课表卡片(v4 增强:周课表视图) |
|
| `components/child-schedule-card.tsx` | 119 | 课表卡片(v4 增强:周课表视图) |
|
||||||
| `components/parent-children-data-page.tsx` | 92 | 共享数据页(v4 增强:headerExtra) |
|
| `components/parent-children-data-page.tsx` | 92 | 共享数据页(v4 增强:headerExtra) |
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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<JSX.Element> {
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{t("homework.result.title")}</h1>
|
||||||
|
<p className="text-muted-foreground">{assignment.title}</p>
|
||||||
|
</div>
|
||||||
|
<HomeworkSubmissionResult submission={submission} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx
Normal file
57
src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx
Normal file
@@ -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<JSX.Element> {
|
||||||
|
const { id } = await params
|
||||||
|
const t = await getTranslations("examHomework")
|
||||||
|
|
||||||
|
const [exam, analytics] = await Promise.all([
|
||||||
|
getExamById(id),
|
||||||
|
getExamAnalytics(id),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!exam) return notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{t("exam.analytics.title")}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground truncate">{exam.title}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t("exam.analytics.description")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/teacher/exams/all">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t("homework.grade.back")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analytics && analytics.gradedCount > 0 ? (
|
||||||
|
<ExamAnalyticsDashboard analytics={analytics} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title={t("exam.analytics.title")}
|
||||||
|
description={t("exam.analytics.noData")}
|
||||||
|
icon={BarChart3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,18 +2,9 @@ import type { JSX } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
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 { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access"
|
||||||
|
import { HomeworkBatchGradingView } from "@/modules/homework/components/homework-batch-grading-view"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -52,39 +43,7 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border bg-card">
|
<HomeworkBatchGradingView submissions={submissions} />
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{t("homework.grade.student")}</TableHead>
|
|
||||||
<TableHead>{t("homework.grade.status")}</TableHead>
|
|
||||||
<TableHead>{t("homework.grade.submitted")}</TableHead>
|
|
||||||
<TableHead>{t("homework.grade.score")}</TableHead>
|
|
||||||
<TableHead>{t("homework.grade.action")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{submissions.map((s) => (
|
|
||||||
<TableRow key={s.id}>
|
|
||||||
<TableCell className="font-medium truncate max-w-[160px]">{s.studentName}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{s.status}
|
|
||||||
</Badge>
|
|
||||||
{s.isLate ? <span className="ml-2 text-xs text-destructive">{t("homework.grade.late")}</span> : null}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
|
|
||||||
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Link href={`/teacher/homework/submissions/${s.id}`} className="text-sm underline-offset-4 hover:underline">
|
|
||||||
{t("homework.grade.title")}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/instrumentation.ts
Normal file
80
src/instrumentation.ts
Normal file
@@ -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<ReturnType<typeof getExamById>>): Exam | null => {
|
||||||
|
if (!raw) return null
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
questionCount: raw.questions?.length ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 适配器:将 getHomeworkAssignmentById 的返回值补全为 HomeworkAssignmentListItem 类型
|
||||||
|
* data-access 返回的对象缺少 averageScore 和 overdueCount 字段
|
||||||
|
*/
|
||||||
|
const adaptAssignment = (raw: Awaited<ReturnType<typeof getHomeworkAssignmentById>>): 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<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
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 { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -36,6 +36,8 @@ import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreview
|
|||||||
import { Exam } from "../types"
|
import { Exam } from "../types"
|
||||||
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
||||||
import type { ExamNode } from "./assembly/selected-question-list"
|
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
|
// Raw structure node shape returned from the DB before hydration
|
||||||
type RawStructureNode = {
|
type RawStructureNode = {
|
||||||
@@ -65,6 +67,7 @@ interface ExamActionsProps {
|
|||||||
export function ExamActions({ exam }: ExamActionsProps) {
|
export function ExamActions({ exam }: ExamActionsProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const t = useTranslations("examHomework")
|
const t = useTranslations("examHomework")
|
||||||
|
const features = useExamHomeworkFeatures()
|
||||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const [isWorking, setIsWorking] = useState(false)
|
||||||
@@ -83,7 +86,8 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
if (node.type === "question") {
|
if (node.type === "question") {
|
||||||
return {
|
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,
|
type: "question" as const,
|
||||||
questionId: node.questionId,
|
questionId: node.questionId,
|
||||||
score: node.score,
|
score: node.score,
|
||||||
@@ -92,7 +96,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
}
|
}
|
||||||
if (node.type === "group") {
|
if (node.type === "group") {
|
||||||
return {
|
return {
|
||||||
id: node.id ?? "",
|
id: node.id ?? createId(),
|
||||||
type: "group" as const,
|
type: "group" as const,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
score: node.score,
|
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
|
// Unknown node type: treat as group with no children to avoid runtime crash
|
||||||
return {
|
return {
|
||||||
id: node.id ?? "",
|
id: node.id ?? createId(),
|
||||||
type: "group" as const,
|
type: "group" as const,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
children: [],
|
children: [],
|
||||||
@@ -124,8 +128,13 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyId = () => {
|
const copyId = () => {
|
||||||
navigator.clipboard.writeText(exam.id)
|
try {
|
||||||
|
void navigator.clipboard.writeText(exam.id)
|
||||||
toast.success(t("exam.actions.idCopied"))
|
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"]) => {
|
const setStatus = async (status: Exam["status"]) => {
|
||||||
@@ -194,7 +203,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
className="h-11 w-11 sm:h-8 sm:w-8 text-muted-foreground hover:text-foreground"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleView()
|
handleView()
|
||||||
@@ -206,7 +215,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label={t("exam.actions.openMenu")}>
|
<Button variant="ghost" className="h-11 w-11 sm:h-8 sm:w-8 p-0" aria-label={t("exam.actions.openMenu")}>
|
||||||
<span className="sr-only">{t("exam.actions.openMenu")}</span>
|
<span className="sr-only">{t("exam.actions.openMenu")}</span>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -217,16 +226,31 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.copyId")}
|
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.copyId")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{features.canBuild && (
|
||||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||||
<Pencil className="mr-2 h-4 w-4" /> {t("exam.actions.edit")}
|
<Pencil className="mr-2 h-4 w-4" /> {t("exam.actions.edit")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{features.canBuild && (
|
||||||
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
|
||||||
<MoreHorizontal className="mr-2 h-4 w-4" /> {t("exam.actions.build")}
|
<MoreHorizontal className="mr-2 h-4 w-4" /> {t("exam.actions.build")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{features.canViewStats && (
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/analytics`)}>
|
||||||
|
<BarChart3 className="mr-2 h-4 w-4" /> {t("exam.analytics.viewAnalytics")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{features.canCreate && (
|
||||||
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
|
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
|
||||||
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.duplicate")}
|
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.duplicate")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{features.canPublish && (
|
||||||
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setStatus("published")}
|
onClick={() => setStatus("published")}
|
||||||
@@ -246,6 +270,11 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
>
|
>
|
||||||
<Archive className="mr-2 h-4 w-4" /> {t("exam.actions.archive")}
|
<Archive className="mr-2 h-4 w-4" /> {t("exam.actions.archive")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{features.canManage && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
@@ -253,6 +282,8 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
|||||||
>
|
>
|
||||||
<Trash className="mr-2 h-4 w-4" /> {t("exam.actions.delete")}
|
<Trash className="mr-2 h-4 w-4" /> {t("exam.actions.delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
226
src/modules/exams/components/exam-analytics-dashboard.tsx
Normal file
226
src/modules/exams/components/exam-analytics-dashboard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t("exam.analytics.totalStudents")}
|
||||||
|
</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{analytics.totalStudents}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("exam.analytics.submitted")}: {analytics.submittedCount} / {analytics.totalStudents}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t("exam.analytics.gradedCount")}
|
||||||
|
</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{analytics.gradedCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("exam.analytics.assignmentCount")}: {analytics.assignmentCount}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t("exam.analytics.averageScore")}
|
||||||
|
</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{analytics.averageScore}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{" / "}{analytics.maxScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t("exam.analytics.passRate")}
|
||||||
|
</CardTitle>
|
||||||
|
<Award className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{(analytics.passRate * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score Distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("exam.analytics.scoreDistribution")}</CardTitle>
|
||||||
|
<CardDescription>{t("exam.analytics.scoreDistributionDesc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analytics.scoreDistribution.map((item) => {
|
||||||
|
const maxCount = Math.max(...analytics.scoreDistribution.map((d) => d.count), 1)
|
||||||
|
const percentage = (item.count / maxCount) * 100
|
||||||
|
return (
|
||||||
|
<div key={item.range} className="flex items-center gap-3">
|
||||||
|
<span className="w-20 text-sm text-muted-foreground tabular-nums">{item.range}</span>
|
||||||
|
<Progress value={percentage} className="h-3 flex-1" />
|
||||||
|
<span className="w-10 text-sm font-medium tabular-nums text-right">{item.count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Per-Question Analysis */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("exam.analytics.questionAnalysis")}</CardTitle>
|
||||||
|
<CardDescription>{t("exam.analytics.questionAnalysisDesc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">#</TableHead>
|
||||||
|
<TableHead>{t("exam.analytics.questionType")}</TableHead>
|
||||||
|
<TableHead className="min-w-[200px]">{t("exam.analytics.questionText")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("exam.analytics.maxScore")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("exam.analytics.errorCount")}</TableHead>
|
||||||
|
<TableHead className="w-[120px]">{t("exam.analytics.errorRate")}</TableHead>
|
||||||
|
<TableHead>{t("exam.analytics.difficulty")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{analytics.questions.map((q, index) => (
|
||||||
|
<TableRow key={q.questionId}>
|
||||||
|
<TableCell className="font-medium tabular-nums">{index + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{q.questionType}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[300px] truncate text-sm text-muted-foreground">
|
||||||
|
{q.questionText}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{q.maxScore}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{q.errorCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={q.errorRate * 100}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-xs tabular-nums text-right">
|
||||||
|
{(q.errorRate * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={difficultyVariant(q.difficulty)}>
|
||||||
|
{difficultyLabel(q.difficulty)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* High Error Rate Warning */}
|
||||||
|
{analytics.questions.filter((q) => q.errorRate >= 0.7).length > 0 && (
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
{t("exam.analytics.highErrorWarning")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
{t("exam.analytics.highErrorWarningDesc")}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{analytics.questions
|
||||||
|
.filter((q) => q.errorRate >= 0.7)
|
||||||
|
.map((q, index) => (
|
||||||
|
<div key={q.questionId} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium tabular-nums">#{index + 1}</span>
|
||||||
|
<span className="truncate text-muted-foreground">{q.questionText}</span>
|
||||||
|
<Badge variant="destructive" className="ml-auto">
|
||||||
|
{(q.errorRate * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
src/modules/exams/stats-service.ts
Normal file
159
src/modules/exams/stats-service.ts
Normal file
@@ -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<string, unknown> => typeof v === "object" && v !== null
|
||||||
|
|
||||||
|
const parseExamMeta = (description: string | null): Record<string, unknown> => {
|
||||||
|
if (!description) return {}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(description)
|
||||||
|
return isRecord(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNumber = (obj: Record<string, unknown>, 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<ExamAnalyticsSummary | null> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
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 { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
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 { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
markHomeworkSubmitted,
|
markHomeworkSubmitted,
|
||||||
saveHomeworkAnswer,
|
saveHomeworkAnswer,
|
||||||
startHomeworkSubmission,
|
startHomeworkSubmission,
|
||||||
|
batchAutoGradeSubmissions,
|
||||||
} from "./data-access-write"
|
} from "./data-access-write"
|
||||||
|
|
||||||
const parseStudentIds = (raw: string): string[] => {
|
const parseStudentIds = (raw: string): string[] => {
|
||||||
@@ -77,7 +80,11 @@ export async function createHomeworkAssignmentAction(
|
|||||||
|
|
||||||
let exam: Awaited<ReturnType<typeof getExamWithQuestionsForHomework>> = null
|
let exam: Awaited<ReturnType<typeof getExamWithQuestionsForHomework>> = null
|
||||||
if (!isQuickAssignment) {
|
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" }
|
if (!examData) return { success: false, message: "Exam not found" }
|
||||||
exam = examData
|
exam = examData
|
||||||
}
|
}
|
||||||
@@ -116,9 +123,9 @@ export async function createHomeworkAssignmentAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assignmentId = createId()
|
const assignmentId = createId()
|
||||||
const availableAt = input.availableAt ? new Date(input.availableAt) : null
|
const availableAt = input.availableAt ? safeParseDate(input.availableAt, "可用时间") : null
|
||||||
const dueAt = input.dueAt ? new Date(input.dueAt) : null
|
const dueAt = input.dueAt ? safeParseDate(input.dueAt, "截止时间") : null
|
||||||
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
const lateDueAt = input.lateDueAt ? safeParseDate(input.lateDueAt, "迟交截止时间") : null
|
||||||
|
|
||||||
await createHomeworkAssignment({
|
await createHomeworkAssignment({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
@@ -141,13 +148,21 @@ export async function createHomeworkAssignmentAction(
|
|||||||
revalidatePath("/teacher/homework/assignments")
|
revalidatePath("/teacher/homework/assignments")
|
||||||
revalidatePath("/teacher/homework/submissions")
|
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 }
|
return { success: true, message: "Assignment created", data: assignmentId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +184,7 @@ export async function startHomeworkSubmissionAction(
|
|||||||
|
|
||||||
return { success: true, message: "Started", data: result.submissionId }
|
return { success: true, message: "Started", data: result.submissionId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,17 +205,16 @@ export async function saveHomeworkAnswerAction(
|
|||||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||||
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
|
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<unknown>(answerJson, "答案数据格式无效")
|
||||||
|
: null
|
||||||
|
|
||||||
await saveHomeworkAnswer(submissionId, questionId, payload)
|
await saveHomeworkAnswer(submissionId, questionId, payload)
|
||||||
|
|
||||||
return { success: true, message: "Saved", data: submissionId }
|
return { success: true, message: "Saved", data: submissionId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,18 +242,30 @@ export async function submitHomeworkAction(
|
|||||||
|
|
||||||
const isLate = Boolean(dueAt && now > dueAt)
|
const isLate = Boolean(dueAt && now > dueAt)
|
||||||
|
|
||||||
await markHomeworkSubmitted(submissionId, isLate)
|
// V3-2: 即时自动批改回写
|
||||||
|
const { isFullyAutoGraded, totalScore } = await markHomeworkSubmitted(submissionId, isLate)
|
||||||
|
|
||||||
revalidatePath("/teacher/homework/submissions")
|
revalidatePath("/teacher/homework/submissions")
|
||||||
revalidatePath("/student/learning/assignments")
|
revalidatePath("/student/learning/assignments")
|
||||||
|
|
||||||
return { success: true, message: "Submitted", data: submissionId }
|
// V3-4: 埋点监控
|
||||||
} catch (e) {
|
await trackExamEvent("homework.submitted", {
|
||||||
if (e instanceof PermissionDeniedError) {
|
userId: ctx.userId,
|
||||||
return { success: false, message: e.message }
|
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 }
|
} catch (e) {
|
||||||
return { success: false, message: "Unexpected error" }
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +280,7 @@ export async function gradeHomeworkSubmissionAction(
|
|||||||
const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null
|
const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null
|
||||||
const parsed = GradeHomeworkSchema.safeParse({
|
const parsed = GradeHomeworkSchema.safeParse({
|
||||||
submissionId: formData.get("submissionId"),
|
submissionId: formData.get("submissionId"),
|
||||||
answers: rawAnswers ? JSON.parse(rawAnswers) : [],
|
answers: rawAnswers ? safeJsonParse<unknown[]>(rawAnswers, "批改数据格式无效") : [],
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -294,12 +316,82 @@ export async function gradeHomeworkSubmissionAction(
|
|||||||
|
|
||||||
revalidatePath("/teacher/homework/submissions")
|
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" }
|
return { success: true, message: "Grading saved" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
}
|
||||||
}
|
}
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
/**
|
||||||
|
* V3-7: 批量自动批改提交
|
||||||
|
*
|
||||||
|
* 教师在提交列表页勾选多份提交后,一键自动批改所有客观题。
|
||||||
|
* 仅批改选择题/判断题,主观题保持原分数。
|
||||||
|
*/
|
||||||
|
export async function batchAutoGradeSubmissionsAction(
|
||||||
|
prevState: ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||||
|
|
||||||
|
const rawSubmissionIds = formData.get("submissionIds")
|
||||||
|
const submissionIdsJson = typeof rawSubmissionIds === "string" ? rawSubmissionIds : "[]"
|
||||||
|
const submissionIds = safeJsonParse<string[]>(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
168
src/modules/homework/components/homework-batch-grading-view.tsx
Normal file
168
src/modules/homework/components/homework-batch-grading-view.tsx
Normal file
@@ -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<Set<string>>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/50 px-4 py-3">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("homework.grade.batchSelected", { count: selectedIds.size })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleBatchAutoGrade}
|
||||||
|
disabled={isPending}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
|
{t("homework.grade.batchAutoGrade")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelectableSelected}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
aria-label={t("homework.grade.selectAll")}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{t("homework.grade.student")}</TableHead>
|
||||||
|
<TableHead>{t("homework.grade.status")}</TableHead>
|
||||||
|
<TableHead>{t("homework.grade.submitted")}</TableHead>
|
||||||
|
<TableHead>{t("homework.grade.score")}</TableHead>
|
||||||
|
<TableHead>{t("homework.grade.action")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{submissions.map((s) => {
|
||||||
|
const isSelectable = s.status === "submitted"
|
||||||
|
const isSelected = selectedIds.has(s.id)
|
||||||
|
return (
|
||||||
|
<TableRow key={s.id} data-selected={isSelected}>
|
||||||
|
<TableCell>
|
||||||
|
{isSelectable ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => toggleSelect(s.id)}
|
||||||
|
aria-label={t("homework.grade.selectRow")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground" aria-hidden="true">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium truncate max-w-[160px]">{s.studentName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
|
{s.isLate ? <span className="ml-2 text-xs text-destructive">{t("homework.grade.late")}</span> : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={`/teacher/homework/submissions/${s.id}`}
|
||||||
|
className="text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{t("homework.grade.title")}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
234
src/modules/homework/components/homework-submission-result.tsx
Normal file
234
src/modules/homework/components/homework-submission-result.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Score Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Award className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl">
|
||||||
|
{totalScore}
|
||||||
|
<span className="text-lg font-normal text-muted-foreground">
|
||||||
|
{" / "}{maxScore}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("homework.result.scoreRate")}: {scorePercentage.toFixed(1)}%
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Progress value={scorePercentage} className="h-3" />
|
||||||
|
{submission.status === "graded" ? (
|
||||||
|
<p className="mt-3 text-center text-sm text-muted-foreground">
|
||||||
|
{t("homework.result.fullyGraded")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-center text-sm text-muted-foreground">
|
||||||
|
{t("homework.result.partiallyGraded")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.correct}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("homework.result.correctCount")}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
|
<XCircle className="h-8 w-8 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.incorrect}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("homework.result.incorrectCount")}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
|
<AlertCircle className="h-8 w-8 text-yellow-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.partial}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("homework.result.partialCount")}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
|
<BookOpen className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.ungraded}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("homework.result.pendingCount")}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wrong Answers Preview */}
|
||||||
|
{wrongAnswers.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("homework.result.wrongAnswersTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("homework.result.wrongAnswersDesc")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{wrongAnswers.map((a, index) => {
|
||||||
|
const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore })
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="rounded-lg border p-4">
|
||||||
|
<div className="mb-2 flex items-start gap-2">
|
||||||
|
<span className="font-medium tabular-nums">#{index + 1}</span>
|
||||||
|
<Badge
|
||||||
|
variant={state === "incorrect" ? "destructive" : "secondary"}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{state === "incorrect"
|
||||||
|
? t("homework.grade.incorrect")
|
||||||
|
: t("homework.grade.partial")}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{a.questionType}
|
||||||
|
</Badge>
|
||||||
|
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
|
||||||
|
{a.score ?? 0} / {a.maxScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-sm">
|
||||||
|
{getQuestionText(a.questionContent) || t("homework.grade.noQuestionText")}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div className="rounded bg-red-50 dark:bg-red-950/20 p-2">
|
||||||
|
<p className="mb-1 text-xs font-medium text-red-600 dark:text-red-400">
|
||||||
|
{t("homework.review.yourAnswer")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{formatStudentAnswer(a.studentAnswer) || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-green-50 dark:bg-green-950/20 p-2">
|
||||||
|
<p className="mb-1 text-xs font-medium text-green-600 dark:text-green-400">
|
||||||
|
{t("homework.review.correctAnswer")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{formatCorrectAnswer(a.questionType, a.questionContent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{a.feedback && (
|
||||||
|
<div className="mt-2 rounded bg-blue-50 dark:bg-blue-950/20 p-2">
|
||||||
|
<p className="mb-1 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
{t("homework.review.teacherFeedback")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{a.feedback}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/student/learning/assignments">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t("homework.result.backToList")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/student/error-book">
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
{t("homework.result.viewErrorBook")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("assignmentId", assignmentId)
|
fd.set("assignmentId", assignmentId)
|
||||||
const res = await startHomeworkSubmissionAction(null, fd)
|
const res = await startHomeworkSubmissionAction(null, fd)
|
||||||
@@ -136,8 +137,12 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
} else {
|
} else {
|
||||||
toast.error(res.message || t("homework.take.startFailed"))
|
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) => {
|
const handleSaveQuestion = async (questionId: string) => {
|
||||||
if (!submissionId) return
|
if (!submissionId) return
|
||||||
@@ -156,7 +161,9 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!submissionId) return
|
if (!submissionId) return
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
|
try {
|
||||||
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
|
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
|
||||||
|
// flush 失败应中止提交,避免丢失未保存的答案
|
||||||
await autoSave.flush()
|
await autoSave.flush()
|
||||||
|
|
||||||
const submitFd = new FormData()
|
const submitFd = new FormData()
|
||||||
@@ -167,12 +174,17 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
clearOfflineCache(offlineStorageKey)
|
clearOfflineCache(offlineStorageKey)
|
||||||
toast.success(t("homework.take.submitSuccess"))
|
toast.success(t("homework.take.submitSuccess"))
|
||||||
setSubmissionStatus("submitted")
|
setSubmissionStatus("submitted")
|
||||||
router.push("/student/learning/assignments")
|
// V3-9: 提交后跳转到结果页,展示即时反馈
|
||||||
|
router.push(`/student/learning/assignments/${assignmentId}/result`)
|
||||||
} else {
|
} else {
|
||||||
toast.error(submitRes.message || t("homework.take.submitFailed"))
|
toast.error(submitRes.message || t("homework.take.submitFailed"))
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("homework.take.submitFailed"))
|
||||||
|
} finally {
|
||||||
setIsBusy(false)
|
setIsBusy(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 统计未作答题目数
|
// 统计未作答题目数
|
||||||
const unansweredCount = initialData.questions.filter((q) => {
|
const unansweredCount = initialData.questions.filter((q) => {
|
||||||
@@ -378,7 +390,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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"
|
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
|
||||||
)}
|
)}
|
||||||
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
type ExamWithQuestionsForHomework,
|
type ExamWithQuestionsForHomework,
|
||||||
} from "@/modules/exams/data-access"
|
} from "@/modules/exams/data-access"
|
||||||
import type { DataScope } from "@/shared/types/permissions"
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
import {
|
||||||
|
autoGradeSubmission,
|
||||||
|
type AutoGradableAnswer,
|
||||||
|
} from "./lib/question-content-utils"
|
||||||
|
|
||||||
// ---- Types ----
|
// ---- Types ----
|
||||||
|
|
||||||
@@ -267,12 +271,84 @@ export const saveHomeworkAnswer = async (
|
|||||||
export const markHomeworkSubmitted = async (
|
export const markHomeworkSubmitted = async (
|
||||||
submissionId: string,
|
submissionId: string,
|
||||||
isLate: boolean
|
isLate: boolean
|
||||||
): Promise<void> => {
|
): Promise<{ isFullyAutoGraded: boolean; totalScore: number }> => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
await db
|
|
||||||
|
// 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)
|
.update(homeworkSubmissions)
|
||||||
.set({ status: "submitted", submittedAt: now, isLate, updatedAt: now })
|
.set({
|
||||||
|
status: isFullyAutoGraded ? "graded" : "submitted",
|
||||||
|
submittedAt: now,
|
||||||
|
isLate,
|
||||||
|
score: totalScore,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
.where(eq(homeworkSubmissions.id, submissionId))
|
.where(eq(homeworkSubmissions.id, submissionId))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { isFullyAutoGraded, totalScore }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gradeHomeworkAnswers = async (
|
export const gradeHomeworkAnswers = async (
|
||||||
@@ -282,10 +358,17 @@ export const gradeHomeworkAnswers = async (
|
|||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
let totalScore = 0
|
let totalScore = 0
|
||||||
for (const ans of answers) {
|
for (const ans of answers) {
|
||||||
|
// 关键安全约束:WHERE 子句同时匹配 answer.id 和 submissionId,
|
||||||
|
// 防止恶意客户端篡改 answer ID 批改其他 submission 的答案
|
||||||
await tx
|
await tx
|
||||||
.update(homeworkAnswers)
|
.update(homeworkAnswers)
|
||||||
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
|
.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
|
totalScore += ans.score
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,3 +378,122 @@ export const gradeHomeworkAnswers = async (
|
|||||||
.where(eq(homeworkSubmissions.id, submissionId))
|
.where(eq(homeworkSubmissions.id, submissionId))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3-7: 批量自动批改提交
|
||||||
|
*
|
||||||
|
* 对多个提交执行自动批改(复用 autoGradeSubmission 逻辑)。
|
||||||
|
* 仅批改客观题(选择题/判断题),主观题保持原分数。
|
||||||
|
*
|
||||||
|
* @param submissionIds 要批量批改的提交 ID 列表
|
||||||
|
* @returns 每个提交的批改结果摘要
|
||||||
|
*/
|
||||||
|
export const batchAutoGradeSubmissions = async (
|
||||||
|
submissionIds: string[]
|
||||||
|
): Promise<Array<{
|
||||||
|
submissionId: string
|
||||||
|
success: boolean
|
||||||
|
isFullyAutoGraded: boolean
|
||||||
|
totalScore: number
|
||||||
|
message?: string
|
||||||
|
}>> => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -282,19 +282,16 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
|||||||
|
|
||||||
const assignmentIds = assignments.map((a) => a.id)
|
const assignmentIds = assignments.map((a) => a.id)
|
||||||
|
|
||||||
const targetCountRows = await db
|
const [targetCountRows, submittedCountRows, gradedCountRows] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||||
targetCount: sql<number>`COUNT(*)`,
|
targetCount: sql<number>`COUNT(*)`,
|
||||||
})
|
})
|
||||||
.from(homeworkAssignmentTargets)
|
.from(homeworkAssignmentTargets)
|
||||||
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||||
.groupBy(homeworkAssignmentTargets.assignmentId)
|
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||||
|
db
|
||||||
const targetCountByAssignmentId = new Map<string, number>()
|
|
||||||
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
|
||||||
|
|
||||||
const submittedCountRows = await db
|
|
||||||
.select({
|
.select({
|
||||||
assignmentId: homeworkSubmissions.assignmentId,
|
assignmentId: homeworkSubmissions.assignmentId,
|
||||||
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
||||||
@@ -306,19 +303,22 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
|||||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.groupBy(homeworkSubmissions.assignmentId)
|
.groupBy(homeworkSubmissions.assignmentId),
|
||||||
|
db
|
||||||
const submittedCountByAssignmentId = new Map<string, number>()
|
|
||||||
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
|
|
||||||
|
|
||||||
const gradedCountRows = await db
|
|
||||||
.select({
|
.select({
|
||||||
assignmentId: homeworkSubmissions.assignmentId,
|
assignmentId: homeworkSubmissions.assignmentId,
|
||||||
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
||||||
})
|
})
|
||||||
.from(homeworkSubmissions)
|
.from(homeworkSubmissions)
|
||||||
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
|
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
|
||||||
.groupBy(homeworkSubmissions.assignmentId)
|
.groupBy(homeworkSubmissions.assignmentId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const targetCountByAssignmentId = new Map<string, number>()
|
||||||
|
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||||
|
|
||||||
|
const submittedCountByAssignmentId = new Map<string, number>()
|
||||||
|
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
|
||||||
|
|
||||||
const gradedCountByAssignmentId = new Map<string, number>()
|
const gradedCountByAssignmentId = new Map<string, number>()
|
||||||
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
|
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
|
const [targetsRows, submissionsRows, submittedRows, gradedRows] = await Promise.all([
|
||||||
|
db
|
||||||
.select({ c: count() })
|
.select({ c: count() })
|
||||||
.from(homeworkAssignmentTargets)
|
.from(homeworkAssignmentTargets)
|
||||||
.where(eq(homeworkAssignmentTargets.assignmentId, id))
|
.where(eq(homeworkAssignmentTargets.assignmentId, id)),
|
||||||
|
db
|
||||||
const [submissionsRow] = await db
|
|
||||||
.select({ c: count() })
|
.select({ c: count() })
|
||||||
.from(homeworkSubmissions)
|
.from(homeworkSubmissions)
|
||||||
.where(eq(homeworkSubmissions.assignmentId, id))
|
.where(eq(homeworkSubmissions.assignmentId, id)),
|
||||||
|
db
|
||||||
const [submittedRow] = await db
|
|
||||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
.from(homeworkSubmissions)
|
.from(homeworkSubmissions)
|
||||||
.where(
|
.where(
|
||||||
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
|
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
|
||||||
)
|
),
|
||||||
|
db
|
||||||
const [gradedRow] = await db
|
|
||||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||||
.from(homeworkSubmissions)
|
.from(homeworkSubmissions)
|
||||||
.where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded")))
|
.where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded"))),
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: assignment.id,
|
id: assignment.id,
|
||||||
@@ -487,15 +486,137 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
|||||||
allowLate: assignment.allowLate,
|
allowLate: assignment.allowLate,
|
||||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||||
maxAttempts: assignment.maxAttempts,
|
maxAttempts: assignment.maxAttempts,
|
||||||
targetCount: targetsRow?.c ?? 0,
|
targetCount: targetsRows[0]?.c ?? 0,
|
||||||
submissionCount: submissionsRow?.c ?? 0,
|
submissionCount: submissionsRows[0]?.c ?? 0,
|
||||||
submittedCount: submittedRow?.c ?? 0,
|
submittedCount: submittedRows[0]?.c ?? 0,
|
||||||
gradedCount: gradedRow?.c ?? 0,
|
gradedCount: gradedRows[0]?.c ?? 0,
|
||||||
createdAt: assignment.createdAt.toISOString(),
|
createdAt: assignment.createdAt.toISOString(),
|
||||||
updatedAt: assignment.updatedAt.toISOString(),
|
updatedAt: assignment.updatedAt.toISOString(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3-8: 获取关联到指定考试的所有作业(跨模块读接口)
|
||||||
|
*
|
||||||
|
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
|
||||||
|
*/
|
||||||
|
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string | null
|
||||||
|
targetCount: number
|
||||||
|
submittedCount: number
|
||||||
|
gradedCount: number
|
||||||
|
dueAt: string | null
|
||||||
|
}>> => {
|
||||||
|
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<number>`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<number>`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<Array<{
|
||||||
|
submissionId: string
|
||||||
|
assignmentId: string
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
score: number
|
||||||
|
answers: Array<{ questionId: string; score: number; answerContent: unknown }>
|
||||||
|
}>> => {
|
||||||
|
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<string, (typeof submissions)[number]>()
|
||||||
|
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<HomeworkSubmissionDetails | null> => {
|
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
|
||||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||||
where: eq(homeworkSubmissions.id, submissionId),
|
where: eq(homeworkSubmissions.id, submissionId),
|
||||||
@@ -507,17 +628,18 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
|||||||
|
|
||||||
if (!submission) return null
|
if (!submission) return null
|
||||||
|
|
||||||
const answers = await db.query.homeworkAnswers.findMany({
|
const [answers, assignmentQ] = await Promise.all([
|
||||||
|
db.query.homeworkAnswers.findMany({
|
||||||
where: eq(homeworkAnswers.submissionId, submissionId),
|
where: eq(homeworkAnswers.submissionId, submissionId),
|
||||||
with: {
|
with: {
|
||||||
question: true,
|
question: true,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
db.query.homeworkAssignmentQuestions.findMany({
|
||||||
const assignmentQ = await db.query.homeworkAssignmentQuestions.findMany({
|
|
||||||
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
|
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
|
||||||
orderBy: [desc(homeworkAssignmentQuestions.order)],
|
orderBy: [desc(homeworkAssignmentQuestions.order)],
|
||||||
})
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
const answersWithDetails = answers
|
const answersWithDetails = answers
|
||||||
.map((ans) => {
|
.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<HomeworkSubmissionDetails | null> => {
|
||||||
|
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<Array<{
|
||||||
|
submissionId: string
|
||||||
|
examId: string
|
||||||
|
examTitle: string
|
||||||
|
assignmentId: string
|
||||||
|
assignmentTitle: string
|
||||||
|
score: number
|
||||||
|
maxScore: number
|
||||||
|
submittedAt: string | null
|
||||||
|
status: string
|
||||||
|
}>> => {
|
||||||
|
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<string, (typeof submissions)[number]>()
|
||||||
|
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 => {
|
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
||||||
if (v === "started") return "in_progress"
|
if (v === "started") return "in_progress"
|
||||||
if (v === "submitted") return "submitted"
|
if (v === "submitted") return "submitted"
|
||||||
|
|||||||
@@ -1,15 +1,86 @@
|
|||||||
|
"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 { ChildGradeSummary } from "./child-grade-summary"
|
||||||
|
import { ChildHomeworkDetail } from "./child-homework-detail"
|
||||||
import { ChildHomeworkSummary } from "./child-homework-summary"
|
import { ChildHomeworkSummary } from "./child-homework-summary"
|
||||||
import { ChildScheduleCard } from "./child-schedule-card"
|
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 }) {
|
export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "schedule" | "attendance" | "diagnostic"
|
||||||
const { basicInfo, todaySchedule, homeworkSummary, gradeTrend } = child
|
|
||||||
|
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 childName = basicInfo.name ?? "Child"
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<ChildDetailTab>(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{siblingSwitcher}
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as ChildDetailTab)} className="w-full">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<TabsList className="w-full justify-start">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={t.id}
|
||||||
|
value={t.id}
|
||||||
|
className="gap-1.5"
|
||||||
|
aria-label={`${t.label} tab`}
|
||||||
|
>
|
||||||
|
<t.icon className="h-3.5 w-3.5" />
|
||||||
|
{t.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="md:col-span-1 lg:col-span-2 space-y-6">
|
<div className="md:col-span-1 lg:col-span-2 space-y-6">
|
||||||
<ChildHomeworkSummary
|
<ChildHomeworkSummary
|
||||||
summary={homeworkSummary}
|
summary={homeworkSummary}
|
||||||
@@ -22,6 +93,116 @@ export function ChildDetailPanel({ child }: { child: ChildDashboardData }) {
|
|||||||
<ChildScheduleCard items={todaySchedule} childName={childName} />
|
<ChildScheduleCard items={todaySchedule} childName={childName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="homework" className="mt-6">
|
||||||
|
<ChildHomeworkDetail
|
||||||
|
summary={homeworkSummary}
|
||||||
|
childId={basicInfo.id}
|
||||||
|
childName={childName}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="grades" className="mt-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
|
||||||
|
Subject Analysis
|
||||||
|
</h3>
|
||||||
|
<ChildGradeDetail grades={gradeTrend} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="exams" className="mt-6">
|
||||||
|
<ChildExamDetail
|
||||||
|
examResults={examResults}
|
||||||
|
childId={basicInfo.id}
|
||||||
|
childName={childName}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="schedule" className="mt-6">
|
||||||
|
<ChildScheduleCard
|
||||||
|
items={todaySchedule}
|
||||||
|
childName={childName}
|
||||||
|
weeklyItems={weeklySchedule}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="attendance" className="mt-6">
|
||||||
|
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Attendance details are available on the{" "}
|
||||||
|
<a
|
||||||
|
href="/parent/attendance"
|
||||||
|
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Attendance page
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="diagnostic" className="mt-6">
|
||||||
|
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Diagnostic reports will be available here once published by the school.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="gap-2">
|
||||||
|
<a href={`/messages?studentId=${basicInfo.id}`} aria-label={`Contact teacher about ${childName}`}>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Contact Teacher
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 紧凑的子女切换器(用于详情页头部)。 */
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||||
|
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">Switch child</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{siblings.map((s) => {
|
||||||
|
const isActive = s.id === current.id
|
||||||
|
const label = s.name ?? "Child"
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={s.id}
|
||||||
|
href={`/parent/children/${s.id}`}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
aria-label={`View ${label}'s details`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
153
src/modules/parent/components/child-exam-detail.tsx
Normal file
153
src/modules/parent/components/child-exam-detail.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<GraduationCap className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Exams Taken</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
|
||||||
|
<TrendingUp className="h-5 w-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Average Score</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||||
|
<Award className="h-5 w-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Best Score</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exam Results List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
{childName}'s Exam Results
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Recent exam scores and performance trends</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!hasResults ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={GraduationCap}
|
||||||
|
title="No exam results"
|
||||||
|
description="Exam results will appear here once available."
|
||||||
|
className="border-none h-48"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{examResults.map((r) => {
|
||||||
|
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
|
||||||
|
const isPass = scoreRate >= 60
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={r.submissionId}
|
||||||
|
href={`/parent/children/${childId}?tab=grades`}
|
||||||
|
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium text-sm truncate">{r.examTitle}</div>
|
||||||
|
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
|
||||||
|
{isPass ? "Pass" : "Below 60%"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{r.submittedAt ? (
|
||||||
|
<span>{formatDate(r.submittedAt)}</span>
|
||||||
|
) : null}
|
||||||
|
<span aria-hidden="true">•</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{r.score} / {r.maxScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={scoreRate} className="h-1.5 mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
|
||||||
|
{scoreRate.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getStudentDashboardGrades,
|
getStudentDashboardGrades,
|
||||||
getStudentHomeworkAssignments,
|
getStudentHomeworkAssignments,
|
||||||
|
getStudentExamResults,
|
||||||
} from "@/modules/homework/data-access"
|
} from "@/modules/homework/data-access"
|
||||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||||
import { getGradeNameById } from "@/modules/school/data-access"
|
import { getGradeNameById } from "@/modules/school/data-access"
|
||||||
@@ -22,6 +23,7 @@ import type {
|
|||||||
ChildDashboardData,
|
ChildDashboardData,
|
||||||
ChildHomeworkSummaryData,
|
ChildHomeworkSummaryData,
|
||||||
ChildScheduleItem,
|
ChildScheduleItem,
|
||||||
|
ChildWeeklyScheduleItem,
|
||||||
ParentChildRelation,
|
ParentChildRelation,
|
||||||
ParentDashboardData,
|
ParentDashboardData,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
@@ -174,26 +176,50 @@ const buildTodaySchedule = (
|
|||||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildWeeklySchedule = (
|
||||||
|
schedule: Awaited<ReturnType<typeof getStudentSchedule>>,
|
||||||
|
): 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(
|
export const getChildDashboardData = cache(
|
||||||
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
|
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
|
||||||
const basicInfo = await getChildBasicInfo(studentId, relation)
|
const basicInfo = await getChildBasicInfo(studentId, relation)
|
||||||
if (!basicInfo) return null
|
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),
|
getStudentClasses(studentId),
|
||||||
getStudentSchedule(studentId),
|
getStudentSchedule(studentId),
|
||||||
getStudentHomeworkAssignments(studentId),
|
getStudentHomeworkAssignments(studentId),
|
||||||
getStudentDashboardGrades(studentId),
|
getStudentDashboardGrades(studentId),
|
||||||
getStudentGradeSummary(studentId),
|
getStudentGradeSummary(studentId),
|
||||||
|
getStudentExamResults(studentId),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
basicInfo,
|
basicInfo,
|
||||||
enrolledClasses,
|
enrolledClasses,
|
||||||
todaySchedule: buildTodaySchedule(schedule),
|
todaySchedule: buildTodaySchedule(schedule),
|
||||||
|
weeklySchedule: buildWeeklySchedule(schedule),
|
||||||
homeworkSummary: buildHomeworkSummary(assignments),
|
homeworkSummary: buildHomeworkSummary(assignments),
|
||||||
gradeTrend,
|
gradeTrend,
|
||||||
gradeSummary,
|
gradeSummary,
|
||||||
|
examResults,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -224,3 +250,20 @@ export const getParentDashboardData = cache(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取家长所有子女的轻量列表(id + name),用于详情页头部多子女切换器。
|
||||||
|
* 一次批量查询,避免 N+1。
|
||||||
|
*/
|
||||||
|
export const getChildNameList = cache(
|
||||||
|
async (parentId: string): Promise<Array<{ id: string; name: string | null }>> => {
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ export type ChildDashboardData = {
|
|||||||
/** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */
|
/** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */
|
||||||
gradeTrend: StudentDashboardGradeProps
|
gradeTrend: StudentDashboardGradeProps
|
||||||
gradeSummary: StudentGradeSummary | null
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 家长仪表盘聚合数据(家长姓名 + 所有子女数据)。 */
|
/** 家长仪表盘聚合数据(家长姓名 + 所有子女数据)。 */
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface TrendLineSeries {
|
|||||||
|
|
||||||
interface TrendLineChartProps {
|
interface TrendLineChartProps {
|
||||||
/** 图表数据 */
|
/** 图表数据 */
|
||||||
data: Array<Record<string, string | number>>
|
data: Array<Record<string, string | number | undefined>>
|
||||||
/** 折线系列配置(支持单条或多条) */
|
/** 折线系列配置(支持单条或多条) */
|
||||||
series: TrendLineSeries[]
|
series: TrendLineSeries[]
|
||||||
/** X 轴数据字段名(默认 "title") */
|
/** X 轴数据字段名(默认 "title") */
|
||||||
|
|||||||
@@ -96,6 +96,33 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"notFound": "Exam not found",
|
"notFound": "Exam not found",
|
||||||
"loadFailed": "Failed to load exam"
|
"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": {
|
"homework": {
|
||||||
@@ -249,7 +276,13 @@
|
|||||||
"nextStudent": "Next Student",
|
"nextStudent": "Next Student",
|
||||||
"prev": "Prev",
|
"prev": "Prev",
|
||||||
"next": "Next",
|
"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": {
|
"review": {
|
||||||
"title": "Review",
|
"title": "Review",
|
||||||
@@ -269,7 +302,23 @@
|
|||||||
"responseSummary": "Response Summary",
|
"responseSummary": "Response Summary",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"noDescription": "No description provided.",
|
"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": {
|
"status": {
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
|
|||||||
@@ -96,6 +96,33 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"notFound": "考试不存在",
|
"notFound": "考试不存在",
|
||||||
"loadFailed": "加载考试失败"
|
"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": {
|
"homework": {
|
||||||
@@ -249,7 +276,13 @@
|
|||||||
"nextStudent": "下一名学生",
|
"nextStudent": "下一名学生",
|
||||||
"prev": "上一页",
|
"prev": "上一页",
|
||||||
"next": "下一页",
|
"next": "下一页",
|
||||||
"gradesAutoSaveNote": "点击提交后成绩将自动保存。学生将在您提交后立即看到成绩和反馈。"
|
"gradesAutoSaveNote": "点击提交后成绩将自动保存。学生将在您提交后立即看到成绩和反馈。",
|
||||||
|
"batchAutoGrade": "批量自动批改",
|
||||||
|
"batchSelected": "已选 {{count}} 份提交",
|
||||||
|
"batchSelectAtLeastOne": "请至少选择一份提交",
|
||||||
|
"batchFailed": "批量批改失败",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"selectRow": "选择此行"
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"title": "复习",
|
"title": "复习",
|
||||||
@@ -269,7 +302,23 @@
|
|||||||
"responseSummary": "作答概览",
|
"responseSummary": "作答概览",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"noDescription": "无描述。",
|
"noDescription": "无描述。",
|
||||||
"totalScore": "总分"
|
"totalScore": "总分",
|
||||||
|
"correctAnswerTrue": "正确",
|
||||||
|
"correctAnswerFalse": "错误"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"title": "提交结果",
|
||||||
|
"scoreRate": "得分率",
|
||||||
|
"fullyGraded": "所有题目已批改完成",
|
||||||
|
"partiallyGraded": "客观题已自动批改,主观题等待教师批改",
|
||||||
|
"correctCount": "正确",
|
||||||
|
"incorrectCount": "错误",
|
||||||
|
"partialCount": "部分正确",
|
||||||
|
"pendingCount": "待批改",
|
||||||
|
"wrongAnswersTitle": "错题预览",
|
||||||
|
"wrongAnswersDesc": "以下题目需要重点复习",
|
||||||
|
"backToList": "返回作业列表",
|
||||||
|
"viewErrorBook": "查看错题本"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"draft": "草稿",
|
"draft": "草稿",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import type { DataScope } from "@/shared/types/permissions"
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
import type { Exam } from "@/modules/exams/types"
|
import type { Exam } from "@/modules/exams/types"
|
||||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/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<{
|
getExamWithQuestionsForHomework(examId: string): Promise<ExamWithQuestionsForHomework | null>
|
||||||
exam: Pick<Exam, "id" | "title" | "totalScore" | "durationMin">
|
|
||||||
questions: Array<{
|
|
||||||
id: string
|
|
||||||
questionType: string
|
|
||||||
questionContent: unknown
|
|
||||||
maxScore: number
|
|
||||||
}>
|
|
||||||
} | null>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user