Files
NextEdu/bugs/lesson_preparation_bug_v2.md
SpecialX 978d9a8309
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
2026-06-22 01:06:16 +08:00

343 lines
12 KiB
Markdown
Raw Permalink 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.
# 备课模块lesson-preparation审查报告 v2
> 审查日期2026-06-20
> 审查范围:`src/modules/lesson-preparation/` 全部文件 + 路由页面
> 审查方式:代码审查 + Playwright 运行时测试
> 前置状态v1 已进行一次修正Tiptap setContent 参数、lint 错误等)
---
## 一、审查结论
| 维度 | 状态 |
|------|------|
| 编辑页可用性 | ✅ 已修复v1 遗留的 Tiptap SSR 崩溃) |
| 功能完整性 | ⚠️ 存在 7 个 P1 功能缺陷 |
| 代码质量 | ⚠️ 存在 8 个 P2 规范违规 |
| 用户体验 | ⚠️ 存在 5 个 P3 改进项 |
| 架构合规 | ✅ 三层架构正确,权限校验完整 |
---
## 二、本次已修复问题
### [P0-已修复] Tiptap SSR immediatelyRender 未设置导致编辑页崩溃
**文件**[rich-text-block.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx)
**现象**:编辑页显示 "Something went wrong!",控制台报错:
```
Error: Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.
```
**原因**Tiptap v3 的 `useEditor` 在 SSR 环境下默认会尝试立即渲染,导致 hydration mismatch。Next.js App Router 的客户端组件会经历 SSR 阶段,必须显式设置 `immediatelyRender: false`
**修复**:在 `useEditor` 配置中添加 `immediatelyRender: false`
**验证**Playwright 测试编辑页正常渲染,无控制台错误。
---
## 三、P1 功能缺陷(建议修复)
### [P1-1] 版本回退后编辑器内容不刷新
**文件**[lesson-plan-editor.tsx:173-175](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L173-L175)
**现象**:用户点击"回退到此版本"后,服务端 content 已更新,但编辑器界面仍显示旧内容。
**原因**`onReverted` 回调是空函数:
```tsx
<VersionHistoryDrawer
onReverted={() => { /* 触发页面刷新由父组件处理 */ }}
/>
```
**修复建议**:回退成功后调用 `useLessonPlanEditor.getState()` 重新拉取课案内容并 `replaceDoc`,或用 `router.refresh()` 刷新服务端数据。
---
### [P1-2] 版本抽屉 loading 状态失效
**文件**[version-history-drawer.tsx:27-39](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/version-history-drawer.tsx#L27-L39)
**现象**:打开版本抽屉时"加载中..."永不显示。
**原因**`loading` 初始为 `false`effect 中从未调用 `setLoading(true)`
```tsx
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const res = await getLessonPlanVersionsAction(planId); // 缺少 setLoading(true)
if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions);
setLoading(false);
})();
// ...
}, [open, planId]);
```
**修复建议**:在 async IIFE 开头添加 `setLoading(true)`
---
### [P1-3] 初始化 useEffect 依赖对象引用导致 store 被重置
**文件**[lesson-plan-editor.tsx:55-63](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L55-L63)
**现象**:父组件 re-render 时,`initialDoc` 对象引用变化,触发 useEffect 重新执行 `useLessonPlanEditor.setState()`,覆盖用户正在编辑的内容。
**原因**
```tsx
useEffect(() => {
useLessonPlanEditor.setState({
planId, title: initialTitle, doc: initialDoc, // ← 整个 doc 被重置
isDirty: false, lastSavedAt: Date.now(),
});
}, [planId, initialTitle, initialDoc]); // ← initialDoc 是对象,引用每次都变
```
**修复建议**:只依赖 `planId`,在 planId 变化时才初始化;或用 `useRef` 缓存 initialDoc 的原始引用。
---
### [P1-4] 自动保存闭包问题导致保存旧内容
**文件**[lesson-plan-editor.tsx:66-83](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L66-L83)
**现象**用户快速连续编辑时3 秒后保存的可能不是最新内容。
**原因**debounce 的 setTimeout 闭包了触发时的 `editor.title``editor.doc` 快照。虽然 effect 依赖包含 `editor.doc`,但用户在 3 秒内继续编辑会创建新的 setTimeout旧的被 clearTimeout所以实际上保存的是最后一次 effect 触发时的快照。但 `editor.title``editor.doc` 是 zustand 的订阅值,在 setTimeout 执行时可能已过期。
**修复建议**:在 setTimeout 回调中用 `useLessonPlanEditor.getState()` 获取最新值,而非闭包值。
---
### [P1-5] 题库搜索无 debounce
**文件**[question-bank-picker.tsx:32-46](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/question-bank-picker.tsx#L32-L46)
**现象**:搜索输入每次按键都触发 server action 请求。
**原因**`useEffect` 依赖 `filters`,而 `filters` 在每次 `onChange` 时更新。
**修复建议**:对搜索输入添加 300ms debounce。
---
### [P1-6] 课案列表搜索无 debounce
**文件**[lesson-plan-filters.tsx:17](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-filters.tsx#L17)
**现象**:搜索框每次按键触发 server action。
**修复建议**:添加 debounce 或使用 `useTransition`
---
### [P1-7] inline-question-editor 知识点标注缺失
**文件**[inline-question-editor.tsx:22](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/inline-question-editor.tsx#L22)
**现象**:课案内新建题目无法关联知识点。
**原因**`kpIds` 被硬编码为常量空数组:
```tsx
const kpIds: string[] = [];
```
**修复建议**:添加知识点选择器 UI或复用 `KnowledgePointPicker`
---
## 四、P2 代码质量/架构问题
### [P2-1] publish-service 用 JSON.parse(JSON.stringify()) 深拷贝
**文件**[publish-service.ts:78-80](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L78-L80)
**问题**:性能差,且不支持 Date 等特殊类型。
**建议**:用 `structuredClone()` 或手动构造新对象。
---
### [P2-2] publish-service 用非空断言 `!`
**文件**[publish-service.ts:82-83](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L82-L83)
**问题**:违反项目规范"可选链后禁止跟非空断言"。
```tsx
const newBlock = newContent.blocks.find((b) => b.id === input.blockId)!;
```
**建议**:添加 null 检查并抛出明确错误。
---
### [P2-3] 多个组件用 alert()/confirm()
**文件**version-history-drawer.tsx:42, lesson-plan-card.tsx:48, inline-question-editor.tsx:26
**问题**:不符合现代 Web UI 规范,阻塞主线程。
**建议**:使用项目的 `AlertDialog` 组件(`@/shared/components/ui/alert-dialog`)或 `sonner` toast。
---
### [P2-4] block-renderer 用 `as never` 类型断言
**文件**[block-renderer.tsx:103,111,117,122](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/block-renderer.tsx)
**问题**`block.data as never` 绕过类型检查,违反"禁止 as 断言"规范。
**建议**:用类型守卫函数根据 `block.type` 收窄 `block.data` 类型。
---
### [P2-5] data-access-knowledge 用 LIKE 查 JSON 字段
**文件**[data-access-knowledge.ts:14-17](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-knowledge.ts#L14-L17)
**问题**`like(lessonPlans.content, '%${id}%')` 可能误匹配(如 ID 是另一个 ID 的子串),且无法用索引。
**建议**MySQL 8.0+ 可用 `JSON_CONTAINS`;或维护关联表。
---
### [P2-6] buildScopeCondition switch 无 default 分支
**文件**[data-access.ts:49-67](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L49-L67)
**问题**switch 未覆盖所有 DataScope 类型时无 fallback虽然 TypeScript 会报错但逻辑上不完整。
**建议**:添加 `default` 分支返回空条件或抛错。
---
### [P2-7] lesson-plan-card 用 window.location.reload()
**文件**[lesson-plan-card.tsx:39,50](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-card.tsx#L39)
**问题**:不符合 SPA 模式,导致整个页面重新加载。
**建议**:用 `useRouter().refresh()``revalidatePath` 后自动刷新。
---
### [P2-8] data-access-templates 用 `as never` 类型断言
**文件**[data-access-templates.ts:66](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-templates.ts#L66)
```tsx
type: b.type as never,
```
**建议**:用 `b.type as BlockType` 并添加运行时校验。
---
## 五、P3 用户体验改进
### [P3-1] 编辑器无离开未保存提示
**问题**:用户有未保存内容时关闭/离开页面不会提示。
**建议**:监听 `beforeunload` 事件,`isDirty` 时弹出确认。
---
### [P3-2] 添加环节菜单点击外部不关闭
**文件**[lesson-plan-editor.tsx:150-166](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L150-L166)
**问题**:点击菜单外部不会关闭菜单。
**建议**:添加 `useRef` + `mousedown` 事件监听,或用 Radix `DropdownMenu`
---
### [P3-3] 版本抽屉无预览功能
**问题**:版本列表只显示版本号和标签,无法预览版本内容差异。
**建议**:点击版本时展开内容预览,或显示 block 数量/摘要。
---
### [P3-4] exercise-block 用 index 作为 key
**文件**[exercise-block.tsx:67](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx#L67)
```tsx
{data.items.map((item, idx) => (
<div key={idx} ...>
```
**问题**:删除/排序时可能导致 React 状态错乱。
**建议**:用 `item.questionId` 作为 key。
---
### [P3-5] 编辑器无 loading 骨架屏
**问题**:编辑器初始化时无加载状态,网络慢时白屏。
**建议**:添加 Suspense fallback 或骨架屏。
---
## 六、架构合规性检查
| 检查项 | 状态 | 说明 |
|--------|------|------|
| 三层架构app→modules→shared | ✅ | 路由层只调用 actions 和 data-access |
| 模块间通过 data-access 通信 | ✅ | publish-service 通过 questions/exams/homework 的 data-access |
| Server Action 权限校验 | ✅ | 所有 action 调用 requirePermission |
| Zod 校验 | ✅ | actions 使用 schema 校验输入 |
| ActionState 返回类型 | ✅ | 统一使用 ActionState<T> |
| "server-only" 标注 | ✅ | 所有 data-access 文件有 "server-only" |
| "use client" 标注 | ✅ | 所有客户端组件有 "use client" |
| revalidatePath 精确刷新 | ✅ | 创建/删除/回退后调用 revalidatePath |
| 架构图同步 | ✅ | 004/005 已同步 |
---
## 七、修复优先级建议
| 优先级 | 问题编号 | 描述 | 影响 |
|--------|----------|------|------|
| **P0** | 已修复 | Tiptap SSR 崩溃 | 编辑页完全不可用 |
| **P1** | P1-3 | 初始化 useEffect 重置 store | 用户编辑内容丢失 |
| **P1** | P1-4 | 自动保存闭包问题 | 保存旧内容 |
| **P1** | P1-1 | 版本回退不刷新 | 回退后看到旧内容 |
| **P1** | P1-2 | 版本抽屉 loading 失效 | UX 体验差 |
| **P1** | P1-7 | inline 题目无知识点 | 功能缺失 |
| **P1** | P1-5,6 | 搜索无 debounce | 性能问题 |
| **P2** | P2-1~8 | 代码规范 | 可维护性 |
| **P3** | P3-1~5 | UX 改进 | 体验优化 |
---
## 八、验证记录
| 验证项 | 命令 | 结果 |
|--------|------|------|
| TypeScript | `npx tsc --noEmit` | ✅ exit 0 |
| ESLint | `npm run lint` | ✅ exit 0 |
| 数据库迁移 | `npm run db:migrate` | ✅ 成功 |
| 编辑页渲染 | Playwright 测试 | ✅ 正常渲染,无错误 |
| 控制台错误 | Playwright 捕获 | ✅ 无 error/warning |
---
## 九、附录:测试截图
- `bugs/v2_list.png` - 课案列表页
- `bugs/v2_new.png` - 新建课案页
- `bugs/v2_edit.png` - 编辑页(修复后正常)