# 备课模块（lesson-preparation）审查报告 v3

> 审查日期：2026-06-22
> 审查范围：`src/modules/lesson-preparation/` 全部 34 个文件 + 3 个路由页面
> 审查方式：代码审查 + Playwright 运行时测试
> 前置状态：v2 已完成节点图编辑器重构（React Flow）+ P1 问题修复

---

## 一、审查结论

| 维度 | 状态 | 说明 |
|------|------|------|
| 编辑器可用性 | ✅ | 节点图渲染、选中、添加、编辑、保存均正常 |
| 功能完整性 | ⚠️ | 存在 5 个 P1 功能缺陷 + 2 个 P2 规范问题 |
| 代码质量 | ⚠️ | 存在 6 个 P2 代码规范违规 |
| 用户体验 | ⚠️ | 存在 4 个 P3 改进项 |
| 架构合规 | ✅ | 三层架构正确，权限校验完整 |
| 运行时稳定性 | ✅ | Playwright 测试无控制台错误 |

---

## 二、运行时测试结果（Playwright）

| 测试项 | 结果 | 说明 |
|--------|------|------|
| 登录 | ✅ | 正常跳转 dashboard |
| 新建课案 | ✅ | 模板选择 → 创建 → 跳转编辑页 |
| 节点渲染 | ✅ | 8 节点 + 7 边正确渲染 |
| 节点选中 | ✅ | 点击节点 → 侧边面板显示 |
| 标题编辑 | ✅ | 侧边面板输入框可编辑 |
| 添加节点 | ✅ | 8 → 9 节点 |
| 连线 Handle | ✅ | 18 个 handle（9 节点 × 2） |
| 版本抽屉 | ✅ | 打开/关闭正常，显示"暂无版本" |
| 保存版本 | ✅ | 点击后无错误 |
| 控制台错误 | ✅ | 无 error/warning |

---

## 三、P1 功能缺陷

### [P1-1] 节点拖拽位置不持久化（position 变化未触发自动保存）

