Files
NextEdu/docs/architecture/audit/archive/adaptive-practice-audit-report.md

816 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 专项练习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 SchemapracticeSessions / practiceAnswers、Server Actions7 个、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`。屏幕阅读器无法识别知识点名称。 |
| 规则 | 违反"a11yARIA 属性"。 |
| 后果 | 视障用户无法使用。 |
### 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 | 全量提取 i18nerror.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)
// 保留 selectedAnswerUI 显示重试按钮
}
}
// 渲染
{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 SchemapracticeSessions / practiceAnswers字段定义不变
- 权限点ADAPTIVE_PRACTICE_READ / ADAPTIVE_PRACTICE_MANAGE不变
- 现有 Server Actions 的对外签名不变(仅内部实现增强校验)
---
## 七、实施清单(本次执行)
### 7.1 P0 修复项(本次完整实施)
- [x] P0-修复-1`completePracticeSession` 增加答题完整性校验 + `submitPracticeAnswer` 事务化 ✅
- [x] P0-修复-2Actions 增加 `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全量 i18nerror.tsx、actions.ts、data-access.ts
- [x] P0-修复-8sourceMeta.reason 改为枚举值 ✅(`AiRecommendedReason` 类型 + `reasons.*` 翻译键)
- [x] P0-修复-9practice-session-view.tsx 拆分 + 引入 QuestionRenderer ✅(拆分为 5 个子组件)
- [x] P0-修复-10答题失败重试 + 区块级 ErrorBoundary + Suspense ✅(`WidgetBoundary` 包裹数据区块)
- [x] P0-修复-11新增 `/parent/practice` 路由 ✅page + loading + errorPracticeServiceProvider 注入WidgetBoundary 隔离)
### 7.2 P1 修复项(本次完整实施)
- [x] P1-修复-1类型守卫替换 `as` 断言 + Zod 判别式 schema ✅
- [x] P1-修复-2practice-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-修复-5PracticeStarter Provider 注入 ✅(`services/practice-service.tsx` + `usePracticeService()` + `usePracticeAnalytics()`
- [x] P1-修复-6a11y 修复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 ✅(见 §六)