# 备课编辑器无边记纸感重构 · 实现计划 > **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[]; } // V4:NodeAnchor 简化(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; 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; 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()((...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/setEdges(V4 不再用画布连线)** 在 `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)" ``` --- ## 阶段 D:Tiptap 锚点系统 ### 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 的字符串偏移。 * * - AnchorMark(range 锚点):包裹选中文本,渲染为高亮 + 底部细线 + 行内标签 * - AnchorPoint(point 锚点):独立 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 衬线 * - 锚点:AnchorMark(range)+ AnchorPoint(point) * - 编辑时浮动工具条出现 */ export function TextbookTiptapEditor({ content, readonly }: Props) { const t = useTranslations("lessonPreparation"); const updateTextbookContent = useLessonPlanEditor((s) => s.updateTextbookContent); const debounceTimer = useRef | 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 (
{!readonly && }
); } ``` - [ ] **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, ) => ( ); return (
{btn("B", () => editor.chain().focus().toggleBold().run(), editor.isActive("bold"), "b", "Bold")} {btn("I", () => editor.chain().focus().toggleItalic().run(), editor.isActive("italic"), "i", "Italic")} {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")} {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")} {btn('"', () => editor.chain().focus().toggleBlockquote().run(), editor.isActive("blockquote"), "quote", "Quote")} {btn("—", () => editor.chain().focus().setHorizontalRule().run(), false, "hr", "Divider")}
); } ``` - [ ] **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 ; } // 其他节点类型用 BlockRenderer(详情编辑模式的只读渲染) // 注意:inline-node 是只读摘要,完整编辑在右栏详情面板 const entry = BLOCK_REGISTRY[node.type]; const dotColorVar = `var(--lp-dot-${node.type})`; return (
{node.type} {node.id.slice(-4)}

{node.title}

{/* 简化:各 block 类型的 inline 渲染在 BlockRenderer 中处理 readonly 模式 */}
); } /** * 各节点类型的 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

; return (
    {d.objectives.map((o, i) => (
  • {o.text}
  • ))}
); } case "summary": { const d = data as { summaryPoints: string[] }; if (!d.summaryPoints?.length) return

; return (
    {d.summaryPoints.map((s, i) => (
  • {s}
  • ))}
); } case "exercise": { const d = data as { items: unknown[] }; return

{d.items?.length ?? 0} 道练习

; } default: return

{node.title}

; } } ``` - [ ] **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 (
{t("v4.interaction.label")} {data.turns?.length ?? 0} {t("v4.interaction.countSuffix" as never) || "轮"}

{node.title}

{data.designIntent && (

{data.designIntent}

)}
{(data.turns ?? []).map((turn, idx) => ( ))} {(!data.turns || data.turns.length === 0) && (

)}
); } 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 (
{roleLabel}
{turn.content} {turn.role === "teacher" && turn.expectedAnswer && ( [预期:{turn.expectedAnswer}] )}
); } ``` - [ ] **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 YAGNI,V2 完整实现) 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 }) => ( ); const section = (label: string) => (
{label}
); const divider = () =>
; return (
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"))} {/* 列出所有教学节点供选择锚定 */} { if (state.selectionRange && nodeId) { // 用 Tiptap toggleMark 包裹选中文本 // 实际实现在 PaperEditor 中通过 ref 调用 editor addAnchor({ nodeId, type: "range" }); } onClose(); }} /> )}
); } 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 (
{teachingNodes.map((n) => ( ))}
); } ``` - [ ] **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({ 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
No textbook content
; } 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 (
{ // 默认右键 = 正文右键(锚定菜单) if (!(e.target as HTMLElement).closest("[data-inline-node]")) { onPaperContextMenu(e); } }} >
{t("v4.paper.textbookHeader")} {t("v4.paper.expandedCount", { count: expandedNodes.length })}
{/* 展开的节点:按 order 排列在正文之后(简化实现) */} {expandedNodes.map((node) => (
onInlineNodeContextMenu(e, node.id)} >
))}
setContextMenu((s) => ({ ...s, visible: false }))} />
); } ``` - [ ] **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 (
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" > { if (hasChildren && onToggleChildren) { e.stopPropagation(); onToggleChildren(); } }} > {hasChildren ? (isChildrenExpanded ? "▾" : "▸") : ""} {node.title} {node.type} {isExpanded && ( )}
{hasChildren && isChildrenExpanded && children && (
{children.map((c) => (
{c.label} {c.kind}
))}
)}
); } ``` - [ ] **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>(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 ( ); } ``` - [ ] **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 (
{node.type} {isExpanded ? ` · ${t("v4.contextMenu.expandToPaper")}` : ""}
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, }} />
); } ``` - [ ] **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 (
updateNode(node.id, { stage: v as TeachingStage })} /> updateNode(node.id, { differentiation: v as DifferentiationLevel })} />
); } function PropItem({ label, value, options, onChange, }: { label: string; value: string; options: readonly string[]; onChange: (v: string) => void; }) { return (
{label}
); } ``` - [ ] **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) => { 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 (