Files
NextEdu/docs/superpowers/plans/2026-07-04-lesson-preparation-paper-redesign.md
SpecialX ccf1618b1c docs(lesson-preparation): V4 纸感重构实现计划
25 个任务,按阶段 A-J 分解:类型定义、状态管理、配色 i18n、Tiptap 锚点、纸区组件、结构树、详情面板、主编辑器改造、迁移 banner、清理验证。
2026-07-04 11:08:28 +08:00

3415 lines
97 KiB
Markdown
Raw 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.
# 备课编辑器无边记纸感重构 · 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把备课模块的正文节点从 React Flow 画布上的"假"富文本显示,重构为真实 Tiptap 富文本编辑器的"纸 + 详情面板"三栏布局,新增师生交互节点和节点展开到正文流的功能。
**Architecture:** 移除 React Flow 画布与字符串偏移锚点,改为左栏结构树 + 中栏 Tiptap 纸区 + 右栏详情面板。锚点用 Tiptap Mark 内嵌,节点用 inline-node 流入正文。文档版本 v3 → v4。
**Tech Stack:** Next.js (app router) + React 18 + TypeScript + Tiptap 2 + Zustand + Tailwind + shadcn/ui + next-intl + Fraunces/Inter/JetBrains Mono 字体
**Spec:** `docs/superpowers/specs/2026-07-04-lesson-preparation-paper-redesign-design.md`
---
## 文件结构
### 新增文件
```
src/modules/lesson-preparation/
├─ hooks/
│ └─ expanded-slice.ts # V4 节点展开状态 slice
├─ lib/
│ └─ anchor-mark.ts # Tiptap AnchorMark + AnchorPoint 扩展
├─ components/
│ ├─ paper-editor/
│ │ ├─ paper-editor.tsx # 中栏:纸区容器
│ │ ├─ textbook-tiptap-editor.tsx # 正文 Tiptap 编辑器
│ │ ├─ inline-node.tsx # 展开节点 inline 渲染
│ │ ├─ inline-qa-dialog.tsx # 师生交互对话体渲染
│ │ ├─ paper-toolbar.tsx # 浮动工具条
│ │ └─ paper-context-menu.tsx # 右键菜单
│ ├─ structure-tree/
│ │ ├─ structure-tree.tsx # 左栏结构树
│ │ └─ tree-node-row.tsx # 树节点行
│ ├─ detail-panel/
│ │ ├─ detail-panel.tsx # 右栏详情面板容器
│ │ ├─ detail-head.tsx # 头部
│ │ ├─ detail-props.tsx # 属性条
│ │ └─ qa-editor.tsx # 师生交互编辑器
│ └─ blocks/
│ └─ interaction-block.tsx # 师生交互 block详情编辑
└─ components/
└─ anchor-migration-banner.tsx # v3 锚点失效提示 banner
```
### 修改文件
```
src/modules/lesson-preparation/
├─ types.ts # 新增 interaction 类型、QATurn、V4 文档
├─ lib/document-migration.ts # 新增 interaction 默认数据、migrateV3ToV4
├─ lib/type-guards.ts # 新增 isInteractionBlockData
├─ hooks/editor-slice.ts # 移除画布依赖,新增 anchorNodeForSelection
├─ hooks/selection-slice.ts # selectedNodeId → activeNodeId
├─ hooks/use-lesson-plan-editor.ts # 组合 expanded-slice
├─ config/block-registry.tsx # 注册 interaction
├─ components/lesson-plan-editor.tsx # 三栏布局改造
└─ components/print-view.tsx # 适配 v4
src/app/globals.css # 移除 --lesson-node-*,添加纸感令牌
src/shared/i18n/messages/zh-CN/lesson-preparation.json # 新增翻译键
src/shared/i18n/messages/en/lesson-preparation.json # 新增翻译键
docs/architecture/004_architecture_impact_map.md # 同步
docs/architecture/005_architecture_data.json # 同步
docs/troubleshooting/known-issues.md # 新规则
```
### 移除文件
```
src/modules/lesson-preparation/
├─ components/node-editor.tsx # React Flow 画布
├─ components/nodes/lesson-node.tsx
├─ components/nodes/textbook-content-node.tsx
├─ components/nodes/textbook-segments.tsx
├─ components/nodes/anchor-node-selector.tsx
├─ lib/anchor-injector.ts # 字符串偏移系统
├─ lib/rf-mappers.ts # React Flow 映射
└─ lib/auto-layout.ts # 自动布局
```
---
## 阶段 A数据层和类型
### Task A1: 新增类型定义
**Files:**
- Modify: `src/modules/lesson-preparation/types.ts`
- [ ] **Step 1: 添加 interaction 到 BlockType 联合**
`types.ts` 第 60 行附近,修改 `BlockType`
```typescript
export type BlockType =
| "objective"
| "key_point"
| "import"
| "new_teaching"
| "consolidation"
| "summary"
| "homework"
| "blackboard"
| "text_study"
| "exercise"
| "rich_text"
| "reflection"
| "interaction"; // V4 新增:师生交互
```
- [ ] **Step 2: 在 ReflectionBlockData 后添加 QATurn 和 InteractionBlockData**
在第 209 行(`ReflectionBlockData` 定义之后)插入:
```typescript
// V4 师生交互
export interface QATurn {
id: string;
role: "teacher" | "student";
content: string;
/** 教师提问的预期答案 / 引导策略(可选)*/
expectedAnswer?: string;
/** 这一轮的顺序 */
order: number;
}
export interface InteractionBlockData {
/** 设计意图 */
designIntent: string;
/** 对话轮次 */
turns: QATurn[];
/** 关联知识点 */
knowledgePointIds: string[];
}
```
- [ ] **Step 3: 把 InteractionBlockData 加入 BlockData 联合**
修改第 219-230 行的 `BlockData`
```typescript
export type BlockData =
| RichTextBlockData
| TextStudyBlockData
| ExerciseBlockData
| ObjectiveBlockData
| KeyPointBlockData
| ImportBlockData
| NewTeachingBlockData
| SummaryBlockData
| HomeworkBlockData
| BlackboardBlockData
| ReflectionBlockData
| InteractionBlockData; // V4 新增
```
- [ ] **Step 4: 添加 LessonPlanDocumentV4 和简化 NodeAnchor**
在第 343 行(`LessonPlanDocument` 定义之后)插入:
```typescript
// v4纸感锚点格式Tiptap Mark 内嵌)
export interface LessonPlanDocumentV4 {
version: 4;
textbookContentNodeId: string;
nodes: AnyLessonPlanNode[];
edges: AnyLessonPlanEdge[]; // 保留但不再用于画布连线
anchors: NodeAnchor[]; // 简化:只存 id 关联
/** V4 新增:节点展开状态 */
expandedNodeIds: string[];
}
// V4NodeAnchor 简化start/end/textPreview/invalid 字段标记为弃用)
// 旧 v3 字段保留用于迁移识别,新代码不应读取
export interface NodeAnchor {
id: string;
nodeId: string;
type: AnchorType;
/** @deprecated v3 字符串偏移v4 改用 Tiptap Mark不再使用 */
start?: number;
/** @deprecated v3 字符串偏移 */
end?: number;
/** @deprecated v3 文字预览 */
textPreview?: string;
/** @deprecated v3 失效标记 */
invalid?: boolean;
}
```
- [ ] **Step 5: 把 LessonPlanDocument 改为 V4 别名**
修改第 338-344 行:
```typescript
// 当前文档版本v4
export type LessonPlanDocument = LessonPlanDocumentV4;
```
(保留 `LessonPlanDocumentV1/V2/V3` 旧类型用于迁移)
- [ ] **Step 6: 验证类型编译**
Run: `npx tsc --noEmit`
Expected: 类型层错误(其他文件还引用旧字段),但 types.ts 本身无错误
- [ ] **Step 7: Commit**
```bash
git add src/modules/lesson-preparation/types.ts
git commit -m "feat(lesson-preparation): V4 类型定义interaction + QATurn + V4 文档)"
```
---
### Task A2: 默认数据生成器
**Files:**
- Modify: `src/modules/lesson-preparation/lib/document-migration.ts`
- [ ] **Step 1: 在 defaultDataForType 的 switch 中添加 interaction 分支**
在第 52 行(`rich_text` 分支前)插入:
```typescript
case "interaction":
return {
designIntent: "",
turns: [],
knowledgePointIds: [],
};
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
Expected: 类型错误减少interaction 已有默认数据)
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/lib/document-migration.ts
git commit -m "feat(lesson-preparation): interaction 默认数据生成器"
```
---
### Task A3: 类型守卫
**Files:**
- Modify: `src/modules/lesson-preparation/lib/type-guards.ts`
- [ ] **Step 1: 先查看现有守卫模式**
Run: Read `src/modules/lesson-preparation/lib/type-guards.ts`(前 30 行)
- [ ] **Step 2: 在文件末尾添加 isInteractionBlockData**
参照其他守卫模式(用 `as unknown as` + 字段检查),添加:
```typescript
export function isInteractionBlockData(
data: unknown,
): data is import("../types").InteractionBlockData {
if (typeof data !== "object" || data === null) return false;
const d = data as Record<string, unknown>;
return (
typeof d.designIntent === "string" &&
Array.isArray(d.turns) &&
Array.isArray(d.knowledgePointIds)
);
}
```
- [ ] **Step 3: 验证编译**
Run: `npx tsc --noEmit`
Expected: 守卫文件本身无错误
- [ ] **Step 4: Commit**
```bash
git add src/modules/lesson-preparation/lib/type-guards.ts
git commit -m "feat(lesson-preparation): isInteractionBlockData 类型守卫"
```
---
### Task A4: v3 → v4 迁移函数
**Files:**
- Modify: `src/modules/lesson-preparation/lib/document-migration.ts`
- [ ] **Step 1: 在文件末尾添加 migrateV3ToV4**
```typescript
import type {
// ... 已有导入
LessonPlanDocumentV3,
LessonPlanDocumentV4,
} from "../types";
/**
* V3 → V4 迁移:
* - 锚点的 start/end/textPreview/invalid 标记为弃用(保留字段用于识别旧锚点)
* - 新增 expandedNodeIds默认空数组
* - 旧 v3 锚点在 v4 中视为失效,编辑器需显示 banner 提示重新锚定
*
* @returns V4 文档 + 是否含旧锚点(用于触发 banner
*/
export function migrateV3ToV4(
doc: LessonPlanDocumentV3,
): { doc: LessonPlanDocumentV4; hasLegacyAnchors: boolean } {
const hasLegacyAnchors = doc.anchors.length > 0;
return {
doc: {
version: 4,
textbookContentNodeId: doc.textbookContentNodeId,
nodes: doc.nodes,
edges: doc.edges,
anchors: doc.anchors.map((a) => ({
id: a.id,
nodeId: a.nodeId,
type: a.type,
// 保留旧字段用于识别(标记失效)
start: a.start,
end: a.end,
textPreview: a.textPreview,
invalid: true,
})),
expandedNodeIds: [],
},
hasLegacyAnchors,
};
}
```
- [ ] **Step 2: 添加 normalizeDocument 入口(兼容 v1/v2/v3/v4**
修改现有 `normalizeDocument` 函数(如果存在)或在文件末尾添加:
```typescript
/**
* 规范化文档到 V4。
* 接受任意版本输入,输出 V4。
*/
export function normalizeDocument(
input: LessonPlanDocumentV1 | LessonPlanDocumentV2 | LessonPlanDocumentV3 | LessonPlanDocumentV4,
): LessonPlanDocumentV4 {
if (input.version === 4) return input;
if (input.version === 3) return migrateV3ToV4(input).doc;
if (input.version === 2) {
const v3 = migrateV2ToV3(input); // 假设已存在
return migrateV3ToV4(v3).doc;
}
// v1
const v2 = migrateV1ToV2(input);
const v3 = migrateV2ToV3(v2);
return migrateV3ToV4(v3).doc;
}
```
注意:先确认 `migrateV2ToV3` 是否已存在,若没有则需补充(查看现有代码)。如果不存在,跳过 v2 路径并在注释中标注。
- [ ] **Step 3: 验证编译**
Run: `npx tsc --noEmit`
Expected: 迁移函数本身无错误
- [ ] **Step 4: Commit**
```bash
git add src/modules/lesson-preparation/lib/document-migration.ts
git commit -m "feat(lesson-preparation): V3→V4 迁移函数(锚点失效标记 + expandedNodeIds"
```
---
## 阶段 B状态管理
### Task B1: expanded-slice
**Files:**
- Create: `src/modules/lesson-preparation/hooks/expanded-slice.ts`
- [ ] **Step 1: 创建 expanded-slice.ts**
```typescript
import type { StateCreator } from "zustand";
import type { EditorState } from "./use-lesson-plan-editor";
export interface ExpandedSlice {
/** 已展开到正文流的节点 ID */
expandedNodeIds: string[];
/** v3 迁移过来的失效锚点提示(按 planId 记录是否已 dismiss */
legacyAnchorDismissed: Record<string, boolean>;
toggleExpand: (nodeId: string) => void;
setExpanded: (nodeIds: string[]) => void;
isExpanded: (nodeId: string) => boolean;
dismissLegacyAnchor: (planId: string) => void;
/** 同步 doc.expandedNodeIds保存/加载时调用) */
syncFromDoc: (expandedNodeIds: string[]) => void;
}
export const createExpandedSlice: StateCreator<
EditorState,
[],
[],
ExpandedSlice
> = (set, get) => ({
expandedNodeIds: [],
legacyAnchorDismissed: {},
toggleExpand: (nodeId) => {
get().pushHistory();
set((s) => {
const isExp = s.expandedNodeIds.includes(nodeId);
const next = isExp
? s.expandedNodeIds.filter((id) => id !== nodeId)
: [...s.expandedNodeIds, nodeId];
// 同步到 doc
return {
expandedNodeIds: next,
doc: { ...s.doc, expandedNodeIds: next },
isDirty: true,
};
});
},
setExpanded: (nodeIds) =>
set((s) => ({
expandedNodeIds: nodeIds,
doc: { ...s.doc, expandedNodeIds: nodeIds },
})),
isExpanded: (nodeId) => get().expandedNodeIds.includes(nodeId),
dismissLegacyAnchor: (planId) =>
set((s) => ({
legacyAnchorDismissed: { ...s.legacyAnchorDismissed, [planId]: true },
})),
syncFromDoc: (expandedNodeIds) => set({ expandedNodeIds }),
});
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
Expected: 报错EditorState 未含 ExpandedSlice下一步修复
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/hooks/expanded-slice.ts
git commit -m "feat(lesson-preparation): expanded-slice节点展开状态"
```
---
### Task B2: use-lesson-plan-editor 组合 expanded-slice
**Files:**
- Modify: `src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts`
- [ ] **Step 1: 引入并组合 ExpandedSlice**
修改文件:
```typescript
"use client";
import { create } from "zustand";
import { createEditorSlice, type EditorSlice } from "./editor-slice";
import { createSelectionSlice, type SelectionSlice } from "./selection-slice";
import { createVersionSlice, type VersionSlice } from "./version-slice";
import { createHistorySlice, type HistorySlice } from "./history-slice";
import { createExpandedSlice, type ExpandedSlice } from "./expanded-slice";
export type EditorState = EditorSlice &
SelectionSlice &
VersionSlice &
HistorySlice &
ExpandedSlice;
export const useLessonPlanEditor = create<EditorState>()((...a) => ({
...createEditorSlice(...a),
...createSelectionSlice(...a),
...createVersionSlice(...a),
...createHistorySlice(...a),
...createExpandedSlice(...a),
}));
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
Expected: 类型组合通过,仍可能有 editor-slice 的旧字段引用错误
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
git commit -m "feat(lesson-preparation): 组合 ExpandedSlice 到 EditorState"
```
---
### Task B3: selection-slice 增强
**Files:**
- Modify: `src/modules/lesson-preparation/hooks/selection-slice.ts`
- [ ] **Step 1: 添加 anchorNodeForSelection 字段**
```typescript
import type { StateCreator } from "zustand";
import type { EditorState } from "./use-lesson-plan-editor";
export interface SelectionSlice {
/** 当前在右栏详情面板显示的节点 ID */
selectedNodeId: string | null;
/** 纸上选中文本时,待锚定的目标节点 ID用户从右键菜单选择节点后设置 */
anchorNodeForSelection: string | null;
selectNode: (id: string | null) => void;
setAnchorNodeForSelection: (id: string | null) => void;
}
export const createSelectionSlice: StateCreator<
EditorState,
[],
[],
SelectionSlice
> = (set) => ({
selectedNodeId: null,
anchorNodeForSelection: null,
selectNode: (id) => set({ selectedNodeId: id }),
setAnchorNodeForSelection: (id) => set({ anchorNodeForSelection: id }),
});
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/hooks/selection-slice.ts
git commit -m "feat(lesson-preparation): selection-slice 增加 anchorNodeForSelection"
```
---
### Task B4: editor-slice 移除画布依赖
**Files:**
- Modify: `src/modules/lesson-preparation/hooks/editor-slice.ts`
- [ ] **Step 1: 移除 auto-layout 和 rf-mappers 导入**
删除第 17 行:
```typescript
import { computeAutoLayout } from "../lib/auto-layout";
```
- [ ] **Step 2: 移除 autoLayout 方法和 updateNodePosition保留 position 字段不破坏数据)**
`EditorSlice` 接口中删除:
- `updateNodePosition`(不再需要)
- `autoLayout`(不再需要)
在实现中删除对应方法体。**保留 `position` 字段在 LessonPlanNode 类型上**(向后兼容,只是不使用)。
- [ ] **Step 3: 默认 doc.version 改为 4**
修改第 60-66 行的默认 doc
```typescript
doc: {
version: 4,
textbookContentNodeId: "",
nodes: [],
edges: [],
anchors: [],
expandedNodeIds: [],
},
```
- [ ] **Step 4: addNode 移除 position 参数(保留兼容)**
修改 `addNode` 签名和实现position 参数变为可选且忽略(生成默认 `{x:0,y:0}`
```typescript
addNode: (type, _position, title) => {
const id = createId();
const state = get();
state.pushHistory();
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
const nodeCount = teachingNodes.length;
const node: LessonPlanNode = {
id,
type,
title: title ?? type,
data: defaultDataForType(type),
order: nodeCount,
position: { x: 0, y: 0 }, // V4不再使用保留字段兼容
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
isDirty: true,
selectedNodeId: id,
}));
return id;
},
```
- [ ] **Step 5: 移除 connect/disconnect/setEdgesV4 不再用画布连线)**
`EditorSlice` 接口中删除:
- `connect`
- `disconnect`
- `setEdges`
在实现中删除对应方法体。
- [ ] **Step 6: addAnchor 简化(不再传 start/end**
修改 `addAnchor`
```typescript
addAnchor: (params: { nodeId: string; type: AnchorType }) => {
const id = createId();
get().pushHistory();
set((s) => ({
doc: {
...s.doc,
anchors: [
...s.doc.anchors,
{ id, nodeId: params.nodeId, type: params.type },
],
},
isDirty: true,
}));
return id;
},
```
- [ ] **Step 7: 验证编译**
Run: `npx tsc --noEmit`
Expected: 仍有调用方错误lesson-plan-editor.tsx 等使用旧 API但 editor-slice 本身干净
- [ ] **Step 8: Commit**
```bash
git add src/modules/lesson-preparation/hooks/editor-slice.ts
git commit -m "refactor(lesson-preparation): editor-slice 移除画布依赖V4"
```
---
## 阶段 C配色和 i18n
### Task C1: globals.css 配色统一
**Files:**
- Modify: `src/app/globals.css`
- [ ] **Step 1: 删除第 51-65 行的 --lesson-node-* 变量**
删除这 15 行:
```css
--lesson-node-objective: #4caf50;
--lesson-node-key-point: #f44336;
--lesson-node-import: #2196f3;
--lesson-node-new-teaching: #9c27b0;
--lesson-node-consolidation: #ff9800;
--lesson-node-summary: #607d8b;
--lesson-node-homework: #795548;
--lesson-node-blackboard: #009688;
--lesson-node-text-study: #3f51b5;
--lesson-node-exercise: #e91e63;
--lesson-node-rich-text: #9e9e9e;
--lesson-node-reflection: #cddc39;
--lesson-node-textbook-content: #455a64;
--lesson-node-selected: #1976d2;
--lesson-node-default: #9e9e9e;
```
- [ ] **Step 2: 在 :root 末尾添加纸感令牌**
```css
/* V4 备课编辑器:纸感令牌(替代 --lesson-node-* */
--lp-paper: #fefefe;
--lp-paper-edge: #f8f8f7;
--lp-paper-shadow: 0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04);
--lp-paper-shadow-active: 0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08);
--lp-anchor-range: rgba(28, 25, 23, 0.08);
--lp-anchor-range-active: rgba(28, 25, 23, 0.16);
--lp-anchor-point: #1c1917;
--lp-inline-node-border: #d6d3d1;
--lp-inline-node-text: #44403c;
--lp-inline-node-meta: #a8a29e;
--lp-interaction: #6366f1;
/* V4 节点类型色点(极克制) */
--lp-dot-objective: #4b5563;
--lp-dot-key-point: #525252;
--lp-dot-import: #6b7280;
--lp-dot-new-teaching: #1c1917;
--lp-dot-consolidation: #6b7280;
--lp-dot-summary: #525252;
--lp-dot-homework: #525252;
--lp-dot-blackboard: #44403c;
--lp-dot-text-study: #4b5563;
--lp-dot-exercise: #6b7280;
--lp-dot-rich-text: #a8a29e;
--lp-dot-reflection: #6b7280;
--lp-dot-interaction: #6366f1;
--lp-dot-textbook: #44403c;
--lp-dot-default: #a8a29e;
```
- [ ] **Step 3: 验证编译**
Run: `npx tsc --noEmit`
Expected: 引用 `--lesson-node-*` 的 TSX 文件报错下一步修复CSS 本身无错误
- [ ] **Step 4: 全局替换 --lesson-node- 引用**
搜索所有引用 `var(--lesson-node-` 的 TSX/TS 文件,改为用 `--lp-dot-*`。常见映射:
- `--lesson-node-objective``--lp-dot-objective`
- `--lesson-node-new-teaching``--lp-dot-new-teaching`
- 等等
对每个文件,把内联 style 中的 `var(--lesson-node-X)` 替换为 `var(--lp-dot-X)`
- [ ] **Step 5: 验证编译**
Run: `npx tsc --noEmit && npm run lint`
Expected: 通过
- [ ] **Step 6: Commit**
```bash
git add src/app/globals.css src/modules/lesson-preparation/
git commit -m "refactor(lesson-preparation): 配色统一到中性令牌(移除 Material 鲜艳色)"
```
---
### Task C2: i18n 新增翻译键
**Files:**
- Modify: `src/shared/i18n/messages/zh-CN/lesson-preparation.json`
- Modify: `src/shared/i18n/messages/en/lesson-preparation.json`
- [ ] **Step 1: 先读取 zh-CN 现有结构**
Run: Read `src/shared/i18n/messages/zh-CN/lesson-preparation.json`(前 30 行查看顶层结构)
- [ ] **Step 2: 在 zh-CN 文件顶层添加 V4 翻译键**
在 JSON 顶层添加(与现有键平级):
```json
{
"v4": {
"tree": {
"title": "结构",
"addNode": "添加节点",
"onPaper": "纸上",
"countSuffix": "节点"
},
"paper": {
"textbookHeader": "教材正文 · 可编辑副本",
"expandedCount": "{count} 节点已展开",
"anchorHere": "锚定到节点"
},
"detail": {
"designIntent": "设计意图",
"qaDialog": "对话设计",
"addTurn": "添加一轮对话",
"turnTeacher": "师 · 提问",
"turnStudent": "生 · 回答",
"turnFollowup": "师 · 追问",
"expectedAnswer": "预期回答",
"aiAssist": "AI 协助",
"aiGenerateLayered": "生成分层提问",
"aiFillExpected": "补充预期回答",
"aiOptimizeFollowup": "优化追问",
"aiDifferentiation": "差异化建议",
"updated": "更新于 {time}"
},
"contextMenu": {
"nodeOps": "节点操作",
"expandToPaper": "展开到正文",
"collapseFromPaper": "从正文收起",
"moveUp": "上移",
"moveDown": "下移",
"aiAssist": "AI 协助",
"aiGenerate": "生成本节点内容",
"aiOptimize": "优化表达",
"aiDifferentiation": "差异化建议",
"aiLayeredQuestions": "生成分层提问",
"copyNode": "复制节点",
"deleteNode": "删除节点",
"anchorToNode": "锚定到节点",
"insertPointAnchor": "插入点锚点"
},
"interaction": {
"label": "师生互动",
"defaultTitle": "师生互动",
"round": "第 {n} 轮",
"roleTeacher": "师",
"roleStudent": "生"
},
"migration": {
"legacyAnchorTitle": "锚点格式升级提示",
"legacyAnchorBody": "此课案使用旧版锚点格式,部分锚点已失效,请重新锚定。",
"legacyAnchorDismiss": "知道了"
}
}
}
```
- [ ] **Step 3: 在 en 文件添加对应英文**
```json
{
"v4": {
"tree": {
"title": "Structure",
"addNode": "Add node",
"onPaper": "on paper",
"countSuffix": "nodes"
},
"paper": {
"textbookHeader": "Textbook content · editable copy",
"expandedCount": "{count} nodes expanded",
"anchorHere": "Anchor to node"
},
"detail": {
"designIntent": "Design intent",
"qaDialog": "Dialog design",
"addTurn": "Add a turn",
"turnTeacher": "Teacher · Ask",
"turnStudent": "Student · Answer",
"turnFollowup": "Teacher · Follow-up",
"expectedAnswer": "Expected answer",
"aiAssist": "AI assist",
"aiGenerateLayered": "Generate layered questions",
"aiFillExpected": "Fill expected answers",
"aiOptimizeFollowup": "Optimize follow-up",
"aiDifferentiation": "Differentiation suggestions",
"updated": "Updated {time}"
},
"contextMenu": {
"nodeOps": "Node operations",
"expandToPaper": "Expand to paper",
"collapseFromPaper": "Collapse from paper",
"moveUp": "Move up",
"moveDown": "Move down",
"aiAssist": "AI assist",
"aiGenerate": "Generate content",
"aiOptimize": "Optimize expression",
"aiDifferentiation": "Differentiation suggestions",
"aiLayeredQuestions": "Generate layered questions",
"copyNode": "Copy node",
"deleteNode": "Delete node",
"anchorToNode": "Anchor to node",
"insertPointAnchor": "Insert point anchor"
},
"interaction": {
"label": "Interaction",
"defaultTitle": "Interaction",
"round": "Round {n}",
"roleTeacher": "T",
"roleStudent": "S"
},
"migration": {
"legacyAnchorTitle": "Anchor format upgrade",
"legacyAnchorBody": "This plan uses a legacy anchor format. Some anchors are invalid. Please re-anchor.",
"legacyAnchorDismiss": "Got it"
}
}
}
```
- [ ] **Step 4: 验证 JSON 语法**
Run: `node -e "JSON.parse(require('fs').readFileSync('src/shared/i18n/messages/zh-CN/lesson-preparation.json','utf8'))"`
Expected: 无输出(解析成功)
- [ ] **Step 5: Commit**
```bash
git add src/shared/i18n/messages/zh-CN/lesson-preparation.json src/shared/i18n/messages/en/lesson-preparation.json
git commit -m "feat(lesson-preparation): V4 i18n 翻译键zh-CN + en"
```
---
## 阶段 DTiptap 锚点系统
### Task D1: AnchorMark 扩展
**Files:**
- Create: `src/modules/lesson-preparation/lib/anchor-mark.ts`
- [ ] **Step 1: 创建 AnchorMark 和 AnchorPoint 扩展**
```typescript
import { Mark, mergeAttributes } from "@tiptap/core";
import { Node, NodeViewRenderer } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
/**
* V4 锚点系统:用 Tiptap Mark 内嵌锚点,替代 v3 的字符串偏移。
*
* - AnchorMarkrange 锚点):包裹选中文本,渲染为高亮 + 底部细线 + 行内标签
* - AnchorPointpoint 锚点):独立 Node渲染为黑色小圆圈带数字
*
* 锚点数据存储在 Mark/Node 的 attributes 中,编辑时自动跟随文本移动。
*/
export interface AnchorMarkAttributes {
anchorId: string;
nodeId: string;
type: "range";
}
export const AnchorMark = Mark.create({
name: "anchor",
addAttributes() {
return {
anchorId: {
default: null,
parseHTML: (el) => el.getAttribute("data-anchor-id"),
renderHTML: (attrs) => ({
"data-anchor-id": attrs.anchorId,
}),
},
nodeId: {
default: null,
parseHTML: (el) => el.getAttribute("data-node-id"),
renderHTML: (attrs) => ({
"data-node-id": attrs.nodeId,
}),
},
type: {
default: "range",
parseHTML: (el) => el.getAttribute("data-anchor-type") ?? "range",
renderHTML: (attrs) => ({
"data-anchor-type": attrs.type,
}),
},
};
},
parseHTML() {
return [
{
tag: "span[data-anchor-id]",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
class: "lp-anchor-range",
}),
];
},
// 不自动添加任何快捷键
addKeyboardShortcuts() {
return {};
},
});
/**
* AnchorPoint点锚点作为独立 Node 插入文本流。
* 渲染为带数字的黑色小圆圈。
*/
export const AnchorPoint = Node.create({
name: "anchorPoint",
group: "inline",
inline: true,
atom: true,
addAttributes() {
return {
anchorId: {
default: null,
parseHTML: (el) => el.getAttribute("data-anchor-id"),
renderHTML: (attrs) => ({
"data-anchor-id": attrs.anchorId,
}),
},
nodeId: {
default: null,
parseHTML: (el) => el.getAttribute("data-node-id"),
renderHTML: (attrs) => ({
"data-node-id": attrs.nodeId,
}),
},
label: {
default: "•",
parseHTML: (el) => el.getAttribute("data-label") ?? "•",
renderHTML: (attrs) => ({
"data-label": attrs.label,
}),
},
};
},
parseHTML() {
return [
{
tag: "span[data-anchor-point]",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-anchor-point": "true",
class: "lp-anchor-point",
contenteditable: "false",
}),
HTMLAttributes.label ?? "•",
];
},
addKeyboardShortcuts() {
return {};
},
});
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
Expected: 通过(只是扩展定义,未被引用)
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/lib/anchor-mark.ts
git commit -m "feat(lesson-preparation): Tiptap AnchorMark + AnchorPoint 扩展"
```
---
## 阶段 E纸区组件
### Task E1: textbook-tiptap-editor正文编辑器
**Files:**
- Create: `src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx`
- [ ] **Step 1: 创建正文 Tiptap 编辑器**
```typescript
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { AnchorMark, AnchorPoint } from "../../lib/anchor-mark";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { PaperToolbar } from "./paper-toolbar";
interface Props {
/** 教材正文 HTML */
content: string;
/** 是否只读 */
readonly?: boolean;
}
/**
* V4 正文 Tiptap 编辑器。
* - 字体Fraunces 衬线
* - 锚点AnchorMarkrange+ AnchorPointpoint
* - 编辑时浮动工具条出现
*/
export function TextbookTiptapEditor({ content, readonly }: Props) {
const t = useTranslations("lessonPreparation");
const updateTextbookContent = useLessonPlanEditor((s) => s.updateTextbookContent);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
// V4禁用一些冲突的特性
codeBlock: false,
}),
Placeholder.configure({
placeholder: t("v4.paper.textbookHeader"),
}),
Image,
AnchorMark,
AnchorPoint,
],
content,
editable: !readonly,
editorProps: {
attributes: {
class: "lp-textbook-editor prose prose-sm max-w-none focus:outline-none",
style: "font-family: 'Fraunces', Georgia, serif; font-size: 16px; line-height: 1.75; color: #1a1a1a;",
},
},
onUpdate: ({ editor: e }) => {
// debounce 3s 保存到 store
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
updateTextbookContent({ content: e.getHTML() });
}, 3000);
},
});
// 外部 content 变化时同步(如切换课案)
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
// 卸载时清理
useEffect(() => {
return () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
};
}, []);
if (!editor) return null;
return (
<div className="relative">
{!readonly && <PaperToolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
Expected: 报错PaperToolbar 未创建),下一步创建
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx
git commit -m "feat(lesson-preparation): 正文 Tiptap 编辑器"
```
---
### Task E2: paper-toolbar浮动工具条
**Files:**
- Create: `src/modules/lesson-preparation/components/paper-editor/paper-toolbar.tsx`
- [ ] **Step 1: 创建浮动工具条**
```typescript
"use client";
import type { Editor } from "@tiptap/react";
import { useTranslations } from "next-intl";
interface Props {
editor: Editor;
}
/**
* V4 浮动工具条:粘在纸顶部,毛玻璃半透明。
* 仅在编辑器聚焦时显示(由父组件控制)。
*/
export function PaperToolbar({ editor }: Props) {
const t = useTranslations("lessonPreparation");
const btn = (
label: string,
action: () => void,
isActive: boolean,
className: string,
title: string,
) => (
<button
type="button"
onClick={action}
title={title}
className={`lp-tb-btn ${isActive ? "is-active" : ""} ${className}`}
onMouseDown={(e) => e.preventDefault()} // 防止失焦
>
{label}
</button>
);
return (
<div className="lp-paper-toolbar">
{btn("B", () => editor.chain().focus().toggleBold().run(), editor.isActive("bold"), "b", "Bold")}
{btn("I", () => editor.chain().focus().toggleItalic().run(), editor.isActive("italic"), "i", "Italic")}
<span className="lp-tb-divider" />
{btn("H1", () => editor.chain().focus().toggleHeading({ level: 1 }).run(), editor.isActive("heading", { level: 1 }), "h1", "Heading 1")}
{btn("H2", () => editor.chain().focus().toggleHeading({ level: 2 }).run(), editor.isActive("heading", { level: 2 }), "h2", "Heading 2")}
<span className="lp-tb-divider" />
{btn("•", () => editor.chain().focus().toggleBulletList().run(), editor.isActive("bulletList"), "ul", "Bullet list")}
{btn("1.", () => editor.chain().focus().toggleOrderedList().run(), editor.isActive("orderedList"), "ol", "Ordered list")}
<span className="lp-tb-divider" />
{btn('"', () => editor.chain().focus().toggleBlockquote().run(), editor.isActive("blockquote"), "quote", "Quote")}
{btn("—", () => editor.chain().focus().setHorizontalRule().run(), false, "hr", "Divider")}
</div>
);
}
```
- [ ] **Step 2: 在 globals.css 添加工具条样式**
在文件末尾追加:
```css
/* V4 备课编辑器:纸区工具条 */
.lp-paper-toolbar {
position: sticky;
top: 0;
margin: -64px -72px 24px;
padding: 10px 72px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
display: flex;
gap: 2px;
z-index: 5;
}
.lp-tb-btn {
width: 28px; height: 28px;
border: none; background: transparent;
border-radius: 4px; cursor: pointer;
font-family: 'Inter', sans-serif;
font-size: 13px; color: var(--foreground);
display: inline-flex; align-items: center; justify-content: center;
transition: background .12s;
}
.lp-tb-btn:hover { background: var(--muted); }
.lp-tb-btn.is-active { background: var(--muted); color: var(--foreground); }
.lp-tb-btn.b { font-weight: 700; }
.lp-tb-btn.i { font-style: italic; font-family: 'Fraunces', serif; }
.lp-tb-btn.h1 { font-size: 11px; font-weight: 600; }
.lp-tb-btn.h2 { font-size: 10px; font-weight: 600; }
.lp-tb-btn.ul { font-size: 14px; }
.lp-tb-btn.quote { font-family: 'Fraunces', serif; font-style: italic; font-size: 14px; }
.lp-tb-divider {
width: 1px;
background: var(--border);
margin: 4px 4px;
}
/* V4 锚点样式 */
.lp-anchor-range {
background: var(--lp-anchor-range);
border-radius: 2px;
padding: 1px 2px;
margin: 0 -2px;
cursor: pointer;
border-bottom: 1.5px solid var(--lp-anchor-point);
transition: background .15s;
}
.lp-anchor-range:hover, .lp-anchor-range.is-active {
background: var(--lp-anchor-range-active);
}
.lp-anchor-point {
display: inline-block;
width: 16px; height: 16px;
line-height: 16px;
text-align: center;
border-radius: 50%;
background: var(--lp-anchor-point);
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 9px;
font-weight: 600;
margin: 0 2px;
vertical-align: middle;
cursor: pointer;
}
/* V4 inline-node 样式 */
.lp-inline-node {
font-family: 'Inter', sans-serif;
margin: 20px 0 20px;
padding: 14px 18px 14px 20px;
border-left: 2px solid var(--lp-inline-node-border);
color: var(--lp-inline-node-text);
font-size: 13.5px;
line-height: 1.65;
transition: border-color .2s;
}
.lp-inline-node:hover { border-left-color: var(--foreground); }
.lp-inline-node-head {
display: flex; align-items: center; gap: 8px;
margin-bottom: 8px;
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--lp-inline-node-meta);
}
.lp-inline-node-title {
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 14px;
color: #1a1a1a;
margin: 0 0 6px;
line-height: 1.35;
}
.lp-inline-node-body {
font-size: 13px;
line-height: 1.6;
color: #404040;
}
/* V4 师生交互对话体 */
.lp-qa-dialog { margin: 8px 0 4px; }
.lp-qa-turn {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
font-size: 12.5px;
line-height: 1.55;
}
.lp-qa-turn:last-child { border-bottom: none; }
.lp-qa-role {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-top: 2px;
text-align: right;
}
.lp-qa-role.teacher { color: #1c1917; }
.lp-qa-role.student { color: #6b7280; }
.lp-qa-content { color: #404040; }
.lp-qa-prompt {
font-style: italic;
color: #525252;
font-size: 11px;
display: block;
margin-top: 2px;
}
```
- [ ] **Step 3: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 4: Commit**
```bash
git add src/modules/lesson-preparation/components/paper-editor/paper-toolbar.tsx src/app/globals.css
git commit -m "feat(lesson-preparation): 浮动工具条 + 锚点/inline-node/对话体样式"
```
---
### Task E3: inline-node展开节点容器
**Files:**
- Create: `src/modules/lesson-preparation/components/paper-editor/inline-node.tsx`
- [ ] **Step 1: 创建 inline-node 容器**
```typescript
"use client";
import { useTranslations } from "next-intl";
import type { Block, LessonPlanNode } from "../../types";
import { BLOCK_REGISTRY } from "../../config/block-registry";
import { BlockRenderer } from "../../config/block-registry";
import { InlineQaDialog } from "./inline-qa-dialog";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
interface Props {
node: LessonPlanNode;
}
/**
* V4 inline-node展开到正文流的节点渲染。
* 用 Inter 字体 + 左侧细线区分正文Fraunces
*/
export function InlineNode({ node }: Props) {
const t = useTranslations("lessonPreparation");
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
const updateNode = useLessonPlanEditor((s) => s.updateNode);
// 师生交互节点用专门的对话体渲染
if (node.type === "interaction") {
return <InlineQaDialog node={node} />;
}
// 其他节点类型用 BlockRenderer详情编辑模式的只读渲染
// 注意inline-node 是只读摘要,完整编辑在右栏详情面板
const entry = BLOCK_REGISTRY[node.type];
const dotColorVar = `var(--lp-dot-${node.type})`;
return (
<div className="lp-inline-node">
<div className="lp-inline-node-head">
<span
style={{
width: 5,
height: 5,
borderRadius: "50%",
background: dotColorVar,
display: "inline-block",
}}
/>
<span>{node.type}</span>
<span style={{ marginLeft: "auto", fontFamily: "JetBrains Mono, monospace", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
{node.id.slice(-4)}
</span>
<button
type="button"
onClick={() => toggleExpand(node.id)}
className="lp-collapse-btn"
style={{
cursor: "pointer",
padding: "2px 6px",
borderRadius: 3,
color: "var(--lp-inline-node-meta)",
fontSize: 11,
background: "transparent",
border: "none",
}}
>
{t("v4.contextMenu.collapseFromPaper")}
</button>
</div>
<h4 className="lp-inline-node-title">{node.title}</h4>
<div className="lp-inline-node-body">
{/* 简化:各 block 类型的 inline 渲染在 BlockRenderer 中处理 readonly 模式 */}
<InlineNodeBody node={node} />
</div>
</div>
);
}
/**
* 各节点类型的 inline 只读渲染。
* 这里给出最常见的几种的简化摘要,完整编辑在右栏详情面板。
*/
function InlineNodeBody({ node }: { node: Block }) {
const data = node.data;
// 按类型渲染摘要
switch (node.type) {
case "objective": {
const d = data as { objectives: { dimension: string; text: string }[] };
if (!d.objectives?.length) return <p style={{ color: "var(--muted-foreground)" }}></p>;
return (
<ul style={{ margin: "4px 0", paddingLeft: 16 }}>
{d.objectives.map((o, i) => (
<li key={i} style={{ marginBottom: 3, fontSize: 13 }}>{o.text}</li>
))}
</ul>
);
}
case "summary": {
const d = data as { summaryPoints: string[] };
if (!d.summaryPoints?.length) return <p style={{ color: "var(--muted-foreground)" }}></p>;
return (
<ul style={{ margin: "4px 0", paddingLeft: 16 }}>
{d.summaryPoints.map((s, i) => (
<li key={i} style={{ marginBottom: 3, fontSize: 13 }}>{s}</li>
))}
</ul>
);
}
case "exercise": {
const d = data as { items: unknown[] };
return <p style={{ fontSize: 13 }}>{d.items?.length ?? 0} </p>;
}
default:
return <p style={{ fontSize: 13, color: "var(--muted-foreground)" }}>{node.title}</p>;
}
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
Expected: 通过InlineQaDialog 未创建,下一步创建)
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/paper-editor/inline-node.tsx
git commit -m "feat(lesson-preparation): inline-node 展开节点容器"
```
---
### Task E4: inline-qa-dialog师生交互对话体
**Files:**
- Create: `src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx`
- [ ] **Step 1: 创建师生交互的对话体渲染**
```typescript
"use client";
import { useTranslations } from "next-intl";
import type { InteractionBlockData, LessonPlanNode, QATurn } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
interface Props {
node: LessonPlanNode;
}
/**
* V4 师生交互节点的 inline 对话体渲染。
* - 师/生 角色标签JetBrains Mono
* - 教师提问下方 italic 灰色 [预期:...]
* - 对话轮次之间 dashed 线分隔
*/
export function InlineQaDialog({ node }: Props) {
const t = useTranslations("lessonPreparation");
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
const data = node.data as InteractionBlockData;
return (
<div className="lp-inline-node">
<div className="lp-inline-node-head">
<span
style={{
width: 5,
height: 5,
borderRadius: "50%",
background: "var(--lp-dot-interaction)",
display: "inline-block",
}}
/>
<span>{t("v4.interaction.label")}</span>
<span style={{ marginLeft: "auto", fontFamily: "JetBrains Mono, monospace", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
{data.turns?.length ?? 0} {t("v4.interaction.countSuffix" as never) || "轮"}
</span>
<button
type="button"
onClick={() => toggleExpand(node.id)}
style={{
cursor: "pointer",
padding: "2px 6px",
borderRadius: 3,
color: "var(--lp-inline-node-meta)",
fontSize: 11,
background: "transparent",
border: "none",
}}
>
{t("v4.contextMenu.collapseFromPaper")}
</button>
</div>
<h4 className="lp-inline-node-title">{node.title}</h4>
<div className="lp-inline-node-body">
{data.designIntent && (
<p style={{ fontSize: 13, marginBottom: 8 }}>{data.designIntent}</p>
)}
<div className="lp-qa-dialog">
{(data.turns ?? []).map((turn, idx) => (
<QaTurnView key={turn.id} turn={turn} index={idx} />
))}
{(!data.turns || data.turns.length === 0) && (
<p style={{ color: "var(--muted-foreground)", fontSize: 12 }}>
</p>
)}
</div>
</div>
</div>
);
}
function QaTurnView({ turn, index }: { turn: QATurn; index: number }) {
const t = useTranslations("lessonPreparation");
const roleClass = turn.role === "teacher" ? "teacher" : "student";
const roleLabel = turn.role === "teacher" ? t("v4.interaction.roleTeacher") : t("v4.interaction.roleStudent");
return (
<div className="lp-qa-turn">
<div className={`lp-qa-role ${roleClass}`}>{roleLabel}</div>
<div className="lp-qa-content">
{turn.content}
{turn.role === "teacher" && turn.expectedAnswer && (
<span className="lp-qa-prompt">[{turn.expectedAnswer}]</span>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx
git commit -m "feat(lesson-preparation): 师生交互对话体 inline 渲染"
```
---
### Task E5: paper-context-menu右键菜单
**Files:**
- Create: `src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx`
- [ ] **Step 1: 创建右键菜单组件**
```typescript
"use client";
import { useTranslations } from "next-intl";
import type { LessonPlanNode } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
export interface ContextMenuState {
visible: boolean;
x: number;
y: number;
/** 右键的节点(如果是 inline-node 右键) */
nodeId: string | null;
/** 右键的选中文本范围(如果是正文右键) */
selectionRange: { from: number; to: number } | null;
}
interface Props {
state: ContextMenuState;
onClose: () => void;
/** AI 协助回调V1: 仅 toast 提示"功能开发中"V2 接入 actions-ai.ts */
onAiAction?: (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => void;
}
/**
* V4 右键菜单:根据 state.nodeId 区分节点菜单 vs 锚定菜单。
*
* V1 范围:展开/收起、删除、锚定 已实现。
* V1 占位上下移动、复制节点、AI 协助 4 项(按钮显示,点击 toast 提示)。
* 这些占位功能在 spec §15 YAGNI 边界内V2 接入。
*/
export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
const t = useTranslations("lessonPreparation");
const {
toggleExpand,
expandedNodeIds,
updateNode,
removeNode,
addAnchor,
selectNode,
doc,
} = useLessonPlanEditor();
// V1 占位实现:上下移动、复制(按 spec §15 YAGNIV2 完整实现)
const moveNodeOrder = (nodeId: string, direction: "up" | "down") => {
const teachingNodes = doc.nodes
.filter((n): n is import("../../types").LessonPlanNode => n.type !== "textbook_content")
.sort((a, b) => a.order - b.order);
const idx = teachingNodes.findIndex((n) => n.id === nodeId);
if (idx < 0) return;
const newIdx = direction === "up" ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= teachingNodes.length) return;
const a = teachingNodes[idx];
const b = teachingNodes[newIdx];
updateNode(a.id, { order: b.order });
updateNode(b.id, { order: a.order });
};
const copyNode = (nodeId: string) => {
// V1 占位toast 提示
import("sonner").then(({ toast }) => toast.info("复制节点功能将在 V2 提供"));
};
// AI 协助默认实现V1 占位)
const handleAi = (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => {
if (onAiAction) {
onAiAction(action, nodeId);
} else {
import("sonner").then(({ toast }) => toast.info("AI 协助功能将在 V2 提供"));
}
};
if (!state.visible) return null;
const isNodeMenu = state.nodeId !== null;
const isExpanded = state.nodeId ? expandedNodeIds.includes(state.nodeId) : false;
const handleAction = (action: () => void) => {
action();
onClose();
};
const item = (label: string, action: () => void, opts?: { danger?: boolean; ai?: boolean; shortcut?: string }) => (
<button
type="button"
onClick={() => handleAction(action)}
className={`lp-cm-item ${opts?.danger ? "danger" : ""} ${opts?.ai ? "ai" : ""}`}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "7px 10px",
borderRadius: 4,
cursor: "pointer",
color: opts?.danger ? "#dc2626" : opts?.ai ? "var(--lp-interaction)" : "var(--foreground)",
background: "transparent",
border: "none",
width: "100%",
textAlign: "left",
fontSize: 12.5,
fontFamily: "inherit",
}}
>
<span style={{ flex: 1 }}>{label}</span>
{opts?.shortcut && (
<span style={{ fontFamily: "JetBrains Mono, monospace", fontSize: 10, color: "var(--muted-foreground)" }}>
{opts.shortcut}
</span>
)}
</button>
);
const section = (label: string) => (
<div style={{ fontSize: 9, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--muted-foreground)", padding: "6px 10px 4px" }}>
{label}
</div>
);
const divider = () => <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }} />;
return (
<div
style={{
position: "fixed",
top: state.y,
left: state.x,
background: "var(--background)",
border: "1px solid var(--border)",
borderRadius: 6,
boxShadow: "0 6px 24px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.06)",
padding: 5,
minWidth: 220,
zIndex: 100,
}}
onClick={(e) => e.stopPropagation()}
>
{isNodeMenu ? (
<>
{section(t("v4.contextMenu.nodeOps"))}
{!isExpanded && item(t("v4.contextMenu.expandToPaper"), () => state.nodeId && toggleExpand(state.nodeId))}
{isExpanded && item(t("v4.contextMenu.collapseFromPaper"), () => state.nodeId && toggleExpand(state.nodeId))}
{item(t("v4.contextMenu.moveUp"), () => state.nodeId && moveNodeOrder(state.nodeId, "up"))}
{item(t("v4.contextMenu.moveDown"), () => state.nodeId && moveNodeOrder(state.nodeId, "down"))}
{divider()}
{section(t("v4.contextMenu.aiAssist"))}
{item(t("v4.contextMenu.aiGenerate"), () => state.nodeId && handleAi("generate", state.nodeId), { ai: true })}
{item(t("v4.contextMenu.aiOptimize"), () => state.nodeId && handleAi("optimize", state.nodeId), { ai: true })}
{item(t("v4.contextMenu.aiDifferentiation"), () => state.nodeId && handleAi("differentiation", state.nodeId), { ai: true })}
{item(t("v4.contextMenu.aiLayeredQuestions"), () => state.nodeId && handleAi("layered", state.nodeId), { ai: true })}
{divider()}
{item(t("v4.contextMenu.copyNode"), () => state.nodeId && copyNode(state.nodeId))}
{item(t("v4.contextMenu.deleteNode"), () => state.nodeId && removeNode(state.nodeId), { danger: true, shortcut: "⌫" })}
</>
) : (
<>
{section(t("v4.contextMenu.anchorToNode"))}
{/* 列出所有教学节点供选择锚定 */}
<NodeAnchorList
onSelect={(nodeId) => {
if (state.selectionRange && nodeId) {
// 用 Tiptap toggleMark 包裹选中文本
// 实际实现在 PaperEditor 中通过 ref 调用 editor
addAnchor({ nodeId, type: "range" });
}
onClose();
}}
/>
</>
)}
</div>
);
}
function NodeAnchorList({ onSelect }: { onSelect: (nodeId: string) => void }) {
const nodes = useLessonPlanEditor((s) => s.doc.nodes);
const teachingNodes = nodes.filter((n): n is LessonPlanNode => n.type !== "textbook_content");
return (
<div style={{ maxHeight: 200, overflowY: "auto" }}>
{teachingNodes.map((n) => (
<button
key={n.id}
type="button"
onClick={() => onSelect(n.id)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 10px",
width: "100%",
background: "transparent",
border: "none",
cursor: "pointer",
fontSize: 12,
color: "var(--foreground)",
textAlign: "left",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: `var(--lp-dot-${n.type})` }} />
<span style={{ flex: 1 }}>{n.title}</span>
<span style={{ fontSize: 10, color: "var(--muted-foreground)" }}>{n.type}</span>
</button>
))}
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx
git commit -m "feat(lesson-preparation): 右键菜单(节点操作 + AI 协助 + 锚定)"
```
---
### Task E6: paper-editor中栏容器
**Files:**
- Create: `src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx`
- [ ] **Step 1: 创建纸区容器**
```typescript
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import type { Editor } from "@tiptap/react";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { TextbookTiptapEditor } from "./textbook-tiptap-editor";
import { InlineNode } from "./inline-node";
import { PaperContextMenu, type ContextMenuState } from "./paper-context-menu";
import type { LessonPlanNode } from "../../types";
/**
* V4 中栏纸区:
* - 教材正文 Tiptap 编辑器max-w-720px 白纸)
* - 展开的节点按 order 顺序在段落间插入 inline-node
* - 右键菜单(节点操作 / 锚定)
*
* 注意V4 的 inline-node 插入位置由节点 order 决定,不由锚点位置决定。
* 锚点是正文内的视觉标记inline-node 是独立块。
* 当前实现:所有 inline-node 渲染在正文之后(简化)。
* 完整实现需要把正文按段落分割,在段落间插入 inline-node。
*/
export function PaperEditor({ readonly }: { readonly?: boolean }) {
const t = useTranslations("lessonPreparation");
const doc = useLessonPlanEditor((s) => s.doc);
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
nodeId: null,
selectionRange: null,
});
const textbookNode = doc.nodes.find((n) => n.type === "textbook_content");
const teachingNodes = useMemo(
() =>
doc.nodes
.filter((n): n is LessonPlanNode => n.type !== "textbook_content")
.sort((a, b) => a.order - b.order),
[doc.nodes],
);
const expandedNodes = useMemo(
() => teachingNodes.filter((n) => expandedNodeIds.includes(n.id)),
[teachingNodes, expandedNodeIds],
);
if (!textbookNode) {
return <div style={{ padding: 48, textAlign: "center", color: "var(--muted-foreground)" }}>No textbook content</div>;
}
const onPaperContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const sel = window.getSelection();
const hasSelection = sel && sel.toString().length > 0;
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
nodeId: null,
selectionRange: hasSelection ? { from: 0, to: 0 } : null, // 实际偏移由 editor 给
});
};
const onInlineNodeContextMenu = (e: React.MouseEvent, nodeId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
nodeId,
selectionRange: null,
});
};
return (
<main
style={{
overflowY: "auto",
padding: "48px 32px 120px",
background: "radial-gradient(circle at 50% 0%, rgba(0,0,0,0.015) 0%, transparent 60%), var(--muted)",
}}
onContextMenu={(e) => {
// 默认右键 = 正文右键(锚定菜单)
if (!(e.target as HTMLElement).closest("[data-inline-node]")) {
onPaperContextMenu(e);
}
}}
>
<article
style={{
background: "var(--lp-paper)",
maxWidth: 720,
margin: "0 auto",
padding: "64px 72px",
boxShadow: "var(--lp-paper-shadow)",
borderRadius: 2,
border: "1px solid var(--lp-paper-edge)",
minHeight: 800,
fontFamily: "'Fraunces', Georgia, serif",
color: "#1a1a1a",
lineHeight: 1.7,
fontSize: 16,
}}
>
<div
style={{
fontFamily: "Inter, sans-serif",
fontSize: 11,
color: "var(--muted-foreground)",
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: 24,
paddingBottom: 16,
borderBottom: "1px solid var(--border)",
display: "flex",
justifyContent: "space-between",
}}
>
<span>{t("v4.paper.textbookHeader")}</span>
<span>{t("v4.paper.expandedCount", { count: expandedNodes.length })}</span>
</div>
<TextbookTiptapEditor
content={textbookNode.data.content}
readonly={readonly}
/>
{/* 展开的节点:按 order 排列在正文之后(简化实现) */}
{expandedNodes.map((node) => (
<div
key={node.id}
data-inline-node={node.id}
onContextMenu={(e) => onInlineNodeContextMenu(e, node.id)}
>
<InlineNode node={node} />
</div>
))}
</article>
<PaperContextMenu
state={contextMenu}
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
/>
</main>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx
git commit -m "feat(lesson-preparation): 中栏纸区容器(正文 + 展开节点 + 右键菜单)"
```
---
## 阶段 F左栏结构树
### Task F1: tree-node-row
**Files:**
- Create: `src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx`
- [ ] **Step 1: 创建树节点行组件**
```typescript
"use client";
import { useTranslations } from "next-intl";
import type { Block } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
interface Props {
node: Block;
/** 是否展开到正文 */
isExpanded: boolean;
/** 是否有子节点(如师生交互的对话轮次) */
hasChildren?: boolean;
isChildrenExpanded?: boolean;
onToggleChildren?: () => void;
/** 子节点(如对话轮次) */
children?: { id: string; label: string; kind: string }[];
}
export function TreeNodeRow({
node,
isExpanded,
hasChildren,
isChildrenExpanded,
onToggleChildren,
children,
}: Props) {
const t = useTranslations("lessonPreparation");
const selectedNodeId = useLessonPlanEditor((s) => s.selectedNodeId);
const selectNode = useLessonPlanEditor((s) => s.selectNode);
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
const isActive = selectedNodeId === node.id;
const dotColor = `var(--lp-dot-${node.type})`;
return (
<div>
<div
role="button"
tabIndex={0}
onClick={() => selectNode(node.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectNode(node.id);
}
}}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 8px",
borderRadius: 5,
cursor: "pointer",
background: isActive ? "var(--muted)" : "transparent",
lineHeight: 1.3,
fontSize: 13,
}}
className="lp-tree-row"
>
<span
style={{
width: 14,
height: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: 9,
color: "var(--muted-foreground)",
cursor: hasChildren ? "pointer" : "default",
visibility: hasChildren ? "visible" : "hidden",
}}
onClick={(e) => {
if (hasChildren && onToggleChildren) {
e.stopPropagation();
onToggleChildren();
}
}}
>
{hasChildren ? (isChildrenExpanded ? "▾" : "▸") : ""}
</span>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: dotColor, flexShrink: 0 }} />
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--foreground)",
}}
>
{node.title}
</span>
<span style={{ fontSize: 10, color: "var(--muted-foreground)", fontWeight: 500 }}>
{node.type}
</span>
{isExpanded && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpand(node.id);
}}
style={{
fontSize: 9,
color: "var(--foreground)",
background: "var(--muted)",
padding: "1px 5px",
borderRadius: 2,
fontWeight: 500,
border: "none",
cursor: "pointer",
}}
>
{t("v4.tree.onPaper")}
</button>
)}
</div>
{hasChildren && isChildrenExpanded && children && (
<div
style={{
marginLeft: 10,
paddingLeft: 8,
borderLeft: "1px solid var(--border)",
}}
>
{children.map((c) => (
<div
key={c.id}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 8px",
borderRadius: 5,
fontSize: 12,
color: "var(--muted-foreground)",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--lp-dot-default)", opacity: 0.5 }} />
<span style={{ flex: 1 }}>{c.label}</span>
<span style={{ fontSize: 10 }}>{c.kind}</span>
</div>
))}
</div>
)}
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx
git commit -m "feat(lesson-preparation): 树节点行组件"
```
---
### Task F2: structure-tree
**Files:**
- Create: `src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx`
- [ ] **Step 1: 创建结构树容器**
```typescript
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { TreeNodeRow } from "./tree-node-row";
import type { InteractionBlockData, LessonPlanNode, TextbookContentNode } from "../../types";
export function StructureTree() {
const t = useTranslations("lessonPreparation");
const doc = useLessonPlanEditor((s) => s.doc);
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
const addNode = useLessonPlanEditor((s) => s.addNode);
const [expandedTreeNodes, setExpandedTreeNodes] = useState<Set<string>>(new Set());
// V1默认添加 rich_text 节点V2 接入节点类型选择对话框)
const onAddNode = () => {
const id = addNode("rich_text");
// 自动选中新节点addNode 内部已设置 selectedNodeId
void id;
};
const textbookNode = doc.nodes.find((n): n is TextbookContentNode => n.type === "textbook_content");
const teachingNodes = useMemo(
() =>
doc.nodes
.filter((n): n is LessonPlanNode => n.type !== "textbook_content")
.sort((a, b) => a.order - b.order),
[doc.nodes],
);
const toggleTreeNode = (id: string) => {
setExpandedTreeNodes((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<aside
style={{
borderRight: "1px solid var(--border)",
background: "var(--background)",
padding: "16px 12px",
overflowY: "auto",
}}
>
<div
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--muted-foreground)",
padding: "0 8px 12px",
display: "flex",
justifyContent: "space-between",
}}
>
<span>{t("v4.tree.title")}</span>
<span style={{ fontWeight: 500, letterSpacing: 0 }}>
{teachingNodes.length} {t("v4.tree.countSuffix")}
</span>
</div>
<div style={{ fontSize: 13 }}>
{/* 正文节点(始终在最上) */}
{textbookNode && (
<TreeNodeRow
node={{ ...textbookNode, type: "textbook_content" } as unknown as import("../../types").Block}
isExpanded={false}
/>
)}
{/* 教学节点 */}
{teachingNodes.map((node) => {
const isExpanded = expandedNodeIds.includes(node.id);
const isTreeNodeExpanded = expandedTreeNodes.has(node.id);
// 师生交互节点:子节点为对话轮次
let childItems: { id: string; label: string; kind: string }[] = [];
let hasChildren = false;
if (node.type === "interaction") {
const data = node.data as InteractionBlockData;
hasChildren = (data.turns?.length ?? 0) > 0;
childItems = (data.turns ?? []).map((turn, idx) => ({
id: turn.id,
label: `${t("v4.interaction.round", { n: idx + 1 })} · ${turn.role === "teacher" ? t("v4.interaction.roleTeacher") : t("v4.interaction.roleStudent")}`,
kind: turn.role === "teacher" ? t("v4.interaction.roleTeacher") : t("v4.interaction.roleStudent"),
}));
}
return (
<TreeNodeRow
key={node.id}
node={node}
isExpanded={isExpanded}
hasChildren={hasChildren}
isChildrenExpanded={isTreeNodeExpanded}
onToggleChildren={() => toggleTreeNode(node.id)}
children={childItems}
/>
);
})}
</div>
<div
style={{
marginTop: 10,
padding: "7px 8px",
border: "1px dashed var(--border)",
borderRadius: 5,
textAlign: "center",
fontSize: 12,
color: "var(--muted-foreground)",
cursor: "pointer",
}}
onClick={onAddNode}
>
+ {t("v4.tree.addNode")}
</div>
</aside>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx
git commit -m "feat(lesson-preparation): 左栏结构树容器"
```
---
## 阶段 G右栏详情面板
### Task G1: detail-head
**Files:**
- Create: `src/modules/lesson-preparation/components/detail-panel/detail-head.tsx`
- [ ] **Step 1: 创建详情头部组件**
```typescript
"use client";
import { useTranslations } from "next-intl";
import type { Block } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
interface Props {
node: Block;
isExpanded: boolean;
}
export function DetailHead({ node, isExpanded }: Props) {
const t = useTranslations("lessonPreparation");
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
const dotColor = `var(--lp-dot-${node.type})`;
return (
<div
style={{
padding: "16px 20px 12px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "flex-start",
gap: 10,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: dotColor,
marginTop: 6,
flexShrink: 0,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--muted-foreground)",
marginBottom: 4,
}}
>
{node.type}
{isExpanded ? ` · ${t("v4.contextMenu.expandToPaper")}` : ""}
</div>
<input
value={node.title}
onChange={(e) => updateNode(node.id, { title: e.target.value })}
style={{
width: "100%",
background: "transparent",
border: "none",
outline: "none",
fontFamily: "Inter, sans-serif",
fontWeight: 600,
fontSize: 16,
color: "var(--foreground)",
lineHeight: 1.3,
}}
/>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button
type="button"
onClick={() => toggleExpand(node.id)}
title={isExpanded ? t("v4.contextMenu.collapseFromPaper") : t("v4.contextMenu.expandToPaper")}
style={{
width: 26,
height: 26,
border: "none",
background: "transparent",
borderRadius: 4,
cursor: "pointer",
color: "var(--muted-foreground)",
fontSize: 14,
}}
>
{isExpanded ? "▴" : "▾"}
</button>
</div>
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/detail-panel/detail-head.tsx
git commit -m "feat(lesson-preparation): 详情面板头部"
```
---
### Task G2: detail-props
**Files:**
- Create: `src/modules/lesson-preparation/components/detail-panel/detail-props.tsx`
- [ ] **Step 1: 创建属性条组件**
```typescript
"use client";
import type { Block } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import type { TeachingStage, DifferentiationLevel } from "../../types";
interface Props {
node: Block;
}
const STAGES: TeachingStage[] = ["import", "new_teaching", "consolidation", "summary"];
const LEVELS: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
export function DetailProps({ node }: Props) {
const updateNode = useLessonPlanEditor((s) => s.updateNode);
return (
<div
style={{
padding: "10px 20px",
borderBottom: "1px solid var(--border)",
display: "flex",
gap: 12,
flexWrap: "wrap",
fontSize: 11.5,
}}
>
<PropItem
label="教学阶段"
value={node.stage ?? "—"}
options={STAGES}
onChange={(v) => updateNode(node.id, { stage: v as TeachingStage })}
/>
<PropItem
label="差异化"
value={node.differentiation ?? "—"}
options={LEVELS}
onChange={(v) => updateNode(node.id, { differentiation: v as DifferentiationLevel })}
/>
</div>
);
}
function PropItem({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: readonly string[];
onChange: (v: string) => void;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span style={{ color: "var(--muted-foreground)" }}>{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
color: "var(--foreground)",
fontWeight: 500,
padding: "1px 6px",
background: "var(--muted)",
border: "1px solid var(--border)",
borderRadius: 3,
fontSize: 11,
cursor: "pointer",
}}
>
<option value="—"></option>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/detail-panel/detail-props.tsx
git commit -m "feat(lesson-preparation): 详情面板属性条"
```
---
### Task G3: qa-editor师生交互编辑器
**Files:**
- Create: `src/modules/lesson-preparation/components/detail-panel/qa-editor.tsx`
- [ ] **Step 1: 创建师生交互编辑器**
```typescript
"use client";
import { useTranslations } from "next-intl";
import { createId } from "@paralleldrive/cuid2";
import type { InteractionBlockData, QATurn } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
interface Props {
nodeId: string;
data: InteractionBlockData;
}
export function QaEditor({ nodeId, data }: Props) {
const t = useTranslations("lessonPreparation");
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const updateData = (next: InteractionBlockData) => {
updateNode(nodeId, { data: next });
};
const addTurn = () => {
const newTurn: QATurn = {
id: createId(),
role: "teacher",
content: "",
expectedAnswer: "",
order: data.turns.length,
};
updateData({ ...data, turns: [...data.turns, newTurn] });
};
const updateTurn = (id: string, patch: Partial<QATurn>) => {
updateData({
...data,
turns: data.turns.map((t) => (t.id === id ? { ...t, ...patch } : t)),
});
};
const removeTurn = (id: string) => {
updateData({
...data,
turns: data.turns
.filter((t) => t.id !== id)
.map((t, i) => ({ ...t, order: i })),
});
};
const moveTurn = (id: string, direction: "up" | "down") => {
const idx = data.turns.findIndex((t) => t.id === id);
if (idx < 0) return;
const newIdx = direction === "up" ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= data.turns.length) return;
const turns = [...data.turns];
[turns[idx], turns[newIdx]] = [turns[newIdx], turns[idx]];
updateData({
...data,
turns: turns.map((t, i) => ({ ...t, order: i })),
});
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
<label
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--muted-foreground)",
display: "block",
marginBottom: 6,
}}
>
{t("v4.detail.designIntent")}
</label>
<textarea
value={data.designIntent}
onChange={(e) => updateData({ ...data, designIntent: e.target.value })}
rows={2}
style={{
width: "100%",
border: "1px solid var(--border)",
borderRadius: 5,
padding: "8px 10px",
background: "var(--background)",
fontFamily: "Inter, sans-serif",
fontSize: 13,
lineHeight: 1.6,
color: "var(--foreground)",
resize: "vertical",
}}
/>
</div>
<div>
<label
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--muted-foreground)",
display: "block",
marginBottom: 8,
}}
>
{t("v4.detail.qaDialog")}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{data.turns.map((turn, idx) => (
<QaTurnEditor
key={turn.id}
turn={turn}
index={idx}
total={data.turns.length}
onUpdate={(patch) => updateTurn(turn.id, patch)}
onRemove={() => removeTurn(turn.id)}
onMoveUp={() => moveTurn(turn.id, "up")}
onMoveDown={() => moveTurn(turn.id, "down")}
/>
))}
<button
type="button"
onClick={addTurn}
style={{
border: "1px dashed var(--border)",
background: "transparent",
padding: 8,
borderRadius: 5,
textAlign: "center",
fontSize: 12,
color: "var(--muted-foreground)",
cursor: "pointer",
}}
>
+ {t("v4.detail.addTurn")}
</button>
</div>
</div>
</div>
);
}
interface TurnProps {
turn: QATurn;
index: number;
total: number;
onUpdate: (patch: Partial<QATurn>) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMoveDown }: TurnProps) {
const t = useTranslations("lessonPreparation");
const isTeacher = turn.role === "teacher";
return (
<div
style={{
border: "1px solid var(--border)",
borderRadius: 5,
padding: "8px 10px",
background: "var(--background)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
fontSize: 10,
}}
>
<select
value={turn.role}
onChange={(e) => onUpdate({ role: e.target.value as "teacher" | "student" })}
className={isTeacher ? "teacher" : "student"}
style={{
fontFamily: "JetBrains Mono, monospace",
fontSize: 9,
fontWeight: 600,
padding: "2px 6px",
borderRadius: 2,
border: "1px solid var(--border)",
background: "var(--background)",
cursor: "pointer",
color: isTeacher ? "#1c1917" : "#6b7280",
}}
>
<option value="teacher">{t("v4.detail.turnTeacher")}</option>
<option value="student">{t("v4.detail.turnStudent")}</option>
</select>
<span style={{ color: "var(--muted-foreground)", fontSize: 10 }}>
{t("v4.interaction.round", { n: index + 1 })}
</span>
<div style={{ marginLeft: "auto" }}>
<button type="button" onClick={onMoveUp} disabled={index === 0} style={{ border: "none", background: "transparent", color: "var(--muted-foreground)", cursor: index === 0 ? "default" : "pointer", padding: "2px 4px", fontSize: 11 }}></button>
<button type="button" onClick={onMoveDown} disabled={index === total - 1} style={{ border: "none", background: "transparent", color: "var(--muted-foreground)", cursor: index === total - 1 ? "default" : "pointer", padding: "2px 4px", fontSize: 11 }}></button>
<button type="button" onClick={onRemove} style={{ border: "none", background: "transparent", color: "var(--muted-foreground)", cursor: "pointer", padding: "2px 4px", fontSize: 11 }}>×</button>
</div>
</div>
<textarea
value={turn.content}
onChange={(e) => onUpdate({ content: e.target.value })}
rows={2}
placeholder="内容"
style={{
width: "100%",
border: "none",
outline: "none",
background: "transparent",
resize: "vertical",
fontFamily: "Inter, sans-serif",
fontSize: 12.5,
lineHeight: 1.5,
color: "var(--foreground)",
minHeight: 36,
}}
/>
{isTeacher && (
<textarea
value={turn.expectedAnswer ?? ""}
onChange={(e) => onUpdate({ expectedAnswer: e.target.value })}
rows={1}
placeholder={t("v4.detail.expectedAnswer")}
style={{
width: "100%",
border: "none",
outline: "none",
background: "var(--muted)",
resize: "vertical",
fontFamily: "Inter, sans-serif",
fontSize: 11,
fontStyle: "italic",
color: "var(--muted-foreground)",
padding: "4px 8px",
borderRadius: 3,
marginTop: 4,
minHeight: 24,
}}
/>
)}
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/detail-panel/qa-editor.tsx
git commit -m "feat(lesson-preparation): 师生交互编辑器(对话轮次增删改)"
```
---
### Task G4: interaction-block
**Files:**
- Create: `src/modules/lesson-preparation/components/blocks/interaction-block.tsx`
- [ ] **Step 1: 创建师生交互 block详情编辑入口**
```typescript
"use client";
import { useTranslations } from "next-intl";
import type { InteractionBlockData } from "../../types";
import { QaEditor } from "../detail-panel/qa-editor";
interface Props {
blockId: string;
data: InteractionBlockData;
onUpdate: (data: InteractionBlockData) => void;
}
/**
* V4 师生交互 block详情面板中的编辑入口。
* 实际编辑器是 QaEditor复用
*/
export function InteractionBlock({ blockId, data, onUpdate }: Props) {
return <QaEditor nodeId={blockId} data={data} />;
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/blocks/interaction-block.tsx
git commit -m "feat(lesson-preparation): 师生交互 block详情编辑入口"
```
---
### Task G5: detail-panel
**Files:**
- Create: `src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx`
- [ ] **Step 1: 创建详情面板容器**
```typescript
"use client";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { DetailHead } from "./detail-head";
import { DetailProps } from "./detail-props";
import { QaEditor } from "./qa-editor";
import { BlockRenderer } from "../../config/block-registry";
import type { InteractionBlockData, LessonPlanNode } from "../../types";
export function DetailPanel() {
const t = useTranslations("lessonPreparation");
const doc = useLessonPlanEditor((s) => s.doc);
const selectedNodeId = useLessonPlanEditor((s) => s.selectedNodeId);
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const node = doc.nodes.find(
(n): n is LessonPlanNode => n.id === selectedNodeId && n.type !== "textbook_content",
);
if (!node) {
return (
<aside
style={{
borderLeft: "1px solid var(--border)",
background: "var(--background)",
padding: 24,
color: "var(--muted-foreground)",
fontSize: 13,
}}
>
</aside>
);
}
const isExpanded = expandedNodeIds.includes(node.id);
return (
<aside
style={{
borderLeft: "1px solid var(--border)",
background: "var(--background)",
overflowY: "auto",
display: "flex",
flexDirection: "column",
}}
>
<DetailHead node={node} isExpanded={isExpanded} />
<DetailProps node={node} />
<div style={{ flex: 1, padding: 20, overflowY: "auto" }}>
{node.type === "interaction" ? (
<QaEditor nodeId={node.id} data={node.data as InteractionBlockData} />
) : (
<BlockRenderer
blockId={node.id}
data={node.data}
onUpdate={(data) => updateNode(node.id, { data })}
/>
)}
</div>
<div
style={{
borderTop: "1px solid var(--border)",
padding: "14px 20px",
background: "var(--muted)",
}}
>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--lp-interaction)",
marginBottom: 8,
}}
>
{t("v4.detail.aiAssist")}
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<AiButton label={t("v4.detail.aiGenerateLayered")} />
<AiButton label={t("v4.detail.aiFillExpected")} />
<AiButton label={t("v4.detail.aiOptimizeFollowup")} />
<AiButton label={t("v4.detail.aiDifferentiation")} />
</div>
</div>
</aside>
);
}
function AiButton({ label }: { label: string }) {
return (
<button
type="button"
style={{
padding: "5px 10px",
border: "1px solid var(--border)",
background: "var(--background)",
borderRadius: 4,
fontSize: 11.5,
cursor: "pointer",
color: "var(--foreground)",
}}
>
{label}
</button>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
Expected: 报错BlockRenderer 可能未导出,下一步修复 registry
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx
git commit -m "feat(lesson-preparation): 右栏详情面板容器"
```
---
## 阶段 H主编辑器改造
### Task H1: block-registry 注册 interaction
**Files:**
- Modify: `src/modules/lesson-preparation/config/block-registry.tsx`
- [ ] **Step 1: 添加 interaction 注册**
在文件顶部导入:
```typescript
import { InteractionBlock } from "../components/blocks/interaction-block";
import { isInteractionBlockData } from "../lib/type-guards";
```
`BLOCK_REGISTRY` 中添加:
```typescript
interaction: {},
```
- [ ] **Step 2: 在 BlockRenderer 中处理 interaction**
查看现有 BlockRenderer 实现,添加 interaction 分支:
```typescript
// 在 BlockRenderer 的 switch/if 分支中
if (isInteractionBlockData(data)) {
return <InteractionBlock blockId={blockId} data={data} onUpdate={onUpdate as (d: InteractionBlockData) => void} />;
}
```
- [ ] **Step 3: 导出 BlockRenderer**
确保 `BlockRenderer` 组件从 `block-registry.tsx` 命名导出(如果未导出,添加 `export function BlockRenderer(...)`)。
- [ ] **Step 4: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 5: Commit**
```bash
git add src/modules/lesson-preparation/config/block-registry.tsx
git commit -m "feat(lesson-preparation): 注册 interaction block 到 registry"
```
---
### Task H2: lesson-plan-editor 三栏布局
**Files:**
- Modify: `src/modules/lesson-preparation/components/lesson-plan-editor.tsx`
- [ ] **Step 1: 替换主布局**
修改 `lesson-plan-editor.tsx` 的主渲染部分。移除 `NodeEditor``NodeEditPanel` 的导入和 JSX改为
```typescript
// 在 imports 中替换
import { StructureTree } from "./structure-tree/structure-tree";
import { PaperEditor } from "./paper-editor/paper-editor";
import { DetailPanel } from "./detail-panel/detail-panel";
import { AnchorMigrationBanner } from "./anchor-migration-banner";
// 在 JSX 主区域替换 NodeEditor + NodeEditPanel
<main style={{ display: "grid", gridTemplateColumns: "260px 1fr 380px", height: "calc(100vh - 52px)", overflow: "hidden" }}>
<StructureTree />
<PaperEditor readonly={!isEditable} />
<DetailPanel />
</main>
```
- [ ] **Step 2: 移除 React Flow 相关导入**
删除:
- `import { NodeEditor } from "./node-editor";`
- `import { NodeEditPanel, ... } from "./node-edit-panel";`
- 任何 `reactflow` / `@xyflow/react` 导入
- [ ] **Step 3: 保留顶部工具栏(标题、保存、撤销重做、发布等)**
顶部工具栏的 JSX 保留不变,但移除画布相关的按钮(如"自动布局")。
- [ ] **Step 4: 添加 AnchorMigrationBanner**
在主布局上方添加:
```typescript
<AnchorMigrationBanner />
```
- [ ] **Step 5: 验证编译**
Run: `npx tsc --noEmit`
Expected: 仍有错误node-editor 等被引用但未删除),下一步清理
- [ ] **Step 6: Commit**
```bash
git add src/modules/lesson-preparation/components/lesson-plan-editor.tsx
git commit -m "refactor(lesson-preparation): 主编辑器三栏布局(结构树 + 纸 + 详情面板)"
```
---
## 阶段 Iv3 锚点失效 banner
### Task I1: anchor-migration-banner
**Files:**
- Create: `src/modules/lesson-preparation/components/anchor-migration-banner.tsx`
- [ ] **Step 1: 创建 banner 组件**
```typescript
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
/**
* V4v3 锚点失效提示 banner。
* 当文档含旧锚点invalid: true且未 dismiss 时显示。
* dismiss 状态记录到 localStorage按 planId
*/
export function AnchorMigrationBanner() {
const t = useTranslations("lessonPreparation");
const doc = useLessonPlanEditor((s) => s.doc);
const planId = useLessonPlanEditor((s) => s.planId);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (planId) {
const stored = localStorage.getItem(`lp-legacy-anchor-dismissed-${planId}`);
setDismissed(stored === "true");
}
}, [planId]);
// 检测是否有旧锚点invalid: true 或含 start/end 字段)
const hasLegacyAnchors = doc.anchors.some(
(a) => a.invalid === true || a.start !== undefined,
);
if (!hasLegacyAnchors || dismissed) return null;
const onDismiss = () => {
setDismissed(true);
if (planId) {
localStorage.setItem(`lp-legacy-anchor-dismissed-${planId}`, "true");
}
};
return (
<div
style={{
background: "#fef3c7",
borderBottom: "1px solid #fcd34d",
padding: "8px 20px",
display: "flex",
alignItems: "center",
gap: 12,
fontSize: 12.5,
color: "#92400e",
}}
>
<strong style={{ fontWeight: 600 }}>{t("v4.migration.legacyAnchorTitle")}</strong>
<span style={{ flex: 1 }}>{t("v4.migration.legacyAnchorBody")}</span>
<button
type="button"
onClick={onDismiss}
style={{
background: "transparent",
border: "1px solid #fcd34d",
borderRadius: 3,
padding: "3px 10px",
fontSize: 11,
color: "#92400e",
cursor: "pointer",
}}
>
{t("v4.migration.legacyAnchorDismiss")}
</button>
</div>
);
}
```
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/anchor-migration-banner.tsx
git commit -m "feat(lesson-preparation): v3 锚点失效提示 banner"
```
---
## 阶段 J清理、验证、架构同步
### Task J1: 删除废弃文件
**Files:**
- Delete: `src/modules/lesson-preparation/components/node-editor.tsx`
- Delete: `src/modules/lesson-preparation/components/nodes/lesson-node.tsx`
- Delete: `src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx`
- Delete: `src/modules/lesson-preparation/components/nodes/textbook-segments.tsx`
- Delete: `src/modules/lesson-preparation/components/nodes/anchor-node-selector.tsx`
- Delete: `src/modules/lesson-preparation/lib/anchor-injector.ts`
- Delete: `src/modules/lesson-preparation/lib/rf-mappers.ts`
- Delete: `src/modules/lesson-preparation/lib/auto-layout.ts`
- [ ] **Step 1: 先全局搜索是否还有引用**
Run: Grep `node-editor|anchor-injector|rf-mappers|auto-layout|lesson-node|textbook-content-node` 在 src 下
- [ ] **Step 2: 修复所有引用**
对每个引用文件,删除/替换为新的导入路径。
- [ ] **Step 3: 删除文件**
使用 DeleteFile 工具删除上述 8 个文件。
- [ ] **Step 4: 验证编译**
Run: `npx tsc --noEmit`
Expected: 通过(无悬空引用)
- [ ] **Step 5: Commit**
```bash
git add -A src/modules/lesson-preparation/
git commit -m "refactor(lesson-preparation): 删除废弃的 React Flow 画布和字符串锚点文件"
```
---
### Task J2: print-view 适配 v4
**Files:**
- Modify: `src/modules/lesson-preparation/components/print-view.tsx`
- [ ] **Step 1: 移除画布依赖**
查看 `print-view.tsx` 是否引用 `position` / 画布 / `node-editor`。如有,改为按 `order` 顺序渲染节点。
- [ ] **Step 2: 验证编译**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/modules/lesson-preparation/components/print-view.tsx
git commit -m "refactor(lesson-preparation): print-view 适配 V4"
```
---
### Task J3: lint + tsc 完整验证
**Files:** N/A
- [ ] **Step 1: 运行 tsc**
Run: `npx tsc --noEmit`
Expected: 0 errors
- [ ] **Step 2: 运行 lint**
Run: `npm run lint`
Expected: 0 errors / 0 warnings
- [ ] **Step 3: 如有错误,逐个修复**
常见问题:
- 未使用的 import删除
- `any` 类型(改为 `unknown` + 类型守卫)
- `as` 断言(除非从 unknown 转换,否则改为类型守卫)
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "chore(lesson-preparation): V4 lint + tsc 零错误验证"
```
---
### Task J4: 架构图同步
**Files:**
- Modify: `docs/architecture/004_architecture_impact_map.md`
- Modify: `docs/architecture/005_architecture_data.json`
- [ ] **Step 1: 更新 004 Markdown**
在 lesson-preparation 模块章节:
- 新增"V4 纸感重构"小节
- 列出新增组件paper-editor/、structure-tree/、detail-panel/、interaction-block
- 列出移除组件node-editor、nodes/*、anchor-injector、rf-mappers、auto-layout
- 更新 hooks 列表(新增 expanded-slice
- [ ] **Step 2: 更新 005 JSON**
`modules.lesson-preparation` 节点:
- `exports` 数组新增:`PaperEditor``StructureTree``DetailPanel``InteractionBlock``AnchorMark``AnchorPoint``QATurn``InteractionBlockData``LessonPlanDocumentV4``ExpandedSlice`
- `exports` 数组移除:`NodeEditor``LessonNode``TextbookContentNode`(画布版)、`AnchorInjector``RfMappers``AutoLayout`
- `blockTypes` 数组新增 `"interaction"`
- `documentVersion` 改为 `4`
- [ ] **Step 3: 验证 JSON 语法**
Run: `node -e "JSON.parse(require('fs').readFileSync('docs/architecture/005_architecture_data.json','utf8'))"`
- [ ] **Step 4: Commit**
```bash
git add docs/architecture/
git commit -m "docs(architecture): 同步备课模块 V4 重构到架构图"
```
---
### Task J5: known-issues 新规则
**Files:**
- Modify: `docs/troubleshooting/known-issues.md`
- [ ] **Step 1: 添加 V4 备课编辑器规则章节**
在文件末尾添加:
```markdown
## V4 备课编辑器(纸感重构)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 正文节点必须是 Tiptap 编辑器 | `TextbookTiptapEditor`(含 AnchorMark 扩展) | 用 `whitespace-pre-wrap` 显示 Markdown 字符串 |
| 锚点用 Tiptap Mark 内嵌 | `editor.chain().toggleMark("anchor", { anchorId, nodeId }).run()` | 用字符串偏移 `start/end` + `injectPlaceholders` |
| 节点展开位置由 order 决定 | `nodes.sort((a,b) => a.order - b.order)` | 按 anchor 位置插入 |
| 节点类型色点用 `--lp-dot-*` | `var(--lp-dot-objective)` | `var(--lesson-node-objective)`(已删除) |
| 文档版本必须是 4 | `doc.version === 4` + `expandedNodeIds` 字段 | `doc.version === 3` |
| 师生交互节点用 QaEditor | `<QaEditor nodeId data onUpdate />` | 自定义对话编辑器 |
| 右键菜单触发对象区分 | inline-node 右键 = 节点菜单;正文右键 = 锚定菜单 | 任何右键都触发同一菜单 |
| v3 锚点迁移失效提示 | `<AnchorMigrationBanner />`(按 planId localStorage | toast 一闪即逝 |
```
- [ ] **Step 2: Commit**
```bash
git add docs/troubleshooting/known-issues.md
git commit -m "docs(troubleshooting): V4 备课编辑器规则"
```
---
## 自审清单
完成所有任务后,对照 spec 检查:
- [ ] §2 视觉设计字体双轨Fraunces + Inter + JetBrains Mono已用
- [ ] §2 配色13 个 `--lesson-node-*` 已删除,改为 `--lp-*` 中性令牌
- [ ] §3 三栏布局260px + 1fr + 380px
- [ ] §4 节点展开:三个入口(左栏标记 / 右栏头部按钮 / 右键菜单)
- [ ] §4.3 右键菜单:节点操作 + AI 协助 + 复制/删除
- [ ] §5 师生交互QATurn / InteractionBlockData / QaEditor / InlineQaDialog
- [ ] §6 锚点AnchorMark + AnchorPointTiptap 扩展)
- [ ] §7 v4 文档expandedNodeIds 字段
- [ ] §8 组件架构:新增/改造/移除清单完成
- [ ] §9 expanded-slice 已创建并组合
- [ ] §11 移除的功能React Flow / 拖拽 / 字符串锚点 / 13 色
- [ ] §12 i18nzh-CN + en 翻译键
- [ ] §13 架构图同步
- [ ] §14 v3 锚点失效 banner
---
## 执行说明
**总任务数**: 25 个任务A1-A4, B1-B4, C1-C2, D1, E1-E6, F1-F2, G1-G5, H1-H2, I1, J1-J5
**执行顺序**: 严格按阶段 A → B → C → D → E → F → G → H → I → J
**每个任务结束**: `npx tsc --noEmit` 必须通过(或减少错误数),然后 commit
**最终验证**: Task J3 的 lint + tsc 必须 0 错误