97 KiB
备课编辑器无边记纸感重构 · 实现计划
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[];
}
// 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 行:
// 当前文档版本(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/setEdges(V4 不再用画布连线)
在 EditorSlice 接口中删除:
connectdisconnectsetEdges
在实现中删除对应方法体。
- 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)"
阶段 D:Tiptap 锚点系统
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 的字符串偏移。
*
* - 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
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 衬线
* - 锚点:AnchorMark(range)+ AnchorPoint(point)
* - 编辑时浮动工具条出现
*/
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 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 }) => (
<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 的主渲染部分。移除 NodeEditor 和 NodeEditPanel 的导入和 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): 主编辑器三栏布局(结构树 + 纸 + 详情面板)"
阶段 I:v3 锚点失效 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";
/**
* V4:v3 锚点失效提示 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数组新增:PaperEditor、StructureTree、DetailPanel、InteractionBlock、AnchorMark、AnchorPoint、QATurn、InteractionBlockData、LessonPlanDocumentV4、ExpandedSlice -
exports数组移除:NodeEditor、LessonNode、TextbookContentNode(画布版)、AnchorInjector、RfMappers、AutoLayout -
blockTypes数组新增"interaction" -
documentVersion改为4 -
Step 3: 验证 JSON 语法
Run: node -e "JSON.parse(require('fs').readFileSync('docs/architecture/005_architecture_data.json','utf8'))"
- Step 4: Commit
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 + AnchorPoint(Tiptap 扩展)
- §7 v4 文档:expandedNodeIds 字段
- §8 组件架构:新增/改造/移除清单完成
- §9 expanded-slice 已创建并组合
- §11 移除的功能:React Flow / 拖拽 / 字符串锚点 / 13 色
- §12 i18n:zh-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 错误