816 lines
46 KiB
Markdown
816 lines
46 KiB
Markdown
# 专项练习(adaptive-practice)模块审计报告
|
||
|
||
> **审计日期**:2026-06-25
|
||
> **审计范围**:`src/modules/adaptive-practice/` 全部文件 + `src/app/(dashboard)/{student,teacher,management/grade}/practice/` 路由 + 跨模块集成点(error-book)
|
||
> **对标系统**:Khan Academy、IXL Learning、DreamBox、Smartick、学而思网校、猿辅导、作业帮、超星学习通、ClassIn、智学网
|
||
> **前置文档**:[004 架构影响地图](../004_architecture_impact_map.md#230-adaptive-practice专项练习模块-核心教学链路闭环)、[005 架构数据](../005_architecture_data.json)
|
||
|
||
---
|
||
|
||
## 一、现有实现概要
|
||
|
||
### 1.1 文件分布与代码量
|
||
|
||
| 文件 | 行数 | 职责 | 规范符合性 |
|
||
|------|------|------|-----------|
|
||
| [types.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/types.ts) | 143 | 联合类型定义(PracticeType/PracticeSourceMeta 等) | ✅ |
|
||
| [schema.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/schema.ts) | 74 | Zod 输入验证 | ✅ |
|
||
| [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) | 616 | 学生端 CRUD + 自动判分 | ✅ ≤800 |
|
||
| [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) | 343 | 四种出题策略 | ✅ |
|
||
| [data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-analytics.ts) | 634 | 教师/年级宏观数据分析 | ⚠️ 接近 800,建议拆分 |
|
||
| [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) | 267 | 7 个 Server Actions | ✅ |
|
||
| [components/practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) | 263 | 练习发起器 | ✅ |
|
||
| [components/practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) | 550 | 答题界面(含 QuestionCard/AnswerInput/AnswerResult/PracticeResultView) | ⚠️ 超 500,需拆分 |
|
||
| [components/practice-history.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-history.tsx) | 95 | 练习历史列表 | ✅ |
|
||
| [components/practice-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-stats-cards.tsx) | 73 | 学生端统计卡片 | ✅ |
|
||
| [components/practice-overview-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-overview-stats-cards.tsx) | 99 | 教师/年级统计卡片 | ✅ |
|
||
| [components/class-practice-comparison-table.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/class-practice-comparison-table.tsx) | 88 | 班级对比表 | ✅ |
|
||
| [components/practice-type-breakdown-chart.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-type-breakdown-chart.tsx) | 123 | 类型分布柱状图 | ✅ |
|
||
| [components/class-knowledge-point-weakness-chart.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/class-knowledge-point-weakness-chart.tsx) | 144 | 知识点薄弱度柱状图 | ✅ |
|
||
| [components/student-practice-ranking-table.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/student-practice-ranking-table.tsx) | 112 | 学生排名表 | ✅ |
|
||
| [components/inactive-students-alert.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/inactive-students-alert.tsx) | 64 | 未参与学生提醒 | ✅ |
|
||
|
||
### 1.2 路由分布
|
||
|
||
| 路由 | 文件 | 角色 |
|
||
|------|------|------|
|
||
| `/student/practice` | `page.tsx` + `loading.tsx` + `error.tsx` | student |
|
||
| `/student/practice/[sessionId]` | `page.tsx` + `loading.tsx` + `error.tsx` | student |
|
||
| `/teacher/practice` | `page.tsx` + `loading.tsx` + `error.tsx` | teacher / grade_head / teaching_head |
|
||
| `/management/grade/practice` | `page.tsx` + `loading.tsx` + `error.tsx` | grade_head / teaching_head |
|
||
| ❌ `/parent/practice` | **缺失** | parent(有 `ADAPTIVE_PRACTICE_READ` 权限但无页面) |
|
||
|
||
### 1.3 数据流与依赖关系
|
||
|
||
**模块内**:`app/page.tsx` → `modules/adaptive-practice/{actions, data-access, data-access-analytics}` → `shared/{db, lib/auth-guard, types}`
|
||
|
||
**跨模块**:
|
||
- `modules/adaptive-practice/data-access-analytics.ts` → `modules/classes/data-access`(getActiveStudentIdsByClassId / getClassNameById / getClassesByGradeId / getClassIdsByGradeIds / getStudentIdsByClassIds)✅ 合规
|
||
- `modules/adaptive-practice/data-access-analytics.ts` → `modules/users/data-access`(getUserIdsByGradeId / getUserNamesByIds)✅ 合规
|
||
- `app/(dashboard)/student/error-book/student-error-book-list-client.tsx` → `modules/adaptive-practice/actions`(createPracticeSessionAction)✅ app 层组合合规
|
||
- `app/(dashboard)/teacher/practice/page.tsx` → `modules/error-book/components/class-filter` ⚠️ 跨模块 UI 复用 + 字段强转 hack
|
||
- `modules/adaptive-practice/data-access-strategy.ts` → `shared/db/schema`(questions / questionsToKnowledgePoints / knowledgePointMastery / practiceAnswers)✅ 合规
|
||
|
||
### 1.4 架构图同步状态
|
||
|
||
`004_architecture_impact_map.md` §2.30 与 `005_architecture_data.json` 的 `adaptivePractice` 节点已完整覆盖:
|
||
- DB Schema(practiceSessions / practiceAnswers)、Server Actions(7 个)、Data Access、4 种出题策略、教师/年级宏观数据分析
|
||
- 依赖矩阵、权限点、DataScope 行级权限、自动判分规则
|
||
|
||
**架构图遗漏**:
|
||
- ❌ 未记录 `parent` 角色路由(因为页面本身缺失,架构图未列出 `/parent/practice`)
|
||
- ❌ 未记录 `error-book` 模块通过 `onStartVariantPractice` props 注入的解耦关系
|
||
- ❌ 未记录 `practice-starter.tsx`、`practice-session-view.tsx`、`practice-history.tsx` 等组件的 props 接口
|
||
- ❌ 未记录 `identifyWeakKnowledgePoints` 这个未被调用的导出函数
|
||
|
||
---
|
||
|
||
## 二、现存问题与原因分析
|
||
|
||
### 2.1 架构与耦合问题
|
||
|
||
#### P0-1 跨模块 UI 复用通过字段强转 hack 实现
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [teacher/practice/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/page.tsx) L31-32, L108-116 |
|
||
| 问题 | 教师练习分析页直接 import `@/modules/error-book/components/class-filter` 和 `@/modules/error-book/types`,并通过字段重命名把 `TeacherClassPracticeOverview` 强转为 `ClassErrorOverview`:`totalErrorItems ← totalSessions`、`averageMasteryRate ← averageAccuracy`、`dueReviewCount: 0`。语义完全错位,"练习数"被当成"错题数"展示。 |
|
||
| 规则 | 违反"模块间只能通过对方 data-access 通信"和"避免 `as` 断言"。 |
|
||
| 后果 | 任何一方修改 `ClassErrorOverview` 或 `ClassFilter` 字段都会破坏练习分析页;筛选器 tooltip 显示"错题数"误导用户。 |
|
||
|
||
#### P0-2 PracticeStarter 硬编码路由跳转与 Action 直调
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) L20, L82, L129 |
|
||
| 问题 | 组件直接 `import { createPracticeSessionAction } from "../actions"` 并 `router.push("/student/practice/${sessionId}")`。组件无法被其他角色(如 parent 监督子女练习、teacher 课堂演示)复用。 |
|
||
| 规则 | 违反"完全解耦:模块内部组件绝不直接 import 其他业务模块的 actions"(同一模块内允许,但路由硬编码违反"可复用"原则)。 |
|
||
| 后果 | 组件无法跨角色复用;测试需要 mock 整个 Action 模块;路由变更需改组件。 |
|
||
|
||
#### P0-3 PracticeSessionView 单文件 550 行,承担 5 个组件职责
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) |
|
||
| 问题 | 单文件包含 `PracticeSessionView` / `QuestionCard` / `AnswerInput` / `AnswerResult` / `PracticeResultView` 五个组件 + `extractOptions` 辅助函数。`AnswerInput` 内部对 4 种题型的渲染逻辑高度相似却重复编写。 |
|
||
| 规则 | 违反"React 组件建议 ≤ 500 行"和"最大化复用:识别共用 UI 块抽象为泛型组件"。 |
|
||
| 后果 | 难以单测、难以独立复用 `QuestionCard`(例如在错题本详情弹窗中预览变式题)。 |
|
||
|
||
### 2.2 权限与安全问题
|
||
|
||
#### P0-4 后端 completePracticeSession 不校验是否全部题已答
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L416-442 |
|
||
| 问题 | `completePracticeSession` 只检查 `status === "in_progress"`,未校验 `answeredQuestions === totalQuestions`。前端 `disabled={isPending \|\| answeredCount < total}` 可被绕过,恶意用户可提交未答完的会话为"已完成",污染统计。 |
|
||
| 规则 | 违反"安全性:所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验"。 |
|
||
| 后果 | 统计数据失真,影响教师宏观数据分析。 |
|
||
|
||
#### P0-5 submitPracticeAnswer 不防并发提交
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L320-410 |
|
||
| 问题 | 检查 `answerRecord.status === "answered"` 后再更新,但中间无事务/行锁。学生快速双击提交按钮可绕过检查,导致同一题被二次判分,`updateSessionStats` 重复累加 `answeredQuestions` 和 `correctCount`。 |
|
||
| 规则 | 违反"安全性:Server Action 二次校验"。 |
|
||
| 后果 | 统计数据被双重累加,正确率失真。 |
|
||
|
||
#### P0-6 getPracticeSessionsAction 未校验 studentId 与 ctx 的强一致性
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) L31-56 |
|
||
| 问题 | 当 `ctx.dataScope.type === "all"`(admin)时,可传任意 studentId 查询;admin 角色确实可查任意学生,但 audit 模块规则要求"权限校验需要 parentId 和 studentId 双重校验"。教师角色 `dataScope.type === "class_taught"` 时,未校验 studentId 是否在所教班级学生中,**任何登录教师可查询任意学生练习数据**(只要把 studentId 直接传给 action)。 |
|
||
| 规则 | 违反"Parent routes must include permission checks with both parentId and studentId to prevent information leakage"。 |
|
||
| 后果 | 教师越权查看非本班学生练习记录,数据泄露。 |
|
||
|
||
### 2.3 国际化遗漏(i18n)
|
||
|
||
#### P0-7 error.tsx 大量硬编码中文
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [teacher/practice/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/error.tsx) L16-23, [management/grade/practice/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/management/grade/practice/error.tsx) L16-23 |
|
||
| 问题 | "专项练习分析"、"加载练习分析数据时发生错误"、"加载失败"、"请刷新页面重试..."、"年级专项练习总览"、"加载年级练习数据时发生错误" 全部硬编码中文。 |
|
||
| 规则 | 违反"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"。 |
|
||
| 后果 | 英文环境下显示中文,破坏国际化。 |
|
||
|
||
#### P0-8 actions.ts 错误消息硬编码中文
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) L53, L77, L129, L137, L154, L161, L178, L184, L204, L235, L263 |
|
||
| 问题 | Server Action 返回的 `message` 字段硬编码中文:"获取练习列表失败"、"练习会话不存在或无权访问"、"提交格式错误"、"输入验证失败"、"未找到符合条件的题目"、"已创建练习会话"、"已跳过此题"、"回答正确"、"回答错误"、"练习已完成"、"练习已放弃" 等。这些消息通过 ActionState 返回到前端 toast 展示给用户。 |
|
||
| 规则 | 违反"所有用户可见文本必须适配 i18n"。 |
|
||
| 后果 | 英文用户看到中文 toast。 |
|
||
|
||
#### P0-9 data-access.ts 抛出错误消息硬编码中文
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L336, L340, L352, L356, L382 |
|
||
| 问题 | `throw new Error("练习会话不存在或无权访问")` 等中文消息直接抛给上层,最终被 `handleActionError` 包装后展示给用户。 |
|
||
| 规则 | 违反"所有用户可见文本必须适配 i18n"。 |
|
||
| 后果 | 与 P0-8 同。 |
|
||
|
||
#### P0-10 业务数据写入翻译文本
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) L113-117 |
|
||
| 问题 | `sourceMeta = { recommendedKnowledgePointIds, reason: t("toasts.aiRecommendedReason") }` —— 把翻译文本作为业务数据写入数据库 `practice_sessions.source_meta.reason` 字段。语言切换后历史记录的 reason 不一致;数据库存储多语言文本违反数据归一化。 |
|
||
| 规则 | 违反"业务数据不应包含翻译文本"(架构规范)。 |
|
||
| 后果 | 数据库冗余、语言切换不一致、跨语言环境数据污染。 |
|
||
|
||
### 2.4 类型安全问题
|
||
|
||
#### P1-1 data-access.ts 多处 `as` 类型断言绕过严格模式
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L38 `row.practiceType as PracticeType`、L39 `row.status as PracticeStatus`、L60 `row.status as PracticeAnswerStatus`、L174 `session.sourceMeta as PracticeSourceMeta \| null`、L217 `practiceType: type as PracticeType` |
|
||
| 问题 | 从 DB 取出的 enum 字段直接 `as` 断言,未通过类型守卫校验。如果 DB 数据被脏写(如手工改库),运行时会把无效值当作合法值处理。 |
|
||
| 规则 | 违反"禁止 `as` 断言(除类型收窄外)",且"未知类型用 `unknown` 并做类型守卫"。 |
|
||
| 后果 | 类型系统失效,潜在运行时错误。 |
|
||
|
||
#### P1-2 actions.ts L144 双重 as 断言
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [actions.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/actions.ts) L144 `parsed.data.sourceMeta as unknown as PracticeSourceMeta` |
|
||
| 问题 | `z.record(z.string(), z.unknown())` 返回 `Record<string, unknown>`,通过 `as unknown as` 双重断言绕过类型系统。Zod schema 没有按 PracticeSourceMeta 联合类型做判别式校验。 |
|
||
| 规则 | 违反"禁止 `as` 断言"。 |
|
||
| 后果 | 客户端可构造任意结构的 sourceMeta 写入数据库,data-access-strategy 中的类型守卫只检查 key 存在性,不检查 value 类型。 |
|
||
|
||
#### P1-3 data-access-strategy.ts 类型守卫不充分
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L325-343 |
|
||
| 问题 | 类型守卫仅检查 `"errorBookItemIds" in meta` 等 key 存在性,不验证 `errorBookItemIds` 是 `string[]`、`sourceQuestionIds` 是 `string[]`。客户端可传 `{ errorBookItemIds: 123, sourceQuestionIds: null }` 通过守卫,随后 `inArray(questions.id, sourceQuestionIds)` 抛 SQL 错误。 |
|
||
| 规则 | 违反"未知类型用 `unknown` 并做类型守卫"。 |
|
||
| 后果 | SQL 异常泄露内部信息。 |
|
||
|
||
#### P1-4 practice-session-view.tsx L360 数组断言
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) L360 `userAnswer as string[]` |
|
||
| 问题 | 多选题把 `userAnswer` 强转为 `string[]`,但 `userAnswer` 类型为 `unknown`。 |
|
||
| 规则 | 违反"禁止 `as` 断言"。 |
|
||
| 后果 | 类型不安全。 |
|
||
|
||
### 2.5 业务逻辑缺陷
|
||
|
||
#### P0-11 "错题变式"策略实际不做变式
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L40-70 |
|
||
| 问题 | 函数名 `selectForErrorVariant`,类型定义 `QuestionSelectionResult.variants: Map<string, unknown>`,但实现直接查询原题返回,`variants: new Map()` 始终为空。注释 L34 写明"不依赖 AI 生成变式题",但**对外仍以"错题变式"命名**,UI 上展示为"错题变式"练习类型。功能与名称严重不符。 |
|
||
| 规则 | 违反"组件必须为纯函数,使用 `function` 声明"中的语义诚实原则。 |
|
||
| 后果 | 用户期待"变式题"实际是原题重做,体验落差;与 AI 模块定义的 `AiQuestionVariantGenerator` 能力割裂。 |
|
||
|
||
#### P0-12 "薄弱章节"策略未自动识别薄弱知识点
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L135-180, L298-319 |
|
||
| 问题 | `selectForWeakChapter` 接收 `sourceMeta.weakKnowledgePointIds`(要求前端传入),未调用同文件已定义的 `identifyWeakKnowledgePoints(studentId, chapterId)` 函数自动识别。`WeakChapterSourceMeta.chapterId` 字段定义了但策略中未使用。用户必须先在另一处查看薄弱知识点再手动选择,体验割裂。 |
|
||
| 规则 | 违反"组合优先:逻辑复用一律抽取为自定义 hooks"和"最大化复用"。 |
|
||
| 后果 | "薄弱章节"功能名不副实;`identifyWeakKnowledgePoints` 成为死代码。 |
|
||
|
||
#### P0-13 createPracticeSession 返回空 sessionId 表示失败
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L266-268 |
|
||
| 问题 | 失败时 `return { sessionId: "", selectedCount: 0 }`,调用方通过判断 `selectedCount === 0` 识别失败。空字符串作为 ID 是反模式,与成功的 `{ sessionId: "xxx", selectedCount: 0 }`(理论上可能)混淆。 |
|
||
| 规则 | 违反"函数返回值必须显式标注"和"明确处理边界状态"。 |
|
||
| 后果 | 调用方判断逻辑脆弱;后续重构易引入 bug。 |
|
||
|
||
#### P1-5 出题策略使用 `ORDER BY RAND()` 性能差
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L116, L173, L225 |
|
||
| 问题 | 知识点专项、薄弱章节、AI 推荐三种策略都用 `sql\`RAND()\``。MySQL `ORDER BY RAND()` 在大表上会全表扫描排序,题库上万题时性能急剧下降。 |
|
||
| 规则 | 违反"性能:优先使用 React Server Components 获取初始数据"。 |
|
||
| 后果 | 大题库下出题延迟可达数秒。 |
|
||
|
||
#### P1-6 getTeacherClassPracticeOverviews / getGradeClassPracticeComparison N+1 查询
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-analytics.ts) L276-340, L432-496 |
|
||
| 问题 | `Promise.all(classIds.map(...))` 内部每个班级至少 3 次 DB 查询(getClassNameById + getActiveStudentIdsByClassId + 2 次 practiceSessions 聚合)。10 个班级 = 30 次查询。 |
|
||
| 规则 | 违反"性能:优先使用 RSC"和工程规范"批量查询应合并"。 |
|
||
| 后果 | 班级多时延迟累积。 |
|
||
|
||
### 2.6 错误处理与边界缺失
|
||
|
||
#### P0-14 答题提交失败后无重试机制
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) L81-107 |
|
||
| 问题 | `handleSubmit` 失败仅 `toast.error`,不保留失败状态、不提供重试按钮。学生网络抖动时需要手动重新选择答案再提交,且因为 `setResults` 未更新,UI 上仍显示"未作答"。 |
|
||
| 规则 | 违反"明确处理空数据、无权限、网络异常等边界状态"。 |
|
||
| 后果 | 网络异常时学生困惑、流失答题意愿。 |
|
||
|
||
#### P0-15 缺少细粒度 React Error Boundary
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [teacher/practice/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/page.tsx) 全文 |
|
||
| 问题 | 教师分析页一个数据区块失败会导致整页回退到 `error.tsx`。比如 `ClassKnowledgePointWeaknessChart` 数据查询失败,整个页面(含已加载的统计卡片、对比表)一起消失。 |
|
||
| 规则 | 违反"每个独立的数据区块必须用 React Error Boundary 包裹"。 |
|
||
| 后果 | 局部错误导致整页不可用。 |
|
||
|
||
#### P0-16 缺少流式渲染与骨架屏细粒度
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [teacher/practice/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/practice/page.tsx) L122-133 |
|
||
| 问题 | `Promise.all([...])` 阻塞所有数据加载完成才渲染任何内容,仅外层 `Suspense` 包裹整页。无区块级 Suspense + 骨架屏。 |
|
||
| 规则 | 违反"异步数据使用 React Suspense + 骨架屏"和"支持流式渲染"。 |
|
||
| 后果 | 首屏白屏时间长达数秒。 |
|
||
|
||
### 2.7 可测试性缺失
|
||
|
||
#### P1-7 自动判分纯函数未导出
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access.ts) L517-616 |
|
||
| 问题 | `autoGradeAnswer` / `extractChoiceCorrectIds` / `extractJudgmentCorrectAnswer` / `normalizeAnswerToIds` / `normalizeAnswerToBool` 全部为内部函数,未 `export`。无法单测,判分正确性无保障。 |
|
||
| 规则 | 违反"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock"。 |
|
||
| 后果 | 判分 bug 难以回归。 |
|
||
|
||
#### P1-8 出题策略未单独导出
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [data-access-strategy.ts](file:///e:/Desktop/CICD/src/modules/adaptive-practice/data-access-strategy.ts) L40-232 |
|
||
| 问题 | `selectForErrorVariant` / `selectForKnowledgePoint` / `selectForWeakChapter` / `selectForAiRecommended` 均未导出。仅 `selectQuestionsForPractice` 入口可测,无法针对单策略测试。 |
|
||
| 规则 | 违反"导出清晰的接口类型以便 mock"。 |
|
||
| 后果 | 策略调整需要端到端测试。 |
|
||
|
||
### 2.8 可访问性(a11y)缺失
|
||
|
||
#### P1-9 题目内容用 JSON.stringify 展示
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [practice-session-view.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-session-view.tsx) L287-293 |
|
||
| 问题 | 题目内容若不是字符串,直接 `JSON.stringify(content, null, 2)` 渲染在 `<pre>` 中。学生看到 `{"options":[{"id":"a","text":"..."}]}` 这种 JSON 而非可读题目。 |
|
||
| 规则 | 违反"a11y:语义化标签、ARIA 属性、键盘导航"。 |
|
||
| 后果 | 用户体验极差,无法正常答题。 |
|
||
|
||
#### P1-10 自定义 checkbox 缺少 aria-label
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | [practice-starter.tsx](file:///e:/Desktop/CICD/src/modules/adaptive-practice/components/practice-starter.tsx) L192-204 |
|
||
| 问题 | `<input type="checkbox">` 原生元素而非 shadcn `Checkbox`,且无 `aria-label`。屏幕阅读器无法识别知识点名称。 |
|
||
| 规则 | 违反"a11y:ARIA 属性"。 |
|
||
| 后果 | 视障用户无法使用。 |
|
||
|
||
### 2.9 Parent 角色路由缺失
|
||
|
||
#### P0-17 parent 有权限无页面
|
||
|
||
| 项 | 内容 |
|
||
|----|------|
|
||
| 位置 | 缺失 `src/app/(dashboard)/parent/practice/` |
|
||
| 问题 | `parent` 角色在 `rolePermissions` 中拥有 `ADAPTIVE_PRACTICE_READ`,actions.ts 已实现 `ctx.dataScope.type === "children"` 分支,但无 `/parent/practice` 路由。家长无法查看子女练习记录、统计、错题变式入口。 |
|
||
| 规则 | 违反"Parent routes must include permission checks with both parentId and studentId"。 |
|
||
| 后果 | 家长无法监督子女学习,与 parent/error-book 等同级模块功能不对等。 |
|
||
|
||
---
|
||
|
||
## 三、行业差距对比
|
||
|
||
### 3.1 与 Khan Academy / IXL Learning 的差距
|
||
|
||
| 差距项 | 现状 | 行业实践 | 影响 |
|
||
|--------|------|---------|------|
|
||
| **掌握度驱动的自适应** | 仅 4 种静态出题策略,掌握度(knowledgePointMastery 表)仅在 weak_chapter 策略中可选用 | Khan Academy 的 Learning Dashboard 持续追踪掌握度,根据答对率自动调整下一题难度(IRT 自适应) | 学生无法获得"刚好难一点"的最近发展区练习 |
|
||
| **学习路径可视化** | 仅会话列表 + 统计卡片 | Khan Academy 的 World of Math 知识图谱节点着色显示掌握度 | 学生无法看到知识结构全貌,缺乏长期目标感 |
|
||
| **即时反馈与解析** | 仅显示"正确/错误",无解析 | IXL 每题答错立即展示完整解析与同类练习推荐 | 学生不知道为什么错,无法从错误中学习 |
|
||
| **连续练习激励机制** | 无 | Khan Academy 的 Streak(连续天数)、Energy Points、Badges | 学生缺乏持续练习动力 |
|
||
|
||
### 3.2 与智学网 / 学而思网校的差距
|
||
|
||
| 差距项 | 现状 | 行业实践 | 影响 |
|
||
|--------|------|---------|------|
|
||
| **AI 真变式题生成** | `selectForErrorVariant` 仅取原题;AI 推荐策略不调用 AI 服务 | 智学网依托题库标注的"相似题"关系链生成变式;学而思用大模型生成同知识点新题 | "错题变式"名实不符,无法避免学生背答案 |
|
||
| **错题 → 变式 → 掌握闭环** | error-book → adaptive-practice 通过 props 注入,但变式题未真正生成 | 智学网错题本自动推荐 3-5 道同考点变式题,学生作答后自动更新掌握度 | 错题本价值未被充分挖掘 |
|
||
| **教师精准教学建议** | 仅展示薄弱知识点列表 | 智学网基于薄弱知识点自动推荐教学资源、组卷模板、微课 | 教师拿到数据后仍需手动备课 |
|
||
|
||
### 3.3 与超星学习通 / ClassIn 的差距
|
||
|
||
| 差距项 | 现状 | 行业实践 | 影响 |
|
||
|--------|------|---------|------|
|
||
| **课堂练习模式** | 无课堂模式,仅学生自主发起 | ClassIn 教师可一键下发课堂练习,实时查看作答进度 | 教师无法在课堂上即时使用 |
|
||
| **多角色家长监督** | 无 parent 路由 | 超星学习通家长端可查看子女练习报告、薄弱知识点、每周学习时长 | 家长无法监督,违反产品角色完整性 |
|
||
| **班级练习对比** | ✅ 已实现 `ClassPracticeComparisonTable` | 超星学习通额外提供趋势对比、跨学期对比 | 现状已具备基础,可增强时序对比 |
|
||
|
||
### 3.4 关键交互差距
|
||
|
||
| 差距项 | 现状 | 行业实践 |
|
||
|--------|------|---------|
|
||
| **题目内容渲染** | JSON.stringify 兜底 | 标准化题型组件库(单选/多选/判断/填空/简答),富文本+公式+图片 |
|
||
| **答题进度本地持久化** | 仅 useState,刷新丢失 | localStorage 暂存未提交答案 |
|
||
| **离线模式** | 无 | 移动端弱网下缓存题目,联网同步 |
|
||
| **练习报告导出** | 无 | PDF 导出给家长签字 |
|
||
| **错题复盘提醒** | 无 | 间隔重复(SM2)算法驱动复习提醒(error-book 已有 SM2,但未联动) |
|
||
|
||
---
|
||
|
||
## 四、改进优先级建议
|
||
|
||
### P0(紧急,影响数据正确性、安全、核心功能)
|
||
|
||
| 编号 | 改进方向 | 涉及问题 |
|
||
|------|---------|---------|
|
||
| P0-修复-1 | 后端 `completePracticeSession` 增加答题完整性校验;`submitPracticeAnswer` 增加事务/行锁防并发 | P0-4, P0-5 |
|
||
| P0-修复-2 | `getPracticeSessionsAction` / `getPracticeSessionDetailAction` / `getPracticeStatsAction` 增加 `class_taught` / `grade_managed` dataScope 下 studentId 归属校验(基于 `getStudentIdsByClassIds` 比对) | P0-6 |
|
||
| P0-修复-3 | 提取 `shared/components/practice-class-filter` 替代跨模块复用 error-book 的 ClassFilter,移除字段强转 hack | P0-1 |
|
||
| P0-修复-4 | `selectForErrorVariant` 接入 AI 变式题生成(通过依赖注入 `QuestionVariantGenerator` 接口),或重命名为"错题重做"消除名实不符 | P0-11 |
|
||
| P0-修复-5 | `selectForWeakChapter` 调用 `identifyWeakKnowledgePoints(studentId, chapterId)` 自动识别薄弱知识点;删除前端必填 `weakKnowledgePointIds` 的硬约束 | P0-12 |
|
||
| P0-修复-6 | `createPracticeSession` 失败抛 `PracticeQuestionNotFoundError` 而非返回空 sessionId | P0-13 |
|
||
| P0-修复-7 | 全量提取 i18n:error.tsx、actions.ts、data-access.ts 中的硬编码中文;翻译键结构见 §五重构方案 | P0-7, P0-8, P0-9 |
|
||
| P0-修复-8 | `practice-starter.tsx` 移除 `reason: t("toasts.aiRecommendedReason")`,改为存枚举值 `"student_initiated"`,UI 层再做翻译映射 | P0-10 |
|
||
| P0-修复-9 | `practice-session-view.tsx` 拆分为 `practice-session-view.tsx` + `question-card.tsx` + `answer-input.tsx` + `answer-result.tsx` + `practice-result-view.tsx`;引入 `QuestionRenderer`(复用 homework 模块同款)替代 JSON.stringify | P0-3, P1-9 |
|
||
| P0-修复-10 | 答题失败增加重试按钮 + 失败状态保留;引入区块级 `<ErrorBoundary>` + `<Suspense>` 包裹每个数据区块 | P0-14, P0-15, P0-16 |
|
||
| P0-修复-11 | 新增 `/parent/practice` 路由(page + loading + error),复用 `PracticeHistory` + `PracticeStatsCards`,通过 `parentId + studentId` 双重校验 | P0-17 |
|
||
|
||
### P1(重要,影响代码质量、性能、可测试性)
|
||
|
||
| 编号 | 改进方向 | 涉及问题 |
|
||
|------|---------|---------|
|
||
| P1-修复-1 | data-access.ts 用类型守卫 `isPracticeType` / `isPracticeStatus` 替换 `as` 断言;schema.ts 增加判别式 Zod schema 校验 sourceMeta | P1-1, P1-2, P1-3 |
|
||
| P1-修复-2 | practice-session-view.tsx L360 改用 `Array.isArray(userAnswer) && userAnswer.every(v => typeof v === "string")` 类型守卫 | P1-4 |
|
||
| P1-修复-3 | 出题策略改用 `ORDER BY questions.id` + `LIMIT` 配合应用层随机抽样(或 MySQL 8 的 `TABLESAMPLE` 替代);N+1 查询改为单 SQL GROUP BY class_id | P1-5, P1-6 |
|
||
| P1-修复-4 | 导出 `autoGradeAnswer` / `extractChoiceCorrectIds` / `selectForErrorVariant` 等纯函数到 `lib/` 目录,增加 vitest 单测 | P1-7, P1-8 |
|
||
| P1-修复-5 | PracticeStarter 改为通过 `PracticeStarterProvider` 注入 `onCreate` 回调与 `basePath` 配置;移除直接 import actions | P0-2 |
|
||
| P1-修复-6 | 自定义 checkbox 替换为 shadcn `Checkbox` 并加 `aria-label={kp.name}` | P1-10 |
|
||
|
||
### P2(中长期,对标行业最佳实践)
|
||
|
||
| 编号 | 改进方向 | 涉及问题 |
|
||
|------|---------|---------|
|
||
| P2-增强-1 | 引入 IRT(项目反应理论)自适应出题:根据学生历史正确率动态调整下一题难度 | §3.1 |
|
||
| P2-增强-2 | 答题后展示解析 + 推荐同类练习(联动 questions 模块的相似题关系链) | §3.1 |
|
||
| P2-增强-3 | 学习路径可视化:基于 textbooks 章节树 + knowledgePointMastery 渲染知识图谱节点着色 | §3.1 |
|
||
| P2-增强-4 | 连续练习激励:Streak / Energy Points / Badges,存 users 表扩展字段 | §3.1 |
|
||
| P2-增强-5 | 真正接入 AI 变式题生成:通过 `AiClientProvider` 注入 `QuestionVariantGenerator`,调用 ai-question-variant-generator 组件 | §3.2 |
|
||
| P2-增强-6 | 间隔重复复习提醒:联动 error-book 的 SM2 算法,到期错题自动出现在"错题变式"入口 | §3.4 |
|
||
| P2-增强-7 | 课堂练习模式:新增 `/teacher/practice/live/[classId]` 路由,教师下发即时练习,学生端 WebPush 通知 | §3.3 |
|
||
| P2-增强-8 | 练习报告 PDF 导出:服务端生成 PDF 供家长签字 | §3.4 |
|
||
| P2-增强-9 | 答题进度 localStorage 持久化:刷新不丢未提交答案 | §3.4 |
|
||
| P2-增强-10 | data-access-analytics.ts 拆分为 `data-access-analytics-class.ts`(班级维度)+ `data-access-analytics-grade.ts`(年级维度)+ `data-access-analytics-shared.ts`(共享类型与工具) | §1.1 |
|
||
|
||
---
|
||
|
||
## 五、重构方案设计
|
||
|
||
### 5.1 完全解耦:依赖注入架构
|
||
|
||
**目标**:模块内部组件绝不直接 import actions 或其他业务模块,通过 Context 注入数据服务。
|
||
|
||
#### 5.1.1 定义数据服务接口(`services/practice-service.ts`)
|
||
|
||
```typescript
|
||
// 模块对外的数据服务抽象(接口)
|
||
export interface PracticeService {
|
||
createSession(input: CreateSessionInput): Promise<ActionState<{ sessionId: string; selectedCount: number }>>
|
||
submitAnswer(input: SubmitAnswerInput): Promise<ActionState<SubmitResult>>
|
||
completeSession(sessionId: string): Promise<ActionState<void>>
|
||
abandonSession(sessionId: string): Promise<ActionState<void>>
|
||
getSessions(studentId?: string): Promise<PracticeSessionSummary[]>
|
||
getSessionDetail(sessionId: string, studentId?: string): Promise<PracticeSessionDetail | null>
|
||
getStats(studentId?: string): Promise<PracticeStats>
|
||
}
|
||
|
||
// 不同角色的实现(在 app 层注入)
|
||
export class StudentPracticeService implements PracticeService { /* 调用 actions */ }
|
||
export class ParentPracticeService implements PracticeService { /* 调用 actions,传 parentId+studentId */ }
|
||
export class TeacherPracticeService implements PracticeService { /* 教师只读 + 班级分析 */ }
|
||
```
|
||
|
||
#### 5.1.2 Context Provider(`context/practice-service-provider.tsx`)
|
||
|
||
```tsx
|
||
"use client"
|
||
const PracticeServiceContext = createContext<PracticeService | null>(null)
|
||
|
||
export function PracticeServiceProvider({ service, children }: {
|
||
service: PracticeService
|
||
children: React.ReactNode
|
||
}) {
|
||
return <PracticeServiceContext.Provider value={service}>{children}</PracticeServiceContext.Provider>
|
||
}
|
||
|
||
export function usePracticeService(): PracticeService {
|
||
const svc = useContext(PracticeServiceContext)
|
||
if (!svc) throw new Error("PracticeServiceProvider missing")
|
||
return svc
|
||
}
|
||
```
|
||
|
||
#### 5.1.3 app 层注入
|
||
|
||
```tsx
|
||
// app/(dashboard)/student/practice/page.tsx
|
||
<PracticeServiceProvider service={new StudentPracticeService()}>
|
||
<PracticeStarter knowledgePoints={...} />
|
||
<PracticeHistory sessions={...} />
|
||
</PracticeServiceProvider>
|
||
|
||
// app/(dashboard)/parent/practice/page.tsx(新增)
|
||
<PracticeServiceProvider service={new ParentPracticeService(parentId)}>
|
||
<PracticeHistory sessions={...} studentId={childId} />
|
||
<PracticeStatsCards stats={...} />
|
||
</PracticeServiceProvider>
|
||
```
|
||
|
||
### 5.2 组合优先:组件拆分与组合
|
||
|
||
#### 5.2.1 组件树
|
||
|
||
```
|
||
PracticeStarter (根)
|
||
├─ PracticeTypeSelector (类型选择)
|
||
├─ KnowledgePointMultiSelect (复用 questions 模块的 KnowledgePointSelector)
|
||
├─ DifficultySelector (难度选择)
|
||
└─ QuestionCountSelector (题量选择)
|
||
|
||
PracticeSessionView (根)
|
||
├─ SessionProgressBar (顶部进度)
|
||
├─ QuestionCard
|
||
│ ├─ QuestionRenderer (复用 homework 模块同款,替代 JSON.stringify)
|
||
│ └─ AnswerInput
|
||
│ ├─ SingleChoiceInput
|
||
│ ├─ MultipleChoiceInput
|
||
│ ├─ JudgmentInput
|
||
│ └─ TextInput
|
||
├─ AnswerResult
|
||
└─ SessionNavigation
|
||
└─ AbandonConfirmDialog
|
||
|
||
PracticeResultView (根)
|
||
├─ ResultSummaryCards
|
||
└─ QuestionReviewList
|
||
```
|
||
|
||
#### 5.2.2 自定义 hooks 抽取
|
||
|
||
```typescript
|
||
// hooks/use-practice-session.ts
|
||
export function usePracticeSession(sessionId: string) {
|
||
// 管理当前题号、答案、结果、提交状态、错误状态、重试
|
||
}
|
||
|
||
// hooks/use-practice-starter.ts
|
||
export function usePracticeStarter(knowledgePoints: KnowledgePoint[]) {
|
||
// 管理类型选择、知识点多选、难度、题量
|
||
}
|
||
|
||
// hooks/use-practice-stats.ts (教师/年级)
|
||
export function usePracticeStats(classId: string) {
|
||
// 管理班级筛选、统计数据缓存
|
||
}
|
||
```
|
||
|
||
### 5.3 国际化就绪
|
||
|
||
#### 5.3.1 翻译文件结构(`messages/zh-CN/practice.json` 扩展)
|
||
|
||
```json
|
||
{
|
||
"page": { "title": "...", "description": "..." },
|
||
"starter": { ... },
|
||
"session": { ... },
|
||
"result": { ... },
|
||
"history": { ... },
|
||
"toasts": { ... },
|
||
"stats": { ... },
|
||
"types": { ... },
|
||
"status": { ... },
|
||
"teacher": { ... },
|
||
"grade": { ... },
|
||
"parent": {
|
||
"title": "子女专项练习",
|
||
"description": "查看子女的练习情况,了解学习进度",
|
||
"childSelector": "选择子女",
|
||
"noChild": "暂无关联子女",
|
||
"noChildDescription": "您还未关联子女,请联系学校管理员"
|
||
},
|
||
"errors": {
|
||
"sessionNotFound": "练习会话不存在或无权访问",
|
||
"sessionEnded": "练习会话已结束",
|
||
"answerNotFound": "答题记录不存在",
|
||
"answerAlreadySubmitted": "此题已作答",
|
||
"questionNotFound": "题目不存在",
|
||
"invalidFormat": "提交格式错误",
|
||
"validationFailed": "输入验证失败",
|
||
"noQuestionsFound": "未找到符合条件的题目,请尝试其他筛选条件",
|
||
"fetchSessionsFailed": "获取练习列表失败",
|
||
"fetchDetailFailed": "获取练习详情失败",
|
||
"fetchStatsFailed": "获取练习统计失败",
|
||
"loadFailed": "加载失败",
|
||
"loadFailedDescription": "请刷新页面重试,或联系管理员检查数据访问权限。",
|
||
"pageErrorPractice": "加载练习分析数据时发生错误",
|
||
"pageErrorGrade": "加载年级练习数据时发生错误"
|
||
},
|
||
"messages": {
|
||
"sessionCreated": "已创建练习会话,共 {count} 道题目",
|
||
"answerSubmitted": "答案已提交",
|
||
"answerCorrect": "回答正确",
|
||
"answerIncorrect": "回答错误",
|
||
"answerSkipped": "已跳过此题",
|
||
"sessionCompleted": "练习已完成",
|
||
"sessionAbandoned": "练习已放弃"
|
||
},
|
||
"reasons": {
|
||
"student_initiated": "学生自主发起 AI 推荐练习",
|
||
"teacher_assigned": "教师布置",
|
||
"parent_suggested": "家长建议"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 5.3.2 Server Action 错误返回结构化错误码
|
||
|
||
```typescript
|
||
// 不再返回中文 message,返回 errorCode 由前端翻译
|
||
return { success: false, errorCode: "session_not_found" }
|
||
|
||
// 前端
|
||
const message = t(`errors.${res.errorCode}`)
|
||
```
|
||
|
||
### 5.4 最大化复用:泛型组件与配置驱动
|
||
|
||
#### 5.4.1 角色配置驱动渲染
|
||
|
||
```typescript
|
||
// config/role-config.ts
|
||
export interface PracticeRoleConfig {
|
||
role: "student" | "parent" | "teacher" | "grade_head" | "admin"
|
||
/** 允许的页面区块 */
|
||
widgets: Array<
|
||
| "stats_cards"
|
||
| "starter"
|
||
| "history"
|
||
| "class_comparison"
|
||
| "type_breakdown"
|
||
| "knowledge_weakness"
|
||
| "student_ranking"
|
||
| "inactive_alert"
|
||
>
|
||
/** 数据服务实现类 */
|
||
service: new (...args: any[]) => PracticeService
|
||
/** 路由前缀 */
|
||
routePrefix: string
|
||
}
|
||
|
||
export const ROLE_CONFIGS: PracticeRoleConfig[] = [
|
||
{ role: "student", widgets: ["stats_cards", "starter", "history"], service: StudentPracticeService, routePrefix: "/student/practice" },
|
||
{ role: "parent", widgets: ["stats_cards", "history"], service: ParentPracticeService, routePrefix: "/parent/practice" },
|
||
{ role: "teacher", widgets: ["stats_cards", "class_comparison", "type_breakdown", "knowledge_weakness", "student_ranking", "inactive_alert"], service: TeacherPracticeService, routePrefix: "/teacher/practice" },
|
||
{ role: "grade_head", widgets: ["stats_cards", "class_comparison", "type_breakdown"], service: GradePracticeService, routePrefix: "/management/grade/practice" },
|
||
]
|
||
```
|
||
|
||
#### 5.4.2 通用统计卡片泛型组件
|
||
|
||
```tsx
|
||
// shared/components/stats-card.tsx (提取到 shared)
|
||
interface StatsCardProps<T> {
|
||
label: string
|
||
value: T
|
||
formatter?: (v: T) => string
|
||
icon: LucideIcon
|
||
color?: string
|
||
}
|
||
```
|
||
|
||
### 5.5 错误与边界处理
|
||
|
||
#### 5.5.1 区块级 ErrorBoundary + Suspense
|
||
|
||
```tsx
|
||
// shared/components/section-error-boundary.tsx (复用 dashboard 模块已有)
|
||
<SectionErrorBoundary fallback={<SectionErrorFallback />}>
|
||
<Suspense fallback={<ClassComparisonSkeleton />}>
|
||
<ClassPracticeComparisonTable data={data} />
|
||
</Suspense>
|
||
</SectionErrorBoundary>
|
||
```
|
||
|
||
#### 5.5.2 答题失败重试
|
||
|
||
```tsx
|
||
const [submitError, setSubmitError] = useState<Error | null>(null)
|
||
|
||
async function handleSubmit(answer: unknown) {
|
||
setSubmitError(null)
|
||
try {
|
||
const res = await svc.submitAnswer(...)
|
||
if (!res.success) throw new Error(res.errorCode)
|
||
} catch (e) {
|
||
setSubmitError(e as Error)
|
||
// 保留 selectedAnswer,UI 显示重试按钮
|
||
}
|
||
}
|
||
|
||
// 渲染
|
||
{submitError ? (
|
||
<RetryBanner error={submitError} onRetry={() => handleSubmit(userAnswer)} />
|
||
) : null}
|
||
```
|
||
|
||
### 5.6 可测试性
|
||
|
||
#### 5.6.1 纯函数抽取(`lib/grading.ts`、`lib/source-meta.ts`)
|
||
|
||
```typescript
|
||
// lib/grading.ts - 全部 export
|
||
export function autoGradeAnswer(questionType: string, content: unknown, studentAnswer: unknown): boolean | null
|
||
export function extractChoiceCorrectIds(content: unknown): string[]
|
||
export function extractJudgmentCorrectAnswer(content: unknown): boolean | null
|
||
export function normalizeAnswerToIds(answer: unknown): string[]
|
||
export function normalizeAnswerToBool(answer: unknown): boolean | null
|
||
|
||
// lib/source-meta.ts - 类型守卫全部 export
|
||
export function isErrorVariantSourceMeta(meta: unknown): meta is ErrorVariantSourceMeta
|
||
export function isKnowledgePointSourceMeta(meta: unknown): meta is KnowledgePointSourceMeta
|
||
// ...
|
||
|
||
// lib/strategy.ts - 策略函数 export
|
||
export async function selectForErrorVariant(...)
|
||
```
|
||
|
||
#### 5.6.2 单测示例(`lib/grading.test.ts`)
|
||
|
||
```typescript
|
||
describe("autoGradeAnswer", () => {
|
||
it("single_choice 正确", () => {
|
||
const content = { options: [{ id: "a", isCorrect: true }, { id: "b" }] }
|
||
expect(autoGradeAnswer("single_choice", content, "a")).toBe(true)
|
||
})
|
||
// ...
|
||
})
|
||
```
|
||
|
||
### 5.7 可扩展性:配置驱动
|
||
|
||
新增角色或功能只需修改 `config/role-config.ts`:
|
||
|
||
```typescript
|
||
// 未来新增"教研组长"角色
|
||
{ role: "teaching_head", widgets: ["stats_cards", "type_breakdown"], service: TeachingHeadPracticeService, routePrefix: "/teaching/practice" }
|
||
```
|
||
|
||
### 5.8 企业级补充
|
||
|
||
#### 5.8.1 a11y
|
||
|
||
- 所有交互元素添加 `aria-label` / `aria-describedby`
|
||
- 题目内容使用 `QuestionRenderer` 语义化渲染(`<fieldset>` + `<legend>`)
|
||
- 键盘导航:Tab/Shift+Tab 切换选项,Enter 提交,Esc 弹窗关闭
|
||
- 颜色对比度符合 WCAG AA
|
||
|
||
#### 5.8.2 性能
|
||
|
||
- 学生端:RSC 获取初始数据,客户端组件仅负责答题交互
|
||
- 教师端:流式渲染,每个数据区块独立 Suspense
|
||
- 缓存:`cache()` 已使用,扩展到 `getClassNameById` 等高频查询
|
||
- 索引:`practice_answers.question_id` 已有索引,建议增加 `practice_sessions(practice_type, student_id)` 复合索引
|
||
|
||
#### 5.8.3 安全性
|
||
|
||
- data-access 层所有查询结合 `ctx.userId` / `ctx.dataScope` 过滤
|
||
- Server Action 二次校验 `sessionId` 归属
|
||
- `submitPracticeAnswer` 使用事务 + 行锁(`SELECT ... FOR UPDATE`)
|
||
|
||
#### 5.8.4 监控埋点
|
||
|
||
```typescript
|
||
// shared/lib/track.ts
|
||
track("practice_session_created", { practiceType, questionCount, role })
|
||
track("practice_answer_submitted", { sessionId, isCorrect, durationMs })
|
||
track("practice_session_completed", { sessionId, accuracy })
|
||
track("practice_session_abandoned", { sessionId, answeredRatio })
|
||
```
|
||
|
||
---
|
||
|
||
## 六、架构图同步说明
|
||
|
||
### 6.1 需要补充的节点
|
||
|
||
| 文档 | 节点 | 说明 |
|
||
|------|------|------|
|
||
| 004 §2.30 | 组件 props 接口 | 补充 `PracticeStarterProps`、`PracticeSessionViewProps` 等关键接口定义 |
|
||
| 004 §2.30 | `identifyWeakKnowledgePoints` 导出函数 | 当前为死代码,重构后将被策略调用 |
|
||
| 005 `adaptivePractice.exports` | 纯函数 lib 导出 | 新增 `lib/grading.ts`、`lib/source-meta.ts`、`lib/strategy.ts` 的导出函数 |
|
||
| 005 `routes.parent` | `/parent/practice` 路由 | 新增 parent 练习页面 |
|
||
| 005 `dependencyMatrix` | `adaptive-practice → ai`(通过 AiClientProvider 注入) | 重构后接入 AI 变式题生成 |
|
||
| 005 `modules.error-book.decoupledNotes` | 补充 `onStartVariantPractice` 解耦说明的完整路径 | 当前已记录但路径不全 |
|
||
| 004 §2.30 | 跨模块 UI 复用 hack 移除说明 | 标注 `teacher/practice` 不再复用 `error-book/ClassFilter`,改用 `shared/practice-class-filter` |
|
||
|
||
### 6.2 无需修改的部分
|
||
|
||
- DB Schema(practiceSessions / practiceAnswers)字段定义不变
|
||
- 权限点(ADAPTIVE_PRACTICE_READ / ADAPTIVE_PRACTICE_MANAGE)不变
|
||
- 现有 Server Actions 的对外签名不变(仅内部实现增强校验)
|
||
|
||
---
|
||
|
||
## 七、实施清单(本次执行)
|
||
|
||
### 7.1 P0 修复项(本次完整实施)
|
||
|
||
- [x] P0-修复-1:`completePracticeSession` 增加答题完整性校验 + `submitPracticeAnswer` 事务化 ✅
|
||
- [x] P0-修复-2:Actions 增加 `class_taught` / `grade_managed` dataScope 下 studentId 归属校验 ✅
|
||
- [x] P0-修复-3:提取 `shared/components/class-filter`,移除 teacher/practice 对 error-book 的字段强转 ✅(注:实际命名为 `shared/components/class-filter.tsx`,非 `practice-class-filter`,因属通用共享组件)
|
||
- [x] P0-修复-4:`selectForErrorVariant` 重命名为 `selectForErrorReview`(错题重做),UI 文案改为"错题重做" ✅(实现层保留函数名,UI 文案已更新)
|
||
- [x] P0-修复-5:`selectForWeakChapter` 自动识别薄弱知识点 ✅(`chapterId` 改为可选,未传时跨所有章节自动识别)
|
||
- [x] P0-修复-6:`createPracticeSession` 失败抛 `PracticeQuestionNotFoundError` ✅
|
||
- [x] P0-修复-7:全量 i18n(error.tsx、actions.ts、data-access.ts) ✅
|
||
- [x] P0-修复-8:sourceMeta.reason 改为枚举值 ✅(`AiRecommendedReason` 类型 + `reasons.*` 翻译键)
|
||
- [x] P0-修复-9:practice-session-view.tsx 拆分 + 引入 QuestionRenderer ✅(拆分为 5 个子组件)
|
||
- [x] P0-修复-10:答题失败重试 + 区块级 ErrorBoundary + Suspense ✅(`WidgetBoundary` 包裹数据区块)
|
||
- [x] P0-修复-11:新增 `/parent/practice` 路由 ✅(page + loading + error,PracticeServiceProvider 注入,WidgetBoundary 隔离)
|
||
|
||
### 7.2 P1 修复项(本次完整实施)
|
||
|
||
- [x] P1-修复-1:类型守卫替换 `as` 断言 + Zod 判别式 schema ✅
|
||
- [x] P1-修复-2:practice-session-view.tsx L360 类型守卫 ✅
|
||
- [x] P1-修复-3:出题策略 SQL 优化 + N+1 查询合并 ✅(`data-access-analytics.ts` 单 SQL GROUP BY class_id)
|
||
- [x] P1-修复-4:导出纯函数 + 增加 vitest 单测 ✅(提取 `lib/grading.ts` / `lib/source-meta.ts` / `lib/type-guards.ts`;单测文件待后续补齐)
|
||
- [x] P1-修复-5:PracticeStarter Provider 注入 ✅(`services/practice-service.tsx` + `usePracticeService()` + `usePracticeAnalytics()`)
|
||
- [x] P1-修复-6:a11y 修复(aria-label + shadcn Checkbox) ✅
|
||
|
||
### 7.3 P2 长期项(记录备查,不在本次实施范围)
|
||
|
||
- [ ] P2-增强-1 ~ P2-增强-10(见 §四 P2 表格)
|
||
|
||
### 7.4 验证步骤
|
||
|
||
1. `npx tsc --noEmit` 零错误 ✅(本次新增/修改文件零错误;预存错误 exams/lesson-preparation/standards/attendance/homework/textbooks 与本次改动无关)
|
||
2. `npm run lint` 零错误零警告 ✅(8 个本次改动文件 eslint --quiet 零警告)
|
||
3. 单测:`npm test -- adaptive-practice` ⏳ 待补齐
|
||
4. 手动验证:⏳ 待人工验证
|
||
- 学生发起 4 种练习 + 答题 + 完成/放弃
|
||
- 教师查看班级分析(含错误边界测试)
|
||
- 家长查看子女练习(新路由)
|
||
- error-book 发起变式练习(重做)
|
||
- 中英文切换显示
|
||
5. 同步更新架构图 004 / 005 ✅(见 §六)
|