主要变更: - 新增 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)
12 KiB
备课模块(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 未设置导致编辑页崩溃
现象:编辑页显示 "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
现象:用户点击"回退到此版本"后,服务端 content 已更新,但编辑器界面仍显示旧内容。
原因:onReverted 回调是空函数:
<VersionHistoryDrawer
onReverted={() => { /* 触发页面刷新由父组件处理 */ }}
/>
修复建议:回退成功后调用 useLessonPlanEditor.getState() 重新拉取课案内容并 replaceDoc,或用 router.refresh() 刷新服务端数据。
[P1-2] 版本抽屉 loading 状态失效
文件:version-history-drawer.tsx:27-39
现象:打开版本抽屉时"加载中..."永不显示。
原因:loading 初始为 false,effect 中从未调用 setLoading(true):
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
现象:父组件 re-render 时,initialDoc 对象引用变化,触发 useEffect 重新执行 useLessonPlanEditor.setState(),覆盖用户正在编辑的内容。
原因:
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
现象:用户快速连续编辑时,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
现象:搜索输入每次按键都触发 server action 请求。
原因:useEffect 依赖 filters,而 filters 在每次 onChange 时更新。
修复建议:对搜索输入添加 300ms debounce。
[P1-6] 课案列表搜索无 debounce
现象:搜索框每次按键触发 server action。
修复建议:添加 debounce 或使用 useTransition。
[P1-7] inline-question-editor 知识点标注缺失
文件:inline-question-editor.tsx:22
现象:课案内新建题目无法关联知识点。
原因:kpIds 被硬编码为常量空数组:
const kpIds: string[] = [];
修复建议:添加知识点选择器 UI,或复用 KnowledgePointPicker。
四、P2 代码质量/架构问题
[P2-1] publish-service 用 JSON.parse(JSON.stringify()) 深拷贝
问题:性能差,且不支持 Date 等特殊类型。
建议:用 structuredClone() 或手动构造新对象。
[P2-2] publish-service 用非空断言 !
问题:违反项目规范"可选链后禁止跟非空断言"。
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
问题:block.data as never 绕过类型检查,违反"禁止 as 断言"规范。
建议:用类型守卫函数根据 block.type 收窄 block.data 类型。
[P2-5] data-access-knowledge 用 LIKE 查 JSON 字段
文件:data-access-knowledge.ts:14-17
问题:like(lessonPlans.content, '%${id}%') 可能误匹配(如 ID 是另一个 ID 的子串),且无法用索引。
建议:MySQL 8.0+ 可用 JSON_CONTAINS;或维护关联表。
[P2-6] buildScopeCondition switch 无 default 分支
问题:switch 未覆盖所有 DataScope 类型时无 fallback,虽然 TypeScript 会报错但逻辑上不完整。
建议:添加 default 分支返回空条件或抛错。
[P2-7] lesson-plan-card 用 window.location.reload()
问题:不符合 SPA 模式,导致整个页面重新加载。
建议:用 useRouter().refresh() 或 revalidatePath 后自动刷新。
[P2-8] data-access-templates 用 as never 类型断言
文件:data-access-templates.ts:66
type: b.type as never,
建议:用 b.type as BlockType 并添加运行时校验。
五、P3 用户体验改进
[P3-1] 编辑器无离开未保存提示
问题:用户有未保存内容时关闭/离开页面不会提示。
建议:监听 beforeunload 事件,isDirty 时弹出确认。
[P3-2] 添加环节菜单点击外部不关闭
文件:lesson-plan-editor.tsx:150-166
问题:点击菜单外部不会关闭菜单。
建议:添加 useRef + mousedown 事件监听,或用 Radix DropdownMenu。
[P3-3] 版本抽屉无预览功能
问题:版本列表只显示版本号和标签,无法预览版本内容差异。
建议:点击版本时展开内容预览,或显示 block 数量/摘要。
[P3-4] exercise-block 用 index 作为 key
{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 |
| "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- 编辑页(修复后正常)