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

97 KiB
Raw Blame History

备课编辑器无边记纸感重构 · 实现计划

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

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 定义之后)插入:

// 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

export type BlockData =
  | RichTextBlockData
  | TextStudyBlockData
  | ExerciseBlockData
  | ObjectiveBlockData
  | KeyPointBlockData
  | ImportBlockData
  | NewTeachingBlockData
  | SummaryBlockData
  | HomeworkBlockData
  | BlackboardBlockData
  | ReflectionBlockData
  | InteractionBlockData;  // V4 新增
  • Step 4: 添加 LessonPlanDocumentV4 和简化 NodeAnchor

在第 343 行(LessonPlanDocument 定义之后)插入:

// 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 行:

// 当前文档版本v4
export type LessonPlanDocument = LessonPlanDocumentV4;

(保留 LessonPlanDocumentV1/V2/V3 旧类型用于迁移)

  • Step 6: 验证类型编译

Run: npx tsc --noEmit Expected: 类型层错误(其他文件还引用旧字段),但 types.ts 本身无错误

  • Step 7: Commit
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 分支前)插入:

    case "interaction":
      return {
        designIntent: "",
        turns: [],
        knowledgePointIds: [],
      };
  • Step 2: 验证编译

Run: npx tsc --noEmit Expected: 类型错误减少interaction 已有默认数据)

  • Step 3: Commit
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 + 字段检查),添加:

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
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

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 函数(如果存在)或在文件末尾添加:

/**
 * 规范化文档到 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
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

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
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

修改文件:

"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
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 字段

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
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 行:

import { computeAutoLayout } from "../lib/auto-layout";
  • Step 2: 移除 autoLayout 方法和 updateNodePosition保留 position 字段不破坏数据)

EditorSlice 接口中删除:

  • updateNodePosition(不再需要)
  • autoLayout(不再需要)

在实现中删除对应方法体。保留 position 字段在 LessonPlanNode 类型上(向后兼容,只是不使用)。

  • Step 3: 默认 doc.version 改为 4

修改第 60-66 行的默认 doc

  doc: {
    version: 4,
    textbookContentNodeId: "",
    nodes: [],
    edges: [],
    anchors: [],
    expandedNodeIds: [],
  },
  • Step 4: addNode 移除 position 参数(保留兼容)

修改 addNode 签名和实现position 参数变为可选且忽略(生成默认 {x:0,y:0}

  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

  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
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 行:

  --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 末尾添加纸感令牌
  /* 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
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 顶层添加(与现有键平级):

{
  "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 文件添加对应英文
{
  "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
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 扩展

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
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 编辑器

"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
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: 创建浮动工具条

"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 添加工具条样式

在文件末尾追加:

/* 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
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 容器

"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
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: 创建师生交互的对话体渲染

"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
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: 创建右键菜单组件

"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
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: 创建纸区容器

"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
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: 创建树节点行组件

"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
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: 创建结构树容器

"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
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: 创建详情头部组件

"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
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: 创建属性条组件

"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
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: 创建师生交互编辑器

"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
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详情编辑入口

"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
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: 创建详情面板容器

"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
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 注册

在文件顶部导入:

import { InteractionBlock } from "../components/blocks/interaction-block";
import { isInteractionBlockData } from "../lib/type-guards";

BLOCK_REGISTRY 中添加:

  interaction: {},
  • Step 2: 在 BlockRenderer 中处理 interaction

查看现有 BlockRenderer 实现,添加 interaction 分支:

// 在 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
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 的主渲染部分。移除 NodeEditorNodeEditPanel 的导入和 JSX改为

// 在 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

在主布局上方添加:

<AnchorMigrationBanner />
  • Step 5: 验证编译

Run: npx tsc --noEmit Expected: 仍有错误node-editor 等被引用但未删除),下一步清理

  • Step 6: Commit
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 组件

"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
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
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
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

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 数组新增:PaperEditorStructureTreeDetailPanelInteractionBlockAnchorMarkAnchorPointQATurnInteractionBlockDataLessonPlanDocumentV4ExpandedSlice

  • exports 数组移除:NodeEditorLessonNodeTextbookContentNode(画布版)、AnchorInjectorRfMappersAutoLayout

  • 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
git add docs/architecture/
git commit -m "docs(architecture): 同步备课模块 V4 重构到架构图"

Task J5: known-issues 新规则

Files:

  • Modify: docs/troubleshooting/known-issues.md

  • Step 1: 添加 V4 备课编辑器规则章节

在文件末尾添加:

## 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
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 错误