Files
NextEdu/docs/superpowers/specs/2026-06-22-lesson-preparation-anchor-canvas-design.md
SpecialX 27db170c0a docs: update architecture docs, audit reports, and bug tracking
- Update architecture impact map, data, feature checklist, gap audit

- Add audit reports for dashboard, exam-homework, grades-diagnostic, settings-profile, textbooks

- Update bug reports (admin, teacher, lesson-preparation, others, shared)

- Update coding standards, DR plan, design docs, and README
2026-06-23 17:36:18 +08:00

544 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 备课模块重构设计 — 课文锚点画布
**日期**2026-06-22
**状态**:已确认,待实现
**作者**brainstorming session
## 背景与目标
当前备课模块基于 React Flow 节点图编辑器v2 nodes+edges支持 12 种 Block 类型、版本管理、自动保存、模板系统。但存在以下问题:
1. **创建课案时无法选择教材/章节**UI 缺失),所有课案 `textbookId/chapterId` 都是 null
2. **TextStudyBlock 与教材课文完全脱节**,教师手动粘贴纯文本到 textarea
3. **编辑器内无法切换关联的教材/章节**
4. **节点与课文无关联**,无法体现教学流程的时间线
**本次重构目标**:以课文正文为核心主体,教学节点围绕课文组织,通过锚点机制建立节点与课文位置的关联,形成教学流程时间线。
## 核心设计决策
### 决策 11 课案 = 1 课文
一个课案对应一篇课文(如《秋天》第一课时)。课文正文在画布中央作为核心主体,教学目标/重难点/导入/新授等节点围绕课文组织。
### 决策 2画布式锚点布局
保留 React Flow 画布交互(缩放/平移/节点拖动/连线),课文作为特殊节点类型 `textbook_content` 嵌在画布中央,`draggable: false`(不可移动)但可缩放。
### 决策 3两种锚定方式
- **范围锚定range**:选中一段文字 → 关联节点。文本背景色 = 节点颜色,默认 `opacity: 0`(完全透明),选中时 `opacity: 0.3`
- **点锚定point**:点击文本某位置 → 插入占位符标记(①②③)。默认 `opacity: 0.3`(半透明),选中时 `opacity: 1`(不透明)
### 决策 4连线透明度策略
- 锚点连线anchor默认 10%,选中节点时 100%
- 流程连线flow节点间教学流程连线正常显示
### 决策 5每个节点类型完全定制字段
每个节点类型有独特的字段和交互,不是统一富文本。详见第 2 节。
### 决策 6默认骨架 10 节点
创建课案时强制选择教材/章节,自动生成 10 个默认教学节点 + 1 个正文节点。
### 决策 7实时拖动
节点拖动改为实时更新位置(`onNodeDrag`),而非当前的 `onNodeDragStop` 才更新。
### 决策 8颜色保持现有方案
节点颜色复用 `lib/node-summary.ts``getNodeColor`,不改变现有配色。
## 第 1 节:整体架构与数据模型
### 1.1 整体布局
```
┌─────────────────────────────────────────────────────────────┐
│ 顶部工具栏:标题 | 教材/章节 | 保存状态 | 版本 | 保存按钮 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌──────────────┐ ┌─────────┐ │
│ │ 导入 │───→│ 课文正文 │←───│ 新授 │ │
│ │ 节点 │ │ (固定中央) │ │ 节点 │ │
│ └─────────┘ │ │ └─────────┘ │
│ │ 天气凉了① │ │
│ ┌─────────┐ │ 天空那么蓝②│ ┌─────────┐ │
│ │ 文本研习│───→│ ... │←───│ 练习 │ │
│ │ 节点 │ │ │ └─────────┘ │
│ └─────────┘ └──────────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 教学目标│ │ 重难点 │ │ 作业 │ (未锚定节点) │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ [+ 添加节点] [+] [-] [⌖] │
└─────────────────────────────────────────────────────────────┘
```
### 1.2 数据模型升级v2 → v3
```typescript
// 新增:正文节点类型
interface TextbookContentNodeData {
chapterId: string;
content: string; // Markdown 正文(缓存)
zoom: number; // 缩放比例 0.5-2.0
}
// 新增:正文节点(继承 LessonPlanNode
interface TextbookContentNode extends LessonPlanNode {
type: "textbook_content";
data: TextbookContentNodeData;
draggable: false; // 不可拖动
}
// 新增:锚点类型
type AnchorType = "range" | "point";
interface NodeAnchor {
id: string;
nodeId: string; // 关联的教学节点 ID
type: AnchorType;
start: number; // 正文纯文本偏移量
end?: number; // range 锚定的结束偏移
textPreview?: string; // range 锚定的文字预览
}
// 新增:边类型
type EdgeType = "anchor" | "flow";
interface AnchorEdge extends LessonPlanEdge {
type: "anchor";
source: string; // 教学节点 ID
target: string; // 正文节点 ID
anchorId: string; // 关联的 NodeAnchor ID
}
interface FlowEdge extends LessonPlanEdge {
type: "flow"; // 教学流程连线(如 导入→新授)
}
// 升级LessonPlanDocument v3
interface LessonPlanDocument {
version: 3;
textbookContentNodeId: string; // 正文节点 ID唯一
nodes: (LessonPlanNode | TextbookContentNode)[];
edges: (AnchorEdge | FlowEdge)[];
anchors: NodeAnchor[]; // 新增:锚点数组
}
```
### 1.3 迁移策略
- `migrateV2ToV3(doc, chapterId?, chapterContent?)`:将 v2 文档升级为 v3
- 如果有关联的 chapterId创建 `TextbookContentNode` 注入正文
- 现有节点保留,`edges` 保留为 `flow` 类型
- `anchors` 初始化为空数组
- `normalizeDocument` 优先识别 v3v2 自动迁移
## 第 2 节:节点类型与定制字段
### 2.1 节点类型清单11 种 + 1 正文节点)
| 类型 | BlockType | 定制字段 | 输入 | 输出 |
|------|-----------|---------|------|------|
| 教学目标 | `objective` | `objectives: { dimension, text }[]` | 三维目标列表 | 结构化目标 |
| 重难点 | `key_point` | `keyPoints: { type: "key"\|"difficult", text }[]` | 重点/难点分组 | 结构化重难点 |
| 导入 | `import` | `method, prompt, durationMin` | 导入方式+提问+时长 | 导入脚本 |
| 新授 | `new_teaching` | `teachingPoints: { knowledgePointIds, outline, boardNotes }[]` | 知识点+讲解要点+板书 | 教学步骤 |
| 练习 | `exercise` | `items: ExerciseItem[]; purpose` | 题目列表+用途 | 可发布作业 |
| 小结 | `summary` | `summaryPoints: string[]; homeworkPreview` | 要点列表+作业预告 | 总结文本 |
| 作业 | `homework` | `assignments: { type, refId?, description }[]` | 作业项列表 | 作业清单 |
| 板书设计 | `blackboard` | `layout, content, knowledgePointIds` | 布局+内容 | 板书图 |
| 教学反思 | `reflection` | `reflection: { aspect, text }[]` | 反维度反思 | 反思记录 |
| 文本研习 | `text_study` | `annotations: TextStudyAnnotation[]` | 课文批注(与正文联动) | 批注列表 |
| 富文本 | `rich_text` | `html, knowledgePointIds` | 自由富文本 | HTML |
| **正文** | `textbook_content` | `chapterId, content, zoom` | 教材章节 Markdown | 只读正文 |
### 2.2 数据类型定义
```typescript
// 教学目标
interface ObjectiveBlockData {
objectives: {
dimension: "knowledge" | "process" | "emotion";
text: string;
}[];
}
// 重难点
interface KeyPointBlockData {
keyPoints: {
type: "key" | "difficult";
text: string;
}[];
}
// 导入
interface ImportBlockData {
method: "question" | "situation" | "review" | "other";
prompt: string;
durationMin: number;
}
// 新授
interface NewTeachingBlockData {
teachingPoints: {
knowledgePointIds: string[];
outline: string;
boardNotes: string;
}[];
}
// 小结
interface SummaryBlockData {
summaryPoints: string[];
homeworkPreview: string;
}
// 作业
interface HomeworkBlockData {
assignments: {
type: "exercise" | "reading" | "writing";
refId?: string;
description: string;
}[];
}
// 板书设计
interface BlackboardBlockData {
layout: "structure" | "mindmap" | "text";
content: string;
knowledgePointIds: string[];
}
// 教学反思
interface ReflectionBlockData {
reflection: {
aspect: "effectiveness" | "problems" | "improvements";
text: string;
}[];
}
// BlockData 联合类型扩展
type BlockData =
| RichTextBlockData
| TextStudyBlockData
| ExerciseBlockData
| ObjectiveBlockData
| KeyPointBlockData
| ImportBlockData
| NewTeachingBlockData
| SummaryBlockData
| HomeworkBlockData
| BlackboardBlockData
| ReflectionBlockData
| TextbookContentNodeData;
```
### 2.3 BlockRegistry 配置驱动
每个节点类型在 `block-registry.tsx` 注册:
- `component`: 对应的编辑组件
- `icon`: 节点图标
- `defaultTitle`: 默认标题i18n 键)
- `defaultData`: 初始数据
- `summaryExtractor`: 节点卡片摘要函数
- `color`: 节点颜色(复用现有 `getNodeColor`
### 2.4 默认骨架10 节点)
创建课案时自动生成:
1. 教学目标(未锚定,全局)
2. 重难点(未锚定,全局)
3. 导入(锚定到正文开头)
4. 文本研习(锚定到正文,范围锚定)
5. 新授(锚定到正文中部)
6. 练习(锚定到正文,点锚定)
7. 小结(锚定到正文结尾)
8. 作业(未锚定,课后)
9. 板书设计(未锚定,全局)
10. 教学反思(未锚定,课后)
## 第 3 节:正文节点与锚点交互
### 3.1 正文节点组件TextbookContentNode
```typescript
// components/nodes/textbook-content-node.tsx
interface Props {
data: TextbookContentNodeData;
selectedNodeId: string | null;
anchors: NodeAnchor[];
onAddAnchor: (anchor: NodeAnchor) => void;
onRemoveAnchor: (anchorId: string) => void;
onSelectNode: (nodeId: string | null) => void;
}
```
**渲染流程**
1. `ReactMarkdown` 渲染 `data.content`(复用教材模块的 `remarkGfm + remarkBreaks + rehypeSanitize`
2. 渲染前调用 `injectPlaceholders(content, anchors)` 在对应偏移位置插入占位符标记
3. 范围锚定的文字用 `<span class="range-anchor">` 包裹,背景色 = 节点颜色
4. 点锚定的位置插入 `<span class="point-anchor">①</span>` 标记
5. 缩放通过 `transform: scale(data.zoom)` 实现
### 3.2 占位符注入算法
```typescript
// lib/anchor-injector.ts
// 将 Markdown 渲染为纯文本,记录偏移映射
function buildOffsetMap(markdown: string): {
plainText: string;
mdToPlain: Map<number, number>;
}
// 在纯文本中注入占位符标记
function injectPlaceholders(
markdown: string,
anchors: NodeAnchor[]
): string {
// 1. buildOffsetMap 得到 plainText + 映射
// 2. 按 start 排序 anchors倒序避免偏移变化
// 3. 对 range 锚定:在 [start, end] 范围包裹 <span class="range-anchor">
// 4. 对 point 锚定:在 start 位置插入 <span class="point-anchor">①</span>
// 5. 返回注入标记后的 HTML供 ReactMarkdown 的 components 自定义渲染)
}
```
### 3.3 CSS 透明度规则
```css
/* 范围锚定:文本背景色 = 节点颜色 */
.range-anchor {
background-color: var(--node-color);
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s;
}
.range-anchor.active {
opacity: 0.3;
}
/* 点锚定:占位符标记 */
.point-anchor {
display: inline-block;
background-color: var(--node-color);
color: #fff;
border-radius: 3px;
padding: 0 4px;
font-size: 0.75em;
font-weight: bold;
opacity: 0.3;
transition: opacity 0.2s;
cursor: pointer;
}
.point-anchor.active {
opacity: 1;
}
.point-anchor:hover {
opacity: 0.6;
}
/* 连线默认 10% */
.react-flow__edge.anchor {
opacity: 0.1;
}
.react-flow__edge.anchor.active {
opacity: 1;
}
```
### 3.4 两种锚定交互流程
**范围锚定(选文本 → 关联节点)**
1. 教师在正文选中一段文字
2. 选中后浮动菜单出现:"关联节点 →"
3. 下拉列表显示所有未锚定的教学节点 + "新建节点"
4. 选择后创建 `NodeAnchor { type: "range", start, end, textPreview }`
5. 创建 `AnchorEdge { source: nodeId, target: textbookContentNodeId, anchorId }`
6. 正文对应文字被 `<span class="range-anchor">` 包裹
**点锚定(点击位置 → 插入占位符)**
1. 教师在正文某位置点击(光标位置或点击空白处)
2. 弹出菜单:"在此处插入节点 →"
3. 下拉列表显示所有未锚定的教学节点 + "新建节点"
4. 选择后创建 `NodeAnchor { type: "point", start }`
5. 创建 `AnchorEdge`
6. 正文对应位置插入 `<span class="point-anchor">①</span>`
### 3.5 选中节点的视觉反馈
```typescript
function getActiveAnchorIds(anchors: NodeAnchor[], selectedNodeId: string | null): Set<string> {
if (!selectedNodeId) return new Set();
return new Set(anchors.filter(a => a.nodeId === selectedNodeId).map(a => a.id));
}
const activeAnchorIds = getActiveAnchorIds(anchors, selectedNodeId);
// 对每个占位符activeAnchorIds.has(anchor.id) ? "active" : ""
```
### 3.6 正文内容变更处理
- 正文来自教材模块的 `chapter.content`,教师不可编辑正文本身
- 如果教材章节内容更新,课案中的正文缓存需要同步
- 提供"同步正文"按钮,调用 `getChapterContentAction(chapterId)` 刷新
- 同步后锚点偏移量可能失效,用 `textPreview` 做模糊匹配尝试重新定位
- 无法定位的锚点标记为"失效",提示教师重新锚定
## 第 4 节:创建课案流程
### 4.1 入口 1从备课模块新建
`template-picker.tsx` 改造为强制选择教材/章节:
1. 选择学科/年级
2. 选择教材(从 `getTextbooksAction` 获取)
3. 选择章节(从 `getChaptersByTextbookIdAction` 获取章节树)
4. 输入课案标题
5. 点击创建 → 自动拉取章节正文 + 生成默认骨架
### 4.2 入口 2从教材阅读器进入
`textbook-reader.tsx` 的章节内容面板添加"为此课文备课"按钮:
- 仅教师角色可见
- 校验教师教授科目与教材学科匹配
- 点击后跳转到 `/teacher/lesson-plans/new?textbookId=xxx&chapterId=xxx`
- `template-picker.tsx` 读取 URL 参数自动预选
### 4.3 默认骨架生成
```typescript
function buildDefaultSkeleton(chapterId: string, chapterContent: string): LessonPlanDocument {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
type: "textbook_content",
position: { x: 400, y: 200 }, // 画布中央
draggable: false,
data: { chapterId, content: chapterContent, zoom: 1 },
};
const defaultNodes = [
{ type: "objective", position: { x: 80, y: 80 }, anchor: null },
{ type: "key_point", position: { x: 80, y: 180 }, anchor: null },
{ type: "import", position: { x: 80, y: 280 }, anchor: { type: "point", start: 0 } },
{ type: "text_study", position: { x: 80, y: 380 }, anchor: { type: "range", start: 0, end: 10 } },
{ type: "new_teaching", position: { x: 720, y: 80 }, anchor: { type: "range", start: 50, end: 60 } },
{ type: "exercise", position: { x: 720, y: 180 }, anchor: { type: "point", start: 100 } },
{ type: "summary", position: { x: 720, y: 280 }, anchor: { type: "point", start: 200 } },
{ type: "homework", position: { x: 80, y: 480 }, anchor: null },
{ type: "blackboard", position: { x: 720, y: 380 }, anchor: null },
{ type: "reflection", position: { x: 720, y: 480 }, anchor: null },
];
// 生成 nodes + anchors + edges
return { version: 3, textbookContentNodeId, nodes, edges, anchors };
}
```
## 第 5 节:编辑器交互改进
### 5.1 实时拖动
修改 `use-lesson-plan-editor.ts`
- 当前:`onNodeDragStop` 才调用 `updateNodePosition`
- 改为:`onNodeDrag` 实时调用 `updateNodePosition`(每次拖动事件都更新)
### 5.2 顶部工具栏增加教材/章节切换
- 显示当前教材/章节名称
- 点击可切换教材/章节
- 切换后重新拉取正文内容,更新 `TextbookContentNode.data`
- 锚点可能失效,提示教师
### 5.3 添加节点菜单
- 左下角"+ 添加节点"按钮
- 弹出 11 种节点类型菜单(不含 textbook_content
- 选择后在画布空闲位置创建节点
### 5.4 节点编辑面板
- 点击节点 → 右侧 `NodeEditPanel` 显示对应编辑组件
- `BlockRenderer` 配置驱动渲染
- 正文节点不可编辑内容但可缩放zoom 控件)
## 第 6 节:错误处理与边界情况
### 6.1 正文内容为空
- 如果章节无 `content`,正文节点显示"暂无课文内容,请在教材模块编辑"
- 锚点功能禁用
### 6.2 锚点失效
- 正文同步后,用 `textPreview` 模糊匹配重新定位
- 无法定位的锚点标记 `invalid: true`
- UI 显示"锚点已失效,请重新选择"
- 教师可删除失效锚点或重新锚定
### 6.3 数据迁移失败
- v2 文档无 chapterId创建空正文节点提示"请选择教材/章节"
- 迁移过程异常:保留 v2 原始数据,记录错误日志
## 第 7 节:测试策略
### 7.1 单元测试
- `lib/anchor-injector.ts`:占位符注入算法
- `lib/document-migration.ts`v2 → v3 迁移
- `lib/node-summary.ts`:新节点类型的摘要提取
### 7.2 集成测试
- 创建课案 → 选择教材/章节 → 验证默认骨架生成
- 选中正文文字 → 关联节点 → 验证锚点创建
- 点击正文位置 → 插入占位符 → 验证点锚定
- 选中节点 → 验证透明度变化
- 正文同步 → 验证锚点重定位
### 7.3 E2E 测试
- 完整备课流程:创建 → 编辑 → 锚定 → 保存 → 版本回退
## 实现范围
本次重构涉及以下文件(预估):
**新增**
- `src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx`
- `src/modules/lesson-preparation/components/anchor-context-menu.tsx`
- `src/modules/lesson-preparation/lib/anchor-injector.ts`
- `src/modules/lesson-preparation/components/blocks/objective-block.tsx`
- `src/modules/lesson-preparation/components/blocks/key-point-block.tsx`
- `src/modules/lesson-preparation/components/blocks/import-block.tsx`
- `src/modules/lesson-preparation/components/blocks/new-teaching-block.tsx`
- `src/modules/lesson-preparation/components/blocks/summary-block.tsx`
- `src/modules/lesson-preparation/components/blocks/homework-block.tsx`
- `src/modules/lesson-preparation/components/blocks/blackboard-block.tsx`
- `src/modules/lesson-preparation/components/blocks/reflection-block.tsx`(重构)
**修改**
- `src/modules/lesson-preparation/types.ts`v3 数据模型)
- `src/modules/lesson-preparation/constants.ts`BlockType 枚举)
- `src/modules/lesson-preparation/config/block-registry.tsx`(注册新节点)
- `src/modules/lesson-preparation/lib/document-migration.ts`v2→v3
- `src/modules/lesson-preparation/lib/node-summary.ts`(新节点摘要)
- `src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts`(实时拖动 + 锚点操作)
- `src/modules/lesson-preparation/components/node-editor.tsx`(正文节点 + 连线透明度)
- `src/modules/lesson-preparation/components/lesson-plan-editor.tsx`(教材/章节切换)
- `src/modules/lesson-preparation/components/template-picker.tsx`(强制选教材)
- `src/modules/lesson-preparation/components/blocks/text-study-block.tsx`(与正文联动)
- `src/modules/lesson-preparation/data-access.ts`buildDefaultSkeleton
- `src/modules/lesson-preparation/actions.ts`(创建课案传入 chapterId
- `src/modules/textbooks/components/textbook-reader.tsx`"为此课文备课"按钮)
- `src/shared/i18n/messages/zh-CN/lesson-preparation.json`(新 i18n 键)
- `src/shared/i18n/messages/en/lesson-preparation.json`(新 i18n 键)
- `src/app/globals.css`(锚点 CSS