# 备课模块（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` - 编辑页（修复后正常）
