# 备课模块(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 { /* 触发页面刷新由父组件处理 */ }} /> ``` **修复建议**:回退成功后调用 `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) => (
``` **问题**:删除/排序时可能导致 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 | | "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` - 编辑页(修复后正常)