3415 lines
97 KiB
Markdown
3415 lines
97 KiB
Markdown
# 备课编辑器无边记纸感重构 · 实现计划
|
||
|
||
> **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<string, unknown>;
|
||
return (
|
||
typeof d.designIntent === "string" &&
|
||
Array.isArray(d.turns) &&
|
||
Array.isArray(d.knowledgePointIds)
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 守卫文件本身无错误
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/lib/type-guards.ts
|
||
git commit -m "feat(lesson-preparation): isInteractionBlockData 类型守卫"
|
||
```
|
||
|
||
---
|
||
|
||
### Task A4: v3 → v4 迁移函数
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/lesson-preparation/lib/document-migration.ts`
|
||
|
||
- [ ] **Step 1: 在文件末尾添加 migrateV3ToV4**
|
||
|
||
```typescript
|
||
import type {
|
||
// ... 已有导入
|
||
LessonPlanDocumentV3,
|
||
LessonPlanDocumentV4,
|
||
} from "../types";
|
||
|
||
/**
|
||
* V3 → V4 迁移:
|
||
* - 锚点的 start/end/textPreview/invalid 标记为弃用(保留字段用于识别旧锚点)
|
||
* - 新增 expandedNodeIds(默认空数组)
|
||
* - 旧 v3 锚点在 v4 中视为失效,编辑器需显示 banner 提示重新锚定
|
||
*
|
||
* @returns V4 文档 + 是否含旧锚点(用于触发 banner)
|
||
*/
|
||
export function migrateV3ToV4(
|
||
doc: LessonPlanDocumentV3,
|
||
): { doc: LessonPlanDocumentV4; hasLegacyAnchors: boolean } {
|
||
const hasLegacyAnchors = doc.anchors.length > 0;
|
||
return {
|
||
doc: {
|
||
version: 4,
|
||
textbookContentNodeId: doc.textbookContentNodeId,
|
||
nodes: doc.nodes,
|
||
edges: doc.edges,
|
||
anchors: doc.anchors.map((a) => ({
|
||
id: a.id,
|
||
nodeId: a.nodeId,
|
||
type: a.type,
|
||
// 保留旧字段用于识别(标记失效)
|
||
start: a.start,
|
||
end: a.end,
|
||
textPreview: a.textPreview,
|
||
invalid: true,
|
||
})),
|
||
expandedNodeIds: [],
|
||
},
|
||
hasLegacyAnchors,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 添加 normalizeDocument 入口(兼容 v1/v2/v3/v4)**
|
||
|
||
修改现有 `normalizeDocument` 函数(如果存在)或在文件末尾添加:
|
||
|
||
```typescript
|
||
/**
|
||
* 规范化文档到 V4。
|
||
* 接受任意版本输入,输出 V4。
|
||
*/
|
||
export function normalizeDocument(
|
||
input: LessonPlanDocumentV1 | LessonPlanDocumentV2 | LessonPlanDocumentV3 | LessonPlanDocumentV4,
|
||
): LessonPlanDocumentV4 {
|
||
if (input.version === 4) return input;
|
||
if (input.version === 3) return migrateV3ToV4(input).doc;
|
||
if (input.version === 2) {
|
||
const v3 = migrateV2ToV3(input); // 假设已存在
|
||
return migrateV3ToV4(v3).doc;
|
||
}
|
||
// v1
|
||
const v2 = migrateV1ToV2(input);
|
||
const v3 = migrateV2ToV3(v2);
|
||
return migrateV3ToV4(v3).doc;
|
||
}
|
||
```
|
||
|
||
注意:先确认 `migrateV2ToV3` 是否已存在,若没有则需补充(查看现有代码)。如果不存在,跳过 v2 路径并在注释中标注。
|
||
|
||
- [ ] **Step 3: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 迁移函数本身无错误
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/lib/document-migration.ts
|
||
git commit -m "feat(lesson-preparation): V3→V4 迁移函数(锚点失效标记 + expandedNodeIds)"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 B:状态管理
|
||
|
||
### Task B1: expanded-slice
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/hooks/expanded-slice.ts`
|
||
|
||
- [ ] **Step 1: 创建 expanded-slice.ts**
|
||
|
||
```typescript
|
||
import type { StateCreator } from "zustand";
|
||
import type { EditorState } from "./use-lesson-plan-editor";
|
||
|
||
export interface ExpandedSlice {
|
||
/** 已展开到正文流的节点 ID */
|
||
expandedNodeIds: string[];
|
||
/** v3 迁移过来的失效锚点提示(按 planId 记录是否已 dismiss) */
|
||
legacyAnchorDismissed: Record<string, boolean>;
|
||
toggleExpand: (nodeId: string) => void;
|
||
setExpanded: (nodeIds: string[]) => void;
|
||
isExpanded: (nodeId: string) => boolean;
|
||
dismissLegacyAnchor: (planId: string) => void;
|
||
/** 同步 doc.expandedNodeIds(保存/加载时调用) */
|
||
syncFromDoc: (expandedNodeIds: string[]) => void;
|
||
}
|
||
|
||
export const createExpandedSlice: StateCreator<
|
||
EditorState,
|
||
[],
|
||
[],
|
||
ExpandedSlice
|
||
> = (set, get) => ({
|
||
expandedNodeIds: [],
|
||
legacyAnchorDismissed: {},
|
||
|
||
toggleExpand: (nodeId) => {
|
||
get().pushHistory();
|
||
set((s) => {
|
||
const isExp = s.expandedNodeIds.includes(nodeId);
|
||
const next = isExp
|
||
? s.expandedNodeIds.filter((id) => id !== nodeId)
|
||
: [...s.expandedNodeIds, nodeId];
|
||
// 同步到 doc
|
||
return {
|
||
expandedNodeIds: next,
|
||
doc: { ...s.doc, expandedNodeIds: next },
|
||
isDirty: true,
|
||
};
|
||
});
|
||
},
|
||
|
||
setExpanded: (nodeIds) =>
|
||
set((s) => ({
|
||
expandedNodeIds: nodeIds,
|
||
doc: { ...s.doc, expandedNodeIds: nodeIds },
|
||
})),
|
||
|
||
isExpanded: (nodeId) => get().expandedNodeIds.includes(nodeId),
|
||
|
||
dismissLegacyAnchor: (planId) =>
|
||
set((s) => ({
|
||
legacyAnchorDismissed: { ...s.legacyAnchorDismissed, [planId]: true },
|
||
})),
|
||
|
||
syncFromDoc: (expandedNodeIds) => set({ expandedNodeIds }),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 报错(EditorState 未含 ExpandedSlice),下一步修复
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/hooks/expanded-slice.ts
|
||
git commit -m "feat(lesson-preparation): expanded-slice(节点展开状态)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B2: use-lesson-plan-editor 组合 expanded-slice
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts`
|
||
|
||
- [ ] **Step 1: 引入并组合 ExpandedSlice**
|
||
|
||
修改文件:
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { create } from "zustand";
|
||
import { createEditorSlice, type EditorSlice } from "./editor-slice";
|
||
import { createSelectionSlice, type SelectionSlice } from "./selection-slice";
|
||
import { createVersionSlice, type VersionSlice } from "./version-slice";
|
||
import { createHistorySlice, type HistorySlice } from "./history-slice";
|
||
import { createExpandedSlice, type ExpandedSlice } from "./expanded-slice";
|
||
|
||
export type EditorState = EditorSlice &
|
||
SelectionSlice &
|
||
VersionSlice &
|
||
HistorySlice &
|
||
ExpandedSlice;
|
||
|
||
export const useLessonPlanEditor = create<EditorState>()((...a) => ({
|
||
...createEditorSlice(...a),
|
||
...createSelectionSlice(...a),
|
||
...createVersionSlice(...a),
|
||
...createHistorySlice(...a),
|
||
...createExpandedSlice(...a),
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 类型组合通过,仍可能有 editor-slice 的旧字段引用错误
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
|
||
git commit -m "feat(lesson-preparation): 组合 ExpandedSlice 到 EditorState"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B3: selection-slice 增强
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/lesson-preparation/hooks/selection-slice.ts`
|
||
|
||
- [ ] **Step 1: 添加 anchorNodeForSelection 字段**
|
||
|
||
```typescript
|
||
import type { StateCreator } from "zustand";
|
||
import type { EditorState } from "./use-lesson-plan-editor";
|
||
|
||
export interface SelectionSlice {
|
||
/** 当前在右栏详情面板显示的节点 ID */
|
||
selectedNodeId: string | null;
|
||
/** 纸上选中文本时,待锚定的目标节点 ID(用户从右键菜单选择节点后设置) */
|
||
anchorNodeForSelection: string | null;
|
||
selectNode: (id: string | null) => void;
|
||
setAnchorNodeForSelection: (id: string | null) => void;
|
||
}
|
||
|
||
export const createSelectionSlice: StateCreator<
|
||
EditorState,
|
||
[],
|
||
[],
|
||
SelectionSlice
|
||
> = (set) => ({
|
||
selectedNodeId: null,
|
||
anchorNodeForSelection: null,
|
||
selectNode: (id) => set({ selectedNodeId: id }),
|
||
setAnchorNodeForSelection: (id) => set({ anchorNodeForSelection: id }),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/hooks/selection-slice.ts
|
||
git commit -m "feat(lesson-preparation): selection-slice 增加 anchorNodeForSelection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B4: editor-slice 移除画布依赖
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/lesson-preparation/hooks/editor-slice.ts`
|
||
|
||
- [ ] **Step 1: 移除 auto-layout 和 rf-mappers 导入**
|
||
|
||
删除第 17 行:
|
||
```typescript
|
||
import { computeAutoLayout } from "../lib/auto-layout";
|
||
```
|
||
|
||
- [ ] **Step 2: 移除 autoLayout 方法和 updateNodePosition(保留 position 字段不破坏数据)**
|
||
|
||
在 `EditorSlice` 接口中删除:
|
||
- `updateNodePosition`(不再需要)
|
||
- `autoLayout`(不再需要)
|
||
|
||
在实现中删除对应方法体。**保留 `position` 字段在 LessonPlanNode 类型上**(向后兼容,只是不使用)。
|
||
|
||
- [ ] **Step 3: 默认 doc.version 改为 4**
|
||
|
||
修改第 60-66 行的默认 doc:
|
||
|
||
```typescript
|
||
doc: {
|
||
version: 4,
|
||
textbookContentNodeId: "",
|
||
nodes: [],
|
||
edges: [],
|
||
anchors: [],
|
||
expandedNodeIds: [],
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 4: addNode 移除 position 参数(保留兼容)**
|
||
|
||
修改 `addNode` 签名和实现,position 参数变为可选且忽略(生成默认 `{x:0,y:0}`):
|
||
|
||
```typescript
|
||
addNode: (type, _position, title) => {
|
||
const id = createId();
|
||
const state = get();
|
||
state.pushHistory();
|
||
const teachingNodes = state.doc.nodes.filter(
|
||
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||
);
|
||
const nodeCount = teachingNodes.length;
|
||
const node: LessonPlanNode = {
|
||
id,
|
||
type,
|
||
title: title ?? type,
|
||
data: defaultDataForType(type),
|
||
order: nodeCount,
|
||
position: { x: 0, y: 0 }, // V4:不再使用,保留字段兼容
|
||
};
|
||
set((s) => ({
|
||
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
|
||
isDirty: true,
|
||
selectedNodeId: id,
|
||
}));
|
||
return id;
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 5: 移除 connect/disconnect/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<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit.configure({
|
||
// V4:禁用一些冲突的特性
|
||
codeBlock: false,
|
||
}),
|
||
Placeholder.configure({
|
||
placeholder: t("v4.paper.textbookHeader"),
|
||
}),
|
||
Image,
|
||
AnchorMark,
|
||
AnchorPoint,
|
||
],
|
||
content,
|
||
editable: !readonly,
|
||
editorProps: {
|
||
attributes: {
|
||
class: "lp-textbook-editor prose prose-sm max-w-none focus:outline-none",
|
||
style: "font-family: 'Fraunces', Georgia, serif; font-size: 16px; line-height: 1.75; color: #1a1a1a;",
|
||
},
|
||
},
|
||
onUpdate: ({ editor: e }) => {
|
||
// debounce 3s 保存到 store
|
||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||
debounceTimer.current = setTimeout(() => {
|
||
updateTextbookContent({ content: e.getHTML() });
|
||
}, 3000);
|
||
},
|
||
});
|
||
|
||
// 外部 content 变化时同步(如切换课案)
|
||
useEffect(() => {
|
||
if (editor && content !== editor.getHTML()) {
|
||
editor.commands.setContent(content, false);
|
||
}
|
||
}, [content, editor]);
|
||
|
||
// 卸载时清理
|
||
useEffect(() => {
|
||
return () => {
|
||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||
};
|
||
}, []);
|
||
|
||
if (!editor) return null;
|
||
|
||
return (
|
||
<div className="relative">
|
||
{!readonly && <PaperToolbar editor={editor} />}
|
||
<EditorContent editor={editor} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 报错(PaperToolbar 未创建),下一步创建
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx
|
||
git commit -m "feat(lesson-preparation): 正文 Tiptap 编辑器"
|
||
```
|
||
|
||
---
|
||
|
||
### Task E2: paper-toolbar(浮动工具条)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/paper-editor/paper-toolbar.tsx`
|
||
|
||
- [ ] **Step 1: 创建浮动工具条**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import type { Editor } from "@tiptap/react";
|
||
import { useTranslations } from "next-intl";
|
||
|
||
interface Props {
|
||
editor: Editor;
|
||
}
|
||
|
||
/**
|
||
* V4 浮动工具条:粘在纸顶部,毛玻璃半透明。
|
||
* 仅在编辑器聚焦时显示(由父组件控制)。
|
||
*/
|
||
export function PaperToolbar({ editor }: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
|
||
const btn = (
|
||
label: string,
|
||
action: () => void,
|
||
isActive: boolean,
|
||
className: string,
|
||
title: string,
|
||
) => (
|
||
<button
|
||
type="button"
|
||
onClick={action}
|
||
title={title}
|
||
className={`lp-tb-btn ${isActive ? "is-active" : ""} ${className}`}
|
||
onMouseDown={(e) => e.preventDefault()} // 防止失焦
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
|
||
return (
|
||
<div className="lp-paper-toolbar">
|
||
{btn("B", () => editor.chain().focus().toggleBold().run(), editor.isActive("bold"), "b", "Bold")}
|
||
{btn("I", () => editor.chain().focus().toggleItalic().run(), editor.isActive("italic"), "i", "Italic")}
|
||
<span className="lp-tb-divider" />
|
||
{btn("H1", () => editor.chain().focus().toggleHeading({ level: 1 }).run(), editor.isActive("heading", { level: 1 }), "h1", "Heading 1")}
|
||
{btn("H2", () => editor.chain().focus().toggleHeading({ level: 2 }).run(), editor.isActive("heading", { level: 2 }), "h2", "Heading 2")}
|
||
<span className="lp-tb-divider" />
|
||
{btn("•", () => editor.chain().focus().toggleBulletList().run(), editor.isActive("bulletList"), "ul", "Bullet list")}
|
||
{btn("1.", () => editor.chain().focus().toggleOrderedList().run(), editor.isActive("orderedList"), "ol", "Ordered list")}
|
||
<span className="lp-tb-divider" />
|
||
{btn('"', () => editor.chain().focus().toggleBlockquote().run(), editor.isActive("blockquote"), "quote", "Quote")}
|
||
{btn("—", () => editor.chain().focus().setHorizontalRule().run(), false, "hr", "Divider")}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 globals.css 添加工具条样式**
|
||
|
||
在文件末尾追加:
|
||
|
||
```css
|
||
/* V4 备课编辑器:纸区工具条 */
|
||
.lp-paper-toolbar {
|
||
position: sticky;
|
||
top: 0;
|
||
margin: -64px -72px 24px;
|
||
padding: 10px 72px;
|
||
background: rgba(255,255,255,0.92);
|
||
backdrop-filter: blur(8px);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
gap: 2px;
|
||
z-index: 5;
|
||
}
|
||
.lp-tb-btn {
|
||
width: 28px; height: 28px;
|
||
border: none; background: transparent;
|
||
border-radius: 4px; cursor: pointer;
|
||
font-family: 'Inter', sans-serif;
|
||
font-size: 13px; color: var(--foreground);
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
transition: background .12s;
|
||
}
|
||
.lp-tb-btn:hover { background: var(--muted); }
|
||
.lp-tb-btn.is-active { background: var(--muted); color: var(--foreground); }
|
||
.lp-tb-btn.b { font-weight: 700; }
|
||
.lp-tb-btn.i { font-style: italic; font-family: 'Fraunces', serif; }
|
||
.lp-tb-btn.h1 { font-size: 11px; font-weight: 600; }
|
||
.lp-tb-btn.h2 { font-size: 10px; font-weight: 600; }
|
||
.lp-tb-btn.ul { font-size: 14px; }
|
||
.lp-tb-btn.quote { font-family: 'Fraunces', serif; font-style: italic; font-size: 14px; }
|
||
.lp-tb-divider {
|
||
width: 1px;
|
||
background: var(--border);
|
||
margin: 4px 4px;
|
||
}
|
||
|
||
/* V4 锚点样式 */
|
||
.lp-anchor-range {
|
||
background: var(--lp-anchor-range);
|
||
border-radius: 2px;
|
||
padding: 1px 2px;
|
||
margin: 0 -2px;
|
||
cursor: pointer;
|
||
border-bottom: 1.5px solid var(--lp-anchor-point);
|
||
transition: background .15s;
|
||
}
|
||
.lp-anchor-range:hover, .lp-anchor-range.is-active {
|
||
background: var(--lp-anchor-range-active);
|
||
}
|
||
.lp-anchor-point {
|
||
display: inline-block;
|
||
width: 16px; height: 16px;
|
||
line-height: 16px;
|
||
text-align: center;
|
||
border-radius: 50%;
|
||
background: var(--lp-anchor-point);
|
||
color: #fff;
|
||
font-family: 'Inter', sans-serif;
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
margin: 0 2px;
|
||
vertical-align: middle;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* V4 inline-node 样式 */
|
||
.lp-inline-node {
|
||
font-family: 'Inter', sans-serif;
|
||
margin: 20px 0 20px;
|
||
padding: 14px 18px 14px 20px;
|
||
border-left: 2px solid var(--lp-inline-node-border);
|
||
color: var(--lp-inline-node-text);
|
||
font-size: 13.5px;
|
||
line-height: 1.65;
|
||
transition: border-color .2s;
|
||
}
|
||
.lp-inline-node:hover { border-left-color: var(--foreground); }
|
||
.lp-inline-node-head {
|
||
display: flex; align-items: center; gap: 8px;
|
||
margin-bottom: 8px;
|
||
font-size: 10px; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
color: var(--lp-inline-node-meta);
|
||
}
|
||
.lp-inline-node-title {
|
||
font-family: 'Inter', sans-serif;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #1a1a1a;
|
||
margin: 0 0 6px;
|
||
line-height: 1.35;
|
||
}
|
||
.lp-inline-node-body {
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: #404040;
|
||
}
|
||
|
||
/* V4 师生交互对话体 */
|
||
.lp-qa-dialog { margin: 8px 0 4px; }
|
||
.lp-qa-turn {
|
||
display: grid;
|
||
grid-template-columns: 28px 1fr;
|
||
gap: 10px;
|
||
padding: 8px 0;
|
||
border-bottom: 1px dashed var(--border);
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
}
|
||
.lp-qa-turn:last-child { border-bottom: none; }
|
||
.lp-qa-role {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 9px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
padding-top: 2px;
|
||
text-align: right;
|
||
}
|
||
.lp-qa-role.teacher { color: #1c1917; }
|
||
.lp-qa-role.student { color: #6b7280; }
|
||
.lp-qa-content { color: #404040; }
|
||
.lp-qa-prompt {
|
||
font-style: italic;
|
||
color: #525252;
|
||
font-size: 11px;
|
||
display: block;
|
||
margin-top: 2px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/paper-editor/paper-toolbar.tsx src/app/globals.css
|
||
git commit -m "feat(lesson-preparation): 浮动工具条 + 锚点/inline-node/对话体样式"
|
||
```
|
||
|
||
---
|
||
|
||
### Task E3: inline-node(展开节点容器)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/paper-editor/inline-node.tsx`
|
||
|
||
- [ ] **Step 1: 创建 inline-node 容器**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import type { Block, LessonPlanNode } from "../../types";
|
||
import { BLOCK_REGISTRY } from "../../config/block-registry";
|
||
import { BlockRenderer } from "../../config/block-registry";
|
||
import { InlineQaDialog } from "./inline-qa-dialog";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
|
||
interface Props {
|
||
node: LessonPlanNode;
|
||
}
|
||
|
||
/**
|
||
* V4 inline-node:展开到正文流的节点渲染。
|
||
* 用 Inter 字体 + 左侧细线区分正文(Fraunces)。
|
||
*/
|
||
export function InlineNode({ node }: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
|
||
const updateNode = useLessonPlanEditor((s) => s.updateNode);
|
||
|
||
// 师生交互节点用专门的对话体渲染
|
||
if (node.type === "interaction") {
|
||
return <InlineQaDialog node={node} />;
|
||
}
|
||
|
||
// 其他节点类型用 BlockRenderer(详情编辑模式的只读渲染)
|
||
// 注意:inline-node 是只读摘要,完整编辑在右栏详情面板
|
||
const entry = BLOCK_REGISTRY[node.type];
|
||
const dotColorVar = `var(--lp-dot-${node.type})`;
|
||
|
||
return (
|
||
<div className="lp-inline-node">
|
||
<div className="lp-inline-node-head">
|
||
<span
|
||
style={{
|
||
width: 5,
|
||
height: 5,
|
||
borderRadius: "50%",
|
||
background: dotColorVar,
|
||
display: "inline-block",
|
||
}}
|
||
/>
|
||
<span>{node.type}</span>
|
||
<span style={{ marginLeft: "auto", fontFamily: "JetBrains Mono, monospace", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
|
||
{node.id.slice(-4)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleExpand(node.id)}
|
||
className="lp-collapse-btn"
|
||
style={{
|
||
cursor: "pointer",
|
||
padding: "2px 6px",
|
||
borderRadius: 3,
|
||
color: "var(--lp-inline-node-meta)",
|
||
fontSize: 11,
|
||
background: "transparent",
|
||
border: "none",
|
||
}}
|
||
>
|
||
{t("v4.contextMenu.collapseFromPaper")} ▴
|
||
</button>
|
||
</div>
|
||
<h4 className="lp-inline-node-title">{node.title}</h4>
|
||
<div className="lp-inline-node-body">
|
||
{/* 简化:各 block 类型的 inline 渲染在 BlockRenderer 中处理 readonly 模式 */}
|
||
<InlineNodeBody node={node} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 各节点类型的 inline 只读渲染。
|
||
* 这里给出最常见的几种的简化摘要,完整编辑在右栏详情面板。
|
||
*/
|
||
function InlineNodeBody({ node }: { node: Block }) {
|
||
const data = node.data;
|
||
// 按类型渲染摘要
|
||
switch (node.type) {
|
||
case "objective": {
|
||
const d = data as { objectives: { dimension: string; text: string }[] };
|
||
if (!d.objectives?.length) return <p style={{ color: "var(--muted-foreground)" }}>—</p>;
|
||
return (
|
||
<ul style={{ margin: "4px 0", paddingLeft: 16 }}>
|
||
{d.objectives.map((o, i) => (
|
||
<li key={i} style={{ marginBottom: 3, fontSize: 13 }}>{o.text}</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
case "summary": {
|
||
const d = data as { summaryPoints: string[] };
|
||
if (!d.summaryPoints?.length) return <p style={{ color: "var(--muted-foreground)" }}>—</p>;
|
||
return (
|
||
<ul style={{ margin: "4px 0", paddingLeft: 16 }}>
|
||
{d.summaryPoints.map((s, i) => (
|
||
<li key={i} style={{ marginBottom: 3, fontSize: 13 }}>{s}</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
case "exercise": {
|
||
const d = data as { items: unknown[] };
|
||
return <p style={{ fontSize: 13 }}>{d.items?.length ?? 0} 道练习</p>;
|
||
}
|
||
default:
|
||
return <p style={{ fontSize: 13, color: "var(--muted-foreground)" }}>{node.title}</p>;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 通过(InlineQaDialog 未创建,下一步创建)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/paper-editor/inline-node.tsx
|
||
git commit -m "feat(lesson-preparation): inline-node 展开节点容器"
|
||
```
|
||
|
||
---
|
||
|
||
### Task E4: inline-qa-dialog(师生交互对话体)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx`
|
||
|
||
- [ ] **Step 1: 创建师生交互的对话体渲染**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import type { InteractionBlockData, LessonPlanNode, QATurn } from "../../types";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
|
||
interface Props {
|
||
node: LessonPlanNode;
|
||
}
|
||
|
||
/**
|
||
* V4 师生交互节点的 inline 对话体渲染。
|
||
* - 师/生 角色标签(JetBrains Mono)
|
||
* - 教师提问下方 italic 灰色 [预期:...]
|
||
* - 对话轮次之间 dashed 线分隔
|
||
*/
|
||
export function InlineQaDialog({ node }: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
|
||
const data = node.data as InteractionBlockData;
|
||
|
||
return (
|
||
<div className="lp-inline-node">
|
||
<div className="lp-inline-node-head">
|
||
<span
|
||
style={{
|
||
width: 5,
|
||
height: 5,
|
||
borderRadius: "50%",
|
||
background: "var(--lp-dot-interaction)",
|
||
display: "inline-block",
|
||
}}
|
||
/>
|
||
<span>{t("v4.interaction.label")}</span>
|
||
<span style={{ marginLeft: "auto", fontFamily: "JetBrains Mono, monospace", fontSize: 9, color: "var(--lp-inline-node-meta)" }}>
|
||
{data.turns?.length ?? 0} {t("v4.interaction.countSuffix" as never) || "轮"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleExpand(node.id)}
|
||
style={{
|
||
cursor: "pointer",
|
||
padding: "2px 6px",
|
||
borderRadius: 3,
|
||
color: "var(--lp-inline-node-meta)",
|
||
fontSize: 11,
|
||
background: "transparent",
|
||
border: "none",
|
||
}}
|
||
>
|
||
{t("v4.contextMenu.collapseFromPaper")} ▴
|
||
</button>
|
||
</div>
|
||
<h4 className="lp-inline-node-title">{node.title}</h4>
|
||
<div className="lp-inline-node-body">
|
||
{data.designIntent && (
|
||
<p style={{ fontSize: 13, marginBottom: 8 }}>{data.designIntent}</p>
|
||
)}
|
||
<div className="lp-qa-dialog">
|
||
{(data.turns ?? []).map((turn, idx) => (
|
||
<QaTurnView key={turn.id} turn={turn} index={idx} />
|
||
))}
|
||
{(!data.turns || data.turns.length === 0) && (
|
||
<p style={{ color: "var(--muted-foreground)", fontSize: 12 }}>
|
||
—
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QaTurnView({ turn, index }: { turn: QATurn; index: number }) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const roleClass = turn.role === "teacher" ? "teacher" : "student";
|
||
const roleLabel = turn.role === "teacher" ? t("v4.interaction.roleTeacher") : t("v4.interaction.roleStudent");
|
||
|
||
return (
|
||
<div className="lp-qa-turn">
|
||
<div className={`lp-qa-role ${roleClass}`}>{roleLabel}</div>
|
||
<div className="lp-qa-content">
|
||
{turn.content}
|
||
{turn.role === "teacher" && turn.expectedAnswer && (
|
||
<span className="lp-qa-prompt">[预期:{turn.expectedAnswer}]</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx
|
||
git commit -m "feat(lesson-preparation): 师生交互对话体 inline 渲染"
|
||
```
|
||
|
||
---
|
||
|
||
### Task E5: paper-context-menu(右键菜单)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx`
|
||
|
||
- [ ] **Step 1: 创建右键菜单组件**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import type { LessonPlanNode } from "../../types";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
|
||
export interface ContextMenuState {
|
||
visible: boolean;
|
||
x: number;
|
||
y: number;
|
||
/** 右键的节点(如果是 inline-node 右键) */
|
||
nodeId: string | null;
|
||
/** 右键的选中文本范围(如果是正文右键) */
|
||
selectionRange: { from: number; to: number } | null;
|
||
}
|
||
|
||
interface Props {
|
||
state: ContextMenuState;
|
||
onClose: () => void;
|
||
/** AI 协助回调(V1: 仅 toast 提示"功能开发中",V2 接入 actions-ai.ts) */
|
||
onAiAction?: (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => void;
|
||
}
|
||
|
||
/**
|
||
* V4 右键菜单:根据 state.nodeId 区分节点菜单 vs 锚定菜单。
|
||
*
|
||
* V1 范围:展开/收起、删除、锚定 已实现。
|
||
* V1 占位:上下移动、复制节点、AI 协助 4 项(按钮显示,点击 toast 提示)。
|
||
* 这些占位功能在 spec §15 YAGNI 边界内,V2 接入。
|
||
*/
|
||
export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const {
|
||
toggleExpand,
|
||
expandedNodeIds,
|
||
updateNode,
|
||
removeNode,
|
||
addAnchor,
|
||
selectNode,
|
||
doc,
|
||
} = useLessonPlanEditor();
|
||
|
||
// V1 占位实现:上下移动、复制(按 spec §15 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**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx
|
||
git commit -m "feat(lesson-preparation): 右键菜单(节点操作 + AI 协助 + 锚定)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task E6: paper-editor(中栏容器)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx`
|
||
|
||
- [ ] **Step 1: 创建纸区容器**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useTranslations } from "next-intl";
|
||
import type { Editor } from "@tiptap/react";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
import { TextbookTiptapEditor } from "./textbook-tiptap-editor";
|
||
import { InlineNode } from "./inline-node";
|
||
import { PaperContextMenu, type ContextMenuState } from "./paper-context-menu";
|
||
import type { LessonPlanNode } from "../../types";
|
||
|
||
/**
|
||
* V4 中栏纸区:
|
||
* - 教材正文 Tiptap 编辑器(max-w-720px 白纸)
|
||
* - 展开的节点按 order 顺序在段落间插入 inline-node
|
||
* - 右键菜单(节点操作 / 锚定)
|
||
*
|
||
* 注意:V4 的 inline-node 插入位置由节点 order 决定,不由锚点位置决定。
|
||
* 锚点是正文内的视觉标记,inline-node 是独立块。
|
||
* 当前实现:所有 inline-node 渲染在正文之后(简化)。
|
||
* 完整实现需要把正文按段落分割,在段落间插入 inline-node。
|
||
*/
|
||
export function PaperEditor({ readonly }: { readonly?: boolean }) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const doc = useLessonPlanEditor((s) => s.doc);
|
||
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
|
||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||
visible: false,
|
||
x: 0,
|
||
y: 0,
|
||
nodeId: null,
|
||
selectionRange: null,
|
||
});
|
||
|
||
const textbookNode = doc.nodes.find((n) => n.type === "textbook_content");
|
||
const teachingNodes = useMemo(
|
||
() =>
|
||
doc.nodes
|
||
.filter((n): n is LessonPlanNode => n.type !== "textbook_content")
|
||
.sort((a, b) => a.order - b.order),
|
||
[doc.nodes],
|
||
);
|
||
|
||
const expandedNodes = useMemo(
|
||
() => teachingNodes.filter((n) => expandedNodeIds.includes(n.id)),
|
||
[teachingNodes, expandedNodeIds],
|
||
);
|
||
|
||
if (!textbookNode) {
|
||
return <div style={{ padding: 48, textAlign: "center", color: "var(--muted-foreground)" }}>No textbook content</div>;
|
||
}
|
||
|
||
const onPaperContextMenu = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
const sel = window.getSelection();
|
||
const hasSelection = sel && sel.toString().length > 0;
|
||
setContextMenu({
|
||
visible: true,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
nodeId: null,
|
||
selectionRange: hasSelection ? { from: 0, to: 0 } : null, // 实际偏移由 editor 给
|
||
});
|
||
};
|
||
|
||
const onInlineNodeContextMenu = (e: React.MouseEvent, nodeId: string) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setContextMenu({
|
||
visible: true,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
nodeId,
|
||
selectionRange: null,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<main
|
||
style={{
|
||
overflowY: "auto",
|
||
padding: "48px 32px 120px",
|
||
background: "radial-gradient(circle at 50% 0%, rgba(0,0,0,0.015) 0%, transparent 60%), var(--muted)",
|
||
}}
|
||
onContextMenu={(e) => {
|
||
// 默认右键 = 正文右键(锚定菜单)
|
||
if (!(e.target as HTMLElement).closest("[data-inline-node]")) {
|
||
onPaperContextMenu(e);
|
||
}
|
||
}}
|
||
>
|
||
<article
|
||
style={{
|
||
background: "var(--lp-paper)",
|
||
maxWidth: 720,
|
||
margin: "0 auto",
|
||
padding: "64px 72px",
|
||
boxShadow: "var(--lp-paper-shadow)",
|
||
borderRadius: 2,
|
||
border: "1px solid var(--lp-paper-edge)",
|
||
minHeight: 800,
|
||
fontFamily: "'Fraunces', Georgia, serif",
|
||
color: "#1a1a1a",
|
||
lineHeight: 1.7,
|
||
fontSize: 16,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontFamily: "Inter, sans-serif",
|
||
fontSize: 11,
|
||
color: "var(--muted-foreground)",
|
||
letterSpacing: "0.08em",
|
||
textTransform: "uppercase",
|
||
marginBottom: 24,
|
||
paddingBottom: 16,
|
||
borderBottom: "1px solid var(--border)",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<span>{t("v4.paper.textbookHeader")}</span>
|
||
<span>{t("v4.paper.expandedCount", { count: expandedNodes.length })}</span>
|
||
</div>
|
||
|
||
<TextbookTiptapEditor
|
||
content={textbookNode.data.content}
|
||
readonly={readonly}
|
||
/>
|
||
|
||
{/* 展开的节点:按 order 排列在正文之后(简化实现) */}
|
||
{expandedNodes.map((node) => (
|
||
<div
|
||
key={node.id}
|
||
data-inline-node={node.id}
|
||
onContextMenu={(e) => onInlineNodeContextMenu(e, node.id)}
|
||
>
|
||
<InlineNode node={node} />
|
||
</div>
|
||
))}
|
||
</article>
|
||
|
||
<PaperContextMenu
|
||
state={contextMenu}
|
||
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
|
||
/>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx
|
||
git commit -m "feat(lesson-preparation): 中栏纸区容器(正文 + 展开节点 + 右键菜单)"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 F:左栏结构树
|
||
|
||
### Task F1: tree-node-row
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx`
|
||
|
||
- [ ] **Step 1: 创建树节点行组件**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import type { Block } from "../../types";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
|
||
interface Props {
|
||
node: Block;
|
||
/** 是否展开到正文 */
|
||
isExpanded: boolean;
|
||
/** 是否有子节点(如师生交互的对话轮次) */
|
||
hasChildren?: boolean;
|
||
isChildrenExpanded?: boolean;
|
||
onToggleChildren?: () => void;
|
||
/** 子节点(如对话轮次) */
|
||
children?: { id: string; label: string; kind: string }[];
|
||
}
|
||
|
||
export function TreeNodeRow({
|
||
node,
|
||
isExpanded,
|
||
hasChildren,
|
||
isChildrenExpanded,
|
||
onToggleChildren,
|
||
children,
|
||
}: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const selectedNodeId = useLessonPlanEditor((s) => s.selectedNodeId);
|
||
const selectNode = useLessonPlanEditor((s) => s.selectNode);
|
||
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
|
||
|
||
const isActive = selectedNodeId === node.id;
|
||
const dotColor = `var(--lp-dot-${node.type})`;
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => selectNode(node.id)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
selectNode(node.id);
|
||
}
|
||
}}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "6px 8px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
background: isActive ? "var(--muted)" : "transparent",
|
||
lineHeight: 1.3,
|
||
fontSize: 13,
|
||
}}
|
||
className="lp-tree-row"
|
||
>
|
||
<span
|
||
style={{
|
||
width: 14,
|
||
height: 14,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
fontSize: 9,
|
||
color: "var(--muted-foreground)",
|
||
cursor: hasChildren ? "pointer" : "default",
|
||
visibility: hasChildren ? "visible" : "hidden",
|
||
}}
|
||
onClick={(e) => {
|
||
if (hasChildren && onToggleChildren) {
|
||
e.stopPropagation();
|
||
onToggleChildren();
|
||
}
|
||
}}
|
||
>
|
||
{hasChildren ? (isChildrenExpanded ? "▾" : "▸") : ""}
|
||
</span>
|
||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: dotColor, flexShrink: 0 }} />
|
||
<span
|
||
style={{
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
color: "var(--foreground)",
|
||
}}
|
||
>
|
||
{node.title}
|
||
</span>
|
||
<span style={{ fontSize: 10, color: "var(--muted-foreground)", fontWeight: 500 }}>
|
||
{node.type}
|
||
</span>
|
||
{isExpanded && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleExpand(node.id);
|
||
}}
|
||
style={{
|
||
fontSize: 9,
|
||
color: "var(--foreground)",
|
||
background: "var(--muted)",
|
||
padding: "1px 5px",
|
||
borderRadius: 2,
|
||
fontWeight: 500,
|
||
border: "none",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
{t("v4.tree.onPaper")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{hasChildren && isChildrenExpanded && children && (
|
||
<div
|
||
style={{
|
||
marginLeft: 10,
|
||
paddingLeft: 8,
|
||
borderLeft: "1px solid var(--border)",
|
||
}}
|
||
>
|
||
{children.map((c) => (
|
||
<div
|
||
key={c.id}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "6px 8px",
|
||
borderRadius: 5,
|
||
fontSize: 12,
|
||
color: "var(--muted-foreground)",
|
||
}}
|
||
>
|
||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--lp-dot-default)", opacity: 0.5 }} />
|
||
<span style={{ flex: 1 }}>{c.label}</span>
|
||
<span style={{ fontSize: 10 }}>{c.kind}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx
|
||
git commit -m "feat(lesson-preparation): 树节点行组件"
|
||
```
|
||
|
||
---
|
||
|
||
### Task F2: structure-tree
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx`
|
||
|
||
- [ ] **Step 1: 创建结构树容器**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { useTranslations } from "next-intl";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
import { TreeNodeRow } from "./tree-node-row";
|
||
import type { InteractionBlockData, LessonPlanNode, TextbookContentNode } from "../../types";
|
||
|
||
export function StructureTree() {
|
||
const t = useTranslations("lessonPreparation");
|
||
const doc = useLessonPlanEditor((s) => s.doc);
|
||
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
|
||
const addNode = useLessonPlanEditor((s) => s.addNode);
|
||
const [expandedTreeNodes, setExpandedTreeNodes] = useState<Set<string>>(new Set());
|
||
|
||
// V1:默认添加 rich_text 节点(V2 接入节点类型选择对话框)
|
||
const onAddNode = () => {
|
||
const id = addNode("rich_text");
|
||
// 自动选中新节点(addNode 内部已设置 selectedNodeId)
|
||
void id;
|
||
};
|
||
|
||
const textbookNode = doc.nodes.find((n): n is TextbookContentNode => n.type === "textbook_content");
|
||
const teachingNodes = useMemo(
|
||
() =>
|
||
doc.nodes
|
||
.filter((n): n is LessonPlanNode => n.type !== "textbook_content")
|
||
.sort((a, b) => a.order - b.order),
|
||
[doc.nodes],
|
||
);
|
||
|
||
const toggleTreeNode = (id: string) => {
|
||
setExpandedTreeNodes((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<aside
|
||
style={{
|
||
borderRight: "1px solid var(--border)",
|
||
background: "var(--background)",
|
||
padding: "16px 12px",
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.08em",
|
||
color: "var(--muted-foreground)",
|
||
padding: "0 8px 12px",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<span>{t("v4.tree.title")}</span>
|
||
<span style={{ fontWeight: 500, letterSpacing: 0 }}>
|
||
{teachingNodes.length} {t("v4.tree.countSuffix")}
|
||
</span>
|
||
</div>
|
||
|
||
<div style={{ fontSize: 13 }}>
|
||
{/* 正文节点(始终在最上) */}
|
||
{textbookNode && (
|
||
<TreeNodeRow
|
||
node={{ ...textbookNode, type: "textbook_content" } as unknown as import("../../types").Block}
|
||
isExpanded={false}
|
||
/>
|
||
)}
|
||
|
||
{/* 教学节点 */}
|
||
{teachingNodes.map((node) => {
|
||
const isExpanded = expandedNodeIds.includes(node.id);
|
||
const isTreeNodeExpanded = expandedTreeNodes.has(node.id);
|
||
|
||
// 师生交互节点:子节点为对话轮次
|
||
let childItems: { id: string; label: string; kind: string }[] = [];
|
||
let hasChildren = false;
|
||
if (node.type === "interaction") {
|
||
const data = node.data as InteractionBlockData;
|
||
hasChildren = (data.turns?.length ?? 0) > 0;
|
||
childItems = (data.turns ?? []).map((turn, idx) => ({
|
||
id: turn.id,
|
||
label: `${t("v4.interaction.round", { n: idx + 1 })} · ${turn.role === "teacher" ? t("v4.interaction.roleTeacher") : t("v4.interaction.roleStudent")}`,
|
||
kind: turn.role === "teacher" ? t("v4.interaction.roleTeacher") : t("v4.interaction.roleStudent"),
|
||
}));
|
||
}
|
||
|
||
return (
|
||
<TreeNodeRow
|
||
key={node.id}
|
||
node={node}
|
||
isExpanded={isExpanded}
|
||
hasChildren={hasChildren}
|
||
isChildrenExpanded={isTreeNodeExpanded}
|
||
onToggleChildren={() => toggleTreeNode(node.id)}
|
||
children={childItems}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
marginTop: 10,
|
||
padding: "7px 8px",
|
||
border: "1px dashed var(--border)",
|
||
borderRadius: 5,
|
||
textAlign: "center",
|
||
fontSize: 12,
|
||
color: "var(--muted-foreground)",
|
||
cursor: "pointer",
|
||
}}
|
||
onClick={onAddNode}
|
||
>
|
||
+ {t("v4.tree.addNode")}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx
|
||
git commit -m "feat(lesson-preparation): 左栏结构树容器"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 G:右栏详情面板
|
||
|
||
### Task G1: detail-head
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/detail-panel/detail-head.tsx`
|
||
|
||
- [ ] **Step 1: 创建详情头部组件**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import type { Block } from "../../types";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
|
||
interface Props {
|
||
node: Block;
|
||
isExpanded: boolean;
|
||
}
|
||
|
||
export function DetailHead({ node, isExpanded }: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const updateNode = useLessonPlanEditor((s) => s.updateNode);
|
||
const toggleExpand = useLessonPlanEditor((s) => s.toggleExpand);
|
||
|
||
const dotColor = `var(--lp-dot-${node.type})`;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: "16px 20px 12px",
|
||
borderBottom: "1px solid var(--border)",
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: "50%",
|
||
background: dotColor,
|
||
marginTop: 6,
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.08em",
|
||
color: "var(--muted-foreground)",
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
{node.type}
|
||
{isExpanded ? ` · ${t("v4.contextMenu.expandToPaper")}` : ""}
|
||
</div>
|
||
<input
|
||
value={node.title}
|
||
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
||
style={{
|
||
width: "100%",
|
||
background: "transparent",
|
||
border: "none",
|
||
outline: "none",
|
||
fontFamily: "Inter, sans-serif",
|
||
fontWeight: 600,
|
||
fontSize: 16,
|
||
color: "var(--foreground)",
|
||
lineHeight: 1.3,
|
||
}}
|
||
/>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 4 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleExpand(node.id)}
|
||
title={isExpanded ? t("v4.contextMenu.collapseFromPaper") : t("v4.contextMenu.expandToPaper")}
|
||
style={{
|
||
width: 26,
|
||
height: 26,
|
||
border: "none",
|
||
background: "transparent",
|
||
borderRadius: 4,
|
||
cursor: "pointer",
|
||
color: "var(--muted-foreground)",
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
{isExpanded ? "▴" : "▾"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/detail-panel/detail-head.tsx
|
||
git commit -m "feat(lesson-preparation): 详情面板头部"
|
||
```
|
||
|
||
---
|
||
|
||
### Task G2: detail-props
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/detail-panel/detail-props.tsx`
|
||
|
||
- [ ] **Step 1: 创建属性条组件**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import type { Block } from "../../types";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
import type { TeachingStage, DifferentiationLevel } from "../../types";
|
||
|
||
interface Props {
|
||
node: Block;
|
||
}
|
||
|
||
const STAGES: TeachingStage[] = ["import", "new_teaching", "consolidation", "summary"];
|
||
const LEVELS: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
|
||
|
||
export function DetailProps({ node }: Props) {
|
||
const updateNode = useLessonPlanEditor((s) => s.updateNode);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: "10px 20px",
|
||
borderBottom: "1px solid var(--border)",
|
||
display: "flex",
|
||
gap: 12,
|
||
flexWrap: "wrap",
|
||
fontSize: 11.5,
|
||
}}
|
||
>
|
||
<PropItem
|
||
label="教学阶段"
|
||
value={node.stage ?? "—"}
|
||
options={STAGES}
|
||
onChange={(v) => updateNode(node.id, { stage: v as TeachingStage })}
|
||
/>
|
||
<PropItem
|
||
label="差异化"
|
||
value={node.differentiation ?? "—"}
|
||
options={LEVELS}
|
||
onChange={(v) => updateNode(node.id, { differentiation: v as DifferentiationLevel })}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PropItem({
|
||
label,
|
||
value,
|
||
options,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
options: readonly string[];
|
||
onChange: (v: string) => void;
|
||
}) {
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||
<span style={{ color: "var(--muted-foreground)" }}>{label}</span>
|
||
<select
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
style={{
|
||
color: "var(--foreground)",
|
||
fontWeight: 500,
|
||
padding: "1px 6px",
|
||
background: "var(--muted)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 3,
|
||
fontSize: 11,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<option value="—">—</option>
|
||
{options.map((o) => (
|
||
<option key={o} value={o}>
|
||
{o}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/detail-panel/detail-props.tsx
|
||
git commit -m "feat(lesson-preparation): 详情面板属性条"
|
||
```
|
||
|
||
---
|
||
|
||
### Task G3: qa-editor(师生交互编辑器)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/detail-panel/qa-editor.tsx`
|
||
|
||
- [ ] **Step 1: 创建师生交互编辑器**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import { createId } from "@paralleldrive/cuid2";
|
||
import type { InteractionBlockData, QATurn } from "../../types";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
|
||
interface Props {
|
||
nodeId: string;
|
||
data: InteractionBlockData;
|
||
}
|
||
|
||
export function QaEditor({ nodeId, data }: Props) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const updateNode = useLessonPlanEditor((s) => s.updateNode);
|
||
|
||
const updateData = (next: InteractionBlockData) => {
|
||
updateNode(nodeId, { data: next });
|
||
};
|
||
|
||
const addTurn = () => {
|
||
const newTurn: QATurn = {
|
||
id: createId(),
|
||
role: "teacher",
|
||
content: "",
|
||
expectedAnswer: "",
|
||
order: data.turns.length,
|
||
};
|
||
updateData({ ...data, turns: [...data.turns, newTurn] });
|
||
};
|
||
|
||
const updateTurn = (id: string, patch: Partial<QATurn>) => {
|
||
updateData({
|
||
...data,
|
||
turns: data.turns.map((t) => (t.id === id ? { ...t, ...patch } : t)),
|
||
});
|
||
};
|
||
|
||
const removeTurn = (id: string) => {
|
||
updateData({
|
||
...data,
|
||
turns: data.turns
|
||
.filter((t) => t.id !== id)
|
||
.map((t, i) => ({ ...t, order: i })),
|
||
});
|
||
};
|
||
|
||
const moveTurn = (id: string, direction: "up" | "down") => {
|
||
const idx = data.turns.findIndex((t) => t.id === id);
|
||
if (idx < 0) return;
|
||
const newIdx = direction === "up" ? idx - 1 : idx + 1;
|
||
if (newIdx < 0 || newIdx >= data.turns.length) return;
|
||
const turns = [...data.turns];
|
||
[turns[idx], turns[newIdx]] = [turns[newIdx], turns[idx]];
|
||
updateData({
|
||
...data,
|
||
turns: turns.map((t, i) => ({ ...t, order: i })),
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<div>
|
||
<label
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.08em",
|
||
color: "var(--muted-foreground)",
|
||
display: "block",
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
{t("v4.detail.designIntent")}
|
||
</label>
|
||
<textarea
|
||
value={data.designIntent}
|
||
onChange={(e) => updateData({ ...data, designIntent: e.target.value })}
|
||
rows={2}
|
||
style={{
|
||
width: "100%",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
padding: "8px 10px",
|
||
background: "var(--background)",
|
||
fontFamily: "Inter, sans-serif",
|
||
fontSize: 13,
|
||
lineHeight: 1.6,
|
||
color: "var(--foreground)",
|
||
resize: "vertical",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.08em",
|
||
color: "var(--muted-foreground)",
|
||
display: "block",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
{t("v4.detail.qaDialog")}
|
||
</label>
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{data.turns.map((turn, idx) => (
|
||
<QaTurnEditor
|
||
key={turn.id}
|
||
turn={turn}
|
||
index={idx}
|
||
total={data.turns.length}
|
||
onUpdate={(patch) => updateTurn(turn.id, patch)}
|
||
onRemove={() => removeTurn(turn.id)}
|
||
onMoveUp={() => moveTurn(turn.id, "up")}
|
||
onMoveDown={() => moveTurn(turn.id, "down")}
|
||
/>
|
||
))}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={addTurn}
|
||
style={{
|
||
border: "1px dashed var(--border)",
|
||
background: "transparent",
|
||
padding: 8,
|
||
borderRadius: 5,
|
||
textAlign: "center",
|
||
fontSize: 12,
|
||
color: "var(--muted-foreground)",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
+ {t("v4.detail.addTurn")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface TurnProps {
|
||
turn: QATurn;
|
||
index: number;
|
||
total: number;
|
||
onUpdate: (patch: Partial<QATurn>) => void;
|
||
onRemove: () => void;
|
||
onMoveUp: () => void;
|
||
onMoveDown: () => void;
|
||
}
|
||
|
||
function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMoveDown }: TurnProps) {
|
||
const t = useTranslations("lessonPreparation");
|
||
const isTeacher = turn.role === "teacher";
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
padding: "8px 10px",
|
||
background: "var(--background)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
marginBottom: 6,
|
||
fontSize: 10,
|
||
}}
|
||
>
|
||
<select
|
||
value={turn.role}
|
||
onChange={(e) => onUpdate({ role: e.target.value as "teacher" | "student" })}
|
||
className={isTeacher ? "teacher" : "student"}
|
||
style={{
|
||
fontFamily: "JetBrains Mono, monospace",
|
||
fontSize: 9,
|
||
fontWeight: 600,
|
||
padding: "2px 6px",
|
||
borderRadius: 2,
|
||
border: "1px solid var(--border)",
|
||
background: "var(--background)",
|
||
cursor: "pointer",
|
||
color: isTeacher ? "#1c1917" : "#6b7280",
|
||
}}
|
||
>
|
||
<option value="teacher">{t("v4.detail.turnTeacher")}</option>
|
||
<option value="student">{t("v4.detail.turnStudent")}</option>
|
||
</select>
|
||
<span style={{ color: "var(--muted-foreground)", fontSize: 10 }}>
|
||
{t("v4.interaction.round", { n: index + 1 })}
|
||
</span>
|
||
<div style={{ marginLeft: "auto" }}>
|
||
<button type="button" onClick={onMoveUp} disabled={index === 0} style={{ border: "none", background: "transparent", color: "var(--muted-foreground)", cursor: index === 0 ? "default" : "pointer", padding: "2px 4px", fontSize: 11 }}>↑</button>
|
||
<button type="button" onClick={onMoveDown} disabled={index === total - 1} style={{ border: "none", background: "transparent", color: "var(--muted-foreground)", cursor: index === total - 1 ? "default" : "pointer", padding: "2px 4px", fontSize: 11 }}>↓</button>
|
||
<button type="button" onClick={onRemove} style={{ border: "none", background: "transparent", color: "var(--muted-foreground)", cursor: "pointer", padding: "2px 4px", fontSize: 11 }}>×</button>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
value={turn.content}
|
||
onChange={(e) => onUpdate({ content: e.target.value })}
|
||
rows={2}
|
||
placeholder="内容"
|
||
style={{
|
||
width: "100%",
|
||
border: "none",
|
||
outline: "none",
|
||
background: "transparent",
|
||
resize: "vertical",
|
||
fontFamily: "Inter, sans-serif",
|
||
fontSize: 12.5,
|
||
lineHeight: 1.5,
|
||
color: "var(--foreground)",
|
||
minHeight: 36,
|
||
}}
|
||
/>
|
||
{isTeacher && (
|
||
<textarea
|
||
value={turn.expectedAnswer ?? ""}
|
||
onChange={(e) => onUpdate({ expectedAnswer: e.target.value })}
|
||
rows={1}
|
||
placeholder={t("v4.detail.expectedAnswer")}
|
||
style={{
|
||
width: "100%",
|
||
border: "none",
|
||
outline: "none",
|
||
background: "var(--muted)",
|
||
resize: "vertical",
|
||
fontFamily: "Inter, sans-serif",
|
||
fontSize: 11,
|
||
fontStyle: "italic",
|
||
color: "var(--muted-foreground)",
|
||
padding: "4px 8px",
|
||
borderRadius: 3,
|
||
marginTop: 4,
|
||
minHeight: 24,
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/detail-panel/qa-editor.tsx
|
||
git commit -m "feat(lesson-preparation): 师生交互编辑器(对话轮次增删改)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task G4: interaction-block
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/blocks/interaction-block.tsx`
|
||
|
||
- [ ] **Step 1: 创建师生交互 block(详情编辑入口)**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import type { InteractionBlockData } from "../../types";
|
||
import { QaEditor } from "../detail-panel/qa-editor";
|
||
|
||
interface Props {
|
||
blockId: string;
|
||
data: InteractionBlockData;
|
||
onUpdate: (data: InteractionBlockData) => void;
|
||
}
|
||
|
||
/**
|
||
* V4 师生交互 block:详情面板中的编辑入口。
|
||
* 实际编辑器是 QaEditor(复用)。
|
||
*/
|
||
export function InteractionBlock({ blockId, data, onUpdate }: Props) {
|
||
return <QaEditor nodeId={blockId} data={data} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/blocks/interaction-block.tsx
|
||
git commit -m "feat(lesson-preparation): 师生交互 block(详情编辑入口)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task G5: detail-panel
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx`
|
||
|
||
- [ ] **Step 1: 创建详情面板容器**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||
import { DetailHead } from "./detail-head";
|
||
import { DetailProps } from "./detail-props";
|
||
import { QaEditor } from "./qa-editor";
|
||
import { BlockRenderer } from "../../config/block-registry";
|
||
import type { InteractionBlockData, LessonPlanNode } from "../../types";
|
||
|
||
export function DetailPanel() {
|
||
const t = useTranslations("lessonPreparation");
|
||
const doc = useLessonPlanEditor((s) => s.doc);
|
||
const selectedNodeId = useLessonPlanEditor((s) => s.selectedNodeId);
|
||
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
|
||
const updateNode = useLessonPlanEditor((s) => s.updateNode);
|
||
|
||
const node = doc.nodes.find(
|
||
(n): n is LessonPlanNode => n.id === selectedNodeId && n.type !== "textbook_content",
|
||
);
|
||
|
||
if (!node) {
|
||
return (
|
||
<aside
|
||
style={{
|
||
borderLeft: "1px solid var(--border)",
|
||
background: "var(--background)",
|
||
padding: 24,
|
||
color: "var(--muted-foreground)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
选择左侧节点查看详情
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
const isExpanded = expandedNodeIds.includes(node.id);
|
||
|
||
return (
|
||
<aside
|
||
style={{
|
||
borderLeft: "1px solid var(--border)",
|
||
background: "var(--background)",
|
||
overflowY: "auto",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
<DetailHead node={node} isExpanded={isExpanded} />
|
||
<DetailProps node={node} />
|
||
|
||
<div style={{ flex: 1, padding: 20, overflowY: "auto" }}>
|
||
{node.type === "interaction" ? (
|
||
<QaEditor nodeId={node.id} data={node.data as InteractionBlockData} />
|
||
) : (
|
||
<BlockRenderer
|
||
blockId={node.id}
|
||
data={node.data}
|
||
onUpdate={(data) => updateNode(node.id, { data })}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
borderTop: "1px solid var(--border)",
|
||
padding: "14px 20px",
|
||
background: "var(--muted)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
color: "var(--lp-interaction)",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
✦ {t("v4.detail.aiAssist")}
|
||
</div>
|
||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||
<AiButton label={t("v4.detail.aiGenerateLayered")} />
|
||
<AiButton label={t("v4.detail.aiFillExpected")} />
|
||
<AiButton label={t("v4.detail.aiOptimizeFollowup")} />
|
||
<AiButton label={t("v4.detail.aiDifferentiation")} />
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function AiButton({ label }: { label: string }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
style={{
|
||
padding: "5px 10px",
|
||
border: "1px solid var(--border)",
|
||
background: "var(--background)",
|
||
borderRadius: 4,
|
||
fontSize: 11.5,
|
||
cursor: "pointer",
|
||
color: "var(--foreground)",
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 报错(BlockRenderer 可能未导出,下一步修复 registry)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx
|
||
git commit -m "feat(lesson-preparation): 右栏详情面板容器"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 H:主编辑器改造
|
||
|
||
### Task H1: block-registry 注册 interaction
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/lesson-preparation/config/block-registry.tsx`
|
||
|
||
- [ ] **Step 1: 添加 interaction 注册**
|
||
|
||
在文件顶部导入:
|
||
```typescript
|
||
import { InteractionBlock } from "../components/blocks/interaction-block";
|
||
import { isInteractionBlockData } from "../lib/type-guards";
|
||
```
|
||
|
||
在 `BLOCK_REGISTRY` 中添加:
|
||
```typescript
|
||
interaction: {},
|
||
```
|
||
|
||
- [ ] **Step 2: 在 BlockRenderer 中处理 interaction**
|
||
|
||
查看现有 BlockRenderer 实现,添加 interaction 分支:
|
||
|
||
```typescript
|
||
// 在 BlockRenderer 的 switch/if 分支中
|
||
if (isInteractionBlockData(data)) {
|
||
return <InteractionBlock blockId={blockId} data={data} onUpdate={onUpdate as (d: InteractionBlockData) => void} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 导出 BlockRenderer**
|
||
|
||
确保 `BlockRenderer` 组件从 `block-registry.tsx` 命名导出(如果未导出,添加 `export function BlockRenderer(...)`)。
|
||
|
||
- [ ] **Step 4: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/config/block-registry.tsx
|
||
git commit -m "feat(lesson-preparation): 注册 interaction block 到 registry"
|
||
```
|
||
|
||
---
|
||
|
||
### Task H2: lesson-plan-editor 三栏布局
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/lesson-preparation/components/lesson-plan-editor.tsx`
|
||
|
||
- [ ] **Step 1: 替换主布局**
|
||
|
||
修改 `lesson-plan-editor.tsx` 的主渲染部分。移除 `NodeEditor` 和 `NodeEditPanel` 的导入和 JSX,改为:
|
||
|
||
```typescript
|
||
// 在 imports 中替换
|
||
import { StructureTree } from "./structure-tree/structure-tree";
|
||
import { PaperEditor } from "./paper-editor/paper-editor";
|
||
import { DetailPanel } from "./detail-panel/detail-panel";
|
||
import { AnchorMigrationBanner } from "./anchor-migration-banner";
|
||
|
||
// 在 JSX 主区域替换 NodeEditor + NodeEditPanel
|
||
<main style={{ display: "grid", gridTemplateColumns: "260px 1fr 380px", height: "calc(100vh - 52px)", overflow: "hidden" }}>
|
||
<StructureTree />
|
||
<PaperEditor readonly={!isEditable} />
|
||
<DetailPanel />
|
||
</main>
|
||
```
|
||
|
||
- [ ] **Step 2: 移除 React Flow 相关导入**
|
||
|
||
删除:
|
||
- `import { NodeEditor } from "./node-editor";`
|
||
- `import { NodeEditPanel, ... } from "./node-edit-panel";`
|
||
- 任何 `reactflow` / `@xyflow/react` 导入
|
||
|
||
- [ ] **Step 3: 保留顶部工具栏(标题、保存、撤销重做、发布等)**
|
||
|
||
顶部工具栏的 JSX 保留不变,但移除画布相关的按钮(如"自动布局")。
|
||
|
||
- [ ] **Step 4: 添加 AnchorMigrationBanner**
|
||
|
||
在主布局上方添加:
|
||
```typescript
|
||
<AnchorMigrationBanner />
|
||
```
|
||
|
||
- [ ] **Step 5: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 仍有错误(node-editor 等被引用但未删除),下一步清理
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/lesson-plan-editor.tsx
|
||
git commit -m "refactor(lesson-preparation): 主编辑器三栏布局(结构树 + 纸 + 详情面板)"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 I:v3 锚点失效 banner
|
||
|
||
### Task I1: anchor-migration-banner
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/anchor-migration-banner.tsx`
|
||
|
||
- [ ] **Step 1: 创建 banner 组件**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useTranslations } from "next-intl";
|
||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||
|
||
/**
|
||
* 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**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/anchor-migration-banner.tsx
|
||
git commit -m "feat(lesson-preparation): v3 锚点失效提示 banner"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 J:清理、验证、架构同步
|
||
|
||
### Task J1: 删除废弃文件
|
||
|
||
**Files:**
|
||
- Delete: `src/modules/lesson-preparation/components/node-editor.tsx`
|
||
- Delete: `src/modules/lesson-preparation/components/nodes/lesson-node.tsx`
|
||
- Delete: `src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx`
|
||
- Delete: `src/modules/lesson-preparation/components/nodes/textbook-segments.tsx`
|
||
- Delete: `src/modules/lesson-preparation/components/nodes/anchor-node-selector.tsx`
|
||
- Delete: `src/modules/lesson-preparation/lib/anchor-injector.ts`
|
||
- Delete: `src/modules/lesson-preparation/lib/rf-mappers.ts`
|
||
- Delete: `src/modules/lesson-preparation/lib/auto-layout.ts`
|
||
|
||
- [ ] **Step 1: 先全局搜索是否还有引用**
|
||
|
||
Run: Grep `node-editor|anchor-injector|rf-mappers|auto-layout|lesson-node|textbook-content-node` 在 src 下
|
||
|
||
- [ ] **Step 2: 修复所有引用**
|
||
|
||
对每个引用文件,删除/替换为新的导入路径。
|
||
|
||
- [ ] **Step 3: 删除文件**
|
||
|
||
使用 DeleteFile 工具删除上述 8 个文件。
|
||
|
||
- [ ] **Step 4: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 通过(无悬空引用)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A src/modules/lesson-preparation/
|
||
git commit -m "refactor(lesson-preparation): 删除废弃的 React Flow 画布和字符串锚点文件"
|
||
```
|
||
|
||
---
|
||
|
||
### Task J2: print-view 适配 v4
|
||
|
||
**Files:**
|
||
- Modify: `src/modules/lesson-preparation/components/print-view.tsx`
|
||
|
||
- [ ] **Step 1: 移除画布依赖**
|
||
|
||
查看 `print-view.tsx` 是否引用 `position` / 画布 / `node-editor`。如有,改为按 `order` 顺序渲染节点。
|
||
|
||
- [ ] **Step 2: 验证编译**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/modules/lesson-preparation/components/print-view.tsx
|
||
git commit -m "refactor(lesson-preparation): print-view 适配 V4"
|
||
```
|
||
|
||
---
|
||
|
||
### Task J3: lint + tsc 完整验证
|
||
|
||
**Files:** N/A
|
||
|
||
- [ ] **Step 1: 运行 tsc**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 0 errors
|
||
|
||
- [ ] **Step 2: 运行 lint**
|
||
|
||
Run: `npm run lint`
|
||
Expected: 0 errors / 0 warnings
|
||
|
||
- [ ] **Step 3: 如有错误,逐个修复**
|
||
|
||
常见问题:
|
||
- 未使用的 import(删除)
|
||
- `any` 类型(改为 `unknown` + 类型守卫)
|
||
- `as` 断言(除非从 unknown 转换,否则改为类型守卫)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore(lesson-preparation): V4 lint + tsc 零错误验证"
|
||
```
|
||
|
||
---
|
||
|
||
### Task J4: 架构图同步
|
||
|
||
**Files:**
|
||
- Modify: `docs/architecture/004_architecture_impact_map.md`
|
||
- Modify: `docs/architecture/005_architecture_data.json`
|
||
|
||
- [ ] **Step 1: 更新 004 Markdown**
|
||
|
||
在 lesson-preparation 模块章节:
|
||
- 新增"V4 纸感重构"小节
|
||
- 列出新增组件(paper-editor/、structure-tree/、detail-panel/、interaction-block)
|
||
- 列出移除组件(node-editor、nodes/*、anchor-injector、rf-mappers、auto-layout)
|
||
- 更新 hooks 列表(新增 expanded-slice)
|
||
|
||
- [ ] **Step 2: 更新 005 JSON**
|
||
|
||
在 `modules.lesson-preparation` 节点:
|
||
- `exports` 数组新增:`PaperEditor`、`StructureTree`、`DetailPanel`、`InteractionBlock`、`AnchorMark`、`AnchorPoint`、`QATurn`、`InteractionBlockData`、`LessonPlanDocumentV4`、`ExpandedSlice`
|
||
- `exports` 数组移除:`NodeEditor`、`LessonNode`、`TextbookContentNode`(画布版)、`AnchorInjector`、`RfMappers`、`AutoLayout`
|
||
- `blockTypes` 数组新增 `"interaction"`
|
||
- `documentVersion` 改为 `4`
|
||
|
||
- [ ] **Step 3: 验证 JSON 语法**
|
||
|
||
Run: `node -e "JSON.parse(require('fs').readFileSync('docs/architecture/005_architecture_data.json','utf8'))"`
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/architecture/
|
||
git commit -m "docs(architecture): 同步备课模块 V4 重构到架构图"
|
||
```
|
||
|
||
---
|
||
|
||
### Task J5: known-issues 新规则
|
||
|
||
**Files:**
|
||
- Modify: `docs/troubleshooting/known-issues.md`
|
||
|
||
- [ ] **Step 1: 添加 V4 备课编辑器规则章节**
|
||
|
||
在文件末尾添加:
|
||
|
||
```markdown
|
||
## V4 备课编辑器(纸感重构)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 正文节点必须是 Tiptap 编辑器 | `TextbookTiptapEditor`(含 AnchorMark 扩展) | 用 `whitespace-pre-wrap` 显示 Markdown 字符串 |
|
||
| 锚点用 Tiptap Mark 内嵌 | `editor.chain().toggleMark("anchor", { anchorId, nodeId }).run()` | 用字符串偏移 `start/end` + `injectPlaceholders` |
|
||
| 节点展开位置由 order 决定 | `nodes.sort((a,b) => a.order - b.order)` | 按 anchor 位置插入 |
|
||
| 节点类型色点用 `--lp-dot-*` | `var(--lp-dot-objective)` | `var(--lesson-node-objective)`(已删除) |
|
||
| 文档版本必须是 4 | `doc.version === 4` + `expandedNodeIds` 字段 | `doc.version === 3` |
|
||
| 师生交互节点用 QaEditor | `<QaEditor nodeId data onUpdate />` | 自定义对话编辑器 |
|
||
| 右键菜单触发对象区分 | inline-node 右键 = 节点菜单;正文右键 = 锚定菜单 | 任何右键都触发同一菜单 |
|
||
| v3 锚点迁移失效提示 | `<AnchorMigrationBanner />`(按 planId localStorage) | toast 一闪即逝 |
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/troubleshooting/known-issues.md
|
||
git commit -m "docs(troubleshooting): V4 备课编辑器规则"
|
||
```
|
||
|
||
---
|
||
|
||
## 自审清单
|
||
|
||
完成所有任务后,对照 spec 检查:
|
||
|
||
- [ ] §2 视觉设计:字体双轨(Fraunces + Inter + JetBrains Mono)已用
|
||
- [ ] §2 配色:13 个 `--lesson-node-*` 已删除,改为 `--lp-*` 中性令牌
|
||
- [ ] §3 三栏布局:260px + 1fr + 380px
|
||
- [ ] §4 节点展开:三个入口(左栏标记 / 右栏头部按钮 / 右键菜单)
|
||
- [ ] §4.3 右键菜单:节点操作 + AI 协助 + 复制/删除
|
||
- [ ] §5 师生交互:QATurn / InteractionBlockData / QaEditor / InlineQaDialog
|
||
- [ ] §6 锚点:AnchorMark + 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 错误
|