**文件**：[node-editor.tsx:59-74](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx#L59-L74)

**现象**：拖拽节点改变位置后，3 秒自动保存未触发，刷新页面位置丢失。

**原因**：`onNodesChange` 中 `updateNodePosition` 调用了 `set({ isDirty: true })`，但 `lesson-plan-editor.tsx:71` 的自动保存 effect 依赖 `[editor.isDirty, editor.doc, planId]`。`editor.doc` 是 zustand 的订阅值，但 `updateNodePosition` 每次都创建新的 doc 对象，导致 effect 频繁触发。然而拖拽过程中会触发多次 position 变化，debounce 3s 应该能生效。

**实际根因**：React Flow 拖拽时 `change.position` 可能是中间状态（dragging: true），最终位置在 dragging: false 时才确定。当前代码未区分 dragging 状态，每次都写入 store，但最终位置是正确的。问题在于 `editor.doc` 引用变化太快，debounce timer 不断重置，如果用户持续拖拽超过 3s 仍未保存。

**修复建议**：在 `onNodesChange` 中检查 `change.dragging === false` 才写入最终位置，避免中间状态污染。

---

### [P1-2] 侧边面板关闭后无法重新打开

**文件**：[lesson-plan-editor.tsx:65-68](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L65-L68)

**现象**：用户点击节点选中 → 侧边面板打开 → 点击面板关闭按钮 → 再次点击同一节点，面板不会重新打开。

**原因**：
```tsx
useEffect(() => {
  if (editor.selectedNodeId) setPanelOpen(true);
}, [editor.selectedNodeId]);
```
点击关闭按钮调用 `selectNode(null)`，`selectedNodeId` 变为 null，`panelOpen` 仍为 true。再次点击同一节点时，`selectedNodeId` 从 null 变为该节点 id，effect 触发 `setPanelOpen(true)`，但 `panelOpen` 已经是 true，React 不会重新渲染。

实际问题是：关闭按钮只调用 `selectNode(null)` 但没有 `setPanelOpen(false)`，导致面板在 `selectedNodeId` 为 null 时仍然显示（因为 `panelOpen && selectedNodeId` 条件中 panelOpen 为 true 但 selectedNodeId 为 null，条件为 false，面板隐藏）。再次点击节点时 selectedNodeId 变化，effect 触发 setPanelOpen(true)，但已经是 true。

**实际根因**：关闭面板后 `panelOpen` 仍为 true，但 `selectedNodeId` 为 null，条件 `panelOpen && selectedNodeId` 为 false。再次点击节点时 `selectedNodeId` 变化，effect 触发 `setPanelOpen(true)`（已是 true），面板应该显示。但 `NodeEditPanel` 内部 `node` 查找依赖 `selectedNodeId`，如果找到了节点应该显示。

**验证**：需要实际测试确认。如果确实无法重新打开，可能是 `panelOpen` 状态管理问题。

**修复建议**：移除 `panelOpen` 状态，直接用 `selectedNodeId !== null` 控制面板显示。

---

### [P1-3] inline-question-editor 知识点标注缺失（v2 遗留）

**文件**：[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`。

---

### [P1-4] exercise-block 用 index 作为 key（v2 遗留）

**文件**：[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。

---

### [P1-5] lesson-plan-card 用 window.location.reload()（v2 遗留）

**文件**：[lesson-plan-card.tsx:39,50](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-card.tsx#L39)

**问题**：不符合 SPA 模式，导致整个页面重新加载。

**修复建议**：用 `useRouter().refresh()`。

---

## 四、P2 代码规范问题

### [P2-1] node-editor 用 `as unknown as Record<string, unknown>` 类型断言

**文件**：[node-editor.tsx:42](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx#L42)

```tsx
data: n as unknown as Record<string, unknown>,
```

**问题**：双重断言绕过类型检查，违反"禁止 as 断言"规范。

**建议**：React Flow 的 `Node` 类型要求 `data` 为 `Record<string, unknown>`，可以构造一个符合类型的对象。

---

### [P2-2] node-editor 隐藏 span 传递 props（hack）

**文件**：[node-editor.tsx:152](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/node-editor.tsx#L152)

```tsx
<span className="hidden" data-textbook={textbookId} data-chapter={chapterId} data-classes={classes?.length} />
```

**问题**：用隐藏 DOM 元素避免 unused 警告，是 hack 做法。

**建议**：`textbookId`/`chapterId`/`classes` 是 NodeEditor 的 props 但未使用（实际由 NodeEditPanel 使用）。应移除这些 props，或让 NodeEditor 不接收它们。

---

### [P2-3] publish-service 用 JSON.parse(JSON.stringify()) 深拷贝（v2 遗留）

**文件**：[publish-service.ts:83-85](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L83-L85)

**问题**：性能差，且不支持 Date 等特殊类型。

**建议**：用 `structuredClone()`。

---

### [P2-4] exercise-block 用 `as never` 类型断言（v2 遗留）

**文件**：[exercise-block.tsx:52](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx#L52)

```tsx
update({ purpose: e.target.value as never })
```

**建议**：用 `as ExercisePurpose` 并添加类型守卫。

---

### [P2-5] 多个组件用 alert()/confirm()（v2 遗留）

**文件**：
- [version-history-drawer.tsx:46](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/version-history-drawer.tsx#L46)
- [lesson-plan-card.tsx:48](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-card.tsx#L48)
- [inline-question-editor.tsx:26](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/inline-question-editor.tsx#L26)
- [text-study-block.tsx:42](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/text-study-block.tsx#L42)

**问题**：阻塞主线程，不符合现代 Web UI 规范。

**建议**：使用 `AlertDialog` 组件或 `sonner` toast。

---

### [P2-6] text-study-block 选区计算错误

**文件**：[text-study-block.tsx:29-37](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/text-study-block.tsx#L29-L37)

**问题**：`range.startOffset`/`range.endOffset` 是相对于当前 DOM 节点的偏移，不是相对于 `sourceText` 的字符偏移。如果 textarea 内有换行或子节点，偏移会不正确。

**建议**：用 `textarea.selectionStart`/`textarea.selectionEnd` 获取相对于文本的偏移。

---

## 五、P3 用户体验改进

### [P3-1] 节点画布无空状态提示

**问题**：空白课案（无节点）时画布只显示网格，无引导提示。

**建议**：当 `doc.nodes.length === 0` 时显示"点击左下角添加节点开始"提示。

---

### [P3-2] 版本抽屉无预览功能（v2 遗留）

**问题**：版本列表只显示版本号和标签，无法预览版本内容差异。

**建议**：点击版本时展开内容预览。

---

### [P3-3] 编辑器无 loading 骨架屏（v2 遗留）

**问题**：编辑器初始化时无加载状态。

**建议**：添加 Suspense fallback。

---

### [P3-4] 列表页英文标题与中文 UI 不一致

**文件**：[page.tsx:24-25](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/lesson-plans/page.tsx#L24-L25)

```tsx
<h1>My Lesson Plans</h1>
<p>Manage your lesson preparation and teaching plans.</p>
```

**问题**：项目其他页面用中文，此处用英文。

**建议**：改为"我的备课"和"管理备课和教学计划"。

---

## 六、架构合规性检查

| 检查项 | 状态 | 说明 |
|--------|------|------|
| 三层架构（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 已同步 v2 节点图结构 |
| 数据结构向后兼容 | ✅ | normalizeDocument 自动迁移 v1→v2 |

---

## 七、修复优先级

| 优先级 | 问题编号 | 描述 | 影响 |
|--------|----------|------|------|
| **P1** | P1-2 | 侧边面板关闭后无法重新打开 | UX 阻塞 |
| **P1** | P1-1 | 节点拖拽位置可能不持久化 | 数据丢失风险 |
| **P1** | P1-4 | exercise-block 用 index 作为 key | 列表状态错乱 |
| **P1** | P1-5 | lesson-plan-card 用 window.location.reload | SPA 体验差 |
| **P1** | P1-3 | inline 题目无知识点标注 | 功能缺失 |
| **P2** | P2-2 | node-editor 隐藏 span hack | 代码质量 |
| **P2** | P2-1 | node-editor 类型断言 | 代码规范 |
| **P2** | P2-6 | text-study-block 选区计算错误 | 功能错误 |
| **P2** | P2-3 | publish-service 深拷贝方式 | 性能 |
| **P2** | P2-4 | exercise-block as never 断言 | 代码规范 |
| **P2** | P2-5 | alert/confirm 使用 | UX 规范 |
| **P3** | P3-4 | 列表页英文标题 | i18n 一致性 |
| **P3** | P3-1 | 画布空状态提示 | UX 引导 |
| **P3** | P3-2 | 版本预览 | UX 增强 |
| **P3** | P3-3 | 编辑器骨架屏 | UX 优化 |

---

## 八、验证记录

| 验证项 | 命令 | 结果 |
|--------|------|------|
| TypeScript | `npx tsc --noEmit` | ✅ exit 0 |
| ESLint | `npm run lint` | ✅ 备课模块零错误 |
| Playwright 节点渲染 | 8 节点 + 7 边 | ✅ |
| Playwright 节点选中 | 侧边面板显示 | ✅ |
| Playwright 添加节点 | 8 → 9 节点 | ✅ |
| Playwright 版本抽屉 | 打开/关闭 | ✅ |
| Playwright 保存版本 | 无错误 | ✅ |
| 控制台错误 | 无 error/warning | ✅ |

---

## 九、附录：测试截图

- `bugs/v3_01_initial.png` - 初始编辑页
- `bugs/v3_02_selected.png` - 节点选中状态
- `bugs/v3_03_versions.png` - 版本抽屉
- `bugs/v3_04_final.png` - 最终状态
