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

20 KiB
Raw Permalink Blame History

备课模块重构设计 — 课文锚点画布

日期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.tsgetNodeColor,不改变现有配色。

第 1 节:整体架构与数据模型

1.1 整体布局

┌─────────────────────────────────────────────────────────────┐
│ 顶部工具栏:标题 | 教材/章节 | 保存状态 | 版本 | 保存按钮    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────┐    ┌──────────────┐    ┌─────────┐          │
│   │ 导入    │───→│  课文正文    │←───│  新授   │          │
│   │ 节点    │    │  (固定中央)  │    │  节点   │          │
│   └─────────┘    │              │    └─────────┘          │
│                  │  天气凉了①  │                          │
│   ┌─────────┐    │  天空那么蓝②│    ┌─────────┐          │
│   │ 文本研习│───→│  ...        │←───│  练习   │          │
│   │ 节点    │    │              │    └─────────┘          │
│   └─────────┘    └──────────────┘                          │
│                                                             │
│   ┌─────────┐ ┌─────────┐ ┌─────────┐                    │
│   │ 教学目标│ │ 重难点  │ │ 作业    │  (未锚定节点)        │
│   └─────────┘ └─────────┘ └─────────┘                    │
│                                                             │
│ [+ 添加节点]                              [+] [-] [⌖]      │
└─────────────────────────────────────────────────────────────┘

1.2 数据模型升级v2 → v3

// 新增:正文节点类型
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 数据类型定义

// 教学目标
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

// 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 占位符注入算法

// 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 透明度规则

/* 范围锚定:文本背景色 = 节点颜色 */
.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 选中节点的视觉反馈

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 默认骨架生成

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.tsv2 → 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.tsv3 数据模型)
  • src/modules/lesson-preparation/constants.tsBlockType 枚举)
  • src/modules/lesson-preparation/config/block-registry.tsx(注册新节点)
  • src/modules/lesson-preparation/lib/document-migration.tsv2→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.tsbuildDefaultSkeleton
  • 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