Compare commits

..

39 Commits

Author SHA1 Message Date
SpecialX
d1ad7a1f75 docs(architecture): update impact map, data, troubleshooting, add mockups
Some checks failed
Security / deep-security-scan (push) Failing after 2m10s
DR Drill / dr-drill (push) Failing after 1m32s
CI / scheduled-backup (push) Failing after 31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Remove obsolete _update_004.cjs helper script

- Update troubleshooting/known-issues.md

- Add docs/mockups/ directory
2026-07-04 23:02:14 +08:00
SpecialX
e962b67050 feat(lesson-preparation): update paper-editor, detail-panel, AI node assist, i18n
- Update actions-ai.ts and curriculum-map-view.tsx

- Update detail-panel: detail-panel, detail-props, qa-editor

- Update paper-editor: inline-node, inline-qa-dialog, paper-context-menu,

  paper-editor, textbook-tiptap-editor

- Update hooks/editor-slice.ts and lib/i18n-errors.ts

- Add hooks/use-node-ai-assist.ts and lib/ai-node-assist.ts

- Update en/zh-CN lesson-preparation i18n messages
2026-07-04 23:02:04 +08:00
SpecialX
0780524c08 docs(troubleshooting): V4 备课编辑器规则
新增 V4 备课编辑器(纸感重构)规则表,8 条规则:正文节点必须是 Tiptap 编辑器、锚点用 Tiptap Mark 内嵌、节点展开位置由 order 决定、节点类型色点用 --lp-dot-*、文档版本必须是 4、师生交互节点用 QaEditor、右键菜单触发对象区分、v3 锚点迁移失效提示。
2026-07-04 12:01:06 +08:00
SpecialX
5ca2a76e3a docs(architecture): V4 纸感重构架构图同步
004_architecture_impact_map.md: 更新 lesson-preparation 章节描述(V4 三栏布局)、新增 V4 纸感重构架构变更章节(H1/I1/J1/J2/J3/J4/J5 + 数据模型 v4)、更新文件清单(移除 9 个废弃文件,新增 paper-editor/structure-tree/detail-panel/interaction-block/anchor-migration-banner 等组件 + anchor-mark/export lib + history/expanded slices)。

005_architecture_data.json: 更新 lesson_preparation 节点——description 改为 V4 描述、dependencies 移除 @xyflow/react 改为 @tiptap/* 包、files 数组移除 9 个废弃文件并新增 V4 组件、auditFixes 新增 V4-PAPER-1~8 + V4-PAPER-DOC 条目。JSON 语法验证通过。
2026-07-04 11:59:39 +08:00
SpecialX
0e4eccec21 chore(lesson-preparation): V4 lint + tsc 零错误验证
重构 anchor-migration-banner:使用 key-based remount + lazy initial state 避免 setState in effect,符合 V4 react-hooks/set-state-in-effect 规则。

验证:tsc --noEmit 0 错误;lint 中 V4 引入的错误已全部修复。剩余 6 个 lint 错误为预存无关模块问题(exams/use-exam-preview-tasks react-hooks/refs 3 个、template-picker V5-9 set-state-in-effect 1 个),13 个 warnings 均为预存代码(schedule-dialog、unread-message-badge 等),按计划要求已有的无关模块错误需记录但不要求修复。
2026-07-04 11:53:13 +08:00
SpecialX
e762490f19 refactor(lesson-preparation): print-view 适配 V4
- export.ts 已使用 order 排序,无画布依赖
- 新增 interaction block 的 flatten 逻辑(设计意图 + 对话轮次)
- 更新文档注释(12 种 Block)
2026-07-04 11:47:50 +08:00
SpecialX
5d306dc686 refactor(lesson-preparation): 删除废弃的 React Flow 画布和字符串锚点文件
- 删除 node-editor.tsx(React Flow 画布)
- 删除 nodes/lesson-node.tsx、textbook-content-node.tsx、textbook-segments.tsx、anchor-node-selector.tsx
- 删除 lib/anchor-injector.ts(字符串偏移系统)
- 删除 lib/rf-mappers.ts(React Flow 映射)
- 删除 lib/auto-layout.ts(自动布局)
- 重构 lesson-plan-readonly-view.tsx 改用线性视图(LessonPlanMobileView)
2026-07-04 11:46:31 +08:00
SpecialX
124ed97060 refactor(lesson-preparation): 主编辑器三栏布局(结构树 + 纸 + 详情面板)
- 移除 NodeEditor/NodeEditPanel 导入和 JSX
- 改为 StructureTree + PaperEditor + DetailPanel 三栏布局
- 新增 AnchorMigrationBanner
- 移除 aiContentGenerator prop(V4 DetailPanel 自带 AI 按钮)
- 删除 node-edit-panel.tsx(不再被引用)
- 删除 ai-content-generator-slot.tsx(依赖 node-edit-panel)
2026-07-04 11:44:26 +08:00
SpecialX
e29f30d278 feat(lesson-preparation): v3 锚点失效提示 banner 2026-07-04 11:41:24 +08:00
SpecialX
6dd3b1fecd feat(lesson-preparation): 注册 interaction block 到 registry 2026-07-04 11:40:46 +08:00
SpecialX
85121c49a3 fix(lesson-preparation): 重命名 TreeNodeRow.children prop 为 childItems 避免 react/no-children-prop 2026-07-04 11:36:41 +08:00
SpecialX
8ff14fce1c feat(lesson-preparation): 右栏详情面板容器 2026-07-04 11:33:03 +08:00
SpecialX
24b8ae78c6 feat(lesson-preparation): 师生交互 block(详情编辑入口) 2026-07-04 11:32:58 +08:00
SpecialX
43f30f4013 feat(lesson-preparation): 师生交互编辑器(对话轮次增删改) 2026-07-04 11:32:54 +08:00
SpecialX
40cba54ed4 feat(lesson-preparation): 详情面板属性条 2026-07-04 11:32:49 +08:00
SpecialX
a4c9eb02b4 feat(lesson-preparation): 详情面板头部 2026-07-04 11:32:44 +08:00
SpecialX
05f9b4fd9d feat(lesson-preparation): 左栏结构树容器 2026-07-04 11:31:03 +08:00
SpecialX
c7cbc86fd4 feat(lesson-preparation): 树节点行组件 2026-07-04 11:30:57 +08:00
SpecialX
6fa712ad15 feat(lesson-preparation): 中栏纸区容器(正文 + 展开节点 + 右键菜单) 2026-07-04 11:29:53 +08:00
SpecialX
4db77b7d1e feat(lesson-preparation): 右键菜单(节点操作 + AI 协助 + 锚定) 2026-07-04 11:29:14 +08:00
SpecialX
cbed4ef508 feat(lesson-preparation): 师生交互对话体 inline 渲染 2026-07-04 11:28:16 +08:00
SpecialX
8583e3e387 feat(lesson-preparation): inline-node 展开节点容器 2026-07-04 11:28:11 +08:00
SpecialX
9b0b7ef619 feat(lesson-preparation): 浮动工具条 + 锚点/inline-node/对话体样式 2026-07-04 11:26:55 +08:00
SpecialX
66a60213bf feat(lesson-preparation): 正文 Tiptap 编辑器 2026-07-04 11:26:50 +08:00
SpecialX
1b898fb6cf fix(lesson-preparation): BLOCK_REGISTRY 补全 interaction 空条目(A1 类型变更扫尾,渲染逻辑留给 H1) 2026-07-04 11:20:18 +08:00
SpecialX
d7b15093f9 fix(lesson-preparation): BLOCK_TYPE_KEYS 补全 interaction(A1 类型变更扫尾) 2026-07-04 11:19:44 +08:00
SpecialX
5c4db2fedc feat(lesson-preparation): Tiptap AnchorMark + AnchorPoint 扩展 2026-07-04 11:18:56 +08:00
SpecialX
ccb2cf05df feat(lesson-preparation): V4 i18n 翻译键(zh-CN + en) 2026-07-04 11:18:03 +08:00
SpecialX
4f00e566df refactor(lesson-preparation): 配色统一到中性令牌(移除 Material 鲜艳色) 2026-07-04 11:17:07 +08:00
SpecialX
ef1be6d04f refactor(lesson-preparation): editor-slice 移除画布依赖(V4) 2026-07-04 11:16:05 +08:00
SpecialX
0f4e58d7e1 feat(lesson-preparation): selection-slice 增加 anchorNodeForSelection 2026-07-04 11:15:14 +08:00
SpecialX
a856ab005d feat(lesson-preparation): 组合 ExpandedSlice 到 EditorState 2026-07-04 11:15:01 +08:00
SpecialX
7b550d3b69 feat(lesson-preparation): expanded-slice(节点展开状态) 2026-07-04 11:14:46 +08:00
SpecialX
f7ea76cd60 feat(lesson-preparation): V3→V4 迁移函数(锚点失效标记 + expandedNodeIds) 2026-07-04 11:14:27 +08:00
SpecialX
8a7d0f69ca feat(lesson-preparation): isInteractionBlockData 类型守卫 2026-07-04 11:12:33 +08:00
SpecialX
217b5b48e4 feat(lesson-preparation): interaction 默认数据生成器 2026-07-04 11:12:13 +08:00
SpecialX
8fc798fbcf feat(lesson-preparation): V4 类型定义(interaction + QATurn + V4 文档) 2026-07-04 11:11:54 +08:00
SpecialX
ccf1618b1c docs(lesson-preparation): V4 纸感重构实现计划
25 个任务,按阶段 A-J 分解:类型定义、状态管理、配色 i18n、Tiptap 锚点、纸区组件、结构树、详情面板、主编辑器改造、迁移 banner、清理验证。
2026-07-04 11:08:28 +08:00
SpecialX
a1c283e10d docs(lesson-preparation): 备课编辑器无边记纸感重构设计
新增设计文档,定义正文节点改造为真实 Tiptap 富文本编辑器、三栏布局(结构树 + 纸 + 详情面板)、节点展开到正文、师生交互节点、Tiptap Mark 锚点系统、v3→v4 数据迁移。
2026-07-04 10:58:55 +08:00
57 changed files with 10324 additions and 2751 deletions

View File

@@ -2544,44 +2544,47 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
## 2.27 lesson-preparation备课模块
**职责**:教师备课,基于教材章节创建课案(**节点图编辑器 React Flow**),支持模板、版本管理、知识点标注、题目创建/拉取、作业发布。
**职责**:教师备课,基于教材章节创建课案(**V4 纸感重构:三栏布局 StructureTree + PaperEditor + DetailPanel**),正文采用 Tiptap 富文本编辑器AnchorMark/AnchorPoint 内联锚点),支持模板、版本管理、知识点标注、题目创建/拉取、作业发布、撤销/重做、AI 反馈/差异化、一致性校验、课时安排
> 架构变更2026-06-21编辑器从列表式BlockRenderer + @dnd-kit升级为节点图式NodeEditor + @xyflow/react。数据结构从 v1blocks 数组)升级到 v2nodes + edges 节点图),旧数据通过 `migrateV1ToV2()` 自动迁移。
> 架构变更2026-06-23数据结构从 v2 升级到 v3课文锚点画布格式。正文节点textbook_content作为画布中央核心教学节点围绕正文组织。新增锚点系统range/point 两种锚定方式、占位符注入算法、11 种定制节点类型。旧数据通过 `migrateV2ToV3()` 自动迁移。
> 架构变更2026-07-04V4 纸感重构):抛弃 React Flow 画布改用三栏布局260px 结构树 + 1fr 纸感编辑器 + 380px 详情面板)。正文采用 Tiptap 富文本编辑器,锚点从字符串偏移升级为 Tiptap MarkAnchorMark 区间锚/AnchorPoint 点锚v3 旧锚点通过 AnchorMigrationBanner 提示失效。doc.version=4editor-slice 移除 updateNodePosition/connect/setEdges/autoLayout。新增 interaction block师生交互对话回合。结构树支持节点展开到正文流expanded-slice。PrintView 改为按 nodes.order 排序渲染。删除 8 个 React Flow 画布和字符串锚点相关文件node-editor/nodes/*/anchor-injector/rf-mappers/auto-layout
**数据结构**
- v1已废弃仅向后兼容读取`{ version: 1, blocks: Block[] }`
- v2已废弃仅向后兼容读取`{ version: 2, nodes: LessonPlanNode[]; edges: LessonPlanEdge[] }`
- v3当前`{ version: 3, textbookContentNodeId: string; nodes: AnyLessonPlanNode[]; edges: AnyLessonPlanEdge[]; anchors: NodeAnchor[] }`
- `TextbookContentNode`:正文节点(`draggable: false`,画布中央,可缩放)
- `LessonPlanNode`:教学节点(`Block` + `position`,可拖动可连线)
- `NodeAnchor`:锚点(`{ id, nodeId, type: "range"|"point", start, end?, textPreview?, invalid? }`
- `AnchorEdge`:锚点连线(教学节点 → 正文节点,默认 10% 透明度
- `FlowEdge`:流程连线(教学节点 → 教学节点
- v3已废弃,仅向后兼容读取`{ version: 3, textbookContentNodeId: string; nodes: AnyLessonPlanNode[]; edges: AnyLessonPlanEdge[]; anchors: NodeAnchor[] }`
- `NodeAnchor`v3 字符串偏移锚点(`{ id, nodeId, type: "range"|"point", start, end?, textPreview?, invalid? }`V4 中通过 `AnchorMigrationBanner` 提示失效
- v4当前`{ version: 4, textbookContentNodeId: string; nodes: AnyLessonPlanNode[]; edges: AnyLessonPlanEdge[]; anchors: NodeAnchor[] }`
- 锚点改为 Tiptap Mark`AnchorMark`(区间锚)+ `AnchorPoint`(点锚),存储在正文富文本 doc 中
- `nodes` 新增 `order` 字段,按顺序渲染(替代画布 position
- `InteractionBlockData`:师生交互对话回合(`{ designIntent, turns: QATurn[], expectedAnswer? }`
**导出函数**
- Data-access`data-access.ts``getLessonPlans`V4查询后按 textbookId+chapterId+creatorId 聚合版本,返回代表项 + versionCount + versions 摘要数组)/ `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`v3 规范化,兼容 v1/v2 旧数据)/ `buildDefaultSkeleton`v3 默认 10 节点骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById` / `publishLessonPlan`V3 新增,设置 status=published/ `unpublishLessonPlan`V3 新增,设置 status=draft仅 published 课案)
- Data-access`data-access.ts``getLessonPlans`V4查询后按 textbookId+chapterId+creatorId 聚合版本,返回代表项 + versionCount + versions 摘要数组)/ `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`v4 规范化,兼容 v1/v2/v3 旧数据)/ `buildDefaultSkeleton`默认骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById` / `publishLessonPlan` / `unpublishLessonPlan`
- Lib`lib/document-migration.ts``defaultDataForType` / `migrateV1ToV2` / `migrateV2ToV3` / `normalizeDocument` / `buildInitialContent` / `buildDefaultSkeleton` / `isTextbookContentNode` / `isAnchorEdge` / `getAnchorsForNode` / `getActiveAnchorIds` / `getAnchorEdges`
- Lib`lib/anchor-injector.ts``markdownToPlainText` / `injectPlaceholders` / `parseAnchoredText` / `toCircledNumber` / `getNextPointIndex` / `relocateAnchors` / `getAnchorColor`
- Lib`lib/anchor-mark.ts`V4 新增)Tiptap `AnchorMark` 扩展(区间锚)+ `AnchorPoint` 扩展(点锚),替代 v3 字符串偏移锚点
- Lib`lib/node-summary.ts``getNodeSummary` / `getTextbookContentSummary` / `getNodeColor` / `NODE_COLORS`
- Lib`lib/rf-mappers.ts``toRfNodes`(支持 textbook_content 节点V3ctx 新增 `anchorableNodes``onCreateNewNode` 字段)/ `toRfEdges`(区分 anchor/flow 边透明度V3锚点边颜色使用 `getNodeColor(anchor.nodeId)` 替代硬编码anchorId 存入 edge.data/ `fromRfEdges`V3`e.data.anchorId` 读取 anchorId回退到 className 判断)
- Lib`lib/export.ts`V5-4 新增 + V4 增强):`flattenLessonPlanForPrint` / `flattenBlockData`V4新增 `interaction` case + `flattenInteraction` 函数)/ `PrintableLessonPlan` 类型
- Data-access-versions`data-access-versions.ts``getLessonPlanVersions` / `createLessonPlanVersion` / `getVersionContent` / `revertToVersion` / `pruneAutoVersions`
- Data-access-templates`data-access-templates.ts``getLessonPlanTemplates` / `saveAsTemplate` / `deletePersonalTemplate`
- Data-access-knowledge`data-access-knowledge.ts``getLessonPlansByKnowledgePoint`V3增加 userId 参数,按 creatorId 过滤 + 排除 archived/ `getLessonPlansByQuestion`V3增加 userId 参数,按 creatorId 过滤 + 排除 archived
- Data-access-knowledge`data-access-knowledge.ts``getLessonPlansByKnowledgePoint` / `getLessonPlansByQuestion`
- Publish-service`publish-service.ts``publishLessonPlanHomework`
- AI-suggest`ai-suggest.ts``suggestKnowledgePoints`
- Actions`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction` / `publishLessonPlanAction`V3 新增requirePermission(LESSON_PLAN_PUBLISH)/ `unpublishLessonPlanAction`V3 新增requirePermission(LESSON_PLAN_PUBLISH)
- Actions`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction` / `publishLessonPlanAction` / `unpublishLessonPlanAction`
**依赖关系**
- 依赖:`shared/*``@/auth``shared/lib/ai``@xyflow/react`(节点图编辑器)、`textbooks`(只读章节/知识点树)、`questions`(创建/查询题目)、`exams`(创建 exam 草稿)、`homework`(创建作业下发)、`classes`(查询教师班级)、`files`(附件)
- 依赖:`shared/*``@/auth``shared/lib/ai``@tiptap/react``@tiptap/starter-kit``@tiptap/pm``@tiptap/extension-image`V4Tiptap 富文本编辑器)、`textbooks`(只读章节/知识点树)、`questions`(创建/查询题目)、`exams`(创建 exam 草稿)、`homework`(创建作业下发)、`classes`(查询教师班级)、`files`(附件)
- 被依赖:无
**已知问题**
- ✅ 通过对方 data-access 调用跨模块数据,无直查跨模块表
- ✅ data-access 按职责拆分为 4 个文件data-access/data-access-versions/data-access-templates/data-access-knowledge
- ✅ actions 按职责拆分为 4 个文件actions/actions-publish/actions-ai/actions-kp
-编辑器架构升级NodeEditorReact Flow 画布)+ NodeEditPanel侧边内容编辑面板+ LessonNode自定义节点组件支持节点拖拽、连线、画布缩放
-V4 纸感重构:三栏布局 StructureTree + PaperEditor + DetailPanel正文采用 Tiptap 富文本编辑器,锚点升级为 Tiptap MarkAnchorMark/AnchorPoint
- ✅ V4删除 8 个 React Flow 画布和字符串锚点相关文件node-editor/nodes/*/anchor-injector/rf-mappers/auto-layout无残留引用
- ✅ V4`block-renderer.tsx`@deprecated)已删除,无残留引用
> 架构变更2026-06-22本次审计修复
@@ -2671,10 +2674,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `schema.ts` | Zod 验证V3错误消息改为 i18n 键,如 `error.titleRequired` |
| `lib/type-guards.ts` | **集中类型守卫V3 新增)**11 种 BlockData 类型守卫isRichTextBlockData/isTextStudyBlockData/isExerciseBlockData/isObjectiveBlockData/isKeyPointBlockData/isImportBlockData/isNewTeachingBlockData/isSummaryBlockData/isHomeworkBlockData/isBlackboardBlockData/isReflectionBlockData+ 节点类型守卫isTextbookContentNode/isLessonPlanNode+ 题目类型守卫isValidQuestionType + **V4 导出 `VALID_QUESTION_TYPES` 常量 + `ValidQuestionType` 类型**+ 基础类型守卫isLessonPlanStatus/isTemplateType/isTemplateScope/isBlockType+ **Block 字段值类型守卫V3 续新增)**isBlackboardLayout/isImportMethod/isExercisePurpose/isObjectiveDimension/isKeyPointType/isHomeworkType/isReflectionAspect用于 select onChange 替代 `as` 断言)+ **normalizeTemplateBlocks 规范化函数V3 续审计新增)**:从 DB `unknown` 安全转换为 `TemplateBlockSkeleton[]`,替代 `as LessonPlanTemplate["blocks"]` 断言 |
| `lib/i18n-errors.ts` | **Zod 错误 i18n 翻译辅助V3 新增)**`translateFieldErrors`(将 Zod fieldErrors 中的 i18n 键翻译为实际消息)/ `safeParseWithI18n`(安全解析 Zod 结果并返回带翻译的 ActionState 错误格式) |
| `lib/document-migration.ts` | **纯函数**v1→v2migrateV1ToV2/ v2→v3migrateV2ToV3/ 规范化normalizeDocument兼容 v1/v2/v3/ 初始内容buildInitialContent/ 默认骨架buildDefaultSkeleton10 节点 + 正文节点/ defaultDataForType / 工具函数isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges |
| `lib/anchor-injector.ts` | **纯函数**锚点注入算法markdownToPlainText/injectPlaceholders/parseAnchoredText/toCircledNumber/getNextPointIndex/relocateAnchors/getAnchorColor**V4`markdownToPlainText` 引用去除 regex 与 `skipPatterns` 一致化为 `/^\s*>\s+/gm`5 个 skipPatterns 统一添加 `g` 标志** |
| `lib/node-summary.ts` | **纯函数**getNodeSummary支持 11 种节点类型)+ getTextbookContentSummary + NODE_COLORS + getNodeColorV3 续审计:移除 `as { html?: string; ... }` 断言,改用 isRecord/getArrayLength/getString/getNumber 安全字段提取从 `unknown` 收窄) |
| `lib/rf-mappers.ts` | **纯函数**toRfNodesV3:移除冗余 `as` 断言TypeScript 判别联合自动收窄)/ toRfEdges / fromRfEdges |
| `lib/document-migration.ts` | **纯函数**v1→v2migrateV1ToV2/ v2→v3migrateV2ToV3/ 规范化normalizeDocument兼容 v1/v2/v3/v4/ 初始内容buildInitialContent/ 默认骨架buildDefaultSkeleton/ defaultDataForType / 工具函数isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges |
| `lib/anchor-mark.ts` | **V4 新增**Tiptap `AnchorMark` 扩展(区间锚)+ `AnchorPoint` 扩展(点锚),替代 v3 字符串偏移锚点;存储在正文富文本 doc 中 |
| `lib/export.ts` | **V5-4 新增 + V4 增强**`flattenLessonPlanForPrint`(按 `nodes.order` 排序扁平化为 `PrintableLessonPlan`+ `flattenBlockData`V4新增 `interaction` case + `flattenInteraction` 函数)+ `PrintableLessonPlan` 类型;支持 detailed/concise 两种 variant |
| `lib/node-summary.ts` | **纯函数**getNodeSummary支持 12 种节点类型,含 V4 interaction+ getTextbookContentSummary + NODE_COLORS + getNodeColorV3 续审计:移除 `as { html?: string; ... }` 断言,改用 isRecord/getArrayLength/getString/getNumber 安全字段提取从 `unknown` 收窄) |
| `config/block-registry.tsx` | **配置驱动**BLOCK_REGISTRY 注册表 + BlockRendererV3使用 `lib/type-guards.ts` 类型守卫替代所有 `as XxxBlockData` 断言) |
| `providers/lesson-plan-provider.tsx` | **Provider + Context**LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口V3扩展 5 个方法 getLessonPlanById/updateLessonPlan/saveLessonPlanVersion/publishLessonPlan/unpublishLessonPlan**V3 续扩展 7 个方法 createLessonPlan/getTextbooksForPicker/getChaptersForPicker/getLessonPlanTemplates/getKnowledgePointOptions/publishLessonPlanHomework/getQuestions + 6 个导出类型**、4 个角色配置、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTrackerhooksuseLessonPlanContextSafe/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe |
| `providers/lesson-plan-provider-setup.tsx` | **页面层 Provider 设置包装组件V3 续新增)**LessonPlanProviderSetup 自动注入默认数据服务createDefaultDataService和角色配置TEACHER_ROLE_CONFIG3 个 teacher 页面用此组件包裹 |
@@ -2691,20 +2694,39 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `ai-suggest.ts` | AI 知识点建议服务V3 续审计:移除 `as Record<string, unknown>` 断言改用 `isRecord` 类型守卫AI prompt 提取为 `AI_SUGGEST_PROMPT_TEMPLATE` 模块常量) |
| `seed-templates.ts` | 模板种子数据 |
| `hooks/use-lesson-plan-editor.ts` | **课案编辑器 Zustand store 主文件V4从 303 行单体 store 重写为 25 行薄层,组合 3 个 slice 并导出 `EditorState` 类型)**V3`updateNode` patch 类型改为 `Omit<Partial<Block>, "type">` 防止类型变更) |
| `hooks/editor-slice.ts` | **编辑器 sliceV4 P2-6 新增)**Zustand `StateCreator`,包含 planId/title/doc 状态及所有文档操作方法addNode/updateNode/updateNodePosition/removeNode/updateTextbookContent/getTextbookContentNode/addAnchor/removeAnchor/updateAnchor/connect/disconnect/setEdges/setTitle/setPlanId |
| `hooks/editor-slice.ts` | **编辑器 sliceV4 P2-6 新增 + V4 纸感重构**Zustand `StateCreator`,包含 planId/title/doc 状态及所有文档操作方法addNode/updateNode/removeNode/updateTextbookContent/getTextbookContentNode/addAnchor/removeAnchor/updateAnchor/setTitle/setPlanId**V4 移除**updateNodePosition/connect/disconnect/setEdges/autoLayout画布相关方法 |
| `hooks/selection-slice.ts` | **选中状态 sliceV4 P2-6 新增)**Zustand `StateCreator`,包含 selectedNodeId 状态 + selectNode 方法 |
| `hooks/version-slice.ts` | **版本与保存状态 sliceV4 P2-6 新增)**Zustand `StateCreator`,包含 isDirty/isSaving/lastSavedAt 状态 + hydrate/markSaved/setSaving/replaceDoc 方法 |
| `hooks/version-slice.ts` | **版本与保存状态 sliceV4 P2-6 新增)**Zustand `StateCreator`,包含 isDirty/isSaving/lastSavedAt/saveError/isOnline 状态 + hydrate/markSaved/setSaving/replaceDoc/setSaveError/setOnline 方法 |
| `hooks/history-slice.ts` | **撤销/重做 sliceV5-2 新增)**Zustand `StateCreator`past/future 栈50 步上限)+ canUndo/canRedo/pushHistory/undo/redo/clearHistory 方法 |
| `hooks/expanded-slice.ts` | **V4 纸感重构新增**Zustand `StateCreator`节点展开到正文流状态expandedNodeIds/toggleExpand/setExpanded |
| `components/lesson-plan-list.tsx` | 课案列表V3完全通过 service 调用,移除直接 import `getLessonPlansAction`V3 续审计:新增 `isLoading` 状态,加载时显示加载文本,传递给 `LessonPlanFilters` |
| `components/lesson-plan-card.tsx` | 课案卡片V3完全通过 service 调用,移除直接 import actionsV3 续审计4 个异步处理函数用 `useCallback` 包裹性能优化) |
| `components/lesson-plan-filters.tsx` | 课案筛选器V3 续审计:新增 `isLoading` prop加载时禁用输入并设置 `aria-busy` |
| `components/lesson-plan-editor.tsx` | 课案编辑器V3完全通过 service 调用,移除直接 import 5 个 actions所有操作改为 `service.updateLessonPlan`/`service.saveLessonPlanVersion`/`service.getLessonPlanById`/`service.publishLessonPlan`/`service.unpublishLessonPlan` |
| `components/lesson-plan-readonly-view.tsx` | **只读画布组件**V3MiniMap 使用 `getNodeColor` 替代硬编码颜色) |
| `components/node-editor.tsx` | **节点图画布**V3MiniMap `nodeColor` 使用类型守卫替代 `as { node?: AnyLessonPlanNode }` 断言) |
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**V3移除冗余 `as LessonPlanNode` 断言TypeScript 判别联合自动收窄) |
| `components/nodes/lesson-node.tsx` | **自定义教学节点组件**V3 续审计:使用 `isLessonNodeData` 类型守卫替代 `as { node: LessonPlanNode }` 断言 + 空状态回退) |
| `components/nodes/textbook-content-node.tsx` | **正文节点组件**V3 续:拆分为 3 文件,主文件 471 行;`as unknown as` 替换为 `isTextbookContentNodePropsData` 类型守卫) |
| `components/nodes/anchor-node-selector.tsx` | **锚点节点选择器V3 续新增,从 textbook-content-node.tsx 抽取)**:渲染可锚定教学节点列表 + 关联到选中节点 + 创建新节点选项 |
| `components/nodes/textbook-segments.tsx` | **锚点段落渲染函数V3 续新增,从 textbook-content-node.tsx 抽取)**renderSegments 遍历 segments 数组渲染文本/区间锚点/点锚点 |
| `components/lesson-plan-editor.tsx` | 课案编辑器V3完全通过 service 调用**V4 纸感重构**:移除 NodeEditor/NodeEditPanel改用三栏布局 StructureTree + PaperEditor + DetailPanel保留顶部工具栏 + 5 个对话框 |
| `components/lesson-plan-readonly-view.tsx` | **只读视图**V3MiniMap 使用 `getNodeColor` 替代硬编码颜色**V4**:移除 React Flow 依赖,委托 `LessonPlanMobileView` 线性卡片视图渲染所有视口 |
| `components/anchor-migration-banner.tsx` | **V4 新增**v3 锚点失效提示 banner检测 `doc.anchors` `invalid:true` 或含 `start` 字段的旧锚点;通过 `key={planId}` 重新挂载 + lazy initial state 读取 localStorage 避免 setState in effect |
| `components/print-view.tsx` | **V5-4 新增 + V4 适配**:打印视图组件,按 `nodes.order` 排序渲染,详细版/简洁版切换 + `window.print()` |
| `components/schedule-dialog.tsx` | **V5-7 新增**:课案绑定课时对话框 |
| `components/consistency-check-dialog.tsx` | **V5-19 新增**:目标-评价一致性校验对话框 |
| `components/ai-feedback-dialog.tsx` | **V5-17 新增**AI 反馈闭环对话框4 维度 strengths/improvements/alignment/differentiation |
| `components/ai-differentiation-dialog.tsx` | **V5-21 新增**AI 差异化教学对话框3 Tab差异化建议/课标核对/可解释评估) |
| `components/curriculum-heatmap.tsx` | **V5-20 新增**:课标覆盖热力图组件 |
| `components/version-diff-view.tsx` | **V5-16 新增**:版本对比视图组件 |
| `components/lesson-plan-mobile-view.tsx` | **V5-13 新增**:移动端只读视图(按教学阶段分组线性卡片视图);**V4**:被 `lesson-plan-readonly-view.tsx` 委托渲染所有视口 |
| `components/attachment-picker.tsx` | **V5-5 新增**:素材库 picker复用 use-file-upload + M6 附件表 data-access |
| `components/paper-editor/paper-editor.tsx` | **V4 新增**:纸感编辑器容器(中栏),承载 Tiptap 富文本编辑器 |
| `components/paper-editor/paper-toolbar.tsx` | **V4 新增**:纸感编辑器工具栏 |
| `components/paper-editor/paper-context-menu.tsx` | **V4 新增**:纸感编辑器右键菜单 |
| `components/paper-editor/textbook-tiptap-editor.tsx` | **V4 新增**:教材正文 Tiptap 编辑器(集成 AnchorMark/AnchorPoint 扩展) |
| `components/paper-editor/inline-node.tsx` | **V4 新增**:内联节点渲染组件 |
| `components/paper-editor/inline-qa-dialog.tsx` | **V4 新增**:内联问答对话框 |
| `components/structure-tree/structure-tree.tsx` | **V4 新增**:结构树容器(左栏 260px支持节点展开到正文流 |
| `components/structure-tree/tree-node-row.tsx` | **V4 新增**:结构树节点行组件 |
| `components/detail-panel/detail-panel.tsx` | **V4 新增**:详情面板容器(右栏 380px |
| `components/detail-panel/detail-head.tsx` | **V4 新增**:详情面板头部 |
| `components/detail-panel/detail-props.tsx` | **V4 新增**:详情面板属性编辑 |
| `components/detail-panel/qa-editor.tsx` | **V4 新增**师生问答编辑器interaction block 配套) |
| `components/blocks/interaction-block.tsx` | **V4 新增**:师生交互 BlockdesignIntent + turns[] + expectedAnswer |
| `components/lesson-plan-error-boundary.tsx` | **错误边界**V3 续:重写为包装组件模式——内部类组件接受 `errorText`/`retryText` props外部函数组件通过 `useTranslations` 注入 i18n 文案) |
| `components/lesson-plan-skeleton.tsx` | **骨架屏**VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton |
| `components/template-picker.tsx` | 模板选择器V3 续:完全通过 service 调用,移除直接 import 4 个 actionsV3 续审计:通过 `ChapterPickerOption.children` 递归类型消除 `as ChapterPickerOption[]` 断言) |
@@ -2788,6 +2810,20 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
> - **i18n 扩展**zh-CN/en 同步新增 editor.stage*/editor.differentiation*/editor.consistency*/consistency.*/diff.*/library.*/feedback.*/heatmap.*/aiDifferentiation.*/blackboard.editMode/previewMode/editHint/previewEmpty/untitled
> - **V5-11 R3 协同编辑**需引入新依赖Yjs/Liveblocks或采用轻量协作方案评论+@提醒+版本对比),暂标记为待规划,待用户决策
> 架构变更2026-07-04V4 纸感重构 — 抛弃 React Flow 画布,改用三栏布局 + Tiptap 富文本编辑器):
> - **H1 interaction block 注册**`config/block-registry.tsx` 新增 `interaction` case导入 `InteractionBlock` 组件 + `isInteractionBlockData` 类型守卫BLOCK_REGISTRY 支持 12 种 Block 类型(含 V4 interaction
> - **H2 三栏布局重构**`lesson-plan-editor.tsx` 移除 `NodeEditor`/`NodeEditPanel` 导入与 JSX改用 `StructureTree + PaperEditor + DetailPanel` 三栏布局(`grid-template-columns: 260px 1fr 380px`),保留顶部工具栏(标题/保存/撤销重做/发布/导出/安排课时/一致性校验/AI 反馈/AI 差异化)+ 5 个对话框VersionHistoryDrawer/ConsistencyCheckDialog/AiFeedbackDialog/AiDifferentiationDialog/PrintView/ScheduleDialog删除 `components/node-edit-panel.tsx` + `app/(dashboard)/teacher/lesson-plans/[planId]/edit/ai-content-generator-slot.tsx`(依赖 NodeEditPanel 类型)
> - **I1 v3 锚点失效 banner**:新增 `components/anchor-migration-banner.tsx`,检测 `doc.anchors` 中 `invalid:true` 或含 `start` 字段的旧锚点并提示用户;通过 `key={planId}` 实现 planId 变化时重新挂载内部组件,使用 lazy initial state 读取 localStoragedismiss 状态按 planId 隔离),避免 useEffect 中 setState符合 V4 `react-hooks/set-state-in-effect` lint 规则)
> - **J1 删除废弃文件**:删除 8 个 React Flow 画布和字符串锚点相关文件——`components/node-editor.tsx`、`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`、`lib/auto-layout.ts``lesson-plan-readonly-view.tsx` 重写为委托 `LessonPlanMobileView`(线性卡片视图)渲染所有视口,移除 React Flow 依赖
> - **J2 PrintView 适配 V4**`lib/export.ts` 的 `flattenBlockData` switch 新增 `interaction` case + `flattenInteraction` 函数(输出设计意图 + 师生对话回合 + 预期回答export.ts 已使用 `nodes.sort((a, b) => a.order - b.order)` 按顺序扁平化无画布依赖docstring 更新为 12 种 Block含 V4 interaction
> - **J3 lint + tsc 零错误验证**tsc --noEmit 0 错误lint 中 V4 引入的 `react-hooks/set-state-in-effect` 错误已通过 `anchor-migration-banner.tsx` 的 key-based remount + lazy initial state 模式修复;剩余 6 个 lint 错误为预存无关模块问题exams/use-exam-preview-tasks react-hooks/refs 3 个、template-picker V5-9 set-state-in-effect 1 个),按计划要求记录不修复
> - **J4 架构图同步**004_architecture_impact_map.md 新增 V4 纸感重构章节005_architecture_data.json 更新 lesson_preparation 节点——description 改为 V4 描述、dependencies 移除 `@xyflow/react` 改为 `@tiptap/*` 包、files 数组移除 9 个废弃文件并新增 V4 组件、auditFixes 新增 V4-PAPER-1~8 + V4-PAPER-DOC 条目
> - **J5 known-issues V4 规则**`docs/troubleshooting/known-issues.md` 新增 V4 纸感重构规则表,记录 8 条规则——React Flow 弃用、Tiptap Mark 锚点替代字符串偏移、setState in effect 禁用、doc.version=4、editor-slice 移除画布相关方法、interaction block 注册、PrintView 按 order 排序、AnchorMigrationBanner 失效提示
> - **数据模型 v4**`types.ts` `LessonPlanDocument.version = 4``lib/document-migration.ts` `normalizeDocument` 兼容 v1/v2/v3/v4 链式迁移;`editor-slice.ts` 移除 `updateNodePosition`/`connect`/`setEdges`/`autoLayout` 画布相关方法;`hooks/expanded-slice.ts` 新增 `expandedNodeIds`/`toggleExpand`/`setExpanded` 状态(节点展开到正文流);`lib/anchor-mark.ts` 新增 Tiptap `AnchorMark` + `AnchorPoint` 扩展(替代 v3 字符串偏移锚点)
> - **V2-1 复制节点**`hooks/editor-slice.ts` 新增 `duplicateNode(id)` 方法(深拷贝 `BlockData` + 新 id + 标题加"副本" + order 置末interaction 节点的 turns 重新生成 id 避免冲突);`components/paper-editor/paper-context-menu.tsx` 的 `copyNode` 从 toast 占位改为调用 `duplicateNode` 并 toast 成功提示i18n 新增 `v4.contextMenu.copied`/`copyFailed` 键zh-CN + en
> - **V2-2 节点级 AI 协助 4 项**`lib/ai-node-assist.ts` 新增 4 个服务函数(`generateNodeContent`/`optimizeNodeExpression`/`suggestNodeDifferentiation`/`generateLayeredQuestions`全部纯服务端、Zod 校验、失败返回 null`actions-ai.ts` 新增 4 个 Server Action每个 action 校验 `LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT` 三个权限点;`hooks/use-node-ai-assist.ts` 新增客户端 hook`components/paper-editor/paper-editor.tsx` 注入 `onAiAction` 回调;`components/detail-panel/detail-panel.tsx` 的 4 个 `AiButton` 接入真实 actioni18n 新增 `v4.contextMenu.aiRunning`/`aiSuccess`/`aiFailed`/`aiComingSoon` 键zh-CN + en
> - **V4-I18N-1 i18n 完整审查与修复**:对 lesson-preparation 模块 i18n 配置进行全量审查并修复——P0 修复运行时 MISSING_MESSAGE`version.diff.*` 3 键title/summary/noChanges+ `schema.ts` 引用的 8 个 `error.*` 键labelTooLong/versionNoInvalid/nameRequired/nameTooLong/queryTooLong/blockIdRequired/commentTooLong/reviewerRequired+ `v4.contextMenu` 6 键copied/copyFailed/aiComingSoon/aiRunning/aiSuccess/aiFailedzh-CN + en 同步P1 修复 en locale 下大面积报错en `calendar` 节键名对齐 zh-CNweekView/monthView/loadFailed + prev/next/eventMeta/weekDays 子节en `analytics` 节补齐 totalPublished/totalSubmitted/totalStandardsLinked/loadFailedP2 修复 7 处硬编码中文detail-props.tsx 的 stageLabel/differentiationLabel、inline-node.tsx 的 exerciseCount、inline-qa-dialog.tsx 的 expectedAnswer、qa-editor.tsx 的 turnContentPlaceholder、curriculum-map-view.tsx 的 legendTitle/noData并新增对应 6 个 i18n 键(`v4.detail.stageLabel`/`differentiationLabel`/`exerciseCount`/`turnContentPlaceholder`、`v4.heatmap.legendTitle`/`noData`);清理 7 个 dead 节review/comment/formative/substitute/evaluation/standards/gradeHeadanalytics 因被 `app/(dashboard)/admin/curriculum-map` 引用而保留);`lib/i18n-errors.ts` 新增 `t.has(msg)` 运行时守卫,避免 `as` 断言绕过类型检查(键不存在时返回原消息而非抛出 MISSING_MESSAGEzh-CN 与 en 顶层键集一致性验证通过43 = 43005_architecture_data.json 同步追加 `V4-I18N-1` auditFixes 条目known-issues.md 追加 3 个 i18n 规则表(键缺失与中英文不同步 / dead 节清理规则 / 动态键翻译守卫)
---
## 2.27b standards课标模块2026-07-01 新增)

View File

@@ -6713,7 +6713,11 @@
],
"uiDeps": [],
"uiDepsNote": "已通过 render prop 解耦TeacherTextbookReader 已移至 app 层src/app/(dashboard)/teacher/textbooks/[id]/_components/textbooks 模块不再直接 import questions 模块组件",
"npmDeps": ["@xyflow/react", "@dagrejs/dagre", "react-force-graph-2d"],
"npmDeps": [
"@xyflow/react",
"@dagrejs/dagre",
"react-force-graph-2d"
],
"npmDepsNote": "v4 新增 react-force-graph-2d基于 d3-force + Canvas实现 Obsidian 风格力导向图谱;通过 next/dynamic ssr:false 加载避免 SSR canvas/window 报错;@xyflow/react + @dagrejs/dagre 用于分层有向图布局",
"knownIssues": [
"v4 新增2026-07-03知识点图谱新增 Obsidian 风格力导向布局force 模式与原分层有向图hierarchical 模式)共存,工具栏可切换;新增组件 force-graph.tsx新增类型 GraphLayoutMode保留全部现有功能掌握度视图/搜索高亮/邻居高亮/前置依赖增删)",
@@ -20330,7 +20334,7 @@
},
"lesson_preparation": {
"path": "src/modules/lesson-preparation",
"description": "教师备课模块(v3 锚点画布以课文正文为核心主体textbook_content 节点置于画布中央draggable=false 可缩放),教学目标/重难点等 11 种定制节点围绕正文组织。两种锚定机制range选择一段文本关联节点未选中 opacity:0+ point插入带圈数字占位符未选中 opacity:0.3)。锚点连线默认 10% 透明度,选中时完整显示。数据模型 v3LessonPlanDocument 含 textbookContentNodeId、nodes、edges、anchors 数组。anchor-injector 实现 Markdown 纯文本偏移映射与占位符注入。实时拖动onNodeDrag 替代 onNodeDragStop。v1→v2→v3 链式迁移。强制选定教材+章节才能备课template-picker 四步流程)。保留模板、版本管理、知识点标注、题目创建/拉取、作业发布能力",
"description": "教师备课模块(V4 纸感重构):抛弃 React Flow 画布改用三栏布局260px 结构树 + 1fr 纸感编辑器 + 380px 详情面板)。正文采用 Tiptap 富文本编辑器,锚点从字符串偏移升级为 Tiptap MarkAnchorMark 区间锚/AnchorPoint 点锚v3 旧锚点通过 AnchorMigrationBanner 提示失效。doc.version=4editor-slice 移除 updateNodePosition/connect/setEdges/autoLayout。新增 interaction block师生交互对话回合含 designIntent/turns[]/expectedAnswer。结构树支持节点展开到正文流expanded-slice。PrintView 改为按 nodes.order 排序渲染,不再依赖画布。v1→v2→v3→v4 链式迁移。保留模板、版本管理、知识点标注、题目创建/拉取、作业发布、撤销/重做、AI 反馈/差异化、一致性校验、课时安排等能力",
"exports": {
"dataAccess": [
{
@@ -20777,6 +20781,30 @@
"permission": "LESSON_PLAN_READ",
"file": "actions-ai.ts",
"purpose": "V5-21 A4 辅助新增:按 textbookId 获取知识点列表(仅 id+name供课标核对 Tab 按需加载"
},
{
"name": "generateNodeContentAction",
"permission": "LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT",
"file": "actions-ai.ts",
"purpose": "V4-N1 新增:节点级 AI 协助 - 根据节点 type 生成对应字段,返回 patch 由前端合并到 node.data"
},
{
"name": "optimizeNodeExpressionAction",
"permission": "LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT",
"file": "actions-ai.ts",
"purpose": "V4-N2 新增:节点级 AI 协助 - 优化节点现有表达,保留结构仅修改字段值"
},
{
"name": "suggestNodeDifferentiationAction",
"permission": "LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT",
"file": "actions-ai.ts",
"purpose": "V4-N3 新增:节点级 AI 协助 - 判定节点适合的差异化层次basic/intermediate/advanced前端据此设置 node.differentiation"
},
{
"name": "generateLayeredQuestionsAction",
"permission": "LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT",
"file": "actions-ai.ts",
"purpose": "V4-N4 新增:节点级 AI 协助 - 为 interaction 节点生成分层提问,返回 QATurn[] 由前端追加到 turns"
}
],
"schemas": [
@@ -20862,7 +20890,10 @@
"classes",
"files",
"shared/lib/ai",
"@xyflow/react"
"@tiptap/react",
"@tiptap/starter-kit",
"@tiptap/pm",
"@tiptap/extension-image"
],
"files": [
"types.ts",
@@ -20871,13 +20902,18 @@
"lib/type-guards.ts",
"lib/i18n-errors.ts",
"lib/document-migration.ts",
"lib/anchor-injector.ts",
"lib/node-summary.ts",
"lib/rf-mappers.ts",
"lib/anchor-mark.ts",
"lib/ai-node-assist.ts",
"lib/export.ts",
"lib/scope-check.ts",
"lib/document-diff.ts",
"config/block-registry.tsx",
"providers/lesson-plan-provider.tsx",
"providers/lesson-plan-provider-setup.tsx",
"services/default-data-service.ts",
"services/default-question-service.ts",
"services/external-questions-bridge.ts",
"data-access.ts",
"data-access-versions.ts",
"data-access-templates.ts",
@@ -20886,6 +20922,7 @@
"actions-publish.ts",
"actions-ai.ts",
"actions-kp.ts",
"actions-questions.ts",
"publish-service.ts",
"ai-suggest.ts",
"seed-templates.ts",
@@ -20893,17 +20930,14 @@
"hooks/editor-slice.ts",
"hooks/selection-slice.ts",
"hooks/version-slice.ts",
"hooks/history-slice.ts",
"hooks/expanded-slice.ts",
"hooks/use-node-ai-assist.ts",
"components/lesson-plan-list.tsx",
"components/lesson-plan-card.tsx",
"components/lesson-plan-filters.tsx",
"components/lesson-plan-editor.tsx",
"components/lesson-plan-readonly-view.tsx",
"components/node-editor.tsx",
"components/node-edit-panel.tsx",
"components/nodes/lesson-node.tsx",
"components/nodes/textbook-content-node.tsx",
"components/nodes/anchor-node-selector.tsx",
"components/nodes/textbook-segments.tsx",
"components/lesson-plan-error-boundary.tsx",
"components/lesson-plan-skeleton.tsx",
"components/template-picker.tsx",
@@ -20912,6 +20946,29 @@
"components/question-bank-picker.tsx",
"components/inline-question-editor.tsx",
"components/publish-homework-dialog.tsx",
"components/anchor-migration-banner.tsx",
"components/print-view.tsx",
"components/schedule-dialog.tsx",
"components/consistency-check-dialog.tsx",
"components/ai-feedback-dialog.tsx",
"components/ai-differentiation-dialog.tsx",
"components/curriculum-heatmap.tsx",
"components/version-diff-view.tsx",
"components/version-diff-viewer.tsx",
"components/lesson-plan-mobile-view.tsx",
"components/attachment-picker.tsx",
"components/paper-editor/paper-editor.tsx",
"components/paper-editor/paper-toolbar.tsx",
"components/paper-editor/paper-context-menu.tsx",
"components/paper-editor/textbook-tiptap-editor.tsx",
"components/paper-editor/inline-node.tsx",
"components/paper-editor/inline-qa-dialog.tsx",
"components/structure-tree/structure-tree.tsx",
"components/structure-tree/tree-node-row.tsx",
"components/detail-panel/detail-panel.tsx",
"components/detail-panel/detail-head.tsx",
"components/detail-panel/detail-props.tsx",
"components/detail-panel/qa-editor.tsx",
"components/blocks/rich-text-block.tsx",
"components/blocks/text-study-block.tsx",
"components/blocks/exercise-block.tsx",
@@ -20923,6 +20980,7 @@
"components/blocks/summary-block.tsx",
"components/blocks/homework-block.tsx",
"components/blocks/blackboard-block.tsx",
"components/blocks/interaction-block.tsx",
"components/calendar-view.tsx",
"components/curriculum-map-view.tsx",
"data-access-calendar.ts",
@@ -20933,6 +20991,7 @@
"data-access-substitutes.ts",
"data-access-ai-evaluation.ts",
"data-access-analytics.ts",
"data-access-schedules.ts",
"actions-calendar.ts",
"actions-review.ts",
"actions-comments.ts",
@@ -20940,7 +20999,8 @@
"actions-attachments.ts",
"actions-substitutes.ts",
"actions-ai-evaluation.ts",
"actions-analytics.ts"
"actions-analytics.ts",
"actions-schedules.ts"
],
"i18n": {
"namespace": "lessonPreparation",
@@ -21020,7 +21080,19 @@
"V5-2": "撤销/重做:新增 hooks/history-slice.tspast/future 栈50 步上限canUndo/canRedo/pushHistory/undo/redo/clearHistory 方法editor-slice.ts 所有 mutation 方法setTitle/addNode/updateNode/removeNode/updateTextbookContent/addAnchor/removeAnchor/updateAnchor/connect/disconnect/setEdges包装 pushHistoryupdateNodePosition 除外(拖拽过程中由调用方在 onDragStart 时推入version-slice hydrate/replaceDoc 调用 clearHistorylesson-plan-editor.tsx 顶部新增 Undo/Redo 按钮 + Ctrl/Cmd+Z 撤销 + Ctrl/Cmd+Shift+Z 或 Ctrl/Cmd+Y 重做快捷键",
"V5-3": "发布前作业单预览publish-homework-dialog.tsx 改为 3 步流程step=select 选班级+时间 → step=preview 预览题目+总分+班级数 → step=confirm 确认发布),第二步显示题目列表(题型/来源/题干预览/分值)+汇总信息(班级数/题目数/总分),第三步显示最终确认信息+警告文案exercise-block.tsx 调用 PublishHomeworkDialog 时传入 items={data.items}",
"V5-4": "导出/打印:新增 lib/export.tsflattenLessonPlanForPrint 函数,将画布式 LessonPlanDocument 扁平化为 PrintableLessonPlan 线性结构,支持 detailed/concise 两种 variantconcise 仅含 objective/new_teaching/exercise/homework+ components/print-view.tsx打印视图组件详细版/简洁版切换 + 调用 window.print() 通过浏览器原生能力保存为 PDF + print: CSS 媒体查询隐藏工具栏 + 页眉含教材/教师/班级/教学时长/最后保存时间 + break-inside-avoid 防止段落跨页lesson-plan-editor.tsx 新增 printablePlan useMemo 构造 + 导出按钮 + PrintView 渲染",
"V5-5": "多媒体嵌入 + 我的素材库types.ts RichTextBlockData 新增 attachments?: RichTextAttachment[] 字段 + RichTextAttachment 类型attachmentId/fileId/displayName/kind: image|audio|video|file/url/mimeType?rich-text-block.tsx 集成 @tiptap/extension-imageImage.configure inline:false allowBase64:false+ 新增附件入口按钮 + 已嵌入附件列表渲染(非图片附件列表 + 图片附件缩略图,含删除按钮)+ 图片附件通过 editor.commands.setImage 直接插入富文本;新增 components/attachment-picker.tsx素材库 picker复用 use-file-upload hook 调用 /api/upload + 调用 service.createLessonPlanAttachment 落库到 M6 lessonPlanAttachments 表 + 列出 service.getLessonPlanAttachments + 删除 service.deleteLessonPlanAttachment + inferAttachmentKind 工具函数LessonPlanDataService 接口新增 getLessonPlanAttachments/createLessonPlanAttachment/deleteLessonPlanAttachment 三个方法 + LessonPlanAttachmentOption 导出类型default-data-service.ts 实现三个方法DB 层 Date 转 ISO string 以匹配接口类型BlockRenderProps 新增 planId? 字段NodeEditPanel 新增 planId prop 透传lesson-plan-editor.tsx NodeEditPanel 调用传入 planId新增依赖 @tiptap/extension-imagefiles/types.ts FileTargetType 新增 'lesson_plan' 类型"
"V5-5": "多媒体嵌入 + 我的素材库types.ts RichTextBlockData 新增 attachments?: RichTextAttachment[] 字段 + RichTextAttachment 类型attachmentId/fileId/displayName/kind: image|audio|video|file/url/mimeType?rich-text-block.tsx 集成 @tiptap/extension-imageImage.configure inline:false allowBase64:false+ 新增附件入口按钮 + 已嵌入附件列表渲染(非图片附件列表 + 图片附件缩略图,含删除按钮)+ 图片附件通过 editor.commands.setImage 直接插入富文本;新增 components/attachment-picker.tsx素材库 picker复用 use-file-upload hook 调用 /api/upload + 调用 service.createLessonPlanAttachment 落库到 M6 lessonPlanAttachments 表 + 列出 service.getLessonPlanAttachments + 删除 service.deleteLessonPlanAttachment + inferAttachmentKind 工具函数LessonPlanDataService 接口新增 getLessonPlanAttachments/createLessonPlanAttachment/deleteLessonPlanAttachment 三个方法 + LessonPlanAttachmentOption 导出类型default-data-service.ts 实现三个方法DB 层 Date 转 ISO string 以匹配接口类型BlockRenderProps 新增 planId? 字段NodeEditPanel 新增 planId prop 透传lesson-plan-editor.tsx NodeEditPanel 调用传入 planId新增依赖 @tiptap/extension-imagefiles/types.ts FileTargetType 新增 'lesson_plan' 类型",
"V4-PAPER-1": "H1 注册 interaction blockconfig/block-registry.tsx 新增 interaction case导入 InteractionBlock 组件 + isInteractionBlockData 类型守卫BLOCK_REGISTRY 支持 12 种 Block 类型(含 V4 interaction",
"V4-PAPER-2": "H2 三栏布局重构lesson-plan-editor.tsx 移除 NodeEditor/NodeEditPanel 导入与 JSX改用 StructureTree + PaperEditor + DetailPanel 三栏布局grid-template-columns: 260px 1fr 380px保留顶部工具栏标题/保存/撤销重做/发布/导出/安排课时/一致性校验/AI 反馈/AI 差异化)+ 5 个对话框VersionHistoryDrawer/ConsistencyCheckDialog/AiFeedbackDialog/AiDifferentiationDialog/PrintView/ScheduleDialog删除 components/node-edit-panel.tsx + app/(dashboard)/teacher/lesson-plans/[planId]/edit/ai-content-generator-slot.tsx依赖 NodeEditPanel 类型)",
"V4-PAPER-3": "I1 v3 锚点失效 banner新增 components/anchor-migration-banner.tsx检测 doc.anchors 中 invalid:true 或含 start 字段的旧锚点并提示用户;通过 key={planId} 实现 planId 变化时重新挂载内部组件,使用 lazy initial state 读取 localStoragedismiss 状态按 planId 隔离),避免 useEffect 中 setState符合 V4 react-hooks/set-state-in-effect lint 规则)",
"V4-PAPER-4": "J1 删除废弃文件:删除 8 个 React Flow 画布和字符串锚点相关文件——components/node-editor.tsx、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、lib/auto-layout.tslesson-plan-readonly-view.tsx 重写为委托 LessonPlanMobileView线性卡片视图渲染所有视口移除 React Flow 依赖",
"V4-PAPER-5": "J2 PrintView 适配 V4lib/export.ts 的 flattenBlockData switch 新增 interaction case + flattenInteraction 函数(输出设计意图 + 师生对话回合 + 预期回答export.ts 已使用 nodes.sort((a, b) => a.order - b.order) 按顺序扁平化无画布依赖docstring 更新为 12 种 Block含 V4 interaction",
"V4-PAPER-6": "J3 lint + tsc 零错误验证tsc --noEmit 0 错误lint 中 V4 引入的 react-hooks/set-state-in-effect 错误已通过 anchor-migration-banner.tsx 的 key-based remount + lazy initial state 模式修复;剩余 6 个 lint 错误为预存无关模块问题exams/use-exam-preview-tasks react-hooks/refs 3 个、template-picker V5-9 set-state-in-effect 1 个),按计划要求记录不修复",
"V4-PAPER-7": "J4 架构图同步004_architecture_impact_map.md 新增 V4 纸感重构章节005_architecture_data.json 更新 lesson_preparation 节点——description 改为 V4 描述、dependencies 移除 @xyflow/react 改为 @tiptap/* 包、files 数组移除 9 个废弃文件并新增 V4 组件paper-editor/structure-tree/detail-panel/interaction-block/anchor-migration-banner/print-view/schedule-dialog/consistency-check-dialog/ai-feedback-dialog/ai-differentiation-dialog/expanded-slice/history-slice/anchor-mark/export/scope-check/document-diff 等、auditFixes 新增 V4-PAPER-1~7 条目",
"V4-PAPER-8": "J5 known-issues V4 规则docs/troubleshooting/known-issues.md 新增 V4 纸感重构规则表,记录 8 条规则——React Flow 弃用、Tiptap Mark 锚点替代字符串偏移、setState in effect 禁用(用 key-based remount + lazy initial state 替代、doc.version=4、editor-slice 移除画布相关方法、interaction block 注册、PrintView 按 order 排序、AnchorMigrationBanner 失效提示",
"V4-PAPER-DOC": "documentVersion 升级为 4types.ts LessonPlanDocument.version = 4lib/document-migration.ts normalizeDocument 兼容 v1/v2/v3/v4 链式迁移editor-slice.ts 移除 updateNodePosition/connect/setEdges/autoLayout 画布相关方法hooks/expanded-slice.ts 新增 expandedNodeIds/toggleExpand/setExpanded 状态节点展开到正文流lib/anchor-mark.ts 新增 Tiptap AnchorMark + AnchorPoint 扩展(替代 v3 字符串偏移锚点)",
"V4-PAPER-V2-1": "V2 复制节点功能完整实现hooks/editor-slice.ts 新增 duplicateNode(id) 方法(深拷贝 BlockData + 新 id + 标题加'副本' + order 置末interaction 节点的 turns 重新生成 id 避免冲突components/paper-editor/paper-context-menu.tsx 的 copyNode 从 toast 占位改为调用 duplicateNode 并 toast 成功提示i18n 新增 v4.contextMenu.copied/copyFailed 键zh-CN + en",
"V4-PAPER-V2-2": "V2 节点级 AI 协助 4 项完整实现lib/ai-node-assist.ts 新增 4 个服务函数generateNodeContent/optimizeNodeExpression/suggestNodeDifferentiation/generateLayeredQuestions全部纯服务端、Zod 校验、失败返回 nullactions-ai.ts 新增 4 个 Server Action每个 action 校验 LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT 三个权限点hooks/use-node-ai-assist.ts 新增客户端 hookcomponents/paper-editor/paper-editor.tsx 注入 onAiAction 回调components/detail-panel/detail-panel.tsx 的 4 个 AiButton 接入真实 actioni18n 新增 v4.contextMenu.aiRunning/aiSuccess/aiFailed/aiComingSoon 键",
"V4-I18N-1": "i18n 完整审查与修复P0 修复 version.diff.* 3 键 + schema.ts 8 个 error.* 键 + contextMenu 6 键zh-CN + en 同步P1 修复 en calendar 节键名对齐 zh-CNP2 修复 7 处硬编码中文 + 新增 6 个 i18n 键;清理 7 个 dead 节review/comment/formative/substitute/evaluation/standards/gradeHeadanalytics 因被 admin/curriculum-map 引用保留lib/i18n-errors.ts 新增 t.has() 守卫"
}
},
"error-book": {

View File

@@ -1,101 +0,0 @@
// 临时脚本:更新 004_architecture_impact_map.md
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '004_architecture_impact_map.md');
const content = fs.readFileSync(filePath, 'utf8');
const oldString = [
'| `components/blocks/reflection-block.tsx` | 反思 BlockV3 续select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) |',
'',
'---',
'',
'## 2.28 error-book错题本模块'
].join('\r\n');
const newLines = [
'| `components/blocks/reflection-block.tsx` | 反思 BlockV3 续select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) |',
'| `components/calendar-view.tsx` | **M9 日历视图组件2026-07-01 新增)**:客户端组件,支持周历/月历切换6 种事件类型颜色映射 |',
'| `components/curriculum-map-view.tsx` | **M4 课程地图热力图组件2026-07-01 新增)**:学科×年级矩阵表格,颜色编码覆盖率 |',
'| `data-access-calendar.ts` | **M9 日历事件数据访问层2026-07-01 新增)** |',
'| `data-access-review.ts` | **M3 审核工作流数据访问层2026-07-01 新增)** |',
'| `data-access-comments.ts` | **M2 协同备课评论数据访问层2026-07-01 新增)** |',
'| `data-access-formative.ts` | **M5 形成性评价数据访问层2026-07-01 新增)** |',
'| `data-access-attachments.ts` | **M6 资源附件库数据访问层2026-07-01 新增)** |',
'| `data-access-substitutes.ts` | **M12 代课教师数据访问层2026-07-01 新增)** |',
'| `data-access-ai-evaluation.ts` | **M8 AI 评估数据访问层2026-07-01 新增)** |',
'| `data-access-analytics.ts` | **M10 备课分析仪表盘数据访问层2026-07-01 新增)** |',
'| `actions-calendar.ts` | **M9 日历 Server Actions2026-07-01 新增)** |',
'| `actions-review.ts` | **M3 审核工作流 Server Actions2026-07-01 新增)** |',
'| `actions-comments.ts` | **M2 评论 Server Actions2026-07-01 新增)** |',
'| `actions-formative.ts` | **M5 形成性评价 Server Actions2026-07-01 新增)** |',
'| `actions-attachments.ts` | **M6 附件库 Server Actions2026-07-01 新增)** |',
'| `actions-substitutes.ts` | **M12 代课教师 Server Actions2026-07-01 新增)** |',
'| `actions-ai-evaluation.ts` | **M8 AI 评估 Server Actions2026-07-01 新增)** |',
'| `actions-analytics.ts` | **M10 分析仪表盘 Server Actions2026-07-01 新增)** |',
'',
'> 架构变更2026-07-01M1-M12 中长期计划完整实施):',
'> - **M1 课标对标体系**:新增 modules/standards/ 模块;新增 3 个 STANDARD_* 权限点;新增路由 /admin/curriculum-map',
'> - **M2 协同备课评论**:新增 data-access-comments.ts + actions-comments.ts支持 Block 级评论、resolved 状态',
'> - **M3 审核工作流**:新增 data-access-review.ts + actions-review.ts状态机 draft → submitted → approved/rejected → published',
'> - **M4 课程地图**:新增 admin/curriculum-map 路由 + curriculum-map-view.tsx 热力图组件',
'> - **M5 形成性评价闭环**:新增 data-access-formative.ts + actions-formative.ts4 种互动类型',
'> - **M6 资源附件库**:新增 data-access-attachments.ts + actions-attachments.ts',
'> - **M7 移动端 PWA**:见 1.1.1 章节',
'> - **M8 AI 差异化生成 + 评估**:新增 data-access-ai-evaluation.ts + actions-ai-evaluation.ts',
'> - **M9 日历视图**:新增 data-access-calendar.ts + actions-calendar.ts + calendar-view.tsx + teacher/lesson-plans/calendar 路由',
'> - **M10 备课分析仪表盘**:新增 data-access-analytics.ts + actions-analytics.ts3 个分析维度',
'> - **M11 版本 diff 预览**data-access-versions.ts 的 getVersionContent 支持读取历史版本内容',
'> - **M12 代课教师**:新增 data-access-substitutes.ts + actions-substitutes.ts',
'> - **权限扩展**:新增 STANDARD_READ/MANAGE/LINK + LESSON_PLAN_SUBMIT/REVIEW/ANALYTICS_READ/SUBSTITUTE_MANAGE',
'> - **i18n 扩展**zh-CN 新增 9 个命名空间status 扩展为 6 状态',
'> - **数据库表**:新增 6 张表lessonPlanComments/lessonPlanReviewRecords/lessonPlanFormativeItems/lessonPlanAttachments/lessonPlanAiEvaluations/lessonPlanSubstituteAssignments',
'',
'---',
'',
'## 2.27b standards课标模块2026-07-01 新增)',
'',
'**职责**K12 课标管理,支持课标树、按学科+年级筛选、课案与课标多对多关联。',
'',
'**导出函数**',
'- Data-accessgetStandards / getStandardsTree / getStandardById / createStandard / updateStandard / deactivateStandard / searchStandards / getStandardsByPlanId / linkPlanToStandard / unlinkPlanFromStandard',
'- Actions10 个 Server Actions全部使用 requirePermission() + ActionState<T>',
'- SchemacreateStandardSchema / updateStandardSchema / getStandardsParamsSchema / linkPlanToStandardSchema',
'- TypesStandard / StandardTreeNode / LessonPlanStandardLink',
'',
'**权限点**',
'- STANDARD_READstandard:read',
'- STANDARD_MANAGEstandard:manage',
'- STANDARD_LINKstandard:link',
'',
'**依赖关系**',
'- 依赖shared/*、modules/lesson-preparation/lib/i18n-errors',
'- 被依赖modules/lesson-preparation、app/(dashboard)/admin/curriculum-map',
'',
'**文件清单**',
'| 文件 | 职责 |',
'|------|------|',
'| types.ts | 类型定义 |',
'| schema.ts | Zod 验证 |',
'| data-access.ts | 课标 CRUD + 树形查询 + 课案关联 |',
'| actions.ts | 10 个 Server Actions |',
'',
'---',
'',
'## 2.28 error-book错题本模块'
];
const newString = newLines.join('\r\n');
if (!content.includes(oldString)) {
console.error('ERROR: old string not found');
process.exit(1);
}
const occurrences = content.split(oldString).length - 1;
if (occurrences !== 1) {
console.error('ERROR: appears ' + occurrences + ' times');
process.exit(1);
}
const newContent = content.replace(oldString, newString);
fs.writeFileSync(filePath, newContent, 'utf8');
console.log('SUCCESS 004 updated, before=' + content.length + ' after=' + newContent.length);

View File

@@ -0,0 +1,637 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>备课编辑器 · 正文节点布局对比</title>
<style>
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-low: #f0f2f5;
--surface-high: #e7e9ee;
--outline: #d1d5db;
--outline-strong: #9ca3af;
--on-surface: #1f2937;
--on-surface-variant: #6b7280;
--primary: #1976d2;
--primary-soft: rgba(25, 118, 210, 0.12);
--anchor: #f59e0b;
--node-objective: #4caf50;
--node-teach: #1976d2;
--node-exercise: #9c27b0;
--node-summary: #ff9800;
--node-textbook: #455a64;
--radius: 10px;
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 10px 30px rgba(0,0,0,0.10), 0 3px 8px rgba(0,0,0,0.06);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--on-surface);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 1400px;
margin: 0 auto;
padding: 40px 24px 80px;
}
header.page-head {
margin-bottom: 32px;
}
header.page-head h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
header.page-head p {
margin: 0;
color: var(--on-surface-variant);
font-size: 15px;
max-width: 720px;
}
.layout-grid {
display: grid;
grid-template-columns: 1fr;
gap: 40px;
}
@media (min-width: 1100px) {
.layout-grid { grid-template-columns: 1fr 1fr 1fr; gap: 24px; }
}
.option {
background: var(--surface);
border-radius: 16px;
box-shadow: var(--shadow);
overflow: hidden;
display: flex;
flex-direction: column;
}
.option-head {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--outline);
}
.option-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--primary);
background: var(--primary-soft);
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 10px;
}
.option-title {
font-size: 18px;
font-weight: 700;
margin: 0 0 6px;
}
.option-sub {
font-size: 13px;
color: var(--on-surface-variant);
margin: 0;
line-height: 1.5;
}
.option-body {
padding: 20px;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
/* 共用 wireframe 样式 */
.wireframe {
background: var(--surface-low);
border: 1px solid var(--outline);
border-radius: 8px;
aspect-ratio: 4 / 3;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 10px;
color: var(--on-surface-variant);
position: relative;
}
.wf-toolbar {
height: 16px;
background: var(--surface);
border-bottom: 1px solid var(--outline);
display: flex;
align-items: center;
padding: 0 6px;
gap: 4px;
flex-shrink: 0;
}
.wf-toolbar .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--outline-strong); }
.wf-toolbar .pill { width: 18px; height: 7px; border-radius: 3px; background: var(--surface-high); }
.wf-toolbar .pill.accent { background: var(--primary-soft); }
.wf-area { flex: 1; display: flex; position: relative; overflow: hidden; }
.wf-sidebar {
width: 22%;
background: var(--surface);
border-right: 1px solid var(--outline);
padding: 6px 4px;
display: flex;
flex-direction: column;
gap: 3px;
flex-shrink: 0;
}
.wf-side-item {
height: 8px;
border-radius: 3px;
background: var(--surface-high);
padding: 0 3px;
display: flex;
align-items: center;
gap: 3px;
font-size: 7px;
}
.wf-side-item.active { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
.wf-side-item .ico { width: 5px; height: 5px; border-radius: 1px; flex-shrink: 0; }
.wf-main { flex: 1; position: relative; overflow: hidden; }
.wf-right {
width: 26%;
background: var(--surface);
border-left: 1px solid var(--outline);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.wf-right .field { height: 7px; border-radius: 2px; background: var(--surface-high); }
.wf-right .field.short { width: 60%; }
.wf-right .field.tall { height: 28px; }
.wf-label {
font-size: 7px;
color: var(--on-surface-variant);
font-weight: 600;
margin-bottom: 2px;
}
/* 节点小卡片(画布模式) */
.wf-node {
position: absolute;
background: var(--surface);
border: 1.5px solid var(--outline-strong);
border-radius: 4px;
padding: 3px 5px;
font-size: 7px;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
.wf-node .ndot { width: 4px; height: 4px; border-radius: 50%; flex-shrink: 0; }
.wf-node.objective { border-color: var(--node-objective); }
.wf-node.objective .ndot { background: var(--node-objective); }
.wf-node.teach { border-color: var(--node-teach); }
.wf-node.teach .ndot { background: var(--node-teach); }
.wf-node.exercise { border-color: var(--node-exercise); }
.wf-node.exercise .ndot { background: var(--node-exercise); }
.wf-node.summary { border-color: var(--node-summary); }
.wf-node.summary .ndot { background: var(--node-summary); }
/* 正文编辑器卡片 */
.wf-textbook {
position: absolute;
background: var(--surface);
border: 2px solid var(--node-textbook);
border-radius: 6px;
box-shadow: var(--shadow-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.wf-textbook-head {
background: var(--node-textbook);
color: white;
font-size: 7px;
padding: 2px 5px;
font-weight: 600;
display: flex;
justify-content: space-between;
}
.wf-textbook-body {
padding: 5px 6px;
font-size: 7px;
line-height: 1.5;
flex: 1;
overflow: hidden;
}
.wf-textbook-body .h { font-weight: 700; font-size: 8px; display: block; margin-bottom: 2px; }
.wf-textbook-body .bold { font-weight: 700; }
.wf-textbook-body .anchor {
background: var(--anchor);
color: white;
padding: 0 2px;
border-radius: 2px;
font-size: 6px;
font-weight: 700;
}
.wf-textbook-body .point-anchor {
display: inline-block;
width: 8px;
height: 8px;
line-height: 8px;
text-align: center;
border-radius: 50%;
background: var(--anchor);
color: white;
font-size: 6px;
font-weight: 700;
}
.wf-textbook-toolbar {
display: flex;
gap: 3px;
padding: 2px 5px;
border-top: 1px solid var(--outline);
background: var(--surface-low);
font-size: 6px;
}
.wf-textbook-toolbar .tb {
padding: 1px 3px;
border-radius: 2px;
background: var(--surface-high);
}
/* 连线 */
.wf-edge {
position: absolute;
background: var(--primary);
opacity: 0.4;
transform-origin: 0 0;
height: 1px;
}
/* 文档流样式 */
.wf-docflow {
flex: 1;
overflow: hidden;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--surface);
}
.wf-docblock {
border-radius: 4px;
padding: 5px 6px;
font-size: 7px;
line-height: 1.5;
}
.wf-docblock.textbook {
background: rgba(69, 90, 100, 0.06);
border-left: 3px solid var(--node-textbook);
}
.wf-docblock.objective {
background: rgba(76, 175, 80, 0.07);
border-left: 3px solid var(--node-objective);
}
.wf-docblock.teach {
background: rgba(25, 118, 210, 0.07);
border-left: 3px solid var(--node-teach);
}
.wf-docblock.exercise {
background: rgba(156, 39, 176, 0.07);
border-left: 3px solid var(--node-exercise);
}
.wf-docblock.summary {
background: rgba(255, 152, 0, 0.07);
border-left: 3px solid var(--node-summary);
}
.wf-docblock .db-head {
font-weight: 700;
font-size: 7px;
display: flex;
align-items: center;
gap: 3px;
margin-bottom: 2px;
}
.wf-docblock .db-head .ico { width: 6px; height: 6px; border-radius: 1px; }
.wf-docblock.textbook .ico { background: var(--node-textbook); }
.wf-docblock.objective .ico { background: var(--node-objective); }
.wf-docblock.teach .ico { background: var(--node-teach); }
.wf-docblock.exercise .ico { background: var(--node-exercise); }
.wf-docblock.summary .ico { background: var(--node-summary); }
.wf-docblock .anchor { background: var(--anchor); color: white; padding: 0 2px; border-radius: 2px; font-size: 6px; font-weight: 700; }
/* 优劣对比 */
.pros-cons { display: flex; flex-direction: column; gap: 8px; }
.pros-cons .pc {
font-size: 12px;
line-height: 1.5;
}
.pros-cons .pc-label {
font-weight: 700;
font-size: 11px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.pros-cons .pc-label.pro { color: #16a34a; }
.pros-cons .pc-label.con { color: #dc2626; }
.pros-cons ul { margin: 0; padding-left: 14px; }
.pros-cons li { margin-bottom: 2px; color: var(--on-surface-variant); }
.recommend {
margin-top: 8px;
padding: 10px 12px;
background: var(--primary-soft);
border-radius: 8px;
font-size: 12px;
color: var(--primary);
font-weight: 600;
display: flex;
align-items: flex-start;
gap: 6px;
}
footer.page-foot {
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid var(--outline);
color: var(--on-surface-variant);
font-size: 13px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
font-size: 12px;
}
.legend-item { display: flex; align-items: center; gap: 5px; }
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
</style>
</head>
<body>
<div class="page">
<header class="page-head">
<h1>备课编辑器 · 正文节点布局对比</h1>
<p>三种交互形态对比。共同前提:正文节点变成<strong>真实的富文本编辑器</strong>Tiptap支持加粗/标题/列表/图片真实排版,锚点用 Mark 系统而非字符串偏移。三种方案只差在"正文和教学节点如何排布"。</p>
<div class="legend">
<div class="legend-item"><span class="legend-dot" style="background:var(--node-textbook)"></span>教材正文</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--node-objective)"></span>教学目标</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--node-teach)"></span>新授</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--node-exercise)"></span>练习</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--node-summary)"></span>总结</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--anchor)"></span>锚点标记</div>
</div>
</header>
<div class="layout-grid">
<!-- 方案 1画布 + 真实编辑器 -->
<section class="option">
<div class="option-head">
<span class="option-tag">方案 A</span>
<h2 class="option-title">保留画布 · 正文节点变真实编辑器</h2>
<p class="option-sub">最小改动。React Flow 画布不变,把中央那个 520px 假正文框换成真正的 Tiptap 富文本编辑器卡片,可拖拽缩放到更大。</p>
</div>
<div class="option-body">
<div class="wireframe">
<div class="wf-toolbar">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="pill accent"></span><span class="pill"></span><span class="pill"></span>
<span style="margin-left:auto;font-size:6px;">已保存</span>
</div>
<div class="wf-area">
<div class="wf-main">
<!-- 散布的教学节点 -->
<div class="wf-node objective" style="top:8%;left:6%;">
<span class="ndot"></span>教学目标
</div>
<div class="wf-node teach" style="top:14%;right:8%;">
<span class="ndot"></span>新授
</div>
<!-- 中央正文编辑器(变大) -->
<div class="wf-textbook" style="top:26%;left:18%;width:64%;height:52%;">
<div class="wf-textbook-head">
<span>📖 教材正文(可编辑副本)</span>
<span>右键锚定</span>
</div>
<div class="wf-textbook-body">
<span class="h">第一章 · 走进大自然</span>
春天的<span class="anchor"></span>轻轻吹过田野,
带来泥土的芬芳。
<br/>
<span class="bold">观察要点:</span>
<br/>• 花朵的颜色变化
<br/><span class="point-anchor"></span> 叶片的形状
<br/>• 气温的回升
</div>
<div class="wf-textbook-toolbar">
<span class="tb">B</span><span class="tb">I</span>
<span class="tb">H1</span><span class="tb"></span>
<span class="tb">🖼</span><span class="tb"></span>
</div>
</div>
<!-- 下方的教学节点 -->
<div class="wf-node exercise" style="bottom:8%;left:20%;">
<span class="ndot"></span>课堂练习
</div>
<div class="wf-node summary" style="bottom:10%;right:14%;">
<span class="ndot"></span>总结
</div>
<!-- 连线 -->
<div class="wf-edge" style="top:13%;left:14%;width:40px;transform:rotate(35deg);"></div>
<div class="wf-edge" style="top:18%;right:14%;width:38px;transform:rotate(150deg);"></div>
</div>
<div class="wf-right">
<div class="wf-label">选中节点</div>
<div class="field"></div>
<div class="field short"></div>
<div class="field tall"></div>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pc">
<div class="pc-label pro">✓ 优点</div>
<ul>
<li>改动最小,画布交互不变</li>
<li>保留"思维导图"式空间关系</li>
<li>锚点系统改造可独立进行</li>
</ul>
</div>
<div class="pc">
<div class="pc-label con">✗ 缺点</div>
<ul>
<li>长文档在画布上仍受限,需频繁缩放</li>
<li>编辑器卡片和教学节点抢空间</li>
<li>移动端几乎不可用</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 方案 2文档流类 Notion -->
<section class="option">
<div class="option-head">
<span class="option-tag">方案 B</span>
<h2 class="option-title">文档流为主(类 Notion</h2>
<p class="option-sub">正文和教学节点都在同一文档流里,按顺序拼成一篇完整教案。左侧大纲,中间文档,右侧选中节点详情。</p>
</div>
<div class="option-body">
<div class="wireframe">
<div class="wf-toolbar">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="pill accent"></span><span class="pill"></span><span class="pill"></span>
<span style="margin-left:auto;font-size:6px;">已保存</span>
</div>
<div class="wf-area">
<div class="wf-sidebar">
<div class="wf-label">大纲</div>
<div class="wf-side-item active"><span class="ico" style="background:var(--node-textbook)"></span>正文</div>
<div class="wf-side-item" style="padding-left:8px;"><span class="ico" style="background:var(--node-objective)"></span>目标</div>
<div class="wf-side-item" style="padding-left:8px;"><span class="ico" style="background:var(--node-teach)"></span>新授</div>
<div class="wf-side-item" style="padding-left:8px;"><span class="ico" style="background:var(--node-exercise)"></span>练习</div>
<div class="wf-side-item" style="padding-left:8px;"><span class="ico" style="background:var(--node-summary)"></span>总结</div>
</div>
<div class="wf-main">
<div class="wf-docflow">
<div class="wf-docblock textbook">
<div class="db-head"><span class="ico"></span>📖 教材正文</div>
<span style="font-weight:700;">第一章 · 走进大自然</span><br/>
春天的<span class="anchor"></span>轻轻吹过田野,带来泥土的芬芳。
<span class="point-anchor" style="display:inline-block;width:8px;height:8px;line-height:8px;text-align:center;border-radius:50%;background:var(--anchor);color:white;font-size:6px;font-weight:700;"></span> 叶片的形状
</div>
<div class="wf-docblock objective">
<div class="db-head"><span class="ico"></span>🎯 教学目标</div>
认识春天的自然现象…
</div>
<div class="wf-docblock teach">
<div class="db-head"><span class="ico"></span>📚 新授</div>
引导学生观察…
</div>
<div class="wf-docblock exercise">
<div class="db-head"><span class="ico"></span>✏ 课堂练习</div>
画一画你看到的春天…
</div>
</div>
</div>
<div class="wf-right">
<div class="wf-label">选中: 新授</div>
<div class="field"></div>
<div class="field short"></div>
<div class="field tall"></div>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pc">
<div class="pc-label pro">✓ 优点</div>
<ul>
<li>编辑体验最好,长文档自然滚动</li>
<li>所见即所得,适合打印导出</li>
<li>移动端友好</li>
<li>教学节点和正文同流,关系清晰</li>
</ul>
</div>
<div class="pc">
<div class="pc-label con">✗ 缺点</div>
<ul>
<li>失去"画布"的空间思维导图感</li>
<li>需要重构数据模型(节点顺序流)</li>
<li>锚点仍需跨"正文块"定位</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 方案 3双栏 -->
<section class="option">
<div class="option-head">
<span class="option-tag">方案 C</span>
<h2 class="option-title">双栏 · 正文编辑器 + 节点面板</h2>
<p class="option-sub">正文富文本编辑器独占主区域(类 A4 文档页),教学节点在右侧面板编辑,左侧是节点树。正文与节点通过锚点关联,互不混排。</p>
</div>
<div class="option-body">
<div class="wireframe">
<div class="wf-toolbar">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="pill accent"></span><span class="pill"></span><span class="pill"></span>
<span style="margin-left:auto;font-size:6px;">已保存</span>
</div>
<div class="wf-area">
<div class="wf-sidebar">
<div class="wf-label">节点树</div>
<div class="wf-side-item"><span class="ico" style="background:var(--node-textbook)"></span>📖 正文</div>
<div class="wf-side-item active"><span class="ico" style="background:var(--node-objective)"></span>🎯 目标</div>
<div class="wf-side-item"><span class="ico" style="background:var(--node-teach)"></span>📚 新授</div>
<div class="wf-side-item"><span class="ico" style="background:var(--node-exercise)"></span>✏ 练习</div>
<div class="wf-side-item"><span class="ico" style="background:var(--node-summary)"></span>📝 总结</div>
</div>
<div class="wf-main" style="background:var(--surface-low);display:flex;justify-content:center;padding:8px;">
<div style="background:var(--surface);width:80%;border-radius:4px;box-shadow:var(--shadow);padding:8px;font-size:7px;line-height:1.6;overflow:hidden;">
<div style="font-weight:700;font-size:8px;margin-bottom:3px;">第一章 · 走进大自然</div>
春天的<span class="anchor" style="background:var(--anchor);color:white;padding:0 2px;border-radius:2px;font-size:6px;font-weight:700;"></span>轻轻吹过田野,带来泥土的芬芳。
<br/><br/>
<span style="font-weight:700;">观察要点:</span>
<br/>• 花朵的颜色变化
<br/><span style="display:inline-block;width:8px;height:8px;line-height:8px;text-align:center;border-radius:50%;background:var(--anchor);color:white;font-size:6px;font-weight:700;"></span> 叶片的形状
<br/>• 气温的回升
<div style="margin-top:6px;padding-top:4px;border-top:1px solid var(--outline);display:flex;gap:3px;">
<span style="padding:1px 3px;border-radius:2px;background:var(--surface-high);font-size:6px;">B</span>
<span style="padding:1px 3px;border-radius:2px;background:var(--surface-high);font-size:6px;">I</span>
<span style="padding:1px 3px;border-radius:2px;background:var(--surface-high);font-size:6px;">H1</span>
<span style="padding:1px 3px;border-radius:2px;background:var(--surface-high);font-size:6px;"></span>
<span style="padding:1px 3px;border-radius:2px;background:var(--surface-high);font-size:6px;">🖼</span>
</div>
</div>
</div>
<div class="wf-right">
<div class="wf-label">🎯 教学目标</div>
<div class="field"></div>
<div class="field short"></div>
<div class="field tall"></div>
<div style="margin-top:4px;padding:3px;border:1px dashed var(--primary);border-radius:3px;font-size:6px;color:var(--primary);text-align:center;">+ AI 生成</div>
</div>
</div>
</div>
<div class="pros-cons">
<div class="pc">
<div class="pc-label pro">✓ 优点</div>
<ul>
<li>正文编辑区最大、最专注</li>
<li>正文与节点分离,职责清晰</li>
<li>锚点只在正文里,定位简单</li>
<li>保留节点树导航,不丢结构</li>
</ul>
</div>
<div class="pc">
<div class="pc-label con">✗ 缺点</div>
<ul>
<li>失去画布的连线/空间感</li>
<li>节点间关系靠树而非图表达</li>
<li>需要新增"节点树"组件</li>
</ul>
</div>
</div>
<div class="recommend">
★ 推荐:在"正文必须好编辑"这个核心诉求下C 兼顾了编辑体验和结构清晰,改动量适中。
</div>
</div>
</section>
</div>
<footer class="page-foot">
<p>三种方案的正文节点都是<strong>真正的 Tiptap 富文本编辑器</strong>,区别只在布局。锚点系统统一从"字符串偏移"改为 Tiptap Mark编辑时锚点自动跟随。</p>
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,938 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>备课编辑器 · 统一展开 + 详情面板</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--muted: #f5f5f4;
--muted-foreground: #78716c;
--border: #e7e5e4;
--primary: #1c1917;
--primary-foreground: #fafaf9;
--paper: #fefefe;
--paper-edge: #f8f8f7;
--paper-shadow: 0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04);
--paper-shadow-active: 0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08);
--anchor-range: rgba(28, 25, 23, 0.08);
--anchor-range-active: rgba(28, 25, 23, 0.16);
--anchor-point: #1c1917;
--dot-objective: #4b5563;
--dot-teach: #1c1917;
--dot-interaction: #6366f1;
--dot-exercise: #6b7280;
--dot-summary: #525252;
--dot-textbook: #44403c;
--dot-default: #a8a29e;
--inline-node-border: #d6d3d1;
--inline-node-text: #44403c;
--inline-node-meta: #a8a29e;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--muted);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
.app { display: grid; grid-template-rows: 52px 1fr; height: 100vh; }
/* 顶部工具栏 */
.topbar {
display: flex; align-items: center; gap: 12px;
padding: 0 20px;
background: var(--background);
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.topbar .title-input {
background: transparent; border: none; outline: none;
font-family: 'Fraunces', serif; font-weight: 600; font-size: 16px;
color: var(--foreground); width: 280px; letter-spacing: -0.01em;
}
.topbar .meta-chip {
padding: 3px 8px; border: 1px solid var(--border);
border-radius: 4px; background: var(--background);
font-size: 11px; color: var(--muted-foreground);
}
.topbar .spacer { flex: 1; }
.topbar .status {
font-size: 11px; color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.topbar .status::before {
content: ""; display: inline-block;
width: 5px; height: 5px; border-radius: 50%;
background: #16a34a; margin-right: 6px; vertical-align: middle;
}
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px; border: 1px solid var(--border);
border-radius: 5px; background: var(--background);
font-size: 12px; color: var(--foreground); cursor: pointer;
font-family: inherit;
}
.btn.primary {
background: var(--primary); color: var(--primary-foreground);
border-color: var(--primary);
}
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: var(--muted); }
.btn-icon { width: 28px; height: 28px; padding: 0; justify-content: center; }
.main {
display: grid;
grid-template-columns: 260px 1fr 380px;
overflow: hidden;
}
/* === 左侧结构树 === */
.sidebar {
border-right: 1px solid var(--border);
background: var(--background);
padding: 16px 12px;
overflow-y: auto;
}
.sidebar-head {
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0 8px 12px;
display: flex; justify-content: space-between; align-items: center;
}
.sidebar-head .count {
font-weight: 500; color: var(--muted-foreground);
letter-spacing: 0;
}
/* 树形结构 */
.tree { font-size: 13px; }
.tree-node { position: relative; }
.tree-row {
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border-radius: 5px;
cursor: pointer; transition: background .12s;
line-height: 1.3;
}
.tree-row:hover { background: var(--muted); }
.tree-row.active { background: var(--muted); }
.tree-row .toggle {
width: 14px; height: 14px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 9px; color: var(--muted-foreground);
cursor: pointer; flex-shrink: 0;
transition: transform .15s;
}
.tree-row .toggle.noop { visibility: hidden; }
.tree-row .dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.tree-row .label {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--foreground);
}
.tree-row .kind {
font-size: 10px; color: var(--muted-foreground);
font-weight: 500;
}
/* 展开到正文标记 */
.tree-row .expanded-mark {
font-size: 9px; color: var(--foreground);
background: var(--muted); padding: 1px 5px;
border-radius: 2px; font-weight: 500;
letter-spacing: 0.02em;
}
.tree-children {
margin-left: 10px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.sidebar-add {
margin-top: 10px; padding: 7px 8px;
border: 1px dashed var(--border); border-radius: 5px;
text-align: center; font-size: 12px;
color: var(--muted-foreground); cursor: pointer;
}
.sidebar-add:hover { border-color: var(--foreground); color: var(--foreground); }
/* === 中间纸区 === */
.paper-area {
overflow-y: auto;
padding: 48px 32px 120px;
background:
radial-gradient(circle at 50% 0%, rgba(0,0,0,0.015) 0%, transparent 60%),
var(--muted);
}
.paper {
background: var(--paper);
max-width: 720px;
margin: 0 auto;
padding: 64px 72px;
box-shadow: var(--paper-shadow);
border-radius: 2px;
border: 1px solid var(--paper-edge);
min-height: 800px;
font-family: 'Fraunces', Georgia, serif;
color: #1a1a1a;
line-height: 1.7;
font-size: 16px;
}
.paper-header {
font-family: 'Inter', sans-serif;
font-size: 11px; color: var(--muted-foreground);
letter-spacing: 0.08em; text-transform: uppercase;
margin-bottom: 24px; padding-bottom: 16px;
border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between;
}
.paper h1 {
font-family: 'Fraunces', serif; font-weight: 600;
font-size: 30px; line-height: 1.2;
margin: 0 0 8px; letter-spacing: -0.015em; color: #0a0a0a;
}
.paper .subtitle {
font-family: 'Fraunces', serif; font-style: italic;
font-weight: 400; font-size: 16px;
color: #525252; margin-bottom: 32px;
}
.paper h2 {
font-family: 'Fraunces', serif; font-weight: 500;
font-size: 20px; margin: 28px 0 12px; color: #1a1a1a;
}
.paper p {
margin: 0 0 16px; font-size: 16px;
line-height: 1.75; color: #1a1a1a;
}
.paper ul, .paper ol { margin: 0 0 16px; padding-left: 22px; }
.paper li { margin-bottom: 6px; line-height: 1.7; }
.paper strong { font-weight: 600; }
/* 锚点 */
.anchor-range {
background: var(--anchor-range); border-radius: 2px;
padding: 1px 2px; margin: 0 -2px; cursor: pointer;
border-bottom: 1.5px solid var(--anchor-point);
}
.anchor-range:hover, .anchor-range.active { background: var(--anchor-range-active); }
.anchor-range::after {
content: attr(data-label);
display: inline-block; margin-left: 4px;
padding: 0 4px; background: var(--anchor-point); color: #fff;
font-family: 'Inter', sans-serif; font-size: 9px;
font-weight: 600; border-radius: 2px; vertical-align: middle;
line-height: 14px; height: 14px;
}
.anchor-point {
display: inline-block; width: 16px; height: 16px;
line-height: 16px; text-align: center; border-radius: 50%;
background: var(--anchor-point); color: #fff;
font-family: 'Inter', sans-serif; font-size: 9px;
font-weight: 600; margin: 0 2px; vertical-align: middle;
cursor: pointer;
}
/* === 展开节点(统一 inline-node 样式) === */
.inline-node {
font-family: 'Inter', sans-serif;
margin: 20px 0 20px;
padding: 14px 18px 14px 20px;
border-left: 2px solid var(--inline-node-border);
color: var(--inline-node-text);
font-size: 13.5px; line-height: 1.65;
position: relative;
transition: border-color .2s;
}
.inline-node:hover { border-left-color: var(--foreground); }
.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(--inline-node-meta);
}
.inline-node-head .dot { width: 5px; height: 5px; border-radius: 50%; }
.inline-node-head .anchor-ref {
font-family: 'JetBrains Mono', monospace;
font-size: 9px; color: var(--inline-node-meta);
margin-left: auto;
}
.inline-node-head .collapse {
cursor: pointer; padding: 2px 6px;
border-radius: 3px; color: var(--inline-node-meta);
font-size: 11px; letter-spacing: 0; text-transform: none;
}
.inline-node-head .collapse:hover { background: var(--muted); color: var(--foreground); }
.inline-node-title {
font-family: 'Inter', sans-serif; font-weight: 600;
font-size: 14px; color: #1a1a1a;
margin: 0 0 6px; line-height: 1.35;
}
.inline-node-body {
font-size: 13px; line-height: 1.6; color: #404040;
}
.inline-node-body p { margin: 0 0 6px; font-size: 13px; line-height: 1.6; }
.inline-node-body ul { margin: 4px 0; padding-left: 16px; }
.inline-node-body li { margin-bottom: 3px; font-size: 13px; }
/* 师生交互对话体 */
.qa-dialog { margin: 8px 0 4px; }
.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;
}
.qa-turn:last-child { border-bottom: none; }
.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;
}
.qa-role.teacher { color: #1c1917; }
.qa-role.student { color: #6b7280; }
.qa-content { color: #404040; }
.qa-content .prompt {
font-style: italic; color: #525252;
font-size: 11px; display: block; margin-top: 2px;
}
/* 右键菜单 */
.context-menu {
position: absolute;
background: var(--background);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 24px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.06);
padding: 5px;
min-width: 220px;
z-index: 100;
font-family: 'Inter', sans-serif;
font-size: 12.5px;
}
.context-menu .cm-section {
font-size: 9px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 6px 10px 4px;
}
.context-menu .cm-item {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px; border-radius: 4px;
cursor: pointer; color: var(--foreground);
transition: background .1s;
}
.context-menu .cm-item:hover { background: var(--muted); }
.context-menu .cm-item .cm-icon {
width: 14px; color: var(--muted-foreground);
font-size: 11px; text-align: center;
}
.context-menu .cm-item .cm-label { flex: 1; }
.context-menu .cm-item .cm-shortcut {
font-family: 'JetBrains Mono', monospace;
font-size: 10px; color: var(--muted-foreground);
}
.context-menu .cm-divider {
height: 1px; background: var(--border);
margin: 4px 0;
}
.context-menu .cm-item.danger { color: #dc2626; }
.context-menu .cm-item.danger .cm-icon { color: #dc2626; }
.context-menu .cm-item.ai { color: #6366f1; }
.context-menu .cm-item.ai .cm-icon { color: #6366f1; }
/* === 右侧详情面板(不再是便签) === */
.detail-panel {
border-left: 1px solid var(--border);
background: var(--background);
overflow-y: auto;
display: flex; flex-direction: column;
}
.detail-head {
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; gap: 10px;
}
.detail-head .dot {
width: 8px; height: 8px; border-radius: 50%;
margin-top: 6px; flex-shrink: 0;
}
.detail-head .head-text { flex: 1; min-width: 0; }
.detail-head .kind {
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted-foreground); margin-bottom: 4px;
}
.detail-head .title-input {
width: 100%; background: transparent; border: none; outline: none;
font-family: 'Inter', sans-serif; font-weight: 600;
font-size: 16px; color: var(--foreground);
line-height: 1.3;
}
.detail-head .actions {
display: flex; gap: 4px;
}
.detail-head .actions .icon-btn {
width: 26px; height: 26px;
border: none; background: transparent;
border-radius: 4px; cursor: pointer;
color: var(--muted-foreground);
font-size: 14px;
}
.detail-head .actions .icon-btn:hover { background: var(--muted); color: var(--foreground); }
/* 属性条 */
.detail-props {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
display: flex; gap: 12px; flex-wrap: wrap;
font-size: 11.5px;
}
.detail-props .prop {
display: flex; align-items: center; gap: 4px;
color: var(--muted-foreground);
}
.detail-props .prop-label { color: var(--muted-foreground); }
.detail-props .prop-value {
color: var(--foreground); font-weight: 500;
padding: 1px 6px; background: var(--muted);
border-radius: 3px;
}
.detail-props .prop-value.select {
cursor: pointer;
}
/* 详情主体 */
.detail-body {
flex: 1; padding: 20px;
overflow-y: auto;
}
.detail-section { margin-bottom: 24px; }
.detail-section .section-label {
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted-foreground);
margin-bottom: 8px;
}
.detail-section .field-edit {
width: 100%;
border: 1px solid var(--border);
border-radius: 5px;
padding: 8px 10px;
background: var(--background);
font-family: 'Inter', sans-serif;
font-size: 13px; line-height: 1.6;
color: var(--foreground);
resize: vertical; min-height: 80px;
}
.detail-section .field-edit:focus {
outline: none; border-color: var(--foreground);
box-shadow: 0 0 0 2px rgba(28,25,23,0.08);
}
/* 师生交互详情 */
.qa-editor { display: flex; flex-direction: column; gap: 8px; }
.qa-edit-turn {
border: 1px solid var(--border);
border-radius: 5px;
padding: 8px 10px;
background: var(--background);
}
.qa-edit-turn .turn-head {
display: flex; align-items: center; gap: 8px;
margin-bottom: 6px;
font-size: 10px;
}
.qa-edit-turn .role-select {
font-family: 'JetBrains Mono', monospace;
font-size: 9px; font-weight: 600;
padding: 2px 6px; border-radius: 2px;
border: 1px solid var(--border);
background: var(--background); cursor: pointer;
}
.qa-edit-turn .role-select.teacher { color: #1c1917; }
.qa-edit-turn .role-select.student { color: #6b7280; }
.qa-edit-turn .turn-actions { margin-left: auto; }
.qa-edit-turn .turn-actions .mini-btn {
border: none; background: transparent;
color: var(--muted-foreground); cursor: pointer;
padding: 2px 4px; font-size: 11px;
}
.qa-edit-turn .turn-actions .mini-btn:hover { color: var(--foreground); }
.qa-edit-turn .turn-content {
width: 100%; border: none; outline: none;
background: transparent; resize: vertical;
font-family: 'Inter', sans-serif; font-size: 12.5px;
line-height: 1.5; color: var(--foreground);
min-height: 36px;
}
.qa-edit-turn .turn-prompt {
width: 100%; border: none; outline: none;
background: var(--muted); resize: vertical;
font-family: 'Inter', sans-serif; font-size: 11px;
font-style: italic; color: var(--muted-foreground);
padding: 4px 8px; border-radius: 3px;
margin-top: 4px; min-height: 24px;
}
.qa-add-turn {
border: 1px dashed var(--border);
background: transparent; padding: 8px;
border-radius: 5px; text-align: center;
font-size: 12px; color: var(--muted-foreground);
cursor: pointer;
}
.qa-add-turn:hover { border-color: var(--foreground); color: var(--foreground); }
/* 详情底部 AI 区 */
.detail-ai {
border-top: 1px solid var(--border);
padding: 14px 20px;
background: var(--muted);
}
.detail-ai .ai-title {
font-size: 11px; font-weight: 600;
color: #6366f1; margin-bottom: 8px;
display: flex; align-items: center; gap: 6px;
}
.detail-ai .ai-btns {
display: flex; gap: 6px; flex-wrap: wrap;
}
.detail-ai .ai-btn {
padding: 5px 10px; border: 1px solid var(--border);
background: var(--background); border-radius: 4px;
font-size: 11.5px; cursor: pointer; color: var(--foreground);
transition: all .12s;
}
.detail-ai .ai-btn:hover {
border-color: #6366f1; color: #6366f1;
}
/* 详情底部署名/元信息 */
.detail-meta {
padding: 10px 20px;
border-top: 1px solid var(--border);
font-size: 10.5px; color: var(--muted-foreground);
display: flex; flex-wrap: wrap; gap: 8px;
}
.detail-meta .tag {
padding: 1px 5px; background: var(--muted);
border-radius: 2px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.2); }
@keyframes paperIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.paper { animation: paperIn .5s cubic-bezier(.2,.7,.3,1); }
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<input class="title-input" value="第一单元 · 走进大自然" />
<span class="meta-chip">二年级 · 语文</span>
<span class="meta-chip">人教版 · 上册</span>
<div class="spacer"></div>
<span class="status">已保存 · 14:32</span>
<button class="btn ghost btn-icon"></button>
<button class="btn ghost btn-icon"></button>
<button class="btn ghost">历史版本</button>
<button class="btn ghost">AI 反馈</button>
<button class="btn primary">发布课案</button>
</header>
<div class="main">
<!-- 左侧结构树(完整结构) -->
<aside class="sidebar">
<div class="sidebar-head">
<span>结构</span>
<span class="count">7 节点 · 3 已展开</span>
</div>
<div class="tree">
<!-- 正文节点 -->
<div class="tree-node">
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-textbook)"></span>
<span class="label">教材正文</span>
<span class="kind">正文</span>
</div>
</div>
<!-- 教学目标(已展开) -->
<div class="tree-node">
<div class="tree-row active">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-objective)"></span>
<span class="label">教学目标</span>
<span class="kind">目标</span>
<span class="expanded-mark">纸上</span>
</div>
</div>
<!-- 字词学习 -->
<div class="tree-node">
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="label">字词学习</span>
<span class="kind">新授</span>
</div>
</div>
<!-- 师生互动(已展开,带子节点) -->
<div class="tree-node">
<div class="tree-row">
<span class="toggle"></span>
<span class="dot" style="background:var(--dot-interaction)"></span>
<span class="label">感受夏天的声音</span>
<span class="kind">互动</span>
<span class="expanded-mark">纸上</span>
</div>
<div class="tree-children">
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-default);opacity:0.5"></span>
<span class="label">第 1 轮 · 提问</span>
<span class="kind"></span>
</div>
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-default);opacity:0.5"></span>
<span class="label">第 2 轮 · 回答</span>
<span class="kind"></span>
</div>
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-default);opacity:0.5"></span>
<span class="label">第 3 轮 · 追问</span>
<span class="kind"></span>
</div>
</div>
</div>
<!-- 课文精读 -->
<div class="tree-node">
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="label">课文精读</span>
<span class="kind">新授</span>
</div>
</div>
<!-- 课堂练习(已展开) -->
<div class="tree-node">
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-exercise)"></span>
<span class="label">仿写:金色的信</span>
<span class="kind">练习</span>
<span class="expanded-mark">纸上</span>
</div>
</div>
<!-- 本课小结 -->
<div class="tree-node">
<div class="tree-row">
<span class="toggle noop"></span>
<span class="dot" style="background:var(--dot-summary)"></span>
<span class="label">本课小结</span>
<span class="kind">总结</span>
</div>
</div>
</div>
<div class="sidebar-add">+ 添加节点</div>
</aside>
<!-- 中间纸区 -->
<main class="paper-area">
<article class="paper">
<div class="paper-header">
<span>教材正文 · 可编辑副本</span>
<span>3 节点已展开</span>
</div>
<h1>1 走进大自然</h1>
<p class="subtitle">—— 选自人教版二年级上册</p>
<p>春天的<span class="anchor-range" data-label="A"></span>轻轻吹过田野,带来泥土的芬芳。柳枝抽出嫩芽,小草从泥土里探出头来,<span class="anchor-range" data-label="B">燕子也从南方飞回来了</span></p>
<!-- 展开的教学目标节点 -->
<div class="inline-node">
<div class="inline-node-head">
<span class="dot" style="background:var(--dot-objective)"></span>
<span>教学目标</span>
<span class="anchor-ref">A · B</span>
<span class="collapse">收起 ▴</span>
</div>
<h4 class="inline-node-title">感受四季变化</h4>
<div class="inline-node-body">
<ul>
<li>认识"春、夏、秋、冬"四个生字,会写"风、燕、荷、稻"等 12 个字。</li>
<li>正确、流利、有感情地朗读课文,背诵第一自然段。</li>
<li>感受四季的美丽,培养观察自然的兴趣。</li>
</ul>
</div>
</div>
<p>夏天,<span class="anchor-range" data-label="C">雷雨过后,天空中常常会出现一道美丽的彩虹</span>。池塘里的荷花开了,青蛙在荷叶上<span class="anchor-point">1</span>呱呱地唱歌。</p>
<!-- 展开的师生交互节点 -->
<div class="inline-node">
<div class="inline-node-head">
<span class="dot" style="background:var(--dot-interaction)"></span>
<span>师生互动</span>
<span class="anchor-ref">C · 1</span>
<span class="collapse">收起 ▴</span>
</div>
<h4 class="inline-node-title">感受夏天的声音</h4>
<div class="inline-node-body">
<p>围绕"雷雨后的夏天"展开问答,引导学生用感官词描述季节。</p>
<div class="qa-dialog">
<div class="qa-turn">
<div class="qa-role teacher"></div>
<div class="qa-content">
课文中说"青蛙在荷叶上呱呱地唱歌",你还听过夏天有哪些声音?
<span class="prompt">[预期:蝉鸣、雷声、雨打芭蕉]</span>
</div>
</div>
<div class="qa-turn">
<div class="qa-role student"></div>
<div class="qa-content">
我听过知了在树上"知了知了"地叫,还有晚上蛐蛐的声音。
</div>
</div>
<div class="qa-turn">
<div class="qa-role teacher"></div>
<div class="qa-content">
说得真好!那这些声音给你什么样的感觉?能用一个词形容吗?
<span class="prompt">[预期:热闹、生机勃勃]</span>
</div>
</div>
</div>
</div>
</div>
<h2>观察要点</h2>
<ul>
<li><strong>花朵的颜色变化</strong>:从早到晚,颜色会有什么不同?</li>
<li><span class="anchor-point">2</span> <strong>叶片的形状</strong>:心形、针形、掌状……</li>
<li><strong>气温的回升</strong>:用温度计记录一周的气温。</li>
</ul>
<p>秋天,树叶<span class="anchor-range" data-label="D">变黄了,一片片飘落下来</span>,像一封封金色的信。田野里,稻谷弯下了腰,<span class="anchor-point">3</span>农民伯伯露出了丰收的喜悦。</p>
<!-- 展开的练习节点 -->
<div class="inline-node">
<div class="inline-node-head">
<span class="dot" style="background:var(--dot-exercise)"></span>
<span>课堂练习</span>
<span class="anchor-ref">D</span>
<span class="collapse">收起 ▴</span>
</div>
<h4 class="inline-node-title">仿写:金色的信</h4>
<div class="inline-node-body">
<p>模仿"树叶变黄了,一片片飘落下来,像一封封金色的信",写一句自己观察到的秋天。</p>
<ul>
<li>提示:可以用"像"字打比方</li>
<li>时间8 分钟</li>
<li>分享3 位同学朗读</li>
</ul>
</div>
</div>
<p>冬天,<span class="anchor-range" data-label="E">雪花纷纷扬扬地飘落</span>,大地披上了银装。孩子们在雪地里堆雪人、打雪仗。</p>
</article>
<!-- 右键菜单示意(绝对定位浮在纸上) -->
<div class="context-menu" style="top: 380px; left: 320px;">
<div class="cm-section">节点操作</div>
<div class="cm-item">
<span class="cm-icon"></span>
<span class="cm-label">展开到正文</span>
<span class="cm-shortcut"></span>
</div>
<div class="cm-item">
<span class="cm-icon"></span>
<span class="cm-label">从正文收起</span>
</div>
<div class="cm-item">
<span class="cm-icon"></span>
<span class="cm-label">上移 / 下移</span>
</div>
<div class="cm-divider"></div>
<div class="cm-section">AI 协助</div>
<div class="cm-item ai">
<span class="cm-icon"></span>
<span class="cm-label">生成本节点内容</span>
</div>
<div class="cm-item ai">
<span class="cm-icon"></span>
<span class="cm-label">优化表达</span>
</div>
<div class="cm-item ai">
<span class="cm-icon"></span>
<span class="cm-label">差异化建议</span>
</div>
<div class="cm-item ai">
<span class="cm-icon">?</span>
<span class="cm-label">生成分层提问</span>
</div>
<div class="cm-divider"></div>
<div class="cm-item">
<span class="cm-icon"></span>
<span class="cm-label">复制节点</span>
</div>
<div class="cm-item danger">
<span class="cm-icon">×</span>
<span class="cm-label">删除节点</span>
<span class="cm-shortcut"></span>
</div>
</div>
</main>
<!-- 右侧详情面板(不是便签) -->
<aside class="detail-panel">
<div class="detail-head">
<span class="dot" style="background:var(--dot-interaction)"></span>
<div class="head-text">
<div class="kind">师生互动 · 已展开到正文</div>
<input class="title-input" value="感受夏天的声音" />
</div>
<div class="actions">
<button class="icon-btn" title="展开/收起"></button>
<button class="icon-btn" title="更多"></button>
</div>
</div>
<div class="detail-props">
<div class="prop">
<span class="prop-label">教学阶段</span>
<span class="prop-value select">新授</span>
</div>
<div class="prop">
<span class="prop-label">差异化</span>
<span class="prop-value select">分层</span>
</div>
<div class="prop">
<span class="prop-label">时长</span>
<span class="prop-value select">8 分钟</span>
</div>
<div class="prop">
<span class="prop-label">锚点</span>
<span class="prop-value">C · 1</span>
</div>
</div>
<div class="detail-body">
<div class="detail-section">
<div class="section-label">设计意图</div>
<textarea class="field-edit" rows="2">围绕"雷雨后的夏天"展开问答,引导学生用感官词描述季节。</textarea>
</div>
<div class="detail-section">
<div class="section-label">对话设计</div>
<div class="qa-editor">
<div class="qa-edit-turn">
<div class="turn-head">
<span class="role-select teacher">师 · 提问</span>
<span style="color:var(--muted-foreground);font-size:10px;">第 1 轮</span>
<div class="turn-actions">
<button class="mini-btn"></button>
<button class="mini-btn"></button>
<button class="mini-btn">×</button>
</div>
</div>
<textarea class="turn-content">课文中说"青蛙在荷叶上呱呱地唱歌",你还听过夏天有哪些声音?</textarea>
<textarea class="turn-prompt">[预期:蝉鸣、雷声、雨打芭蕉]</textarea>
</div>
<div class="qa-edit-turn">
<div class="turn-head">
<span class="role-select student">生 · 回答</span>
<span style="color:var(--muted-foreground);font-size:10px;">第 2 轮</span>
<div class="turn-actions">
<button class="mini-btn"></button>
<button class="mini-btn"></button>
<button class="mini-btn">×</button>
</div>
</div>
<textarea class="turn-content">我听过知了在树上"知了知了"地叫,还有晚上蛐蛐的声音。</textarea>
</div>
<div class="qa-edit-turn">
<div class="turn-head">
<span class="role-select teacher">师 · 追问</span>
<span style="color:var(--muted-foreground);font-size:10px;">第 3 轮</span>
<div class="turn-actions">
<button class="mini-btn"></button>
<button class="mini-btn"></button>
<button class="mini-btn">×</button>
</div>
</div>
<textarea class="turn-content">说得真好!那这些声音给你什么样的感觉?能用一个词形容吗?</textarea>
<textarea class="turn-prompt">[预期:热闹、生机勃勃、夏天的味道]</textarea>
</div>
<div class="qa-add-turn">+ 添加一轮对话</div>
</div>
</div>
</div>
<div class="detail-ai">
<div class="ai-title">✦ AI 协助</div>
<div class="ai-btns">
<button class="ai-btn">生成分层提问</button>
<button class="ai-btn">补充预期回答</button>
<button class="ai-btn">优化追问</button>
<button class="ai-btn">差异化建议</button>
</div>
</div>
<div class="detail-meta">
<span class="tag">互动</span>
<span class="tag">感官词</span>
<span class="tag">3 轮对话</span>
<span style="margin-left:auto;">更新于 14:30</span>
</div>
</aside>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,799 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>备课编辑器 · 无边记纸感设计</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
/* shadcn 中性令牌light */
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--muted: #f5f5f4;
--muted-foreground: #78716c;
--border: #e7e5e4;
--input: #e7e5e4;
--primary: #1c1917;
--primary-foreground: #fafaf9;
--secondary: #f5f5f4;
--secondary-foreground: #1c1917;
--accent: #f5f5f4;
--accent-foreground: #1c1917;
--ring: #1c1917;
/* 纸感专用 */
--paper: #fefefe;
--paper-edge: #f8f8f7;
--paper-shadow: 0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04);
--paper-shadow-active: 0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08);
/* 便签 */
--sticky-bg: #fffdf5;
--sticky-bg-alt: #f7f6f1;
--sticky-border: #ebe8df;
--sticky-shadow: 0 1px 1px rgba(15,15,15,0.04), 0 2px 4px rgba(15,15,15,0.04);
--sticky-shadow-hover: 0 2px 4px rgba(15,15,15,0.06), 0 6px 14px rgba(15,15,15,0.06);
/* 锚点高亮 */
--anchor-range: rgba(28, 25, 23, 0.08);
--anchor-range-active: rgba(28, 25, 23, 0.16);
--anchor-point: #1c1917;
/* 节点类型色点(极克制) */
--dot-objective: #4b5563;
--dot-teach: #1c1917;
--dot-exercise: #6b7280;
--dot-summary: #525252;
--dot-textbook: #44403c;
--dot-default: #a8a29e;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--muted);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "ss01", "cv11";
overflow: hidden;
}
.app {
display: grid;
grid-template-rows: 52px 1fr;
height: 100vh;
}
/* 顶部工具栏 */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
background: var(--background);
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.topbar .title-input {
background: transparent;
border: none;
outline: none;
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 16px;
color: var(--foreground);
width: 280px;
letter-spacing: -0.01em;
}
.topbar .meta {
color: var(--muted-foreground);
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.topbar .meta-chip {
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
font-size: 11px;
}
.topbar .spacer { flex: 1; }
.topbar .status {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.topbar .status::before {
content: "";
display: inline-block;
width: 5px; height: 5px;
border-radius: 50%;
background: #16a34a;
margin-right: 6px;
vertical-align: middle;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--background);
font-size: 12px;
color: var(--foreground);
cursor: pointer;
transition: background .15s;
font-family: inherit;
}
.btn:hover { background: var(--muted); }
.btn.primary {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.btn.primary:hover { background: #292524; }
.btn.ghost {
border-color: transparent;
background: transparent;
}
.btn.ghost:hover { background: var(--muted); }
.btn-icon {
width: 28px; height: 28px;
padding: 0;
justify-content: center;
}
/* 主区域三栏 */
.main {
display: grid;
grid-template-columns: 220px 1fr 360px;
overflow: hidden;
}
/* 左侧节点树 */
.sidebar {
border-right: 1px solid var(--border);
background: var(--background);
padding: 16px 12px;
overflow-y: auto;
}
.sidebar-head {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0 8px 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-head .count {
font-weight: 500;
color: var(--muted-foreground);
letter-spacing: 0;
}
.node-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.node-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 8px;
border-radius: 5px;
font-size: 13px;
color: var(--foreground);
cursor: pointer;
transition: background .12s;
line-height: 1.3;
}
.node-item:hover { background: var(--muted); }
.node-item.active {
background: var(--muted);
color: var(--foreground);
}
.node-item.active .dot { transform: scale(1.15); }
.node-item .dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
transition: transform .15s;
}
.node-item .label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-item .kind {
font-size: 10px;
color: var(--muted-foreground);
font-weight: 500;
letter-spacing: 0.02em;
}
.sidebar-add {
margin-top: 10px;
padding: 7px 8px;
border: 1px dashed var(--border);
border-radius: 5px;
text-align: center;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
transition: all .15s;
}
.sidebar-add:hover {
border-color: var(--foreground);
color: var(--foreground);
}
/* 中间纸区 */
.paper-area {
overflow-y: auto;
padding: 48px 32px 120px;
background:
radial-gradient(circle at 50% 0%, rgba(0,0,0,0.015) 0%, transparent 60%),
var(--muted);
}
.paper {
background: var(--paper);
max-width: 720px;
margin: 0 auto;
padding: 64px 72px;
box-shadow: var(--paper-shadow);
border-radius: 2px;
border: 1px solid var(--paper-edge);
min-height: 800px;
transition: box-shadow .25s;
font-family: 'Fraunces', Georgia, serif;
color: #1a1a1a;
line-height: 1.7;
font-size: 16px;
position: relative;
}
.paper:focus-within { box-shadow: var(--paper-shadow-active); }
.paper-header {
font-family: 'Inter', sans-serif;
font-size: 11px;
color: var(--muted-foreground);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
}
.paper h1 {
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 30px;
line-height: 1.2;
margin: 0 0 8px;
letter-spacing: -0.015em;
color: #0a0a0a;
}
.paper .subtitle {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 400;
font-size: 16px;
color: #525252;
margin-bottom: 32px;
}
.paper h2 {
font-family: 'Fraunces', serif;
font-weight: 500;
font-size: 20px;
margin: 28px 0 12px;
color: #1a1a1a;
}
.paper p {
margin: 0 0 16px;
font-size: 16px;
line-height: 1.75;
color: #1a1a1a;
}
.paper ul, .paper ol {
margin: 0 0 16px;
padding-left: 22px;
}
.paper li {
margin-bottom: 6px;
line-height: 1.7;
}
.paper strong { font-weight: 600; }
.paper em { font-style: italic; }
/* 锚点标记 */
.anchor-range {
background: var(--anchor-range);
border-radius: 2px;
padding: 1px 2px;
margin: 0 -2px;
cursor: pointer;
transition: background .15s;
border-bottom: 1.5px solid var(--anchor-point);
}
.anchor-range:hover, .anchor-range.active {
background: var(--anchor-range-active);
}
.anchor-range::after {
content: attr(data-label);
display: inline-block;
margin-left: 4px;
padding: 0 4px;
background: var(--anchor-point);
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 9px;
font-weight: 600;
border-radius: 2px;
vertical-align: middle;
letter-spacing: 0.02em;
line-height: 14px;
height: 14px;
}
.anchor-point {
display: inline-block;
width: 16px; height: 16px;
line-height: 16px;
text-align: center;
border-radius: 50%;
background: var(--anchor-point);
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 9px;
font-weight: 600;
margin: 0 2px;
vertical-align: middle;
cursor: pointer;
transition: transform .15s;
}
.anchor-point:hover, .anchor-point.active {
transform: scale(1.15);
}
/* 纸上的工具条(聚焦时显示) */
.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;
opacity: 0;
pointer-events: none;
transition: opacity .2s;
z-index: 5;
}
.paper:focus-within .paper-toolbar { opacity: 1; pointer-events: auto; }
.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;
}
.tb-btn:hover { background: var(--muted); }
.tb-btn.b { font-weight: 700; }
.tb-btn.i { font-style: italic; font-family: 'Fraunces', serif; }
.tb-btn.h1 { font-size: 11px; font-weight: 600; }
.tb-btn.ul { font-size: 14px; }
.tb-btn.divider {
width: 1px;
background: var(--border);
margin: 4px 4px;
pointer-events: none;
}
/* 右侧便签区 */
.sticky-area {
border-left: 1px solid var(--border);
background: var(--muted);
padding: 24px 20px;
overflow-y: auto;
}
.sticky-area-head {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.sticky-area-head .hint {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
font-size: 11px;
}
.sticky {
background: var(--sticky-bg);
border: 1px solid var(--sticky-border);
border-radius: 4px;
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: var(--sticky-shadow);
transition: all .2s;
cursor: pointer;
position: relative;
}
.sticky:hover {
box-shadow: var(--sticky-shadow-hover);
transform: translateY(-1px);
}
.sticky.active {
border-color: var(--foreground);
box-shadow: 0 0 0 1px var(--foreground), var(--sticky-shadow-hover);
}
.sticky-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.sticky-head .dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sticky-head .kind {
font-family: 'Inter', sans-serif;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
}
.sticky-head .spacer { flex: 1; }
.sticky-head .anchor-ref {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--muted-foreground);
background: var(--background);
padding: 1px 5px;
border-radius: 2px;
border: 1px solid var(--border);
}
.sticky-title {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--foreground);
margin: 0 0 6px;
line-height: 1.35;
}
.sticky-body {
font-family: 'Inter', sans-serif;
font-size: 12.5px;
line-height: 1.55;
color: #404040;
}
.sticky-body p { margin: 0 0 6px; }
.sticky-body ul { margin: 4px 0; padding-left: 16px; }
.sticky-body li { margin-bottom: 2px; }
.sticky-footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--sticky-border);
display: flex;
gap: 6px;
font-family: 'Inter', sans-serif;
font-size: 10px;
color: var(--muted-foreground);
}
.sticky-footer .tag {
padding: 1px 5px;
background: var(--background);
border: 1px solid var(--border);
border-radius: 2px;
}
.sticky-add {
border: 1px dashed var(--border);
background: transparent;
padding: 14px 16px;
border-radius: 4px;
text-align: center;
font-family: 'Inter', sans-serif;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
transition: all .15s;
}
.sticky-add:hover {
border-color: var(--foreground);
color: var(--foreground);
background: var(--background);
}
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.12);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.2); }
/* 微动效 */
@keyframes paperIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes stickyIn {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
.paper { animation: paperIn .5s cubic-bezier(.2,.7,.3,1); }
.sticky { animation: stickyIn .4s cubic-bezier(.2,.7,.3,1) backwards; }
.sticky:nth-child(2) { animation-delay: .05s; }
.sticky:nth-child(3) { animation-delay: .1s; }
.sticky:nth-child(4) { animation-delay: .15s; }
.sticky:nth-child(5) { animation-delay: .2s; }
@keyframes cursorBlink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.caret {
display: inline-block;
width: 1.5px;
height: 1.1em;
background: var(--anchor-point);
vertical-align: text-bottom;
margin-left: 1px;
animation: cursorBlink 1s infinite;
}
</style>
</head>
<body>
<div class="app">
<!-- 顶部工具栏 -->
<header class="topbar">
<input class="title-input" value="第一单元 · 走进大自然" />
<div class="meta">
<span class="meta-chip">二年级 · 语文</span>
<span class="meta-chip">人教版 · 上册</span>
</div>
<div class="spacer"></div>
<span class="status">已保存 · 14:32</span>
<button class="btn ghost btn-icon" title="撤销"></button>
<button class="btn ghost btn-icon" title="重做"></button>
<button class="btn ghost">历史版本</button>
<button class="btn ghost">一致性校验</button>
<button class="btn ghost">AI 反馈</button>
<button class="btn primary">发布课案</button>
</header>
<div class="main">
<!-- 左侧节点树 -->
<aside class="sidebar">
<div class="sidebar-head">
<span>结构</span>
<span class="count">6</span>
</div>
<div class="node-list">
<div class="node-item">
<span class="dot" style="background:var(--dot-textbook)"></span>
<span class="label">教材正文</span>
<span class="kind">正文</span>
</div>
<div class="node-item active">
<span class="dot" style="background:var(--dot-objective)"></span>
<span class="label">教学目标</span>
<span class="kind">目标</span>
</div>
<div class="node-item">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="label">字词学习</span>
<span class="kind">新授</span>
</div>
<div class="node-item">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="label">课文精读</span>
<span class="kind">新授</span>
</div>
<div class="node-item">
<span class="dot" style="background:var(--dot-exercise)"></span>
<span class="label">课堂练习</span>
<span class="kind">练习</span>
</div>
<div class="node-item">
<span class="dot" style="background:var(--dot-summary)"></span>
<span class="label">本课小结</span>
<span class="kind">总结</span>
</div>
</div>
<div class="sidebar-add">+ 添加节点</div>
</aside>
<!-- 中间纸区 -->
<main class="paper-area">
<article class="paper" contenteditable="false" tabindex="0">
<!-- 浮动工具条(聚焦时显示) -->
<div class="paper-toolbar">
<button class="tb-btn b" title="加粗">B</button>
<button class="tb-btn i" title="斜体">I</button>
<span class="tb-btn divider"></span>
<button class="tb-btn h1" title="标题">H1</button>
<button class="tb-btn h1" title="副标题" style="font-size:10px;">H2</button>
<span class="tb-btn divider"></span>
<button class="tb-btn ul" title="无序列表"></button>
<button class="tb-btn ul" title="有序列表" style="font-size:11px;">1.</button>
<span class="tb-btn divider"></span>
<button class="tb-btn" title="引用" style="font-family:'Fraunces',serif;font-style:italic;font-size:14px;">"</button>
<button class="tb-btn" title="分隔线"></button>
</div>
<div class="paper-header">
<span>教材正文 · 可编辑副本</span>
<span>第 1 / 3 页</span>
</div>
<h1>1 走进大自然</h1>
<p class="subtitle">—— 选自人教版二年级上册</p>
<p>春天的<span class="anchor-range" data-label="A"></span>轻轻吹过田野,带来泥土的芬芳。柳枝抽出嫩芽,小草从泥土里探出头来,<span class="anchor-range" data-label="B">燕子也从南方飞回来了</span></p>
<p>夏天,<span class="anchor-range" data-label="C">雷雨过后,天空中常常会出现一道美丽的彩虹</span>。池塘里的荷花开了,青蛙在荷叶上<span class="anchor-point">1</span>呱呱地唱歌。</p>
<h2>观察要点</h2>
<ul>
<li><strong>花朵的颜色变化</strong>:从早到晚,颜色会有什么不同?</li>
<li><span class="anchor-point">2</span> <strong>叶片的形状</strong>:心形、针形、掌状……</li>
<li><strong>气温的回升</strong>:用温度计记录一周的气温。</li>
</ul>
<p>秋天,树叶<span class="anchor-range" data-label="D">变黄了,一片片飘落下来</span>,像一封封金色的信。田野里,稻谷弯下了腰,<span class="anchor-point">3</span>农民伯伯露出了丰收的喜悦。</p>
<p>冬天,<span class="anchor-range" data-label="E">雪花纷纷扬扬地飘落</span>,大地披上了银装。孩子们在雪地里堆雪人、打雪仗,<span class="caret"></span></p>
</article>
</main>
<!-- 右侧便签区 -->
<aside class="sticky-area">
<div class="sticky-area-head">
<span>教学节点</span>
<span class="hint">5 个便签 · 锚定到正文</span>
</div>
<div class="sticky active">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-objective)"></span>
<span class="kind">教学目标</span>
<span class="spacer"></span>
<span class="anchor-ref">A · B</span>
</div>
<h3 class="sticky-title">感受四季变化</h3>
<div class="sticky-body">
<p>1. 认识"春、夏、秋、冬"四个生字,会写"风、燕、荷、稻"等 12 个字。</p>
<p>2. 正确、流利、有感情地朗读课文,背诵第一自然段。</p>
<p>3. 感受四季的美丽,培养观察自然的兴趣。</p>
</div>
<div class="sticky-footer">
<span class="tag">朗读</span>
<span class="tag">识字</span>
<span class="tag">2 课时</span>
</div>
</div>
<div class="sticky">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="kind">新授</span>
<span class="spacer"></span>
<span class="anchor-ref">C · 1</span>
</div>
<h3 class="sticky-title">字词学习</h3>
<div class="sticky-body">
<p>重点字词:</p>
<ul>
<li>芬芳 — 香气</li>
<li>嫩芽 — 初生的芽</li>
<li>纷纷扬扬 — 飘洒的样子</li>
</ul>
</div>
<div class="sticky-footer">
<span class="tag">生字 12</span>
<span class="tag">词语 8</span>
</div>
</div>
<div class="sticky">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="kind">新授</span>
<span class="spacer"></span>
<span class="anchor-ref">2 · D</span>
</div>
<h3 class="sticky-title">课文精读</h3>
<div class="sticky-body">
<p>引导提问:</p>
<ul>
<li>为什么说"燕子从南方飞回来了"是春天的信号?</li>
<li>课文按什么顺序写的?你怎么看出来的?</li>
</ul>
</div>
<div class="sticky-footer">
<span class="tag">精读</span>
<span class="tag">15 分钟</span>
</div>
</div>
<div class="sticky">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-exercise)"></span>
<span class="kind">课堂练习</span>
<span class="spacer"></span>
<span class="anchor-ref">E</span>
</div>
<h3 class="sticky-title">仿写练习</h3>
<div class="sticky-body">
<p>模仿课文"春天的风轻轻吹过田野,带来泥土的芬芳",写一句自己观察到的季节变化。</p>
</div>
<div class="sticky-footer">
<span class="tag">仿写</span>
<span class="tag">8 分钟</span>
</div>
</div>
<div class="sticky">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-summary)"></span>
<span class="kind">总结</span>
<span class="spacer"></span>
<span class="anchor-ref">3</span>
</div>
<h3 class="sticky-title">本课小结</h3>
<div class="sticky-body">
<p>回顾四季特点,串联课文脉络。引导学生用一句话说说自己最喜欢的季节。</p>
</div>
<div class="sticky-footer">
<span class="tag">回顾</span>
<span class="tag">5 分钟</span>
</div>
</div>
<div class="sticky-add">+ 添加教学节点</div>
</aside>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,882 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>备课编辑器 · 节点展开与师生交互</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--muted: #f5f5f4;
--muted-foreground: #78716c;
--border: #e7e5e4;
--primary: #1c1917;
--primary-foreground: #fafaf9;
--paper: #fefefe;
--paper-edge: #f8f8f7;
--paper-shadow: 0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04);
--paper-shadow-active: 0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08);
--sticky-bg: #fffdf5;
--sticky-border: #ebe8df;
--sticky-shadow: 0 1px 1px rgba(15,15,15,0.04), 0 2px 4px rgba(15,15,15,0.04);
--sticky-shadow-hover: 0 2px 4px rgba(15,15,15,0.06), 0 6px 14px rgba(15,15,15,0.06);
--anchor-range: rgba(28, 25, 23, 0.08);
--anchor-range-active: rgba(28, 25, 23, 0.16);
--anchor-point: #1c1917;
--dot-objective: #4b5563;
--dot-teach: #1c1917;
--dot-interaction: #6366f1;
--dot-exercise: #6b7280;
--dot-summary: #525252;
--dot-textbook: #44403c;
--dot-default: #a8a29e;
/* 展开节点(流入正文)的样式 */
--inline-node-bg: transparent;
--inline-node-border: #d6d3d1;
--inline-node-text: #44403c;
--inline-node-meta: #a8a29e;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--muted);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
.app {
display: grid;
grid-template-rows: 52px 1fr;
height: 100vh;
}
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
background: var(--background);
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.topbar .title-input {
background: transparent;
border: none;
outline: none;
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 16px;
color: var(--foreground);
width: 280px;
letter-spacing: -0.01em;
}
.topbar .meta-chip {
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
font-size: 11px;
color: var(--muted-foreground);
}
.topbar .spacer { flex: 1; }
.topbar .status {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.topbar .status::before {
content: "";
display: inline-block;
width: 5px; height: 5px;
border-radius: 50%;
background: #16a34a;
margin-right: 6px;
vertical-align: middle;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--background);
font-size: 12px;
color: var(--foreground);
cursor: pointer;
font-family: inherit;
}
.btn.primary {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: var(--muted); }
.btn-icon { width: 28px; height: 28px; padding: 0; justify-content: center; }
.main {
display: grid;
grid-template-columns: 220px 1fr 360px;
overflow: hidden;
}
/* 左侧节点树 */
.sidebar {
border-right: 1px solid var(--border);
background: var(--background);
padding: 16px 12px;
overflow-y: auto;
}
.sidebar-head {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0 8px 10px;
display: flex;
justify-content: space-between;
}
.node-list { display: flex; flex-direction: column; gap: 1px; }
.node-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 8px;
border-radius: 5px;
font-size: 13px;
cursor: pointer;
transition: background .12s;
line-height: 1.3;
}
.node-item:hover { background: var(--muted); }
.node-item.active { background: var(--muted); }
.node-item .dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.node-item .label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-item .kind {
font-size: 10px;
color: var(--muted-foreground);
font-weight: 500;
}
/* 展开状态指示 */
.node-item .expand-state {
font-size: 9px;
color: var(--muted-foreground);
width: 12px;
text-align: center;
}
.node-item.expanded .expand-state { color: var(--foreground); }
.sidebar-add {
margin-top: 10px;
padding: 7px 8px;
border: 1px dashed var(--border);
border-radius: 5px;
text-align: center;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
}
.sidebar-add:hover { border-color: var(--foreground); color: var(--foreground); }
/* 中间纸区 */
.paper-area {
overflow-y: auto;
padding: 48px 32px 120px;
background:
radial-gradient(circle at 50% 0%, rgba(0,0,0,0.015) 0%, transparent 60%),
var(--muted);
}
.paper {
background: var(--paper);
max-width: 720px;
margin: 0 auto;
padding: 64px 72px;
box-shadow: var(--paper-shadow);
border-radius: 2px;
border: 1px solid var(--paper-edge);
min-height: 800px;
font-family: 'Fraunces', Georgia, serif;
color: #1a1a1a;
line-height: 1.7;
font-size: 16px;
}
.paper-header {
font-family: 'Inter', sans-serif;
font-size: 11px;
color: var(--muted-foreground);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
}
.paper h1 {
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 30px;
line-height: 1.2;
margin: 0 0 8px;
letter-spacing: -0.015em;
color: #0a0a0a;
}
.paper .subtitle {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 400;
font-size: 16px;
color: #525252;
margin-bottom: 32px;
}
.paper h2 {
font-family: 'Fraunces', serif;
font-weight: 500;
font-size: 20px;
margin: 28px 0 12px;
color: #1a1a1a;
}
.paper p {
margin: 0 0 16px;
font-size: 16px;
line-height: 1.75;
color: #1a1a1a;
}
.paper ul, .paper ol { margin: 0 0 16px; padding-left: 22px; }
.paper li { margin-bottom: 6px; line-height: 1.7; }
.paper strong { font-weight: 600; }
/* 锚点 */
.anchor-range {
background: var(--anchor-range);
border-radius: 2px;
padding: 1px 2px;
margin: 0 -2px;
cursor: pointer;
border-bottom: 1.5px solid var(--anchor-point);
}
.anchor-range:hover, .anchor-range.active { background: var(--anchor-range-active); }
.anchor-range::after {
content: attr(data-label);
display: inline-block;
margin-left: 4px;
padding: 0 4px;
background: var(--anchor-point);
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 9px;
font-weight: 600;
border-radius: 2px;
vertical-align: middle;
line-height: 14px;
height: 14px;
}
.anchor-point {
display: inline-block;
width: 16px; height: 16px;
line-height: 16px;
text-align: center;
border-radius: 50%;
background: var(--anchor-point);
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 9px;
font-weight: 600;
margin: 0 2px;
vertical-align: middle;
cursor: pointer;
}
/* === 关键:节点展开到正文流 === */
.inline-node {
font-family: 'Inter', sans-serif;
margin: 18px 0 18px;
padding: 14px 18px 14px 20px;
border-left: 2px solid var(--inline-node-border);
background: var(--inline-node-bg);
color: var(--inline-node-text);
font-size: 13.5px;
line-height: 1.65;
position: relative;
transition: border-color .2s;
}
.inline-node:hover { border-left-color: var(--foreground); }
.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(--inline-node-meta);
}
.inline-node-head .dot {
width: 5px; height: 5px;
border-radius: 50%;
}
.inline-node-head .anchor-ref {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--inline-node-meta);
margin-left: auto;
}
.inline-node-head .collapse {
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
color: var(--inline-node-meta);
font-size: 11px;
letter-spacing: 0;
text-transform: none;
}
.inline-node-head .collapse:hover { background: var(--muted); color: var(--foreground); }
.inline-node-title {
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 14px;
color: #1a1a1a;
margin: 0 0 6px;
line-height: 1.35;
}
.inline-node-body {
font-size: 13px;
line-height: 1.6;
color: #404040;
}
.inline-node-body p { margin: 0 0 6px; font-size: 13px; line-height: 1.6; }
.inline-node-body ul { margin: 4px 0; padding-left: 16px; }
.inline-node-body li { margin-bottom: 3px; font-size: 13px; }
/* 师生交互对话体 */
.qa-dialog {
margin: 8px 0 4px;
padding: 0;
}
.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;
}
.qa-turn:last-child { border-bottom: none; }
.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;
}
.qa-role.teacher { color: #1c1917; }
.qa-role.student { color: #6b7280; }
.qa-content { color: #404040; }
.qa-content .prompt {
font-style: italic;
color: #525252;
font-size: 11px;
display: block;
margin-top: 2px;
}
/* 右侧便签区 */
.sticky-area {
border-left: 1px solid var(--border);
background: var(--muted);
padding: 24px 20px;
overflow-y: auto;
}
.sticky-area-head {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.sticky-area-head .hint {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
font-size: 11px;
}
.sticky {
background: var(--sticky-bg);
border: 1px solid var(--sticky-border);
border-radius: 4px;
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: var(--sticky-shadow);
transition: all .2s;
cursor: pointer;
}
.sticky:hover {
box-shadow: var(--sticky-shadow-hover);
transform: translateY(-1px);
}
.sticky.active {
border-color: var(--foreground);
box-shadow: 0 0 0 1px var(--foreground), var(--sticky-shadow-hover);
}
.sticky.collapsed-on-paper {
opacity: 0.5;
background: var(--muted);
}
.sticky-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.sticky-head .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.sticky-head .kind {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
}
.sticky-head .spacer { flex: 1; }
.sticky-head .anchor-ref {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--muted-foreground);
background: var(--background);
padding: 1px 5px;
border-radius: 2px;
border: 1px solid var(--border);
}
.sticky-head .expand-toggle {
font-size: 11px;
color: var(--muted-foreground);
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
}
.sticky-head .expand-toggle:hover { background: var(--background); color: var(--foreground); }
.sticky-title {
font-size: 14px;
font-weight: 600;
color: var(--foreground);
margin: 0 0 6px;
line-height: 1.35;
}
.sticky-body {
font-size: 12.5px;
line-height: 1.55;
color: #404040;
}
.sticky-body p { margin: 0 0 6px; }
.sticky-body ul { margin: 4px 0; padding-left: 16px; }
.sticky-body li { margin-bottom: 2px; }
.sticky-footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--sticky-border);
display: flex;
gap: 6px;
font-size: 10px;
color: var(--muted-foreground);
}
.sticky-footer .tag {
padding: 1px 5px;
background: var(--background);
border: 1px solid var(--border);
border-radius: 2px;
}
.sticky-add {
border: 1px dashed var(--border);
background: transparent;
padding: 14px 16px;
border-radius: 4px;
text-align: center;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
}
.sticky-add:hover { border-color: var(--foreground); color: var(--foreground); background: var(--background); }
/* 便签内的师生交互预览(折叠态) */
.qa-preview {
font-size: 11.5px;
line-height: 1.5;
color: #525252;
padding: 6px 8px;
background: var(--background);
border-radius: 3px;
border: 1px solid var(--border);
margin-top: 4px;
}
.qa-preview .role-tag {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
margin-right: 4px;
}
.qa-preview .role-tag.t { color: #1c1917; }
.qa-preview .role-tag.s { color: #6b7280; }
.qa-preview .ellipsis { color: var(--muted-foreground); font-size: 10px; margin-top: 2px; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.2); }
@keyframes paperIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.paper { animation: paperIn .5s cubic-bezier(.2,.7,.3,1); }
/* 展开状态指示器(小标签) */
.expand-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: 'Inter', sans-serif;
font-size: 9px;
font-weight: 500;
color: var(--muted-foreground);
padding: 2px 6px;
background: var(--muted);
border-radius: 2px;
margin-left: 6px;
vertical-align: middle;
cursor: pointer;
}
.expand-badge:hover { color: var(--foreground); background: var(--border); }
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<input class="title-input" value="第一单元 · 走进大自然" />
<span class="meta-chip">二年级 · 语文</span>
<span class="meta-chip">人教版 · 上册</span>
<div class="spacer"></div>
<span class="status">已保存 · 14:32</span>
<button class="btn ghost btn-icon"></button>
<button class="btn ghost btn-icon"></button>
<button class="btn ghost">历史版本</button>
<button class="btn ghost">AI 反馈</button>
<button class="btn primary">发布课案</button>
</header>
<div class="main">
<!-- 左侧节点树 -->
<aside class="sidebar">
<div class="sidebar-head">
<span>结构</span>
<span>6</span>
</div>
<div class="node-list">
<div class="node-item">
<span class="dot" style="background:var(--dot-textbook)"></span>
<span class="label">教材正文</span>
<span class="kind">正文</span>
<span class="expand-state"></span>
</div>
<div class="node-item active">
<span class="dot" style="background:var(--dot-objective)"></span>
<span class="label">教学目标</span>
<span class="kind">目标</span>
<span class="expand-state"></span>
</div>
<div class="node-item">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="label">字词学习</span>
<span class="kind">新授</span>
<span class="expand-state"></span>
</div>
<div class="node-item expanded">
<span class="dot" style="background:var(--dot-interaction)"></span>
<span class="label">师生互动</span>
<span class="kind">互动</span>
<span class="expand-state"></span>
</div>
<div class="node-item">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="label">课文精读</span>
<span class="kind">新授</span>
<span class="expand-state"></span>
</div>
<div class="node-item expanded">
<span class="dot" style="background:var(--dot-exercise)"></span>
<span class="label">课堂练习</span>
<span class="kind">练习</span>
<span class="expand-state"></span>
</div>
<div class="node-item">
<span class="dot" style="background:var(--dot-summary)"></span>
<span class="label">本课小结</span>
<span class="kind">总结</span>
<span class="expand-state"></span>
</div>
</div>
<div class="sidebar-add">+ 添加节点</div>
</aside>
<!-- 中间纸区 -->
<main class="paper-area">
<article class="paper">
<div class="paper-header">
<span>教材正文 · 可编辑副本</span>
<span>2 个节点已展开到正文</span>
</div>
<h1>1 走进大自然</h1>
<p class="subtitle">—— 选自人教版二年级上册</p>
<p>春天的<span class="anchor-range" data-label="A"></span>轻轻吹过田野,带来泥土的芬芳。柳枝抽出嫩芽,小草从泥土里探出头来,<span class="anchor-range" data-label="B">燕子也从南方飞回来了</span></p>
<p>夏天,<span class="anchor-range" data-label="C">雷雨过后,天空中常常会出现一道美丽的彩虹</span>。池塘里的荷花开了,青蛙在荷叶上<span class="anchor-point">1</span>呱呱地唱歌。</p>
<!-- 展开的师生交互节点(流入正文) -->
<div class="inline-node">
<div class="inline-node-head">
<span class="dot" style="background:var(--dot-interaction)"></span>
<span>师生互动</span>
<span class="anchor-ref">C · 1</span>
<span class="collapse" title="收起回便签">收起 ▴</span>
</div>
<h4 class="inline-node-title">感受夏天的声音</h4>
<div class="inline-node-body">
<p>围绕"雷雨后的夏天"展开问答,引导学生用感官词描述季节。</p>
<div class="qa-dialog">
<div class="qa-turn">
<div class="qa-role teacher"></div>
<div class="qa-content">
课文中说"青蛙在荷叶上呱呱地唱歌",你还听过夏天有哪些声音?
<span class="prompt">[预期:蝉鸣、雷声、雨打芭蕉]</span>
</div>
</div>
<div class="qa-turn">
<div class="qa-role student"></div>
<div class="qa-content">
我听过知了在树上"知了知了"地叫,还有晚上蛐蛐的声音。
</div>
</div>
<div class="qa-turn">
<div class="qa-role teacher"></div>
<div class="qa-content">
说得真好!那这些声音给你什么样的感觉?能用一个词形容吗?
<span class="prompt">[预期:热闹、生机勃勃、夏天的味道]</span>
</div>
</div>
</div>
</div>
</div>
<h2>观察要点</h2>
<ul>
<li><strong>花朵的颜色变化</strong>:从早到晚,颜色会有什么不同?</li>
<li><span class="anchor-point">2</span> <strong>叶片的形状</strong>:心形、针形、掌状……</li>
<li><strong>气温的回升</strong>:用温度计记录一周的气温。</li>
</ul>
<p>秋天,树叶<span class="anchor-range" data-label="D">变黄了,一片片飘落下来</span>,像一封封金色的信。田野里,稻谷弯下了腰,<span class="anchor-point">3</span>农民伯伯露出了丰收的喜悦。</p>
<!-- 展开的练习节点 -->
<div class="inline-node">
<div class="inline-node-head">
<span class="dot" style="background:var(--dot-exercise)"></span>
<span>课堂练习</span>
<span class="anchor-ref">D</span>
<span class="collapse" title="收起回便签">收起 ▴</span>
</div>
<h4 class="inline-node-title">仿写:金色的信</h4>
<div class="inline-node-body">
<p>模仿"树叶变黄了,一片片飘落下来,像一封封金色的信",写一句自己观察到的秋天。</p>
<ul>
<li>提示:可以用"像"字打比方</li>
<li>时间8 分钟</li>
<li>分享3 位同学朗读</li>
</ul>
</div>
</div>
<p>冬天,<span class="anchor-range" data-label="E">雪花纷纷扬扬地飘落</span>,大地披上了银装。孩子们在雪地里堆雪人、打雪仗。</p>
</article>
</main>
<!-- 右侧便签区 -->
<aside class="sticky-area">
<div class="sticky-area-head">
<span>教学节点</span>
<span class="hint">5 个便签 · 2 已展开</span>
</div>
<div class="sticky active">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-objective)"></span>
<span class="kind">教学目标</span>
<span class="spacer"></span>
<span class="anchor-ref">A · B</span>
<span class="expand-toggle" title="展开到正文"></span>
</div>
<h3 class="sticky-title">感受四季变化</h3>
<div class="sticky-body">
<p>1. 认识"春、夏、秋、冬"四个生字,会写"风、燕、荷、稻"等 12 个字。</p>
<p>2. 正确、流利、有感情地朗读课文,背诵第一自然段。</p>
<p>3. 感受四季的美丽,培养观察自然的兴趣。</p>
</div>
<div class="sticky-footer">
<span class="tag">朗读</span>
<span class="tag">识字</span>
<span class="tag">2 课时</span>
</div>
</div>
<div class="sticky">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="kind">新授</span>
<span class="spacer"></span>
<span class="anchor-ref">C · 1</span>
<span class="expand-toggle"></span>
</div>
<h3 class="sticky-title">字词学习</h3>
<div class="sticky-body">
<p>重点字词:</p>
<ul>
<li>芬芳 — 香气</li>
<li>嫩芽 — 初生的芽</li>
<li>纷纷扬扬 — 飘洒的样子</li>
</ul>
</div>
<div class="sticky-footer">
<span class="tag">生字 12</span>
<span class="tag">词语 8</span>
</div>
</div>
<!-- 师生交互便签(折叠态) -->
<div class="sticky collapsed-on-paper">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-interaction)"></span>
<span class="kind">师生互动</span>
<span class="spacer"></span>
<span class="anchor-ref">C · 1</span>
<span class="expand-toggle" title="已展开到正文,点击收起"></span>
</div>
<h3 class="sticky-title">感受夏天的声音</h3>
<div class="sticky-body">
<p>围绕"雷雨后的夏天"展开问答,引导学生用感官词描述季节。</p>
<div class="qa-preview">
<div><span class="role-tag t"></span>课文中说"青蛙在荷叶上呱呱地唱歌"…</div>
<div><span class="role-tag s"></span>我听过知了在树上"知了知了"…</div>
<div class="ellipsis">共 3 轮对话 · 已展开到正文 ▾</div>
</div>
</div>
<div class="sticky-footer">
<span class="tag">问答</span>
<span class="tag">感官词</span>
<span class="tag">8 分钟</span>
</div>
</div>
<div class="sticky">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-teach)"></span>
<span class="kind">新授</span>
<span class="spacer"></span>
<span class="anchor-ref">2 · D</span>
<span class="expand-toggle"></span>
</div>
<h3 class="sticky-title">课文精读</h3>
<div class="sticky-body">
<p>引导提问:</p>
<ul>
<li>为什么说"燕子从南方飞回来了"是春天的信号?</li>
<li>课文按什么顺序写的?</li>
</ul>
</div>
<div class="sticky-footer">
<span class="tag">精读</span>
<span class="tag">15 分钟</span>
</div>
</div>
<!-- 练习便签(折叠态,已展开) -->
<div class="sticky collapsed-on-paper">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-exercise)"></span>
<span class="kind">课堂练习</span>
<span class="spacer"></span>
<span class="anchor-ref">D</span>
<span class="expand-toggle" title="已展开到正文,点击收起"></span>
</div>
<h3 class="sticky-title">仿写:金色的信</h3>
<div class="sticky-body">
<p>模仿课文写一句自己观察到的秋天。</p>
<div class="qa-preview">
<div class="ellipsis">已展开到正文 ▾</div>
</div>
</div>
<div class="sticky-footer">
<span class="tag">仿写</span>
<span class="tag">8 分钟</span>
</div>
</div>
<div class="sticky">
<div class="sticky-head">
<span class="dot" style="background:var(--dot-summary)"></span>
<span class="kind">总结</span>
<span class="spacer"></span>
<span class="anchor-ref">3</span>
<span class="expand-toggle"></span>
</div>
<h3 class="sticky-title">本课小结</h3>
<div class="sticky-body">
<p>回顾四季特点,串联课文脉络。引导学生用一句话说说自己最喜欢的季节。</p>
</div>
<div class="sticky-footer">
<span class="tag">回顾</span>
<span class="tag">5 分钟</span>
</div>
</div>
<div class="sticky-add">+ 添加教学节点</div>
</aside>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,472 @@
# 备课编辑器 · 无边记纸感重构设计
**日期**: 2026-07-04
**主题**: 备课模块正文节点改为真实富文本编辑器 + 三栏布局重构 + 节点展开到正文 + 师生交互节点
**状态**: 已视觉确认,待规格审核
---
## 1. 背景与问题
### 1.1 当前问题
备课模块编辑器(`src/modules/lesson-preparation/components/lesson-plan-editor.tsx`)存在以下核心问题:
1. **正文节点是"假"的富文本编辑器**
- `textbook-content-node.tsx`(画布中央节点)只是把 Markdown 字符串作为纯文本用 `whitespace-pre-wrap` 显示
- 没有真实排版(加粗/标题/列表/图片都看不到)、没有光标、不可直接编辑
-`rich-text-block.tsx`(侧栏教学节点已用 Tiptap形成不一致体验
2. **画布交互不适合长文档编辑**
- React Flow 画布上 520px 小框装不下教材正文
- 频繁缩放/拖动画布打断写作流
- 移动端几乎不可用
3. **锚点系统脆弱**
- `lib/anchor-injector.ts` 用字符串偏移(`plainToMd` Map定位
- 任何编辑都引起偏移漂移,要靠 `relocateAnchors` 修补
- Markdown → 纯文本的偏移映射在复杂格式下不可靠
4. **配色不统一**
- `globals.css``--lesson-node-*` 用了 13 个 Material Design 鲜艳原色(#4caf50#f44336#9c27b0 等)
- 与 shadcn/ui 设计语言冲突
### 1.2 用户期望
- "像在无边记Freeform里做教案"——一张安静的白纸居中,便签轻贴其侧
- 正文节点必须是真实富文本编辑器,可编辑(教材正文副本,不影响原课文)
- 节点能展开到正文流里,用字体差异区分(不框住)
- 师生交互环节设计(师问生答)
- 右键菜单含 AI 协助
- 不用奇怪颜色和图标,符合系统设计语言
---
## 2. 视觉设计
### 2.1 视觉主张
一张安静的白纸居中,节点内容轻贴其侧——像在无边记里把教案摊开写,而不是在画布上摆积木。
### 2.2 字体双轨
| 用途 | 字体 | 字号 | 颜色 |
|------|------|------|------|
| 正文(纸上教材) | Fraunces 衬线 | 16px / 1.75 行高 | #1a1a1a |
| UI / 节点文本 / 详情 | Inter 无衬线 | 13px / 1.6 行高 | #44403c |
| 锚点标签 / 元数据 | JetBrains Mono | 9-11px | #a8a29e |
### 2.3 配色(移除鲜艳 Material 色)
- 全中性调:白/灰/近黑
- 节点类型只用 6px 小色点区分:
- objective `#4b5563` / new_teaching `#1c1917` / interaction `#6366f1`(克制靛蓝)
- exercise `#6b7280` / summary `#525252` / textbook `#44403c`
- 锚点:区间高亮 `rgba(28,25,23,0.08)` + 底部 1.5px 黑线;点锚点黑色小圆圈
### 2.4 禁用元素
- 所有 emoji📖🎯📚 等)
- 所有装饰性图标
- 鲜艳色块
---
## 3. 布局架构
### 3.1 三栏布局(替换 React Flow 画布)
```
┌──────────────────────────────────────────────────────────────┐
│ 顶部工具栏(标题 / 保存状态 / 撤销重做 / 发布 / AI
├──────────┬────────────────────────────┬────────────────────┤
│ │ │ │
│ 左栏 │ 中栏(纸) │ 右栏(详情面板) │
│ 260px │ 1fr │ 380px │
│ │ │ │
│ 结构树 │ 教材正文富文本编辑器 │ 选中节点的详情 │
│ (折叠) │ 居中 max-w-720px │ (普通卡片样式) │
│ │ 白底 shadow │ │
└──────────┴────────────────────────────┴────────────────────┘
```
### 3.2 左栏:结构树
- 所有节点(含正文)按 `order` 排序,纯列表
- 每行:折叠箭头 + 6px 色点 + 标题 + 类型标签 + 「纸上」标记(如已展开)
- 师生交互节点可展开子节点(对话轮次)
- 底部"+ 添加节点"
### 3.3 中栏:纸
- 教材正文的 Tiptap 富文本编辑器
- `max-width: 720px``padding: 64px 72px``box-shadow`、白底
- 聚焦时顶部出现毛玻璃浮动工具条B / I / H1 / H2 / • / 1. / " / —)
- 锚点标记内嵌在文本流里
- 展开的节点作为 `inline-node` 嵌入段落之间
### 3.4 右栏:详情面板
- **不再是便签样式**,改为普通详情卡片
- 头部:色点 + 类型标签 + 标题输入框 + 展开切换按钮 + 更多按钮
- 属性条:教学阶段 / 差异化 / 时长 / 锚点(可点击切换)
- 主体:节点类型的专属编辑器(复用现有 `BlockRenderer`
- 底部 AI 区4 个 AI 协助按钮(靛蓝 `#6366f1`
- 元信息条:标签 + 更新时间
---
## 4. 节点展开/收起机制
### 4.1 统一展开(所有节点类型)
所有教学节点objective / new_teaching / exercise / interaction 等)都支持展开到正文流。
**展开态inline-node 嵌入纸面)**
- 左侧 2px 细线(`#d6d3d1`hover 变 `#1c1917`
- Inter 字体 13.5px / 暖灰 `#44403c`
- 头部:色点 + 类型标签 + 锚点引用 + 「收起 ▴」按钮
- 标题Inter 14px 加粗 `#1a1a1a`
- 主体:节点类型的渲染内容
- 视觉上"从正文里长出来",不框住
**插入位置**:按节点 `order` 字段顺序,在教材正文的段落之间插入 inline-node。锚点区间高亮/点圆圈仍留在正文文本内仅作为视觉标记inline-node 的位置由 order 决定,不由锚点位置决定。这样保证展开顺序稳定,编辑正文不会引起 inline-node 位置漂移。
**收起态**
- inline-node 从纸上消失
- 节点数据仍在右栏详情和左栏结构树可见
### 4.2 三个展开入口
1. **左栏结构树**:点击「纸上」标记 → 切换展开/收起
2. **右栏详情头部**:展开切换按钮 ▾/▴
3. **右键菜单**:纸上右键节点 → "展开到正文" / "从正文收起"
### 4.3 右键菜单(节点级)
**触发对象**:纸上的 inline-node已展开的节点。右键教材正文文本触发的是"锚定"菜单(见 §6.3),不是节点菜单。右键左栏结构树节点不触发菜单(左栏用按钮操作)。
分组结构:
- **节点操作**:展开到正文 / 从正文收起 / 上移 / 下移
- **AI 协助**:生成本节点内容 / 优化表达 / 差异化建议 / 生成分层提问
- **其他**:复制节点 / 删除节点
---
## 5. 师生交互节点(新节点类型)
### 5.1 类型定义
新增 `interaction` BlockType
```typescript
export type BlockType =
| "objective" | "key_point" | "import"
| "new_teaching" | "consolidation" | "summary"
| "homework" | "blackboard" | "text_study"
| "exercise" | "rich_text" | "reflection"
| "interaction"; // 新增
```
### 5.2 数据结构
```typescript
export interface QATurn {
id: string;
role: "teacher" | "student";
content: string;
/** 教师提问的预期答案 / 引导策略(可选)*/
expectedAnswer?: string;
/** 这一轮的顺序 */
order: number;
}
export interface InteractionBlockData {
/** 设计意图 */
designIntent: string;
/** 对话轮次 */
turns: QATurn[];
/** 关联知识点 */
knowledgePointIds: string[];
}
```
### 5.3 渲染样式
**纸上展开态QA 对话体)**
- 每轮对话:`师`/`生` 角色标签JetBrains Mono 9px黑/灰区分)+ 内容
- 教师提问下方用 italic 灰色小字标注 `[预期:...]`
- 对话轮次之间用 dashed 线分隔
**右栏详情编辑器**
- 每轮对话独立卡片:角色选择 + 内容文本框 + 预期答案文本框
- 上下移 / 删除按钮
- 底部"+ 添加一轮对话"
### 5.4 左栏子节点
师生交互节点在左栏可展开显示对话轮次:
- 第 1 轮 · 提问(师)
- 第 2 轮 · 回答(生)
- 第 3 轮 · 追问(师)
---
## 6. 锚点系统重构
### 6.1 从字符串偏移改为 Tiptap Mark
**当前**`NodeAnchor.start/end` 是基于 `markdownToPlainText` 的纯文本偏移,靠 `injectPlaceholders` 注入 `[[anchor:id]]` 标记,靠 `relocateAnchors` 修补漂移。
**改为**Tiptap 自定义 Mark `AnchorMark`,存储 `anchorId``nodeId`
```typescript
// Tiptap Mark 定义
const AnchorMark = Mark.create({
name: "anchor",
addAttributes() {
return {
anchorId: { default: null },
nodeId: { default: null },
type: { default: "range" }, // "range" | "point"
};
},
parseHTML() { return [{ tag: "span[data-anchor-id]" }]; },
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, {
"data-anchor-id": HTMLAttributes.anchorId,
"data-node-id": HTMLAttributes.nodeId,
"data-anchor-type": HTMLAttributes.type,
})];
},
});
```
### 6.2 锚点数据结构变更
```typescript
export interface NodeAnchor {
id: string;
nodeId: string;
type: AnchorType;
// 删除 start/end/textPreview/invalid改为 Mark 内嵌)
// 保留 id/nodeId/type 用于关联
}
```
### 6.3 锚点交互
- **区间锚定**:在纸上选中文本 → 右键 → "锚定到节点" → 选择节点 → Tiptap `toggleMark` 包裹选中文本
- **点锚定**:在纸上点击位置 → 右键 → "插入锚点" → 选择节点 → 插入 PointMark 节点(带圈数字)
- **编辑时自动跟随**:因为是 Mark文本增删时 Mark 自动随文本移动,不再需要 `relocateAnchors`
- **删除锚点**:右栏详情面板的锚点列表 → 删除按钮 → Tiptap `unsetMark`
### 6.4 锚点视觉
- 区间锚点:浅灰高亮 `rgba(28,25,23,0.08)` + 底部 1.5px 黑线 + 行内小标签 `A` `B` `C`(黑底白字 9px
- 点锚点16px 黑色圆圈带数字 ①②③
- hover/active高亮加深圆点放大
---
## 7. 数据模型变更
### 7.1 文档版本升级到 v4
```typescript
export interface LessonPlanDocumentV4 {
version: 4;
textbookContentNodeId: string;
nodes: AnyLessonPlanNode[];
edges: AnyLessonPlanEdge[]; // 保留但不再用于画布连线
anchors: NodeAnchor[]; // 简化:只存 id 关联
/** V4 新增:节点展开状态 */
expandedNodeIds: string[];
}
```
### 7.2 迁移
- `lib/document-migration.ts` 新增 `migrateV3ToV4`
- v3 的 `anchor.start/end/textPreview/invalid` 字段忽略(旧锚点在 v4 中失效,需重新锚定)
- v3 的 `position {x,y}` 字段保留但忽略(不再用于画布)
- v3 的 `edges` 保留但忽略(不再画连线)
### 7.3 向后兼容
- 读取 v3 文档时自动迁移到 v4
- v4 文档保存时 `version: 4`
- 旧版本无法读取 v4v4 是新创建分支,不回写 v3
---
## 8. 组件架构
### 8.1 移除的组件
- `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`
### 8.2 新增组件
```
src/modules/lesson-preparation/components/
├─ paper-editor/
│ ├─ paper-editor.tsx # 中栏:纸区容器
│ ├─ textbook-tiptap-editor.tsx # 正文 Tiptap 编辑器(含锚点 Mark
│ ├─ inline-node.tsx # 展开节点的 inline 渲染
│ ├─ inline-qa-dialog.tsx # 师生交互的对话体渲染
│ ├─ anchor-mark.ts # Tiptap AnchorMark 定义
│ ├─ 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详情编辑
```
### 8.3 改造的组件
- `lesson-plan-editor.tsx`:移除 NodeEditor + NodeEditPanel改为三栏布局StructureTree + PaperEditor + DetailPanel
- `node-edit-panel.tsx`:拆分到 `detail-panel/` 下,逻辑保留
- `config/block-registry.tsx`:新增 `interaction` 类型注册
### 8.4 保留的组件
- `blocks/rich-text-block.tsx`(仍用于非正文节点的富文本编辑)
- `blocks/objective-block.tsx` 等其他 block 编辑器
- `version-history-drawer.tsx` / `print-view.tsx` / `consistency-check-dialog.tsx` / `ai-feedback-dialog.tsx` / `ai-differentiation-dialog.tsx`
- 所有 `actions-*.ts` / `data-access-*.ts`
---
## 9. 状态管理
### 9.1 新增 slice`expanded-slice.ts`
```typescript
interface ExpandedState {
expandedNodeIds: string[];
toggleExpand: (nodeId: string) => void;
setExpanded: (nodeIds: string[]) => void;
isExpanded: (nodeId: string) => boolean;
}
```
### 9.2 现有 slice 调整
- `editor-slice.ts`:移除 `selectedNodeId` 改为 `activeNodeId`(详情面板显示的节点),新增 `anchorNodeForSelection`(待锚定的节点 ID用于纸上选中文本时锚定
- `selection-slice.ts`:移除画布选择相关,保留节点选择
- `history-slice.ts`:保持不变(撤销/重做覆盖 v4 文档)
---
## 10. 关键交互流
### 10.1 编辑教材正文
1. 中栏纸区聚焦 → Tiptap 编辑器激活 → 浮动工具条出现
2. 输入 → `onUpdate``editor-slice.updateNode(textbookNodeId, { data: { content: html } })`
3. debounce 3s 自动保存(现有逻辑保留)
### 10.2 锚定选中文本到节点
1. 在纸上选中文本 → Tiptap selection change
2. 右键 → 上下文菜单 → "锚定到节点"
3. 选择节点 → `toggleMark("anchor", { anchorId, nodeId, type: "range" })`
4. `expandedNodeIds` 不变,但右栏详情面板的"锚点"属性更新
### 10.3 展开节点到正文
1. 右栏详情头部点击 ▾ → `expanded-slice.toggleExpand(nodeId)`
2. `paper-editor` 监听 `expandedNodeIds` → 在教材正文对应锚点位置插入 `<InlineNode>`
3. InlineNode 渲染节点内容(用 Inter 字体区分正文 Fraunces
### 10.4 师生交互对话编辑
1. 左栏添加 `interaction` 节点 → 默认 3 轮空对话
2. 右栏详情显示 `qa-editor` → 编辑每轮角色/内容/预期
3. 展开到正文 → `inline-qa-dialog` 用对话体渲染
4. 编辑右栏 → 纸上展开态实时更新
---
## 11. 移除的功能
| 功能 | 原因 |
|------|------|
| React Flow 画布 | 不适合长文档编辑,纸感设计取代 |
| 节点拖拽 / 连线 | 树结构 + 展开流取代空间布局 |
| Minimap / Controls | 画布移除 |
| 字符串偏移锚点 | Tiptap Mark 取代 |
| `relocateAnchors` | Mark 自动跟随 |
| 13 个鲜艳 Material 色 | 统一到中性令牌 |
| `position {x,y}` 字段使用 | 保留字段但忽略(向后兼容) |
---
## 12. i18n
新增翻译键zh-CN / en
- `lessonPreparation.interaction.*`:师生交互节点相关
- `lessonPreparation.paper.*`:纸区相关
- `lessonPreparation.contextMenu.*`:右键菜单
- `lessonPreparation.detail.*`:详情面板
- `lessonPreparation.tree.*`:结构树
---
## 13. 架构图同步
完成后更新:
- `docs/architecture/004_architecture_impact_map.md`lesson-preparation 模块章节
- `docs/architecture/005_architecture_data.json`
- 新增 `interaction` 到 BlockType
- 新增 `QATurn` / `InteractionBlockData` 类型
- 新增组件 exports
- 移除的组件
- 数据结构版本 v4
---
## 14. 已知风险
1. **v3 锚点丢失**v3 文档的字符串偏移锚点在迁移到 v4 后失效,需重新锚定。迁移后在编辑器顶部显示一条不可关闭的黄色 banner"此课案使用旧版锚点格式,部分锚点已失效,请重新锚定"),点击"知道了"后消失(记录到 localStorage 不再提示该 planId
2. **Tiptap Mark 与 React 状态同步**Tiptap 的 Mark 状态在编辑时是内部的,需要 `onSelectionUpdate` 同步到 React 状态以驱动右栏锚点列表。
3. **展开节点的性能**:大量节点展开时,纸上 DOM 数量增加。需 `onlyRenderVisibleElements` 或虚拟化(暂不实现,超过 20 个展开节点时再说)。
4. **打印视图**`print-view.tsx` 需适配 v4 文档结构(移除画布依赖,按展开顺序打印)。
---
## 15. 实现范围
本设计涵盖:
- 三栏布局重构
- 正文 Tiptap 富文本编辑器
- 锚点 Mark 系统
- 节点展开/收起
- 师生交互节点
- 右键菜单 + AI 协助入口
- 配色统一
- v3 → v4 数据迁移
- i18n
- 架构图同步
**不涵盖**YAGNI
- 协同编辑V5-11 R3 单独立项)
- 移动端独立优化(先桌面端可用)
- 展开节点的虚拟化(性能问题出现再处理)
- 自定义字体加载(先引 Google Fonts CDN

View File

@@ -650,3 +650,47 @@ export function SubjectTabs({ subjects, currentSubjectId }: Props) {
**根因 2**`next/dynamic` 加载的第三方库若在顶层用值导入(`import X from "lib"`),即使 `dynamic({ ssr: false })`,模块顶层代码仍会在 SSR 求值时执行,导致 canvas/window 访问报错dynamic loading 永久卡住。必须用 `import type` 引入类型。
---
## 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 一闪即逝 |
### i18n 键缺失与中英文不同步
模块新增 t() 调用时,必须同步更新 zh-CN 和 en 两个语言文件,且键名必须完全一致(不能用 `week`/`weekView` 这种同义不同名的两套键)。
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 嵌套路径不存在时需创建子对象 | JSON 中 `"version": { "diff": { "title": "..." } }` 对应 `t("version.diff.title")` | JSON 中 `"version"` 和 `"diff"` 是平级节,却调用 `t("version.diff.title")` → MISSING_MESSAGE |
| Zod schema 错误键必须存在于 JSON `error` 节 | `z.string().min(1, "error.labelTooLong")` + JSON `error.labelTooLong` 存在 | schema 引用 `error.labelTooLong` 但 JSON 中无此键 → 校验失败时显示键路径 |
| zh-CN 与 en 键名必须完全一致 | zh-CN `calendar.weekView` + en `calendar.weekView` | zh-CN `calendar.weekView` + en `calendar.week` → en locale 下 MISSING_MESSAGE |
| 禁止 JSX 中硬编码中文 | `label={t("v4.detail.stageLabel")}` | `label="教学阶段"` → en 用户看到中文 |
| 未启用 useTranslations 的组件如需展示文本,必须先引入 | `import { useTranslations } from "next-intl"; const t = useTranslations("lessonPreparation");` | 组件内直接写中文字面量 |
### i18n dead 节清理规则
语言文件中未被任何 `t()` 调用引用的节dead keys应定期清理避免 zh-CN 与 en 各持不同 schema 导致维护混乱。清理前必须全代码库搜索(含 `src/app/`),确认无引用。
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 删除 dead 节前必须验证全代码库无引用 | `grep -r "t(\"review." src/` 无结果后删除 | 直接删除节,导致隐藏引用方 MISSING_MESSAGE |
| zh-CN 和 en 键集必须完全一致 | 两文件均保留 `analytics` 节且键名相同 | zh-CN 有 `analytics.totalPublished`en 只有 `analytics.publishedPlans` |
### i18n 动态键翻译守卫
Zod schema 中存储的 i18n 键(如 `"error.titleRequired"`)在服务端翻译时,必须先用 `t.has()` 守卫检查键是否存在,避免 schema 误写不存在的键时抛出 MISSING_MESSAGE。
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 动态键翻译前必须用 t.has() 守卫 | `if (t.has(msg)) { return t(msg); } return msg;` | `return t(msg);` — 键不存在时抛 MISSING_MESSAGE |

View File

@@ -1,27 +0,0 @@
"use client";
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
import type { AiContentGeneratorSlotProps } from "@/modules/lesson-preparation/components/node-edit-panel";
/**
* P0-11 修复AI 内容生成器 slot 实现。
* 此组件在 app 层组合 @/modules/ai 的具体实现,
* 通过 props 注入到 lesson-preparation 模块的 NodeEditPanel
* 避免备课模块直接依赖 @/modules/ai。
*/
export function AiContentGeneratorSlot({
topic,
textbookId,
chapterId,
}: AiContentGeneratorSlotProps) {
const aiClient = useAiClientOptional();
if (!aiClient) return null;
return (
<AiLessonContentGenerator
topic={topic}
textbookId={textbookId}
chapterId={chapterId}
/>
);
}

View File

@@ -11,8 +11,6 @@ import { Permissions } from "@/shared/types/permissions"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
// P0-11 修复:通过 slot 组件注入 AI 功能,避免备课模块直接 import @/modules/ai
import { AiContentGeneratorSlot } from "./ai-content-generator-slot"
export const dynamic = "force-dynamic"
@@ -70,7 +68,6 @@ export default async function EditLessonPlanPage({
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
classes={classes}
aiContentGenerator={AiContentGeneratorSlot}
/>
</Suspense>
</div>

View File

@@ -48,22 +48,37 @@
--chart-5: 27 87% 67%;
/* Lesson Plan Node Colors (V4 P1-4 修复:从硬编码提取到设计令牌) */
--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;
--lesson-canvas-bg: #ccc;
/* V4 纸感重构:原 --lesson-node-* 已删除,改为 --lp-dot-* 中性色点令牌 */
/* 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;
/* Sidebar Specific */
--sidebar-background: 0 0% 98%;
@@ -326,3 +341,137 @@
page-break-inside: avoid;
}
}
/* ============================================================
* V4 备课编辑器:纸区工具条 + 锚点 + inline-node + 对话体样式
* ============================================================ */
/* 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;
}

View File

@@ -16,7 +16,15 @@ import {
type CurriculumCheckItem,
type ExplainableAssessment,
} from "./lib/ai-differentiation";
import type { LessonPlanDocument } from "./types";
import {
generateNodeContent,
optimizeNodeExpression,
suggestNodeDifferentiation,
generateLayeredQuestions,
type NodeContentUpdate,
type NodeDifferentiationUpdate,
} from "./lib/ai-node-assist";
import type { LessonPlanDocument, LessonPlanNode, QATurn } from "./types";
import type { ActionState } from "@/shared/types/action-state";
export async function suggestKnowledgePointsAction(input: {
@@ -152,3 +160,100 @@ export async function getKnowledgePointsForAlignmentAction(
return handleActionError(e);
}
}
// ---- V4 节点级 AI 协助4 项) ----
/**
* V4-N1生成本节点内容。
* 根据节点 type 生成对应字段html / objectives / turns 等),返回 patch 由前端合并到 node.data。
*/
export async function generateNodeContentAction(
node: LessonPlanNode,
): Promise<ActionState<NodeContentUpdate>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
const result = await generateNodeContent(node);
if (!result) {
return { success: false, message: "AI_GENERATE_FAILED" };
}
return { success: true, data: result };
} catch (e) {
return handleActionError(e);
}
}
/**
* V4-N2优化节点表达。
* 保留结构,仅优化字段值。
*/
export async function optimizeNodeExpressionAction(
node: LessonPlanNode,
): Promise<ActionState<NodeContentUpdate>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
const result = await optimizeNodeExpression(node);
if (!result) {
return { success: false, message: "AI_OPTIMIZE_FAILED" };
}
return { success: true, data: result };
} catch (e) {
return handleActionError(e);
}
}
/**
* V4-N3差异化建议。
* 判定该节点适合的差异化层次,前端据此设置 node.differentiation。
*/
export async function suggestNodeDifferentiationAction(
node: LessonPlanNode,
): Promise<ActionState<NodeDifferentiationUpdate>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
const result = await suggestNodeDifferentiation(node);
if (!result) {
return { success: false, message: "AI_DIFFERENTIATION_FAILED" };
}
return { success: true, data: result };
} catch (e) {
return handleActionError(e);
}
}
/**
* V4-N4生成分层提问仅 interaction 节点)。
* 返回 3 轮 teacher/student 对话,前端追加到 turns。
*/
export async function generateLayeredQuestionsAction(
node: LessonPlanNode,
): Promise<ActionState<{ turns: QATurn[] }>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
if (node.type !== "interaction") {
return { success: false, message: "AI_LAYERED_REQUIRES_INTERACTION" };
}
const turns = await generateLayeredQuestions(node);
if (!turns) {
return { success: false, message: "AI_LAYERED_FAILED" };
}
return { success: true, data: { turns } };
} catch (e) {
return handleActionError(e);
}
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import type { LessonPlanDocument } from "../types";
/**
* V4v3 锚点失效提示 banner。
* 当文档含旧锚点invalid: true 或含 start/end 字段)且未 dismiss 时显示。
* dismiss 状态记录到 localStorage按 planId
*
* 通过 key={planId} 实现 planId 变化时重新挂载内部组件,
* 从而使用 lazy initial state 读取 localStorage避免在 effect 中 setState。
*/
export function AnchorMigrationBanner() {
const doc = useLessonPlanEditor((s) => s.doc);
const planId = useLessonPlanEditor((s) => s.planId);
return (
<BannerContent
key={planId || "no-plan"}
doc={doc}
planId={planId}
/>
);
}
function BannerContent({
doc,
planId,
}: {
doc: LessonPlanDocument;
planId: string;
}) {
const t = useTranslations("lessonPreparation");
const [dismissed, setDismissed] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
if (!planId) return false;
return localStorage.getItem(`lp-legacy-anchor-dismissed-${planId}`) === "true";
});
// 检测是否有旧锚点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>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
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) {
void onUpdate;
return <QaEditor nodeId={blockId} data={data} />;
}

View File

@@ -1,5 +1,6 @@
import type { JSX } from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/lib/utils";
import type { GradeOption, SubjectOption } from "@/modules/school/data-access";
import type { StandardsCoverageCell } from "@/modules/lesson-preparation/data-access-analytics";
@@ -20,6 +21,7 @@ function getCoverageColor(percent: number): string {
}
export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
// 构建查找表subjectId|gradeId -> cell
const cellMap = useMemo(() => {
const map = new Map<string, StandardsCoverageCell>();
@@ -99,8 +101,8 @@ export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Ele
{/* 图例 */}
<div className="flex flex-wrap items-center gap-4 text-xs">
<span className="text-muted-foreground"></span>
<LegendItem color="bg-muted/40" label="无数据" />
<span className="text-muted-foreground">{t("v4.heatmap.legendTitle")}</span>
<LegendItem color="bg-muted/40" label={t("v4.heatmap.noData")} />
<LegendItem color="bg-red-100 dark:bg-red-950/50" label="0-25%" />
<LegendItem color="bg-amber-100 dark:bg-amber-950/50" label="25-50%" />
<LegendItem color="bg-blue-100 dark:bg-blue-950/50" label="50-75%" />

View File

@@ -0,0 +1,90 @@
"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>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { useNodeAiAssist, type NodeAiAction } from "../../hooks/use-node-ai-assist";
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 { run: runNodeAi, isRunning } = useNodeAiAssist();
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);
const isInteraction = node.type === "interaction";
const trigger = (action: NodeAiAction) => {
void runNodeAi(action, 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" }}>
{isInteraction ? (
<QaEditor nodeId={node.id} data={node.data as InteractionBlockData} />
) : (
<BlockRenderer
type={node.type}
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" }}>
{isInteraction ? (
<>
<AiButton
label={t("v4.detail.aiGenerateLayered")}
disabled={isRunning}
onClick={() => trigger("layered")}
/>
<AiButton
label={t("v4.detail.aiFillExpected")}
disabled={isRunning}
onClick={() => trigger("generate")}
/>
<AiButton
label={t("v4.detail.aiOptimizeFollowup")}
disabled={isRunning}
onClick={() => trigger("optimize")}
/>
</>
) : (
<>
<AiButton
label={t("v4.contextMenu.aiGenerate")}
disabled={isRunning}
onClick={() => trigger("generate")}
/>
<AiButton
label={t("v4.contextMenu.aiOptimize")}
disabled={isRunning}
onClick={() => trigger("optimize")}
/>
</>
)}
<AiButton
label={t("v4.detail.aiDifferentiation")}
disabled={isRunning}
onClick={() => trigger("differentiation")}
/>
</div>
</div>
</aside>
);
}
function AiButton({
label,
onClick,
disabled,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
style={{
padding: "5px 10px",
border: "1px solid var(--border)",
background: "var(--background)",
borderRadius: 4,
fontSize: 11.5,
cursor: disabled ? "not-allowed" : "pointer",
color: "var(--foreground)",
opacity: disabled ? 0.6 : 1,
}}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import type { Block, DifferentiationLevel, TeachingStage } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
interface Props {
node: Block;
}
const STAGES: TeachingStage[] = ["import", "new_teaching", "consolidation", "summary"];
const LEVELS: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
export function DetailProps({ node }: Props) {
const t = useTranslations("lessonPreparation");
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={t("v4.detail.stageLabel")}
value={node.stage ?? "—"}
options={STAGES}
onChange={(v) => updateNode(node.id, { stage: v as TeachingStage })}
/>
<PropItem
label={t("v4.detail.differentiationLabel")}
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>
);
}

View File

@@ -0,0 +1,250 @@
"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={t("v4.detail.turnContentPlaceholder")}
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>
);
}

View File

@@ -3,15 +3,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
import { NodeEditPanel, type AiContentGeneratorSlot } from "./node-edit-panel";
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";
import { VersionHistoryDrawer } from "./version-history-drawer";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import {
useLessonPlanContextSafe,
useLessonPlanTrackerSafe,
} from "../providers/lesson-plan-provider";
import type { BlockType, LessonPlanStatus } from "../types";
import type { LessonPlanStatus } from "../types";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
@@ -24,7 +26,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog";
import { Plus, Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
import { Save, History, Book, FileText, Send, Undo2, RotateCw, WifiOff, Printer, Undo, Redo, CalendarClock, ClipboardCheck, Sparkles, Layers } from "lucide-react";
import { toast } from "sonner";
import { PrintView } from "./print-view";
import { ScheduleDialog } from "./schedule-dialog";
@@ -43,25 +45,8 @@ interface Props {
textbookTitle?: string;
chapterTitle?: string;
classes?: { id: string; name: string }[];
/** AI 内容生成器(可选,通过 props 注入避免模块耦合P0-11 修复)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
const BLOCK_TYPES_TO_ADD: BlockType[] = [
"objective",
"key_point",
"import",
"new_teaching",
"consolidation",
"summary",
"homework",
"blackboard",
"exercise",
"text_study",
"rich_text",
"reflection",
];
export function LessonPlanEditor({
planId,
initialTitle,
@@ -72,7 +57,6 @@ export function LessonPlanEditor({
textbookTitle,
chapterTitle,
classes,
aiContentGenerator,
}: Props) {
const t = useTranslations("lessonPreparation");
const editor = useLessonPlanEditor();
@@ -80,7 +64,6 @@ export function LessonPlanEditor({
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
const [showPrint, setShowPrint] = useState(false); // V5-4打印视图
const [showSchedule, setShowSchedule] = useState(false); // V5-7安排课时对话框
const [showConsistency, setShowConsistency] = useState(false); // V5-19一致性校验对话框
@@ -90,7 +73,6 @@ export function LessonPlanEditor({
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const addMenuRef = useRef<HTMLDivElement>(null);
// 初始化:仅在 planId 变化时 hydrate修复 P1-3
const initKey = planId;
@@ -261,18 +243,6 @@ export function LessonPlanEditor({
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
// 添加节点菜单点击外部关闭P3-2
useEffect(() => {
if (!showAddMenu) return;
function handleClickOutside(e: MouseEvent) {
if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) {
setShowAddMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddMenu]);
const handleManualSave = useCallback(async () => {
if (!service) return;
const state = useLessonPlanEditor.getState();
@@ -524,53 +494,24 @@ export function LessonPlanEditor({
)}
</div>
{/* 主区域:画布 + 侧边面板 */}
<div className="flex-1 flex overflow-hidden">
{/* 节点画布 */}
<div className="flex-1 relative">
<LessonPlanErrorBoundary>
<NodeEditor />
</LessonPlanErrorBoundary>
{/* 添加节点浮动按钮 */}
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
<Button
variant="default"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<Plus className="w-4 h-4 mr-1" /> {t("action.addNode")}
</Button>
{showAddMenu && (
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
{BLOCK_TYPES_TO_ADD.map((blockType) => (
<button
key={blockType}
onClick={() => {
editor.addNode(blockType, undefined, t(`blockType.${blockType}`));
setShowAddMenu(false);
}}
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
>
{t(`blockType.${blockType}`)}
</button>
))}
</div>
)}
</div>
</div>
{/* V4v3 锚点失效提示 banner */}
<AnchorMigrationBanner />
{/* 侧边内容编辑面板:直接用 selectedNodeId 控制显示(修复 P1-2 */}
{editor.selectedNodeId && (
<div className="w-[420px] flex-shrink-0">
<NodeEditPanel
planId={planId}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
aiContentGenerator={aiContentGenerator}
/>
</div>
)}
</div>
{/* V4三栏布局结构树 + 纸区 + 详情面板 */}
<LessonPlanErrorBoundary>
<main
style={{
display: "grid",
gridTemplateColumns: "260px 1fr 380px",
height: "calc(100vh - 52px)",
overflow: "hidden",
}}
>
<StructureTree />
<PaperEditor />
<DetailPanel />
</main>
</LessonPlanErrorBoundary>
<LessonPlanErrorBoundary>
<VersionHistoryDrawer

View File

@@ -1,29 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
MiniMap,
type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
import { getNodeColor } from "../lib/node-summary";
import { useMediaQuery } from "@/shared/hooks/use-media-query";
import { LessonPlanMobileView } from "./lesson-plan-mobile-view";
import type { LessonPlanDocument } from "../types";
const nodeTypes = {
lesson: LessonNode,
textbook_content: TextbookContentNodeComponent,
};
interface Props {
doc: LessonPlanDocument;
textbookTitle?: string;
@@ -31,111 +10,14 @@ interface Props {
}
/**
* 只读课案画布视图(学生/家长/管理员/教研组长使用)。
* V4 只读课案视图(学生/家长/管理员/教研组长使用)。
*
* 复用 React Flow 渲染,但禁用所有编辑交互:
* - 节点不可拖动、不可连线
* - 可缩放、可平移(便于查看)
* - 可点击节点查看详情(通过 onSelectNode 回调)
* V4 重构:移除 React Flow 画布,统一使用线性视图(按 order 排序)渲染节点。
* 原 V3 的画布只读视图已被 LessonPlanMobileView 的线性卡片列表替代,
* 该组件在 V4 中作为统一入口,桌面端和移动端共用同一渲染逻辑。
*/
export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Props) {
const t = useTranslations("lessonPreparation");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
// V5-13 P3小屏设备使用线性移动视图替代画布
const isMobile = useMediaQuery("(max-width: 768px)");
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
const rfEdges = useMemo(
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? [], doc.nodes),
[doc.edges, doc.anchors, selectedNodeId, doc.nodes],
);
// 为正文节点准备 data锚点、选中节点、选择回调
const nodesWithData: Node[] = useMemo(() => {
return rfNodes.map((n) => {
if (n.type === "textbook_content") {
return {
...n,
data: {
...n.data,
node: doc.nodes.find((nn) => nn.id === n.id),
anchors: doc.anchors ?? [],
selectedNodeId,
onSelectNode: setSelectedNodeId,
},
};
}
return n;
});
}, [rfNodes, doc.nodes, doc.anchors, selectedNodeId]);
// V5-13 P3移动端渲染线性视图
if (isMobile) {
return (
<LessonPlanMobileView doc={doc} textbookTitle={textbookTitle} chapterTitle={chapterTitle} />
);
}
return (
<div className="h-full w-full relative">
{/* 顶部信息条 */}
{(textbookTitle || chapterTitle) && (
<div className="absolute top-2 left-2 z-10 bg-surface/95 backdrop-blur border border-outline-variant rounded-md px-3 py-1.5 text-xs text-on-surface-variant flex items-center gap-2 shadow-sm">
{textbookTitle && (
<span className="flex items-center gap-1">
<span className="font-medium">{t("editor.textbookLabel")}</span>
{textbookTitle}
</span>
)}
{chapterTitle && (
<span className="flex items-center gap-1">
<span className="font-medium">{t("editor.chapterLabel")}</span>
{chapterTitle}
</span>
)}
</div>
)}
<ReactFlow
nodes={nodesWithData}
edges={rfEdges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
panOnDrag={true}
zoomOnScroll={true}
zoomOnPinch={true}
panOnScroll={false}
zoomOnDoubleClick={false}
selectionOnDrag={false}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Controls showInteractive={false} />
<MiniMap
pannable
zoomable
nodeColor={(n) => {
// V3 修复:使用 getNodeColor 替代硬编码颜色,与编辑器保持一致
const data = n.data;
if (!data || typeof data !== "object") return getNodeColor(n.type ?? "");
const nodeData = data.node;
if (
nodeData &&
typeof nodeData === "object" &&
nodeData !== null &&
"type" in nodeData &&
typeof nodeData.type === "string"
) {
return getNodeColor(nodeData.type);
}
return getNodeColor(n.type ?? "");
}}
/>
</ReactFlow>
</div>
<LessonPlanMobileView doc={doc} textbookTitle={textbookTitle} chapterTitle={chapterTitle} />
);
}

View File

@@ -1,304 +0,0 @@
"use client";
import { useState, type ComponentType } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, ChevronDown, ChevronUp } from "lucide-react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { BlockRenderer, BLOCK_REGISTRY } from "../config/block-registry";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
import { getNodeColor } from "../lib/node-summary";
import type { TeachingStage, DifferentiationLevel } from "../types";
import { TEACHING_STAGE_KEYS, DIFFERENTIATION_LEVEL_KEYS } from "../types";
/**
* P0-11 修复AI 内容生成器 slot 类型。
* 通过 props 注入 AI 组件,避免直接 import @/modules/ai。
* 调用方teacher edit 页面)提供具体实现。
*/
export interface AiContentGeneratorSlotProps {
topic: string;
textbookId?: string;
chapterId?: string;
}
export type AiContentGeneratorSlot = ComponentType<AiContentGeneratorSlotProps>;
interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
/** V5-5当前课案 ID透传给 RichTextBlock 用于素材库 picker */
planId?: string;
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
export function NodeEditPanel({ textbookId, chapterId, classes, planId, aiContentGenerator }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
useLessonPlanEditor();
const [showAiPanel, setShowAiPanel] = useState(false);
const node = doc.nodes.find((n) => n.id === selectedNodeId);
if (!node) {
return (
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
{t("editor.selectNodeHint")}
</div>
);
}
// P2-1正文节点显示操作提示 + 锚点列表(而非误导性的"内容为空"
if (node.type === "textbook_content") {
// 收集所有锚点,并关联到对应的教学节点
const anchorsWithNode = doc.anchors
.map((a) => {
const linkedNode = doc.nodes.find((n) => n.id === a.nodeId);
return { anchor: a, nodeTitle: linkedNode?.title ?? "?", nodeType: linkedNode?.type ?? "rich_text" };
})
.sort((a, b) => a.anchor.start - b.anchor.start);
return (
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
<span className="flex-1 font-title-md text-title-md">
{t("editor.textbookContent")}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => selectNode(null)}
aria-label={t("action.close")}
>
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 操作提示 */}
<div className="rounded-md border border-outline-variant bg-surface-container-low p-3 text-sm text-on-surface-variant">
{t("editor.textbookOperateHint")}
</div>
{/* 锚点列表 */}
<div>
<div className="text-xs font-medium text-on-surface-variant mb-2">
{t("editor.anchorListTitle")}{anchorsWithNode.length}
</div>
{anchorsWithNode.length === 0 ? (
<p className="text-sm text-on-surface-variant italic">
{t("editor.anchorListEmpty")}
</p>
) : (
<ul className="space-y-1">
{anchorsWithNode.map(({ anchor, nodeTitle, nodeType }) => (
<li
key={anchor.id}
className="flex items-center gap-2 px-2 py-1.5 rounded border border-outline-variant bg-surface-container-lowest text-sm"
>
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getNodeColor(nodeType) }}
/>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-secondary text-secondary-foreground flex-shrink-0">
{anchor.type === "range"
? t("editor.anchorRangeLabel")
: t("editor.anchorPointLabel")}
</span>
<span className="truncate flex-1" title={nodeTitle}>
{nodeTitle}
</span>
{anchor.textPreview && (
<span className="truncate text-xs text-on-surface-variant max-w-[120px]" title={anchor.textPreview}>
&ldquo;{anchor.textPreview}&rdquo;
</span>
)}
<Button
variant="ghost"
size="sm"
className="!p-1 !h-6 !w-6 text-on-surface-variant hover:text-error"
onClick={() => removeAnchor(anchor.id)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
// 教学节点textbook_content 分支已上方 return此处 TypeScript 已收窄为 LessonPlanNode
const lessonNode = node;
// 从节点标题提取主题用于 AI 内容生成
const aiTopic = lessonNode.title || t("editor.textbookContent");
return (
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
{/* 面板头部 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
<input
value={lessonNode.title}
onChange={(e) => updateNode(lessonNode.id, { title: e.target.value })}
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
aria-label={lessonNode.title}
/>
<Button
variant="ghost"
size="sm"
onClick={() => selectNode(null)}
aria-label={t("action.close")}
>
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
{/* V5-15 T1 + V5-18 W6节点属性条教学阶段 + 差异化标记) */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface-container-low">
{/* 教学阶段 */}
<label className="text-xs text-on-surface-variant flex items-center gap-1">
{t("editor.stageLabel")}
</label>
<select
value={lessonNode.stage ?? ""}
onChange={(e) => {
const v = e.target.value;
updateNode(lessonNode.id, {
stage: v === "" ? undefined : (v as TeachingStage),
});
}}
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
aria-label={t("editor.stageLabel")}
>
<option value="">{t("editor.stageNone")}</option>
{TEACHING_STAGE_KEYS.map((s) => (
<option key={s} value={s}>
{t(`editor.stage.${s}`)}
</option>
))}
</select>
{/* 差异化标记 */}
<label className="text-xs text-on-surface-variant flex items-center gap-1 ml-2">
{t("editor.differentiationLabel")}
</label>
<select
value={lessonNode.differentiation ?? ""}
onChange={(e) => {
const v = e.target.value;
updateNode(lessonNode.id, {
differentiation: v === "" ? undefined : (v as DifferentiationLevel),
});
}}
className="text-xs border border-outline-variant rounded px-1.5 py-0.5 bg-surface"
aria-label={t("editor.differentiationLabel")}
>
<option value="">{t("editor.differentiationNone")}</option>
{DIFFERENTIATION_LEVEL_KEYS.map((d) => (
<option key={d} value={d}>
{t(`editor.differentiation.${d}`)}
</option>
))}
</select>
</div>
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
<div className="flex-1 overflow-y-auto p-3">
<LessonPlanErrorBoundary>
<BlockRenderer
type={lessonNode.type}
blockId={lessonNode.id}
data={lessonNode.data}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
planId={planId}
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
/>
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
<UnknownBlockHint type={lessonNode.type} t={t} />
</LessonPlanErrorBoundary>
{/* AI 内容生成区(可折叠)— P0-11 修复:通过 props 注入,不直接 import @/modules/ai */}
{aiContentGenerator ? (
<div className="mt-4 border-t border-outline-variant pt-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
onClick={() => setShowAiPanel(!showAiPanel)}
aria-expanded={showAiPanel}
>
<span className="flex items-center gap-1">
<Sparkles className="w-3.5 h-3.5 text-primary" />
{tAi("lessonPrep.generateContent")}
</span>
{showAiPanel ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
</Button>
{showAiPanel ? (
<div className="mt-2">
{(() => {
const AiGenerator = aiContentGenerator;
return (
<AiGenerator
topic={aiTopic}
textbookId={textbookId}
chapterId={chapterId}
/>
);
})()}
</div>
) : null}
</div>
) : null}
</div>
{/* 底部操作 */}
<div className="px-4 py-2 border-t border-outline-variant">
<Button
variant="outline"
size="sm"
className="text-error"
onClick={() => removeNode(lessonNode.id)}
>
<Trash2 className="w-3 h-3 mr-1" aria-hidden="true" />
{t("action.delete")}
</Button>
</div>
</div>
);
}
/**
* 未知 Block 类型提示(配置驱动,当 BlockRenderer 返回 null 时显示)。
* 独立组件避免在主组件中条件渲染。
*/
function UnknownBlockHint({
type,
t,
}: {
type: string;
t: ReturnType<typeof useTranslations>;
}) {
// V4 P1-9 修复knownTypes 从 BLOCK_REGISTRY 派生,消除重复定义
const knownTypes: readonly string[] = Object.keys(BLOCK_REGISTRY);
if (knownTypes.includes(type)) {
return null;
}
return (
<div className="text-on-surface-variant text-sm p-4">
{t("editor.unknownBlockType")}
</div>
);
}

View File

@@ -1,294 +0,0 @@
"use client";
import { useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
type NodeChange,
type EdgeChange,
type Connection,
applyEdgeChanges,
BackgroundVariant,
Panel,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LayoutGrid } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { LessonNode } from "./nodes/lesson-node";
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
import { getNodeColor } from "../lib/node-summary";
import type { AnyLessonPlanNode, BlockType } from "../types";
const nodeTypes = {
lesson: LessonNode,
textbook_content: TextbookContentNodeComponent,
};
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
type Props = Record<string, never>;
export function NodeEditor({}: Props) {
const t = useTranslations("lessonPreparation");
const {
doc,
selectedNodeId,
updateNodePosition,
removeNode,
connect,
selectNode,
setEdges,
addAnchor,
addNode,
autoLayout,
} = useLessonPlanEditor();
// V5-8自动布局按钮
const handleAutoLayout = useCallback(() => {
autoLayout("TB");
}, [autoLayout]);
// P1-1构建可锚定的教学节点列表排除正文节点
const anchorableNodes = useMemo(
() =>
doc.nodes
.filter((n): n is Extract<AnyLessonPlanNode, { type: BlockType }> => n.type !== "textbook_content")
.map((n) => ({ id: n.id, title: n.title, type: n.type })),
[doc.nodes],
);
// 锚点添加回调(正文节点使用)
const handleAddRangeAnchor = useCallback(
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
// __selected__ 表示使用当前选中节点
const actualNodeId =
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId) return;
addAnchor({
nodeId: actualNodeId,
type: "range",
start: params.start,
end: params.end,
textPreview: params.textPreview,
});
},
[addAnchor, selectedNodeId],
);
const handleAddPointAnchor = useCallback(
(params: { nodeId: string; start: number }) => {
const actualNodeId =
params.nodeId === "__selected__"
? selectedNodeId ?? ""
: params.nodeId;
if (!actualNodeId) return;
addAnchor({
nodeId: actualNodeId,
type: "point",
start: params.start,
});
},
[addAnchor, selectedNodeId],
);
// P1-1创建新节点并锚定
const handleCreateNewNode = useCallback(
(params: {
anchorType: "range" | "point";
start: number;
end?: number;
textPreview?: string;
}) => {
// 默认创建 rich_text 节点(最通用的类型),用户可后续切换
const newNodeId = addNode("rich_text", undefined, t("blockType.rich_text"));
addAnchor({
nodeId: newNodeId,
type: params.anchorType,
start: params.start,
...(params.end !== undefined ? { end: params.end } : {}),
...(params.textPreview ? { textPreview: params.textPreview } : {}),
});
},
[addNode, addAnchor, t],
);
// 使用纯函数映射 nodes/edges
const rfNodes: Node[] = useMemo(
() =>
toRfNodes(doc.nodes, selectedNodeId, {
anchors: doc.anchors,
selectedNodeId,
anchorableNodes,
onAddRangeAnchor: handleAddRangeAnchor,
onAddPointAnchor: handleAddPointAnchor,
onCreateNewNode: handleCreateNewNode,
onSelectNode: selectNode,
}),
[doc.nodes, doc.anchors, selectedNodeId, anchorableNodes, handleAddRangeAnchor, handleAddPointAnchor, handleCreateNewNode, selectNode],
);
const rfEdges: Edge[] = useMemo(
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors, doc.nodes),
[doc.edges, selectedNodeId, doc.anchors, doc.nodes],
);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
changes.forEach((change) => {
if (change.type === "position" && change.position) {
// 实时拖动:每次 position 变化都更新(不再等待 dragging=false
// 但仅在节点正在被拖动或拖动结束时更新
updateNodePosition(change.id, change.position);
} else if (change.type === "remove") {
removeNode(change.id);
} else if (change.type === "select") {
selectNode(change.selected ? change.id : null);
}
});
},
[updateNodePosition, removeNode, selectNode],
);
const onConnect = useCallback(
(conn: Connection) => {
if (conn.source && conn.target) {
connect(conn.source, conn.target);
}
},
[connect],
);
// 同步 edges 变化(如拖拽重连)
const onEdgesChangeSync = useCallback(
(changes: EdgeChange[]) => {
// 简单处理:删除时调用 disconnect
const nextEdges = applyEdgeChanges(changes, rfEdges);
const ourEdges = nextEdges.map((e) => {
// 保留原有的 type 信息
const original = doc.edges.find((oe) => oe.id === e.id);
if (original?.type === "anchor") {
return {
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
type: "anchor" as const,
anchorId: original.anchorId,
};
}
return {
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
type: "flow" as const,
};
});
setEdges(ourEdges);
},
[rfEdges, setEdges, doc.edges],
);
return (
<div
className="w-full h-full relative"
role="application"
aria-label={t("editor.canvasLabel")}
// 禁用整个画布的浏览器默认右键菜单
onContextMenu={(e) => e.preventDefault()}
>
{doc.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="text-center text-on-surface-variant">
<p className="text-lg font-medium">{t("editor.canvasEmpty")}</p>
<p className="text-sm mt-1">{t("editor.canvasEmptyHint")}</p>
</div>
</div>
)}
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChangeSync}
onConnect={onConnect}
onNodeClick={(_, node) => selectNode(node.id)}
onPaneClick={() => selectNode(null)}
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
nodesFocusable
nodesDraggable
edgesFocusable
elementsSelectable
panOnDrag
zoomOnPinch
zoomOnDoubleClick={false}
selectionOnDrag={false}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
defaultEdgeOptions={{
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
}}
proOptions={{ hideAttribution: true }}
className="bg-surface-container-low"
onlyRenderVisibleElements
minZoom={0.2}
maxZoom={2.5}
elevateNodesOnSelect={false}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#ccc"
/>
<Controls className="!bg-surface !border-outline-variant" />
{/* V5-8自动布局按钮 */}
<Panel position="top-right" className="!m-2">
<Button
variant="outline"
size="sm"
onClick={handleAutoLayout}
disabled={doc.nodes.length === 0}
title={t("editor.autoLayoutHint")}
aria-label={t("editor.autoLayout")}
>
<LayoutGrid className="w-4 h-4 mr-1" />
{t("editor.autoLayout")}
</Button>
</Panel>
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {
// V3 修复:从 React Flow 的 Node.dataRecord<string, unknown>)安全提取 node 字段
// 使用类型守卫替代 as 断言
const data = n.data;
if (!data || typeof data !== "object") return "#9e9e9e";
const nodeData = data.node;
if (
nodeData &&
typeof nodeData === "object" &&
nodeData !== null &&
"type" in nodeData &&
typeof nodeData.type === "string"
) {
return getNodeColor(nodeData.type);
}
return "#9e9e9e";
}}
/>
</ReactFlow>
</div>
);
}

View File

@@ -1,70 +0,0 @@
"use client";
import { useTranslations } from "next-intl";
import { getNodeColor } from "../../lib/node-summary";
interface AnchorNodeSelectorProps {
t: ReturnType<typeof useTranslations>;
anchorableNodes: { id: string; title: string; type: string }[];
hasSelectedNode: boolean;
onPickNode: (nodeId: string) => void;
onCreateNew: () => void;
}
/**
* 锚点节点选择器P1-1 完善)
* - 渲染可锚定的教学节点列表(点击即关联到该节点)
* - 提供"关联到当前选中节点"快捷项(仅当有选中节点时)
* - 提供"创建新节点并关联"选项(触发 onCreateNew 回调)
*/
export function AnchorNodeSelector({
t,
anchorableNodes,
hasSelectedNode,
onPickNode,
onCreateNew,
}: AnchorNodeSelectorProps) {
return (
<div className="space-y-1">
{hasSelectedNode && (
<button
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded font-medium"
onClick={() => onPickNode("__selected__")}
>
{t("editor.anchorToSelectedNode")}
</button>
)}
{anchorableNodes.length > 0 && (
<>
<div className="text-[10px] uppercase tracking-wide text-on-surface-variant/70 px-2 pt-1">
{t("editor.selectNodeForAnchor")}
</div>
<div className="max-h-[200px] overflow-y-auto">
{anchorableNodes.map((n) => (
<button
key={n.id}
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded flex items-center gap-2"
onClick={() => onPickNode(n.id)}
>
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getNodeColor(n.type) }}
/>
<span className="truncate">{n.title}</span>
</button>
))}
</div>
</>
)}
<div className="border-t border-outline-variant mt-1 pt-1">
<button
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded text-primary"
onClick={onCreateNew}
>
+ {t("editor.createNewNode")}
</button>
</div>
</div>
);
}

View File

@@ -1,73 +0,0 @@
"use client";
import { memo } from "react";
import { useTranslations } from "next-intl";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { LessonPlanNode } from "../../types";
import { getNodeColor, getNodeSummary, type NodeSummaryT } from "../../lib/node-summary";
// P1 修复:类型守卫替代 as 断言,安全从 React Flow NodeProps.data 收窄
function isLessonNodeData(data: unknown): data is { node: LessonPlanNode } {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.node === "object" &&
obj.node !== null &&
typeof (obj.node as Record<string, unknown>).type === "string"
);
}
export const LessonNode = memo(function LessonNode({
data,
selected,
}: NodeProps) {
const t = useTranslations("lessonPreparation");
// P1 修复:使用类型守卫安全收窄,若数据形状不符则渲染空状态避免运行时崩溃
if (!isLessonNodeData(data)) {
return (
<div className="rounded-lg border-2 border-outline-variant bg-surface p-3 text-xs text-on-surface-variant">
{t("editor.unknownBlockType")}
</div>
);
}
const nodeData = data.node;
const color = getNodeColor(nodeData.type);
// 适配 next-intl 的 t 到 NodeSummaryT 接口
const summaryT: NodeSummaryT = (key, values) => t(key, values);
const summary = getNodeSummary(nodeData, summaryT);
return (
<div
className="rounded-lg border-2 bg-surface shadow-md min-w-[200px] max-w-[260px] transition-shadow"
style={{
borderColor: selected ? "#1976d2" : color,
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
}}
>
<Handle
type="target"
position={Position.Top}
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
/>
<div
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center gap-1"
style={{ backgroundColor: color }}
>
<span>{t(`blockType.${nodeData.type}`) ?? nodeData.type}</span>
</div>
<div className="px-3 py-2">
<div className="text-sm font-medium text-on-surface truncate">
{nodeData.title}
</div>
<div className="text-xs text-on-surface-variant mt-1">
{summary}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
/>
</div>
);
});

View File

@@ -1,471 +0,0 @@
"use client";
import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { NodeProps } from "@xyflow/react";
import type { NodeAnchor, TextbookContentNode as TextbookContentNodeModel } from "../../types";
import {
injectPlaceholders,
parseAnchoredText,
toCircledNumber,
getNextPointIndex,
markdownToPlainText,
} from "../../lib/anchor-injector";
import { getNodeColor } from "../../lib/node-summary";
import { AnchorNodeSelector } from "./anchor-node-selector";
import { renderSegments } from "./textbook-segments";
interface TextbookContentNodeProps {
node: TextbookContentNodeModel;
anchors: NodeAnchor[];
selectedNodeId: string | null;
/** 可锚定的教学节点列表(用于锚点节点选择器)*/
anchorableNodes?: { id: string; title: string; type: string }[];
onAddRangeAnchor?: (params: {
nodeId: string;
start: number;
end: number;
textPreview: string;
}) => void;
onAddPointAnchor?: (params: {
nodeId: string;
start: number;
}) => void;
/** 创建新节点并锚定 */
onCreateNewNode?: (params: {
anchorType: "range" | "point";
start: number;
end?: number;
textPreview?: string;
}) => void;
onSelectNode?: (id: string | null) => void;
onResize?: (width: number, height: number) => void;
}
/**
* 类型守卫:安全收窄 React Flow NodeProps.data 为 TextbookContentNodeProps
* 替代 `as unknown as` 断言,通过结构检查确保数据形状正确
*/
function isTextbookContentNodePropsData(
data: unknown,
): data is TextbookContentNodeProps {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.node === "object" &&
obj.node !== null &&
typeof (obj.node as Record<string, unknown>).type === "string" &&
Array.isArray(obj.anchors)
);
}
export const TextbookContentNode = memo(function TextbookContentNode({
data,
selected,
}: NodeProps) {
const t = useTranslations("lessonPreparation");
const props = isTextbookContentNodePropsData(data) ? data : null;
const contentRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resizeHandleRef = useRef<HTMLDivElement>(null);
const [showAnchorMenu, setShowAnchorMenu] = useState<{
x: number;
y: number;
selection: { start: number; end: number; text: string } | null;
point: number | null;
} | null>(null);
// 光标位置指示器(左键点击时显示)
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null);
// 拖拽缩放状态
const [resizing, setResizing] = useState(false);
const resizeStart = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
const node = props?.node;
const anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]);
const selectedNodeId = props?.selectedNodeId ?? null;
const anchorableNodes = props?.anchorableNodes ?? [];
// 注入锚点标记后的 Markdown
const injectedContent = useMemo(() => {
if (!node) return "";
return injectPlaceholders(node.data.content, anchors);
}, [node, anchors]);
// 解析为段落数组(用于自定义渲染)
const segments = useMemo(
() => parseAnchoredText(injectedContent),
[injectedContent],
);
// 选中节点的激活锚点 ID 集合
const activeAnchorIds = useMemo(() => {
if (!selectedNodeId) return new Set<string>();
return new Set(
anchors.filter((a) => a.nodeId === selectedNodeId).map((a) => a.id),
);
}, [anchors, selectedNodeId]);
// 获取锚点对应的节点颜色
const getAnchorNodeColor = useCallback(
(anchorId: string): string => {
const anchor = anchors.find((a) => a.id === anchorId);
if (!anchor) return "#9e9e9e";
return getNodeColor(anchor.nodeId);
},
[anchors],
);
// 计算选中文本在纯文本中的偏移
const computeSelectionOffset = useCallback(
(selectedText: string): { start: number; end: number } | null => {
if (!node) return null;
const plainText = markdownToPlainText(node.data.content);
const start = plainText.indexOf(selectedText);
if (start >= 0) {
return { start, end: start + selectedText.length };
}
return null;
},
[node],
);
// 计算点击位置在纯文本中的偏移,并返回 caret 的视口坐标(用于精确定位光标指示器)
const computePointOffset = useCallback(
(clientX: number, clientY: number): { offset: number; rect: DOMRect | null } => {
let offset = -1;
let rect: DOMRect | null = null;
// 优先用 caretPositionFromPoint标准 API
if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(clientX, clientY);
if (pos) {
offset = pos.offset;
try {
const range = document.createRange();
range.setStart(pos.offsetNode, pos.offset);
range.setEnd(pos.offsetNode, pos.offset);
// collapsed range 用 getClientRects 获取 caret 位置
const rects = range.getClientRects();
rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();
} catch {
rect = null;
}
}
} else if (document.caretRangeFromPoint) {
// 回退WebKit 专用 API
const range = document.caretRangeFromPoint(clientX, clientY);
if (range) {
offset = range.startOffset;
const rects = range.getClientRects();
rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();
}
}
return { offset, rect };
},
[],
);
// 右键菜单:在右键位置弹出锚点菜单
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!node) return;
e.preventDefault();
e.stopPropagation();
const selection = window.getSelection();
const selectedText = selection && !selection.isCollapsed ? selection.toString() : "";
if (selectedText) {
// 有选中文本:提供区间锚定
const offsets = computeSelectionOffset(selectedText);
if (!offsets) return;
setShowAnchorMenu({
x: e.clientX,
y: e.clientY,
selection: { ...offsets, text: selectedText },
point: null,
});
} else {
// 无选中文本:提供点锚定
const { offset } = computePointOffset(e.clientX, e.clientY);
if (offset < 0) return;
setShowAnchorMenu({
x: e.clientX,
y: e.clientY,
selection: null,
point: offset,
});
}
},
[node, computeSelectionOffset, computePointOffset],
);
// 左键点击:显示光标位置指示器(用 caret 实际坐标精确定位)
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (!node) return;
// 如果有选中文本,不显示光标(让浏览器处理选择)
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
setCursorPos(null);
return;
}
// 计算 caret 位置,优先用 caret 的 rect 坐标fallback 到鼠标坐标
const { offset, rect } = computePointOffset(e.clientX, e.clientY);
if (offset < 0) {
setCursorPos(null);
return;
}
setCursorPos({
x: rect && rect.width >= 0 ? rect.left : e.clientX,
y: rect && rect.width >= 0 ? rect.top : e.clientY,
});
},
[node, computePointOffset],
);
// 关闭锚点菜单
useEffect(() => {
if (!showAnchorMenu) return;
function handleOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest("[data-anchor-menu]")) {
setShowAnchorMenu(null);
}
}
document.addEventListener("mousedown", handleOutside);
return () => document.removeEventListener("mousedown", handleOutside);
}, [showAnchorMenu]);
// 光标指示器自动消失
useEffect(() => {
if (!cursorPos) return;
const timer = setTimeout(() => setCursorPos(null), 2000);
return () => clearTimeout(timer);
}, [cursorPos]);
// 阻止 React Flow 在正文内容区和缩放手柄上拦截 pointerdown 事件(切实保障文本选择和缩放可用)
// nodrag class 只能阻止拖拽,但 React Flow 可能在更上层 preventDefault 阻止文本选择
// 使用原生事件监听器 stopPropagation让 React Flow 完全收不到 pointerdown
useEffect(() => {
const stopPointer = (e: PointerEvent) => {
e.stopPropagation();
};
const contentEl = contentRef.current;
const resizeEl = resizeHandleRef.current;
contentEl?.addEventListener("pointerdown", stopPointer);
resizeEl?.addEventListener("pointerdown", stopPointer);
return () => {
contentEl?.removeEventListener("pointerdown", stopPointer);
resizeEl?.removeEventListener("pointerdown", stopPointer);
};
}, []);
// 拖拽缩放
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
resizeStart.current = { x: e.clientX, y: e.clientY, w: rect.width, h: rect.height };
setResizing(true);
},
[],
);
useEffect(() => {
if (!resizing || !resizeStart.current || !containerRef.current) return;
function handleMove(e: MouseEvent) {
if (!resizeStart.current || !containerRef.current) return;
const dx = e.clientX - resizeStart.current.x;
const dy = e.clientY - resizeStart.current.y;
const newW = Math.max(300, resizeStart.current.w + dx);
const newH = Math.max(200, resizeStart.current.h + dy);
containerRef.current.style.width = `${newW}px`;
containerRef.current.style.height = `${newH}px`;
}
function handleUp() {
setResizing(false);
resizeStart.current = null;
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
return () => {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
};
}, [resizing]);
if (!node) {
return (
<div className="rounded-lg border-2 border-outline-variant bg-surface p-4 text-on-surface-variant">
{t("editor.textbookContentMissing")}
</div>
);
}
const nextPointNumber = getNextPointIndex(anchors);
return (
<div
ref={containerRef}
className="rounded-lg border-2 bg-surface shadow-lg flex flex-col relative"
style={{
borderColor: selected ? "#1976d2" : "#455a64",
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
width: 520,
minWidth: 300,
minHeight: 200,
}}
>
{/* 头部 */}
<div
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center justify-between flex-shrink-0"
style={{ backgroundColor: "#455a64" }}
>
<span>{t("editor.textbookContent")}</span>
<span className="text-white/60 text-[10px]">
{t("editor.rightClickHint")}
</span>
</div>
{/* 正文内容 */}
<div
ref={contentRef}
// nodrag class 让 React Flow 跳过拖拽逻辑,允许在正文上选择文本
className="px-4 py-3 flex-1 overflow-y-auto text-sm leading-relaxed text-on-surface select-text nodrag"
onContextMenu={handleContextMenu}
onClick={handleClick}
style={{ userSelect: "text", WebkitUserSelect: "text" }}
>
{node.data.content ? (
<div className="whitespace-pre-wrap break-words">
{renderSegments({
segments,
activeAnchorIds,
getAnchorNodeColor,
onSelectNode: props?.onSelectNode,
anchors,
})}
</div>
) : (
<div className="text-on-surface-variant text-sm py-8 text-center">
{t("editor.textbookContentEmpty")}
</div>
)}
</div>
{/* 拖拽缩放手柄 */}
<div
ref={resizeHandleRef}
// nodrag class 让 React Flow 跳过拖拽逻辑,允许缩放手柄独立工作
className="absolute bottom-0 right-0 w-5 h-5 cursor-nwse-resize bg-surface-container-high border-l-2 border-t-2 border-outline-variant rounded-tl-md flex items-center justify-center hover:bg-surface-container-highest z-10 nodrag"
onMouseDown={handleResizeStart}
title={t("editor.dragToResize")}
>
<svg width="8" height="8" viewBox="0 0 8 8" className="text-on-surface-variant">
<path d="M0 8 L8 0 M3 8 L8 3 M6 8 L8 6" stroke="currentColor" strokeWidth="1" fill="none" />
</svg>
</div>
{/* 光标位置指示器(通过 portal 渲染到 body避免 React Flow transform 容器影响 fixed 定位)*/}
{cursorPos && typeof document !== "undefined" && createPortal(
<div
className="fixed pointer-events-none z-40"
style={{
left: cursorPos.x,
top: cursorPos.y,
width: 2,
height: 16,
backgroundColor: "#1976d2",
animation: "cursor-blink 1s infinite",
}}
/>,
document.body,
)}
{/* 锚点浮动菜单(右键触发,通过 portal 渲染到 body 保证 fixed 定位相对视口)*/}
{showAnchorMenu && typeof document !== "undefined" && createPortal(
<div
data-anchor-menu
className="fixed z-50 bg-surface border border-outline-variant rounded-lg shadow-xl p-2 min-w-[220px] max-h-[60vh] overflow-y-auto"
style={{
left: showAnchorMenu.x,
top: showAnchorMenu.y,
}}
>
{showAnchorMenu.selection ? (
<div>
<div className="text-xs text-on-surface-variant mb-2 px-2">
{t("editor.rangeAnchorTitle")}
</div>
<AnchorNodeSelector
t={t}
anchorableNodes={anchorableNodes}
hasSelectedNode={!!selectedNodeId}
onPickNode={(nodeId) => {
if (props?.onAddRangeAnchor && showAnchorMenu.selection) {
props.onAddRangeAnchor({
nodeId,
start: showAnchorMenu.selection.start,
end: showAnchorMenu.selection.end,
textPreview: showAnchorMenu.selection.text,
});
}
setShowAnchorMenu(null);
}}
onCreateNew={() => {
if (props?.onCreateNewNode && showAnchorMenu.selection) {
props.onCreateNewNode({
anchorType: "range",
start: showAnchorMenu.selection.start,
end: showAnchorMenu.selection.end,
textPreview: showAnchorMenu.selection.text,
});
}
setShowAnchorMenu(null);
}}
/>
</div>
) : showAnchorMenu.point !== null ? (
<div>
<div className="text-xs text-on-surface-variant mb-2 px-2">
{t("editor.pointAnchorTitle", { number: toCircledNumber(nextPointNumber) })}
</div>
<AnchorNodeSelector
t={t}
anchorableNodes={anchorableNodes}
hasSelectedNode={!!selectedNodeId}
onPickNode={(nodeId) => {
if (props?.onAddPointAnchor && showAnchorMenu.point !== null) {
props.onAddPointAnchor({
nodeId,
start: showAnchorMenu.point,
});
}
setShowAnchorMenu(null);
}}
onCreateNew={() => {
if (props?.onCreateNewNode && showAnchorMenu.point !== null) {
props.onCreateNewNode({
anchorType: "point",
start: showAnchorMenu.point,
});
}
setShowAnchorMenu(null);
}}
/>
</div>
) : null}
</div>,
document.body,
)}
</div>
);
});

View File

@@ -1,115 +0,0 @@
import type { ReactNode, CSSProperties, KeyboardEvent } from "react";
import type { NodeAnchor } from "../../types";
import {
parseAnchoredText,
toCircledNumber,
} from "../../lib/anchor-injector";
interface RenderSegmentsParams {
segments: ReturnType<typeof parseAnchoredText>;
activeAnchorIds: Set<string>;
getAnchorNodeColor: (anchorId: string) => string;
onSelectNode?: (id: string | null) => void;
anchors?: NodeAnchor[];
}
/**
* 键盘激活回调Enter / Space 触发与点击同等的选中逻辑V4 P0-3 修复 a11y
*/
function handleAnchorKeyDown(
e: KeyboardEvent<HTMLSpanElement>,
anchor: NodeAnchor | undefined,
onSelectNode: ((id: string | null) => void) | undefined,
): void {
if (!anchor || !onSelectNode) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelectNode(anchor.nodeId);
}
}
/**
* 渲染锚点段落数组(简化版:直接遍历 segments不使用 ReactMarkdown
* 解决问题 7避免每个段落重复渲染整个文档内容
*/
export function renderSegments({
segments,
activeAnchorIds,
getAnchorNodeColor,
onSelectNode,
anchors,
}: RenderSegmentsParams): ReactNode {
return segments.map((seg, idx) => {
if (seg.type === "text") {
return <span key={idx}>{seg.content}</span>;
}
if (seg.type === "anchor-range") {
const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false;
const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e";
const anchor = anchors?.find((a) => a.id === seg.anchorId);
// V4 P0-3 修复:锚点 span 添加 role/tabIndex/onKeyDown/aria-label支持键盘导航与读屏
return (
<span
key={idx}
role={anchor ? "button" : undefined}
tabIndex={anchor ? 0 : undefined}
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
aria-pressed={anchor ? isActive : undefined}
className={`range-anchor ${isActive ? "active" : ""}`}
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
style={
{
backgroundColor: color,
"--node-color": color,
} as CSSProperties
}
onClick={(e) => {
e.stopPropagation();
if (anchor && onSelectNode) {
onSelectNode(anchor.nodeId);
}
}}
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
>
{seg.content}
</span>
);
}
// point anchor
const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false;
const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e";
const anchor = anchors?.find((a) => a.id === seg.anchorId);
const pointIndex = anchor
? (anchors?.filter((a) => a.type === "point").indexOf(anchor) ?? -1) + 1
: 1;
// V4 P0-3 修复point anchor 同样补齐 a11y 属性
return (
<span
key={idx}
role={anchor ? "button" : undefined}
tabIndex={anchor ? 0 : undefined}
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
aria-pressed={anchor ? isActive : undefined}
className={`point-anchor ${isActive ? "active" : ""}`}
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
style={
{
backgroundColor: color,
"--node-color": color,
} as CSSProperties
}
onClick={(e) => {
e.stopPropagation();
if (anchor && onSelectNode) {
onSelectNode(anchor.nodeId);
}
}}
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
>
{toCircledNumber(pointIndex ?? 1)}
</span>
);
});
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useTranslations } from "next-intl";
import type { Block, LessonPlanNode } from "../../types";
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);
// 师生交互节点用专门的对话体渲染
if (node.type === "interaction") {
return <InlineQaDialog node={node} />;
}
// 其他节点类型:用 InlineNodeBody 渲染只读摘要
// 完整编辑在右栏详情面板
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 渲染在 InlineNodeBody 中处理 readonly 模式 */}
<InlineNodeBody node={node} />
</div>
</div>
);
}
/**
* 各节点类型的 inline 只读渲染。
* 这里给出最常见的几种的简化摘要,完整编辑在右栏详情面板。
*/
function InlineNodeBody({ node }: { node: Block }) {
const t = useTranslations("lessonPreparation");
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} {t("v4.detail.exerciseCount")}</p>;
}
default:
return <p style={{ fontSize: 13, color: "var(--muted-foreground)" }}>{node.title}</p>;
}
}

View File

@@ -0,0 +1,91 @@
"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}
</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");
void index;
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">[{t("v4.detail.expectedAnswer")}{turn.expectedAnswer}]</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
"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 协助回调V2接入 actions-ai.ts 的节点级 AI 服务) */
onAiAction?: (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => void;
}
/**
* V4 右键菜单:根据 state.nodeId 区分节点菜单 vs 锚定菜单。
*
* 已实现:展开/收起、删除、上下移动、复制节点、锚定。
* V2 接入AI 协助 4 项(通过 onAiAction 回调,由 PaperEditor 注入 useNodeAiAssist
*/
export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
const t = useTranslations("lessonPreparation");
const {
toggleExpand,
expandedNodeIds,
updateNode,
removeNode,
addAnchor,
duplicateNode,
doc,
} = useLessonPlanEditor();
// V1 占位实现:上下移动、复制(按 spec §15 YAGNIV2 完整实现)
const moveNodeOrder = (nodeId: string, direction: "up" | "down") => {
const teachingNodes = doc.nodes
.filter((n): n is 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 = async (nodeId: string) => {
const newId = duplicateNode(nodeId);
const { toast } = await import("sonner");
if (newId) {
toast.success(t("v4.contextMenu.copied"));
} else {
toast.error(t("v4.contextMenu.copyFailed"));
}
};
// AI 协助:交由 PaperEditor 注入的 onAiAction 回调处理useNodeAiAssist
const handleAi = (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => {
if (onAiAction) {
onAiAction(action, nodeId);
} else {
void import("sonner").then(({ toast }) => toast.info(t("v4.contextMenu.aiComingSoon")));
}
};
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>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { useNodeAiAssist } from "../../hooks/use-node-ai-assist";
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 { run: runNodeAi } = useNodeAiAssist();
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 as { data: { content: string } }).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 }))}
onAiAction={(action, nodeId) => void runNodeAi(action, nodeId)}
/>
</main>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import type { Editor } from "@tiptap/react";
interface Props {
editor: Editor;
}
/**
* V4 浮动工具条:粘在纸顶部,毛玻璃半透明。
* 仅在编辑器聚焦时显示(由父组件控制)。
*/
export function PaperToolbar({ editor }: Props) {
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>
);
}

View File

@@ -0,0 +1,85 @@
"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 衬线
* - 锚点AnchorMarkrange+ AnchorPointpoint
* - 编辑时浮动工具条出现
*/
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,
// SSR 必填:避免 hydration mismatchesTiptap v2.4+ 要求显式设置)
immediatelyRender: false,
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, { emitUpdate: 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>
);
}

View File

@@ -0,0 +1,126 @@
"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 { Block, 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 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)}
childItems={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>
);
}

View File

@@ -0,0 +1,149 @@
"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;
/** 子节点(如对话轮次) */
childItems?: { id: string; label: string; kind: string }[];
}
export function TreeNodeRow({
node,
isExpanded,
hasChildren,
isChildrenExpanded,
onToggleChildren,
childItems,
}: 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 && childItems && (
<div
style={{
marginLeft: 10,
paddingLeft: 8,
borderLeft: "1px solid var(--border)",
}}
>
{childItems.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>
);
}

View File

@@ -10,6 +10,7 @@ import {
isExerciseBlockData,
isHomeworkBlockData,
isImportBlockData,
isInteractionBlockData,
isKeyPointBlockData,
isNewTeachingBlockData,
isObjectiveBlockData,
@@ -29,6 +30,8 @@ import { SummaryBlock } from "../components/blocks/summary-block";
import { HomeworkBlock } from "../components/blocks/homework-block";
import { BlackboardBlock } from "../components/blocks/blackboard-block";
import { ReflectionBlock } from "../components/blocks/reflection-block";
import { InteractionBlock } from "../components/blocks/interaction-block";
import type { InteractionBlockData } from "../types";
/**
* Block 注册表:配置驱动渲染。
@@ -69,6 +72,7 @@ export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
exercise: {},
rich_text: { isRichText: true },
reflection: {},
interaction: {},
};
/**
@@ -198,6 +202,16 @@ export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): Re
/>
);
}
case "interaction": {
if (!isInteractionBlockData(rest.data)) return null;
return (
<InteractionBlock
blockId={rest.blockId}
data={rest.data}
onUpdate={(d) => rest.onUpdate(d as InteractionBlockData)}
/>
);
}
default:
return null;
}

View File

@@ -15,6 +15,7 @@ export const BLOCK_TYPE_KEYS: Record<BlockType, string> = {
exercise: "exercise",
rich_text: "rich_text",
reflection: "reflection",
interaction: "interaction",
};
// 向后兼容:保留 BLOCK_TYPE_LABELS 名称但值为 i18n 键(实际翻译由组件层完成)

View File

@@ -1,12 +1,11 @@
import type { StateCreator } from "zustand";
import { createId } from "@paralleldrive/cuid2";
import type {
AnchorEdge,
AnchorType,
AnyLessonPlanEdge,
Block,
BlockData,
BlockType,
FlowEdge,
InteractionBlockData,
LessonPlanDocument,
LessonPlanNode,
NodeAnchor,
@@ -14,9 +13,25 @@ import type {
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
import { computeAutoLayout } from "../lib/auto-layout";
import type { EditorState } from "./use-lesson-plan-editor";
/**
* V4深拷贝 BlockData为 interaction 节点的 turns 重新生成 id避免 id 冲突)。
* 其他类型直接结构化克隆JSON 序列化足够,数据全是 POJO
*/
function cloneBlockData(data: BlockData, type: BlockType): BlockData {
if (type === "interaction") {
const d = data as InteractionBlockData;
return {
designIntent: d.designIntent,
knowledgePointIds: [...d.knowledgePointIds],
turns: d.turns.map((t) => ({ ...t, id: createId() })),
};
}
// 其他类型JSON 深拷贝(数据全是 POJO无函数/Date/Symbol
return JSON.parse(JSON.stringify(data)) as BlockData;
}
export interface EditorSlice {
planId: string;
title: string;
@@ -25,24 +40,29 @@ export interface EditorSlice {
setPlanId: (planId: string) => void;
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
updateNode: (id: string, patch: Omit<Partial<Block>, "type">) => void;
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
/** V4复制节点深拷贝数据 + 新 id + 标题加"副本" + order 置末) */
duplicateNode: (id: string) => string | null;
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
getTextbookContentNode: () => TextbookContentNode | undefined;
/**
* V4添加锚点不再传 start/end锚点位置由 Tiptap Mark 内嵌)。
*
* 兼容说明:旧调用方可能传入 { nodeId, type, start, end, textPreview }
* 这些 v3 字段会被忽略,等阶段 H 改造调用方时再清理。
*/
addAnchor: (params: {
nodeId: string;
type: AnchorType;
start: number;
/** @deprecated v3 字段v4 已由 Tiptap Mark 承载,忽略 */
start?: number;
/** @deprecated v3 字段v4 忽略 */
end?: number;
/** @deprecated v3 字段v4 忽略 */
textPreview?: string;
}) => string;
removeAnchor: (anchorId: string) => void;
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: AnyLessonPlanEdge[]) => void;
/** V5-8自动布局使用 dagre 计算并应用新位置 */
autoLayout: (direction?: "TB" | "LR") => void;
}
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
@@ -58,11 +78,12 @@ export const createEditorSlice: StateCreator<
planId: "",
title: "",
doc: {
version: 3,
version: 4,
textbookContentNodeId: "",
nodes: [],
edges: [],
anchors: [],
expandedNodeIds: [],
},
setTitle: (title) => {
@@ -71,7 +92,7 @@ export const createEditorSlice: StateCreator<
},
setPlanId: (planId) => set({ planId }),
addNode: (type, position, title) => {
addNode: (type, _position, title) => {
const id = createId();
const state = get();
state.pushHistory(); // V5-2撤销/重做
@@ -85,10 +106,7 @@ export const createEditorSlice: StateCreator<
title: title ?? type,
data: defaultDataForType(type),
order: nodeCount,
position: position ?? {
x: 80 + (nodeCount % 4) * 280,
y: 80 + Math.floor(nodeCount / 4) * 200,
},
position: { x: 0, y: 0 }, // V4不再使用保留字段兼容
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
@@ -115,24 +133,6 @@ export const createEditorSlice: StateCreator<
}));
},
updateNodePosition: (id, position) => {
// V5-2 注:拖拽过程中会产生大量 position 更新,仅在拖拽开始时推一次 history。
// 调用方应在 onDragStart 时调用 pushHistory此处不重复推入。
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id
? n.type === "textbook_content"
? ({ ...n, position } as TextbookContentNode)
: ({ ...n, position } as LessonPlanNode)
: n,
),
},
isDirty: true,
}));
},
removeNode: (id) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => {
@@ -162,6 +162,37 @@ export const createEditorSlice: StateCreator<
});
},
duplicateNode: (id) => {
const state = get();
const src = state.doc.nodes.find(
(n): n is LessonPlanNode => n.id === id && n.type !== "textbook_content",
);
if (!src) return null;
state.pushHistory();
const newId = createId();
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
// 深拷贝 data避免引用共享interaction 节点的 turns 内 id 重新生成
const clonedData = cloneBlockData(src.data, src.type);
const newNode: LessonPlanNode = {
id: newId,
type: src.type,
title: `${src.title}(副本)`,
data: clonedData,
order: teachingNodes.length,
position: { x: 0, y: 0 },
stage: src.stage,
differentiation: src.differentiation,
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, newNode] },
isDirty: true,
selectedNodeId: newId,
}));
return newId;
},
updateTextbookContent: (data) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
@@ -184,38 +215,19 @@ export const createEditorSlice: StateCreator<
);
},
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
addAnchor: ({ nodeId, type }) => {
const anchorId = createId();
const state = get();
state.pushHistory(); // V5-2撤销/重做
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
id: anchorId,
nodeId,
type,
start,
...(end !== undefined ? { end } : {}),
...(textPreview ? { textPreview } : {}),
};
const edge: AnchorEdge = {
id: `ae_${nodeId}_${textbookNodeId}_${anchorId.slice(0, 6)}`,
source: nodeId,
target: textbookNodeId,
type: "anchor",
anchorId,
};
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
anchors: [...s.doc.anchors, anchor],
edges: [...s.doc.edges, edge],
anchors: [
...s.doc.anchors,
{ id: anchorId, nodeId, type },
],
},
isDirty: true,
}));
return anchorId;
},
@@ -245,61 +257,4 @@ export const createEditorSlice: StateCreator<
isDirty: true,
}));
},
connect: (source, target) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => {
if (
s.doc.edges.some((e) => e.source === source && e.target === target)
)
return s;
const edge: FlowEdge = {
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
source,
target,
type: "flow",
};
return {
doc: { ...s.doc, edges: [...s.doc.edges, edge] },
isDirty: true,
};
});
},
disconnect: (edgeId) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
edges: s.doc.edges.filter((e) => e.id !== edgeId),
},
isDirty: true,
}));
},
setEdges: (edges) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({ doc: { ...s.doc, edges }, isDirty: true }));
},
autoLayout: (direction = "TB") => {
const state = get();
const positions = computeAutoLayout(state.doc.nodes, state.doc.edges, { direction });
if (positions.size === 0) return;
state.pushHistory(); // V5-2撤销/重做
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) => {
const p = positions.get(n.id);
return p
? n.type === "textbook_content"
? ({ ...n, position: p } as TextbookContentNode)
: ({ ...n, position: p } as LessonPlanNode)
: n;
}),
},
isDirty: true,
}));
},
});

View File

@@ -0,0 +1,56 @@
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 }),
});

View File

@@ -2,8 +2,12 @@ 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<
@@ -13,5 +17,7 @@ export const createSelectionSlice: StateCreator<
SelectionSlice
> = (set) => ({
selectedNodeId: null,
anchorNodeForSelection: null,
selectNode: (id) => set({ selectedNodeId: id }),
setAnchorNodeForSelection: (id) => set({ anchorNodeForSelection: id }),
});

View File

@@ -5,25 +5,33 @@ 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";
/**
* V4 P2-6 修复:将单体 Zustand store原 303 行)拆分为 3 个独立 slice。
* V5-2 新增history-slice 撤销/重做栈50 步上限)。
* V4 纸感重构:新增 expanded-slice节点展开到正文流的状态
*
* - editor-slice: 文档结构planId/title/doc及所有文档操作方法
* - selection-slice: 节点选中状态selectedNodeId / selectNode
* - selection-slice: 节点选中状态selectedNodeId / selectNode / anchorNodeForSelection
* - version-slice: 草稿版本与保存状态isDirty/isSaving/lastSavedAt/saveError/isOnline
* - history-slice: 撤销/重做栈past/future/canUndo/canRedo/undo/redo
* - expanded-slice: V4 节点展开到正文流的状态expandedNodeIds / toggleExpand / setExpanded
*
* 主文件仅负责组合 slice 并导出统一的 EditorState 类型,方便测试与维护。
* 各 slice 通过 `import type { EditorState }` 引用合并后的类型TypeScript
* 编译后该类型导入会被完全移除,运行时无循环依赖。
*/
export type EditorState = EditorSlice & SelectionSlice & VersionSlice & HistorySlice;
export type EditorState = EditorSlice &
SelectionSlice &
VersionSlice &
HistorySlice &
ExpandedSlice;
export const useLessonPlanEditor = create<EditorState>()((...a) => ({
...createEditorSlice(...a),
...createSelectionSlice(...a),
...createVersionSlice(...a),
...createHistorySlice(...a),
...createExpandedSlice(...a),
}));

View File

@@ -0,0 +1,111 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
import {
generateNodeContentAction,
optimizeNodeExpressionAction,
suggestNodeDifferentiationAction,
generateLayeredQuestionsAction,
} from "../actions-ai";
import type {
BlockData,
InteractionBlockData,
LessonPlanNode,
} from "../types";
export type NodeAiAction = "generate" | "optimize" | "differentiation" | "layered";
/**
* V4 节点级 AI 协助 hook。
*
* - 调用对应 Server Action 获取 AI 结果
* - 将结果合并到 node.data 或 node.differentiation
* - 通过 toast 反馈运行/成功/失败状态
*
* 用法:
* const { run, isRunning } = useNodeAiAssist();
* <button onClick={() => run("generate", nodeId)}>生成</button>
*/
export function useNodeAiAssist() {
const t = useTranslations("lessonPreparation");
const doc = useLessonPlanEditor((s) => s.doc);
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const [isRunning, setIsRunning] = useState(false);
const run = useCallback(
async (action: NodeAiAction, nodeId: string) => {
const node = doc.nodes.find(
(n): n is LessonPlanNode => n.id === nodeId && n.type !== "textbook_content",
);
if (!node) return;
setIsRunning(true);
toast.info(t("v4.contextMenu.aiRunning"));
try {
if (action === "generate") {
const res = await generateNodeContentAction(node);
if (res.success && res.data) {
// AI patch 合并到 node.datapatch 字段由 lib/ai-node-assist.ts 的 Zod schema 保证类型安全
const merged = { ...node.data, ...res.data.data } as BlockData;
updateNode(nodeId, { data: merged });
toast.success(t("v4.contextMenu.aiSuccess"));
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
return;
}
if (action === "optimize") {
const res = await optimizeNodeExpressionAction(node);
if (res.success && res.data) {
const merged = { ...node.data, ...res.data.data } as BlockData;
updateNode(nodeId, { data: merged });
toast.success(t("v4.contextMenu.aiSuccess"));
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
return;
}
if (action === "differentiation") {
const res = await suggestNodeDifferentiationAction(node);
if (res.success && res.data) {
updateNode(nodeId, { differentiation: res.data.level });
toast.success(`${t("v4.contextMenu.aiSuccess")}${res.data.reason}`);
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
return;
}
// layered
if (node.type !== "interaction") {
toast.error(t("v4.contextMenu.aiFailed"));
return;
}
const res = await generateLayeredQuestionsAction(node);
if (res.success && res.data) {
// interaction 节点 data 类型收窄(已通过 node.type 守卫)
const interactionData = node.data as InteractionBlockData;
const merged = {
...node.data,
turns: [...(interactionData.turns ?? []), ...res.data.turns],
} as BlockData;
updateNode(nodeId, { data: merged });
toast.success(t("v4.contextMenu.aiSuccess"));
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
} finally {
setIsRunning(false);
}
},
[doc.nodes, updateNode, t],
);
return { run, isRunning };
}

View File

@@ -0,0 +1,250 @@
/**
* V4 节点级 AI 协助4 项功能。
*
* - generate根据节点类型生成默认内容写入 data
* - optimize优化节点现有表达覆盖 data 中对应字段)
* - differentiation判定该节点适合的差异化层次写入 node.differentiation
* - layered为 interaction 节点生成分层提问(追加到 turns
*
* 全部纯服务端,复用 createAiChatCompletion。
* 失败时返回 null由调用方提示用户。
*/
import "server-only";
import { env } from "@/env.mjs";
import { createAiChatCompletion } from "@/shared/lib/ai";
import { isRecord } from "@/shared/lib/type-guards";
import { z } from "zod";
import type {
BlockData,
DifferentiationLevel,
InteractionBlockData,
LessonPlanNode,
QATurn,
} from "../types";
import { createId } from "@paralleldrive/cuid2";
// ---- 公共返回类型 ----
export interface NodeContentUpdate {
/** 合并到 node.data 的 patch */
data: Partial<BlockData>;
}
export interface NodeDifferentiationUpdate {
level: DifferentiationLevel;
reason: string;
}
// ---- Zod schemas ----
const GenerateResultSchema = z.object({
/** 生成的字段路径 → 值(如 { html: "..." } / { designIntent: "..." } / { objectives: [...] } */
patch: z.record(z.string(), z.unknown()),
});
const OptimizeResultSchema = z.object({
patch: z.record(z.string(), z.unknown()),
});
const DifferentiationResultSchema = z.object({
level: z.enum(["basic", "intermediate", "advanced"]),
reason: z.string(),
});
const LayeredQuestionsResultSchema = z.object({
turns: z.array(
z.object({
role: z.enum(["teacher", "student"]),
content: z.string(),
expectedAnswer: z.string().optional(),
}),
),
});
// ---- 节点摘要辅助 ----
function extractNodeText(node: LessonPlanNode): string {
const data = node.data as unknown;
if (!isRecord(data)) return "";
const html = typeof data.html === "string" ? data.html : "";
const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
const designIntent = typeof data.designIntent === "string" ? data.designIntent : "";
return html || sourceText || designIntent || "";
}
function nodeSummary(node: LessonPlanNode): string {
return JSON.stringify({
id: node.id,
type: node.type,
title: node.title,
stage: node.stage,
differentiation: node.differentiation,
text: extractNodeText(node).slice(0, 500),
data: node.data,
});
}
// ---- 1. 生成本节点内容 ----
const GENERATE_PROMPT = `你是资深教学设计专家。请根据课案节点信息生成对应内容。
节点信息:
---
{node}
---
要求:
- 根据节点 type 生成对应字段:
- rich_text / consolidation / summary / homework / reflection / blackboard / import / new_teaching生成 html 字段HTML 字符串,可含 <p>/<ul>/<h3> 等基础标签)
- objective生成 objectives 数组(含 dimension: knowledge|process|emotion 与 text
- key_point生成 keyPoints 数组(含 type: key|difficult 与 text
- interaction生成 designIntent 字符串 + turns 数组(每项含 role: teacher|student / content / 可选 expectedAnswer
- exercise生成 items 空数组占位即可
- 内容要紧扣节点标题与类型,体现教学设计专业性
- 中文课案用中文生成,英文课案用英文生成
返回 JSON 对象:{ patch: { 字段名: 值 } }`;
export async function generateNodeContent(
node: LessonPlanNode,
): Promise<NodeContentUpdate | null> {
const prompt = GENERATE_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.6,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = GenerateResultSchema.safeParse(parsed);
if (!validated.success) return null;
return { data: validated.data.patch as Partial<BlockData> };
} catch {
return null;
}
}
// ---- 2. 优化表达 ----
const OPTIMIZE_PROMPT = `你是教学设计专家。请优化以下课案节点的表达,使其更清晰、专业、符合教学规范。
节点信息:
---
{node}
---
要求:
- 保留原有结构,仅优化文字表达
- 不改变字段集合,只修改字段值
- 学科术语使用准确
- 中文课案用中文优化,英文课案用英文优化
返回 JSON 对象:{ patch: { 字段名: 优化后的值 } }`;
export async function optimizeNodeExpression(
node: LessonPlanNode,
): Promise<NodeContentUpdate | null> {
const prompt = OPTIMIZE_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.4,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = OptimizeResultSchema.safeParse(parsed);
if (!validated.success) return null;
return { data: validated.data.patch as Partial<BlockData> };
} catch {
return null;
}
}
// ---- 3. 差异化建议 ----
const DIFFERENTIATION_PROMPT = `你是教学设计专家。请根据以下课案节点内容,判定该节点最适合的差异化教学层次。
节点信息:
---
{node}
---
判定标准:
- basic基础知识点为基础概念或技能所有学生必学节点内容偏识记与模仿
- intermediate提高知识点为学科核心内容多数学生需掌握节点内容偏理解与应用
- advanced拓展知识点为延伸拓展或综合应用适合学有余力的学生
返回 JSON 对象:{ level: "basic"|"intermediate"|"advanced", reason: "判定依据(一句话)" }`;
export async function suggestNodeDifferentiation(
node: LessonPlanNode,
): Promise<NodeDifferentiationUpdate | null> {
const prompt = DIFFERENTIATION_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.3,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = DifferentiationResultSchema.safeParse(parsed);
if (!validated.success) return null;
return { level: validated.data.level, reason: validated.data.reason };
} catch {
return null;
}
}
// ---- 4. 生成分层提问(仅 interaction 节点) ----
const LAYERED_PROMPT = `你是教学设计专家。请为以下师生交互节点生成分层提问,覆盖基础/提高/拓展三个层次。
节点信息:
---
{node}
---
要求:
- 生成 3 轮对话basic / intermediate / advanced 各一轮)
- 每轮含 teacher 提问 + student 预期回答
- 提问层层递进,从识记 → 理解 → 应用
- 中文课案用中文生成,英文课案用英文生成
返回 JSON 对象:{ turns: [{ role: "teacher"|"student", content: "提问/回答", expectedAnswer: "可选,仅 teacher 轮需要" }] }`;
export async function generateLayeredQuestions(
node: LessonPlanNode,
): Promise<QATurn[] | null> {
if (node.type !== "interaction") return null;
const prompt = LAYERED_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.5,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = LayeredQuestionsResultSchema.safeParse(parsed);
if (!validated.success) return null;
// 为每条 turn 分配 id + order
const existing = (node.data as InteractionBlockData).turns ?? [];
let order = existing.length;
return validated.data.turns.map((t) => ({
id: createId(),
role: t.role,
content: t.content,
expectedAnswer: t.expectedAnswer,
order: order++,
}));
} catch {
return null;
}
}

View File

@@ -1,305 +0,0 @@
import type { NodeAnchor } from "../types";
/**
* 锚点注入算法:将锚点信息注入到 Markdown 文本中,生成带标记的纯文本。
*
* 策略:
* - 由于 Markdown 渲染后是 HTML纯文本偏移量无法直接对应 DOM 节点
* - 简化方案:将 Markdown 视为纯文本(去除 markdown 语法符号),在纯文本上做偏移注入
* - 注入特殊标记符号(如 ①②③ 或 [anchor:id]),由 ReactMarkdown 的 components 自定义渲染
*
* 对于 range 锚定:在 [start, end] 范围包裹 [[anchor:id]]...[[/anchor]] 标记
* 对于 point 锚定:在 start 位置插入 [[point:id]] 标记
*
* 渲染时由 textbook-content-node.tsx 的 components 自定义解析这些标记。
*/
// 标记格式:[[anchor:id]]range text[[/anchor]] 或 [[point:id]]
const ANCHOR_RANGE_START = (id: string) => `[[anchor:${id}]]`;
const ANCHOR_RANGE_END = `[[/anchor]]`;
const ANCHOR_POINT = (id: string) => `[[point:${id}]]`;
/**
* 将 Markdown 文本简化为纯文本(去除常见 markdown 语法符号)。
* 仅用于锚点偏移计算,不影响实际渲染。
*/
export function markdownToPlainText(markdown: string): string {
return markdown
// 去除标题标记
.replace(/^#{1,6}\s+/gm, "")
// 去除强调符号
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/__(.+?)__/g, "$1")
.replace(/_(.+?)_/g, "$1")
// 去除链接,保留文本
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
// 去除图片
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
// 去除代码块
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
// 去除引用标记
.replace(/^\s*>\s+/gm, "")
// 去除列表标记
.replace(/^[\s]*[-*+]\s+/gm, "")
.replace(/^[\s]*\d+\.\s+/gm, "")
// 去除水平分割线
.replace(/^---+$/gm, "")
// 去除 HTML 标签
.replace(/<[^>]+>/g, "");
}
/**
* 在 Markdown 文本中注入锚点标记。
*
* 注意偏移量基于纯文本markdownToPlainText 的输出)。
* 由于 Markdown 语法符号的存在,纯文本偏移与 Markdown 原文偏移不一致。
* 此函数通过构建偏移映射,将纯文本偏移转换为 Markdown 原文偏移。
*
* @param markdown 原始 Markdown 文本
* @param anchors 锚点列表
* @returns 注入标记后的 Markdown 文本
*/
export function injectPlaceholders(
markdown: string,
anchors: NodeAnchor[],
): string {
if (anchors.length === 0) return markdown;
// 构建偏移映射plainText[i] → markdown 原文位置
const { plainToMd } = buildOffsetMap(markdown);
// 过滤失效锚点,按 markdown 偏移排序(倒序注入,避免偏移变化)
const validAnchors = anchors
.filter((a) => !a.invalid && a.start >= 0)
.map((a) => {
const mdStart = plainToMd.get(a.start) ?? a.start;
const mdEnd = a.end !== undefined
? plainToMd.get(a.end) ?? a.end
: undefined;
return { ...a, mdStart, mdEnd };
})
.sort((a, b) => b.mdStart - a.mdStart);
let result = markdown;
for (const anchor of validAnchors) {
if (anchor.type === "range" && anchor.mdEnd !== undefined && anchor.mdEnd > anchor.mdStart) {
// 范围锚定:包裹标记
const before = result.slice(0, anchor.mdStart);
const middle = result.slice(anchor.mdStart, anchor.mdEnd);
const after = result.slice(anchor.mdEnd);
result = before + ANCHOR_RANGE_START(anchor.id) + middle + ANCHOR_RANGE_END + after;
} else if (anchor.type === "point") {
// 点锚定:插入标记
const before = result.slice(0, anchor.mdStart);
const after = result.slice(anchor.mdStart);
result = before + ANCHOR_POINT(anchor.id) + after;
}
}
return result;
}
/**
* 构建 Markdown → 纯文本的偏移映射。
* 返回 plainToMd纯文本位置 → Markdown 原文位置)。
*/
function buildOffsetMap(markdown: string): {
plainToMd: Map<number, number>;
} {
const plainToMd = new Map<number, number>();
let mdIdx = 0;
let plainIdx = 0;
// 简化映射:逐字符遍历 Markdown跳过被去除的字符
// 这里采用与 markdownToPlainText 一致的简化逻辑
// V4 P2-5 修复:统一 regex 标志为 gm与 markdownToPlainText 保持一致
const skipPatterns: RegExp[] = [
/^#{1,6}\s+/gm,
/^\s*[-*+]\s+/gm,
/^\s*\d+\.\s+/gm,
/^\s*>\s+/gm,
/^---+$/gm,
];
while (mdIdx < markdown.length) {
// 检查是否处于需要跳过的模式
let skipped = false;
for (const pattern of skipPatterns) {
const rest = markdown.slice(mdIdx);
const match = rest.match(pattern);
if (match && match.index === 0) {
mdIdx += match[0].length;
skipped = true;
break;
}
}
if (skipped) continue;
// 处理行内标记(**、*、`、_
const ch = markdown[mdIdx];
if (ch === "*" || ch === "_" || ch === "`") {
// 跳过成对的标记符号
if (markdown[mdIdx + 1] === ch) {
mdIdx += 2; // 跳过 ** 或 __ 或 ``
continue;
}
mdIdx += 1; // 跳过单个 * 或 _ 或 `
continue;
}
// 处理 HTML 标签
if (ch === "<") {
const closeIdx = markdown.indexOf(">", mdIdx);
if (closeIdx !== -1) {
mdIdx = closeIdx + 1;
continue;
}
}
// 处理链接 [text](url)
if (ch === "[") {
const closeBracket = markdown.indexOf("]", mdIdx);
if (closeBracket !== -1 && markdown[closeBracket + 1] === "(") {
const closeParen = markdown.indexOf(")", closeBracket + 2);
if (closeParen !== -1) {
// 链接文本部分映射到纯文本
const linkText = markdown.slice(mdIdx + 1, closeBracket);
for (const _ of linkText) {
plainToMd.set(plainIdx, mdIdx + 1 + plainIdx);
plainIdx++;
}
mdIdx = closeParen + 1;
continue;
}
}
}
// 普通字符:建立映射
plainToMd.set(plainIdx, mdIdx);
plainIdx++;
mdIdx++;
}
return { plainToMd };
}
/**
* 解析注入标记后的文本,提取锚点段。
* 用于 ReactMarkdown 自定义渲染。
*/
export interface ParsedSegment {
type: "text" | "anchor-range" | "anchor-point";
content: string;
anchorId?: string;
}
export function parseAnchoredText(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// 匹配 [[anchor:id]]...[[/anchor]] 或 [[point:id]]
const pattern = /\[\[(anchor|point):([^\]]+)\]\](?:([\s\S]*?)\[\[\/anchor\]\])?/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
// 前面的普通文本
if (match.index > lastIndex) {
segments.push({
type: "text",
content: text.slice(lastIndex, match.index),
});
}
if (match[1] === "anchor") {
segments.push({
type: "anchor-range",
content: match[3] ?? "",
anchorId: match[2],
});
} else {
// point
segments.push({
type: "anchor-point",
content: "",
anchorId: match[2],
});
}
lastIndex = pattern.lastIndex;
}
// 剩余文本
if (lastIndex < text.length) {
segments.push({
type: "text",
content: text.slice(lastIndex),
});
}
return segments;
}
/**
* 根据锚点 ID 获取对应的节点颜色。
*/
export function getAnchorColor(
anchorId: string,
anchors: NodeAnchor[],
getNodeColorFn: (nodeId: string) => string,
): string {
const anchor = anchors.find((a) => a.id === anchorId);
if (!anchor) return "#9e9e9e";
return getNodeColorFn(anchor.nodeId);
}
/**
* 生成下一个点锚定的序号(①②③...)。
* 使用 anchors 中 point 类型的数量 + 1。
*/
export function getNextPointIndex(anchors: NodeAnchor[]): number {
const pointCount = anchors.filter((a) => a.type === "point").length;
return pointCount + 1;
}
/**
* 将数字转换为带圈数字(①②③...⑨⑩等)。
* 超过 20 时回退为 [1] [2] 格式。
*/
export function toCircledNumber(n: number): string {
if (n < 1) return "";
if (n > 20) return `[${n}]`;
// Unicode 带圈数字 ①=U+2460
return String.fromCharCode(0x2460 + n - 1);
}
/**
* 正文内容变更后,尝试用 textPreview 重新定位锚点。
* 无法定位的标记为 invalid。
*/
export function relocateAnchors(
anchors: NodeAnchor[],
newPlainText: string,
): NodeAnchor[] {
return anchors.map((anchor) => {
if (anchor.type === "range" && anchor.textPreview) {
const newStart = newPlainText.indexOf(anchor.textPreview);
if (newStart >= 0) {
return {
...anchor,
start: newStart,
end: newStart + anchor.textPreview.length,
invalid: false,
};
}
}
// point 锚定无法自动重定位(位置语义已变)
if (anchor.type === "point") {
// 如果 start 仍在范围内,保留
if (anchor.start >= 0 && anchor.start <= newPlainText.length) {
return { ...anchor, invalid: false };
}
}
return { ...anchor, invalid: true };
});
}

View File

@@ -0,0 +1,133 @@
import { Mark, mergeAttributes } from "@tiptap/core";
import { Node } from "@tiptap/core";
/**
* V4 锚点系统:用 Tiptap Mark 内嵌锚点,替代 v3 的字符串偏移。
*
* - AnchorMarkrange 锚点):包裹选中文本,渲染为高亮 + 底部细线 + 行内标签
* - AnchorPointpoint 锚点):独立 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 {};
},
});

View File

@@ -1,82 +0,0 @@
/**
* V5-8画布自动布局
*
* 使用 dagre 计算 DAG 布局,将教学节点按流程关系自动排列。
* 仅对可拖动的教学节点(非正文节点)布局,正文节点位置保持不变。
*/
import dagre from "@dagrejs/dagre";
import type {
AnyLessonPlanNode,
AnyLessonPlanEdge,
} from "../types";
export interface AutoLayoutOptions {
/** 布局方向TB上→下/ LR左→右默认 TB */
direction?: "TB" | "LR";
/** 节点宽度,默认 240 */
nodeWidth?: number;
/** 节点高度,默认 120 */
nodeHeight?: number;
/** 节点水平间距,默认 40 */
rankSep?: number;
/** 节点垂直间距,默认 60 */
nodeSep?: number;
}
/**
* 计算自动布局,返回每个节点的新位置(含原始位置作为兜底)
*/
export function computeAutoLayout(
nodes: AnyLessonPlanNode[],
edges: AnyLessonPlanEdge[],
options: AutoLayoutOptions = {},
): Map<string, { x: number; y: number }> {
const {
direction = "TB",
nodeWidth = 240,
nodeHeight = 120,
rankSep = 60,
nodeSep = 40,
} = options;
const result = new Map<string, { x: number; y: number }>();
if (nodes.length === 0) return result;
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep });
g.setDefaultEdgeLabel(() => ({}));
// 仅对教学节点布局(正文节点固定位置)
const layoutableNodes = nodes.filter((n) => n.type !== "textbook_content");
const layoutableIds = new Set(layoutableNodes.map((n) => n.id));
for (const node of layoutableNodes) {
g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
}
// 仅添加两端都可布局的 flow 边
for (const edge of edges) {
if (edge.type !== "flow") continue;
if (!layoutableIds.has(edge.source) || !layoutableIds.has(edge.target)) continue;
g.setEdge(edge.source, edge.target);
}
// 孤立节点(无边)也要 setNodedagre 会自动排列
dagre.layout(g);
for (const node of layoutableNodes) {
const laid = g.node(node.id);
if (laid) {
// dagre 返回中心点React Flow 使用左上角,需减去半宽/半高
result.set(node.id, {
x: laid.x - nodeWidth / 2,
y: laid.y - nodeHeight / 2,
});
} else {
result.set(node.id, node.position);
}
}
return result;
}

View File

@@ -9,6 +9,8 @@ import type {
LessonPlanDocument,
LessonPlanDocumentV1,
LessonPlanDocumentV2,
LessonPlanDocumentV3,
LessonPlanDocumentV4,
LessonPlanEdge,
LessonPlanNode,
NodeAnchor,
@@ -44,6 +46,12 @@ export function defaultDataForType(type: BlockType): BlockData {
return { items: [], purpose: "class_practice", knowledgePointIds: [] };
case "text_study":
return { sourceText: "", annotations: [], knowledgePointIds: [] };
case "interaction":
return {
designIntent: "",
turns: [],
knowledgePointIds: [],
};
case "rich_text":
case "consolidation":
default:
@@ -74,7 +82,7 @@ export function migrateV2ToV3(
doc: LessonPlanDocumentV2,
chapterId?: string | null,
chapterContent?: string | null,
): LessonPlanDocument {
): LessonPlanDocumentV3 {
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
@@ -105,8 +113,63 @@ export function migrateV2ToV3(
};
}
/**
* 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,
};
}
// ---- 类型守卫:判断是否为 v4 文档 ----
function isV4Document(content: unknown): content is LessonPlanDocumentV4 {
if (!content || typeof content !== "object") return false;
const c = content as {
version?: unknown;
textbookContentNodeId?: unknown;
nodes?: unknown;
edges?: unknown;
anchors?: unknown;
expandedNodeIds?: unknown;
};
return (
c.version === 4 &&
typeof c.textbookContentNodeId === "string" &&
Array.isArray(c.nodes) &&
Array.isArray(c.edges) &&
Array.isArray(c.anchors) &&
Array.isArray(c.expandedNodeIds)
);
}
// ---- 类型守卫:判断是否为 v3 文档 ----
function isV3Document(content: unknown): content is LessonPlanDocument {
function isV3Document(content: unknown): content is LessonPlanDocumentV3 {
if (!content || typeof content !== "object") return false;
const c = content as {
version?: unknown;
@@ -142,20 +205,23 @@ function isV1Document(content: unknown): content is LessonPlanDocumentV1 {
return c.version === 1 && Array.isArray(c.blocks);
}
// ---- 规范化:确保 content 是 v3 格式(兼容 v1/v2 旧数据)----
// ---- 规范化:确保 content 是 v4 格式(兼容 v1/v2/v3 旧数据)----
export function normalizeDocument(
content: unknown,
chapterId?: string | null,
chapterContent?: string | null,
): LessonPlanDocument {
if (isV3Document(content)) return content;
if (isV4Document(content)) return content;
if (isV3Document(content)) return migrateV3ToV4(content).doc;
if (isV2Document(content)) {
return migrateV2ToV3(content, chapterId, chapterContent);
return migrateV3ToV4(migrateV2ToV3(content, chapterId, chapterContent)).doc;
}
if (isV1Document(content)) {
return migrateV2ToV3(migrateV1ToV2(content), chapterId, chapterContent);
return migrateV3ToV4(
migrateV2ToV3(migrateV1ToV2(content), chapterId, chapterContent),
).doc;
}
// 空文档:创建一个无正文的 v3
// 空文档:创建一个无正文的 v4
const textbookContentNodeId = createId();
const textbookNode: TextbookContentNode = {
id: textbookContentNodeId,
@@ -167,16 +233,17 @@ export function normalizeDocument(
draggable: false,
};
return {
version: 3,
version: 4,
textbookContentNodeId,
nodes: [textbookNode],
edges: [],
anchors: [],
expandedNodeIds: [],
};
}
// ---- 模板初始化:根据骨架生成初始 contentv3,无正文节点)----
// 注意:此函数不创建正文节点,调用方应使用 buildDefaultSkeleton 创建完整 v3 文档
// ---- 模板初始化:根据骨架生成初始 contentv4,无正文节点)----
// 注意:此函数不创建正文节点,调用方应使用 buildDefaultSkeleton 创建完整 v4 文档
export function buildInitialContent(
blocks: TemplateBlockSkeleton[],
): LessonPlanDocument {
@@ -211,11 +278,12 @@ export function buildInitialContent(
}
return {
version: 3,
version: 4,
textbookContentNodeId,
nodes: [textbookNode, ...nodes],
edges,
anchors: [],
expandedNodeIds: [],
};
}
@@ -277,11 +345,12 @@ export function buildDefaultSkeleton(
}));
return {
version: 3,
version: 4,
textbookContentNodeId,
nodes: [textbookNode, ...nodes],
edges,
anchors: [],
expandedNodeIds: [],
};
}

View File

@@ -1,9 +1,9 @@
/**
* V5-4课案导出/打印工具
* V5-4课案导出/打印工具V4 适配:按 order 排序,无画布依赖)
*
* 将画布式 LessonPlanDocument 扁平化为线性教学环节列表,
* 将 LessonPlanDocument 扁平化为线性教学环节列表,
* 供打印视图print-view.tsx渲染。支持详细版/简洁版两种模式:
* - detailed: 包含所有 11 种 Block
* - detailed: 包含所有 12 种 Block(含 V4 interaction
* - concise: 仅包含 objective / new_teaching / exercise / homework
*/
@@ -13,6 +13,7 @@ import type {
ExerciseBlockData,
HomeworkBlockData,
ImportBlockData,
InteractionBlockData,
KeyPointBlockData,
LessonPlan,
LessonPlanDocument,
@@ -152,6 +153,8 @@ function flattenBlockData(type: string, data: BlockData): string[] {
return flattenTextStudy(data as TextStudyBlockData);
case "rich_text":
return flattenRichText(data as RichTextBlockData);
case "interaction":
return flattenInteraction(data as InteractionBlockData);
default:
return [];
}
@@ -261,6 +264,19 @@ function flattenRichText(data: RichTextBlockData): string[] {
return text.length > 0 ? [text] : [];
}
function flattenInteraction(data: InteractionBlockData): string[] {
const lines: string[] = [];
if (data.designIntent) lines.push(`设计意图:${data.designIntent}`);
data.turns.forEach((turn, i) => {
const roleLabel = turn.role === "teacher" ? "师" : "生";
lines.push(`${i + 1} 轮 [${roleLabel}]${turn.content}`);
if (turn.role === "teacher" && turn.expectedAnswer) {
lines.push(` 预期回答:${turn.expectedAnswer}`);
}
});
return lines;
}
// 仅用于类型推导的本地导入别名,避免在 switch case 中重复 import
type ObjectiveItem = ObjectiveBlockData["objectives"][number];
type HomeworkAssignment = HomeworkBlockData["assignments"][number];

View File

@@ -21,10 +21,9 @@ export async function translateFieldErrors(
result[field] = messages.map((msg) => {
// 仅翻译以 "error." 开头的 i18n 键,其他保持原样
if (msg.startsWith("error.")) {
// V4 P1-13 修复next-intl 的 t 函数对键有字面量类型约束,
// 动态字符串需断言为键类型。msg 已通过 startsWith 校验为合法 i18n 键前缀,
// 此处 as 属于"从 string 收窄到字面量联合类型"的类型收窄,符合项目规则例外。
return t(msg as Parameters<typeof t>[0]);
// 运行时守卫:先用 t.has() 检查键是否存在,避免 as 断言绕过类型检查
// 若键不存在schema 中误写不存在的键),返回原消息而非抛出 MISSING_MESSAGE
if (t.has(msg)) { return t(msg as Parameters<typeof t>[0]); } return msg;
}
return msg;
});

View File

@@ -113,29 +113,31 @@ export function getTextbookContentSummary(
/**
* 节点类型 → CSS 变量名V4 P1-4 修复:从硬编码 hex 提取到 globals.css 设计令牌)。
* V4 纸感重构:原 --lesson-node-* 已替换为 --lp-dot-* 中性色点令牌。
* 供 lesson-node 和 minimap 复用。
*/
export const NODE_COLOR_VARS: Record<string, string> = {
objective: "var(--lesson-node-objective)",
key_point: "var(--lesson-node-key-point)",
import: "var(--lesson-node-import)",
new_teaching: "var(--lesson-node-new-teaching)",
consolidation: "var(--lesson-node-consolidation)",
summary: "var(--lesson-node-summary)",
homework: "var(--lesson-node-homework)",
blackboard: "var(--lesson-node-blackboard)",
text_study: "var(--lesson-node-text-study)",
exercise: "var(--lesson-node-exercise)",
rich_text: "var(--lesson-node-rich-text)",
reflection: "var(--lesson-node-reflection)",
textbook_content: "var(--lesson-node-textbook-content)",
objective: "var(--lp-dot-objective)",
key_point: "var(--lp-dot-key-point)",
import: "var(--lp-dot-import)",
new_teaching: "var(--lp-dot-new-teaching)",
consolidation: "var(--lp-dot-consolidation)",
summary: "var(--lp-dot-summary)",
homework: "var(--lp-dot-homework)",
blackboard: "var(--lp-dot-blackboard)",
text_study: "var(--lp-dot-text-study)",
exercise: "var(--lp-dot-exercise)",
rich_text: "var(--lp-dot-rich-text)",
reflection: "var(--lp-dot-reflection)",
interaction: "var(--lp-dot-interaction)",
textbook_content: "var(--lp-dot-textbook)",
}
/** 选中态颜色变量 */
export const NODE_SELECTED_COLOR_VAR = "var(--lesson-node-selected)"
/** 选中态颜色变量V4复用 lp-anchor-point 作为强调色) */
export const NODE_SELECTED_COLOR_VAR = "var(--lp-anchor-point)"
/** 默认颜色变量(未匹配类型时使用) */
export const NODE_DEFAULT_COLOR_VAR = "var(--lesson-node-default)"
export const NODE_DEFAULT_COLOR_VAR = "var(--lp-dot-default)"
/**
* @deprecated 使用 `getNodeColorVar` 替代。保留是为了向后兼容旧代码引用。

View File

@@ -1,186 +0,0 @@
import type { Node, Edge } from "@xyflow/react";
import type {
AnyLessonPlanEdge,
AnyLessonPlanNode,
NodeAnchor,
} from "../types";
import { getNodeColor } from "./node-summary";
/**
* 纯函数:根据 nodeId 从节点列表中查找节点类型。
* P0-8 修复toRfEdges 需要节点类型来获取颜色,而非传入 nodeId。
*/
function getNodeTypeById(
nodes: AnyLessonPlanNode[],
nodeId: string,
): string {
const node = nodes.find((n) => n.id === nodeId);
return node?.type ?? "rich_text";
}
/**
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
* 从 node-editor.tsx 抽取,便于单元测试。
*
* v3 升级:
* - 区分教学节点type="lesson"和正文节点type="textbook_content"
* - 正文节点传入 anchors/selectedNodeId/onAddAnchor 等回调
* - 边区分 anchor/flow 类型,应用不同透明度
*/
export interface ToRfNodesContext {
anchors: NodeAnchor[];
selectedNodeId: string | null;
/** 可锚定的教学节点列表P1-1用于节点选择器*/
anchorableNodes?: { id: string; title: string; type: string }[];
onAddRangeAnchor?: (params: {
nodeId: string;
start: number;
end: number;
textPreview: string;
}) => void;
onAddPointAnchor?: (params: {
nodeId: string;
start: number;
}) => void;
/** 创建新节点并锚定P1-1*/
onCreateNewNode?: (params: {
anchorType: "range" | "point";
start: number;
end?: number;
textPreview?: string;
}) => void;
onSelectNode?: (id: string | null) => void;
}
export function toRfNodes(
nodes: AnyLessonPlanNode[],
selectedNodeId: string | null,
ctx?: ToRfNodesContext,
): Node[] {
// 当有选中节点时,收集所有与选中节点相关的节点 ID通过锚点关联
const relatedNodeIds = new Set<string>();
if (selectedNodeId && ctx?.anchors) {
for (const a of ctx.anchors) {
if (a.nodeId === selectedNodeId) {
relatedNodeIds.add(a.nodeId);
}
}
// 正文节点始终相关(因为锚点在正文上)
const textbookNode = nodes.find((n) => n.type === "textbook_content");
if (textbookNode) relatedNodeIds.add(textbookNode.id);
relatedNodeIds.add(selectedNodeId);
}
return nodes.map((n) => {
// 正文节点n.type === "textbook_content" 已收窄为 TextbookContentNode
if (n.type === "textbook_content") {
const tbNode = n;
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(tbNode.id);
return {
id: tbNode.id,
type: "textbook_content",
position: tbNode.position,
data: {
node: tbNode,
anchors: ctx?.anchors ?? [],
selectedNodeId,
anchorableNodes: ctx?.anchorableNodes ?? [],
onAddRangeAnchor: ctx?.onAddRangeAnchor,
onAddPointAnchor: ctx?.onAddPointAnchor,
onCreateNewNode: ctx?.onCreateNewNode,
onSelectNode: ctx?.onSelectNode,
} as Record<string, unknown>,
selected: tbNode.id === selectedNodeId,
draggable: false,
style: isDimmed ? { opacity: 0.3 } : undefined,
};
}
// 教学节点textbook_content 分支已上方 return此处 n 已收窄为 LessonPlanNode
const lessonNode = n;
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(lessonNode.id);
return {
id: lessonNode.id,
type: "lesson",
position: lessonNode.position,
data: { node: lessonNode } as Record<string, unknown>,
selected: lessonNode.id === selectedNodeId,
style: isDimmed ? { opacity: 0.3 } : undefined,
};
});
}
export function toRfEdges(
edges: AnyLessonPlanEdge[],
selectedNodeId: string | null,
anchors: NodeAnchor[],
nodes: AnyLessonPlanNode[] = [],
): Edge[] {
return edges.map((e) => {
if (e.type === "anchor") {
// 锚点边:默认 40% 透明度,选中关联节点时 100%
const anchor = anchors.find((a) => a.id === e.anchorId);
const isActive = anchor && anchor.nodeId === selectedNodeId;
// P0-8 修复:传入节点 type 而非 nodeId使颜色映射正确生效
const nodeType = anchor ? getNodeTypeById(nodes, anchor.nodeId) : "rich_text";
const strokeColor = getNodeColor(nodeType);
return {
...e,
animated: isActive,
className: isActive ? "anchor-edge active" : "anchor-edge",
// P1-3 修复:将 anchorId 存入 datafromRfEdges 从 data 读取
data: { anchorId: e.anchorId },
style: {
stroke: strokeColor,
strokeWidth: isActive ? 3 : 2,
opacity: isActive ? 1 : 0.4,
},
};
}
// 流程边
const isDimmed = selectedNodeId !== null && e.source !== selectedNodeId && e.target !== selectedNodeId;
return {
...e,
animated: !isDimmed,
style: {
stroke: "#1976d2",
strokeWidth: 2,
opacity: isDimmed ? 0.2 : 1,
},
};
});
}
/**
* 将 React Flow edges 转回课案 edges 格式。
*/
export function fromRfEdges(
rfEdges: Edge[],
): AnyLessonPlanEdge[] {
return rfEdges.map((e) => {
const base = {
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
};
// P1-3 修复:优先从 data.anchorId 读取,回退到 className 判断
const dataAnchorId = (e.data as { anchorId?: string } | undefined)?.anchorId;
if (dataAnchorId || e.className?.includes("anchor-edge")) {
return {
...base,
type: "anchor" as const,
anchorId: dataAnchorId ?? e.id,
};
}
return {
...base,
type: "flow" as const,
};
});
}

View File

@@ -156,6 +156,18 @@ export function isReflectionBlockData(data: BlockData): data is ReflectionBlockD
return isObject(data) && Array.isArray(data.reflection);
}
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)
);
}
// ---- Block 字段值类型守卫(用于 select onChange 等场景,替代 `as` 断言)----
const BLACKBOARD_LAYOUTS = ["structure", "mindmap", "text"] as const;
@@ -244,6 +256,7 @@ const VALID_BLOCK_TYPES: BlockType[] = [
"exercise",
"rich_text",
"reflection",
"interaction",
];
export function isBlockType(v: string): v is BlockType {
return (VALID_BLOCK_TYPES as readonly string[]).includes(v);

View File

@@ -57,7 +57,8 @@ export type BlockType =
| "text_study"
| "exercise"
| "rich_text"
| "reflection";
| "reflection"
| "interaction"; // V4 新增:师生交互
// 正文节点类型(特殊:不可拖动,画布中央)
export type TextbookContentNodeType = "textbook_content";
@@ -208,6 +209,26 @@ export interface ReflectionBlockData {
reflection: ReflectionItem[];
}
// V4 师生交互
export interface QATurn {
id: string;
role: "teacher" | "student";
content: string;
/** 教师提问的预期答案 / 引导策略(可选)*/
expectedAnswer?: string;
/** 这一轮的顺序 */
order: number;
}
export interface InteractionBlockData {
/** 设计意图 */
designIntent: string;
/** 对话轮次 */
turns: QATurn[];
/** 关联知识点 */
knowledgePointIds: string[];
}
// 正文节点数据
export interface TextbookContentNodeData {
chapterId: string;
@@ -227,7 +248,8 @@ export type BlockData =
| SummaryBlockData
| HomeworkBlockData
| BlackboardBlockData
| ReflectionBlockData;
| ReflectionBlockData
| InteractionBlockData; // V4 新增
// V5-15 T1教学阶段用于画布节点分组与可视化排序
// - import: 导入环节
@@ -284,14 +306,20 @@ export type AnyLessonPlanNode = LessonPlanNode | TextbookContentNode;
// ---- 锚点 ----
export type AnchorType = "range" | "point";
// V4NodeAnchor 简化start/end/textPreview/invalid 字段标记为弃用)
// 旧 v3 字段保留用于迁移识别,新代码不应读取
export interface NodeAnchor {
id: string;
nodeId: string; // 关联的教学节点 ID
type: AnchorType;
start: number; // 正文纯文本偏移量
end?: number; // range 锚定的结束偏移
textPreview?: string; // range 锚定的文字预览(用于失效重定位)
invalid?: boolean; // 正文变更后无法重定位时标记为失效
/** @deprecated v3 字符串偏移v4 改用 Tiptap Mark不再使用 */
start?: number;
/** @deprecated v3 字符串偏移 */
end?: number;
/** @deprecated v3 文字预览 */
textPreview?: string;
/** @deprecated v3 失效标记 */
invalid?: boolean;
}
// ---- 边 ----
@@ -335,7 +363,7 @@ export interface LessonPlanDocumentV2 {
}
// v3课文锚点画布格式
export interface LessonPlanDocument {
export interface LessonPlanDocumentV3 {
version: 3;
textbookContentNodeId: string;
nodes: AnyLessonPlanNode[];
@@ -343,6 +371,20 @@ export interface LessonPlanDocument {
anchors: NodeAnchor[];
}
// v4纸感锚点格式Tiptap Mark 内嵌)
export interface LessonPlanDocumentV4 {
version: 4;
textbookContentNodeId: string;
nodes: AnyLessonPlanNode[];
edges: AnyLessonPlanEdge[]; // 保留但不再用于画布连线
anchors: NodeAnchor[]; // 简化:只存 id 关联
/** V4 新增:节点展开状态 */
expandedNodeIds: string[];
}
// 当前文档版本v4
export type LessonPlanDocument = LessonPlanDocumentV4;
// 课案
export interface LessonPlan {
id: string;

View File

@@ -208,10 +208,6 @@
"archived": "Archived"
}
},
"gradeHead": {
"title": "Grade Lesson Plans",
"description": "View lesson plans from teachers in your grade"
},
"library": {
"title": "School Lesson Plan Library",
"description": "Browse outstanding lesson plans shared by teachers in your school",
@@ -316,7 +312,12 @@
"revertConfirm": "Revert to v{versionNo}? A new version will be created.",
"revertSuccess": "Reverted to v{versionNo}",
"autoLabel": "Auto version",
"revertLabel": "Revert to v{versionNo}"
"revertLabel": "Revert to v{versionNo}",
"diff": {
"title": "Version v{versionNo} comparison",
"summary": "Added {added}, removed {removed}, unchanged {unchanged}",
"noChanges": "No differences between versions"
}
},
"diff": {
"summary": "Added {added} · Removed {removed} · Modified {modified}",
@@ -602,20 +603,31 @@
"unauthorized": "Unauthorized operation",
"planIdRequired": "Plan ID is required",
"classIdRequired": "Class ID is required",
"dateRequired": "Date is required"
"dateRequired": "Date is required",
"labelTooLong": "Label cannot exceed 100 characters",
"versionNoInvalid": "Version number must be a positive integer",
"nameRequired": "Name is required",
"nameTooLong": "Name cannot exceed 100 characters",
"queryTooLong": "Search query cannot exceed 200 characters",
"blockIdRequired": "Block ID is required",
"commentTooLong": "Comment cannot exceed 500 characters",
"reviewerRequired": "Reviewer ID is required"
},
"confirm": {
"archive": "Archive this lesson plan?",
"archiveTitle": "Confirm Archive"
},
"calendar": {
"title": "Calendar View",
"week": "Week",
"month": "Month",
"title": "Lesson Calendar",
"description": "View lesson preparation activities by date",
"weekView": "Week",
"monthView": "Month",
"today": "Today",
"prev": "Previous",
"next": "Next",
"noEvents": "No events",
"loading": "Loading calendar...",
"error": "Failed to load calendar events",
"loading": "Loading...",
"loadFailed": "Failed to load calendar",
"eventType": {
"created": "Created",
"updated": "Updated",
@@ -623,106 +635,17 @@
"submitted": "Submitted",
"published": "Published",
"reviewed": "Reviewed"
}
},
"review": {
"title": "Review Workflow",
"submit": "Submit for Review",
"approve": "Approve",
"reject": "Reject",
"pending": "Pending Review",
"approved": "Approved",
"rejected": "Rejected",
"submitConfirm": "Are you sure you want to submit this lesson plan for review?",
"submitSuccess": "Lesson plan submitted for review",
"submitFailed": "Failed to submit for review",
"approveConfirm": "Are you sure you want to approve this lesson plan?",
"approveSuccess": "Lesson plan approved",
"approveFailed": "Failed to approve lesson plan",
"rejectConfirm": "Are you sure you want to reject this lesson plan?",
"rejectSuccess": "Lesson plan rejected",
"rejectFailed": "Failed to reject lesson plan",
"records": "Review Records",
"noRecords": "No review records",
"reviewer": "Reviewer",
"decision": "Decision",
"comment": "Comment",
"time": "Time",
"pendingReviews": "Pending Reviews",
"noPending": "No pending reviews"
},
"comment": {
"title": "Comments",
"add": "Add Comment",
"edit": "Edit",
"delete": "Delete",
"reply": "Reply",
"resolve": "Resolve",
"resolved": "Resolved",
"reopen": "Reopen",
"noComments": "No comments yet",
"placeholder": "Write a comment...",
"blockComment": "Block Comment",
"deleteConfirm": "Are you sure you want to delete this comment?",
"deleteSuccess": "Comment deleted",
"deleteFailed": "Failed to delete comment",
"createSuccess": "Comment added",
"createFailed": "Failed to add comment",
"updateSuccess": "Comment updated",
"updateFailed": "Failed to update comment",
"resolveSuccess": "Comment resolved",
"resolveFailed": "Failed to resolve comment"
},
"formative": {
"title": "Formative Assessment",
"add": "Add Interaction",
"edit": "Edit",
"delete": "Delete",
"noItems": "No formative assessment items",
"interactionType": {
"question": "Question",
"poll": "Poll",
"discussion": "Discussion",
"reflection": "Reflection"
},
"question": "Question",
"options": "Options",
"correctAnswer": "Correct Answer",
"pollQuestion": "Poll Question",
"pollOptions": "Poll Options",
"discussionTopic": "Discussion Topic",
"reflectionPrompt": "Reflection Prompt",
"responses": "Responses",
"noResponses": "No responses yet",
"deleteConfirm": "Are you sure you want to delete this interaction?",
"deleteSuccess": "Interaction deleted",
"deleteFailed": "Failed to delete interaction",
"createSuccess": "Interaction added",
"createFailed": "Failed to add interaction",
"updateSuccess": "Interaction updated",
"updateFailed": "Failed to update interaction",
"recordSuccess": "Response recorded",
"recordFailed": "Failed to record response"
},
"substitute": {
"title": "Substitute Teacher",
"add": "Add Assignment",
"edit": "Edit",
"delete": "Delete",
"noAssignments": "No substitute assignments",
"originalTeacher": "Original Teacher",
"substituteTeacher": "Substitute Teacher",
"startDate": "Start Date",
"endDate": "End Date",
"classes": "Classes",
"subject": "Subject",
"deleteConfirm": "Are you sure you want to delete this substitute assignment?",
"deleteSuccess": "Substitute assignment deleted",
"deleteFailed": "Failed to delete substitute assignment",
"createSuccess": "Substitute assignment created",
"createFailed": "Failed to create substitute assignment",
"updateSuccess": "Substitute assignment updated",
"updateFailed": "Failed to update substitute assignment"
"eventMeta": "v{versionNo}",
"weekDays": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
}
},
"schedule": {
"title": "Schedule Lesson",
@@ -742,22 +665,6 @@
"addSuccess": "Schedule binding added",
"deleteSuccess": "Schedule binding deleted"
},
"evaluation": {
"title": "AI Evaluation",
"generate": "Generate Evaluation",
"regenerate": "Regenerate",
"score": "Score",
"suggestions": "Suggestions",
"differentiation": "Differentiation Strategies",
"noEvaluation": "No AI evaluation yet",
"loading": "Generating AI evaluation...",
"generateConfirm": "Generate AI evaluation for this lesson plan?",
"generateSuccess": "AI evaluation generated",
"generateFailed": "Failed to generate AI evaluation",
"strengths": "Strengths",
"weaknesses": "Areas for Improvement",
"recommendations": "Recommendations"
},
"analytics": {
"title": "Analytics Dashboard",
"teacherInvestment": "Teacher Investment",
@@ -777,35 +684,80 @@
"loading": "Loading analytics...",
"heatmap": "Coverage Heatmap",
"subject": "Subject",
"grade": "Grade"
},
"standards": {
"title": "Standards Alignment",
"add": "Add Standard",
"edit": "Edit",
"delete": "Delete",
"search": "Search standards...",
"noStandards": "No standards found",
"code": "Code",
"description": "Description",
"subject": "Subject",
"grade": "Grade",
"parent": "Parent Standard",
"linkToPlan": "Link to Lesson Plan",
"unlink": "Unlink",
"linkedStandards": "Linked Standards",
"availableStandards": "Available Standards",
"tree": "Standards Tree",
"deleteConfirm": "Are you sure you want to delete this standard?",
"deleteSuccess": "Standard deleted",
"deleteFailed": "Failed to delete standard",
"createSuccess": "Standard created",
"createFailed": "Failed to create standard",
"updateSuccess": "Standard updated",
"updateFailed": "Failed to update standard",
"linkSuccess": "Standard linked to lesson plan",
"linkFailed": "Failed to link standard",
"unlinkSuccess": "Standard unlinked from lesson plan",
"unlinkFailed": "Failed to unlink standard"
"totalPublished": "Published",
"totalSubmitted": "Submitted",
"totalStandardsLinked": "Standards Linked",
"loadFailed": "Failed to load analytics"
},
"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}",
"stageLabel": "Teaching stage",
"differentiationLabel": "Differentiation",
"exerciseCount": "exercises",
"turnContentPlaceholder": "Content"
},
"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",
"copied": "Node duplicated",
"copyFailed": "Copy failed: node not found",
"aiComingSoon": "AI assist coming soon",
"aiRunning": "AI is processing...",
"aiSuccess": "AI updated node content",
"aiFailed": "AI processing failed, please retry"
},
"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"
},
"heatmap": {
"legendTitle": "Coverage legend:",
"noData": "No data"
}
}
}

View File

@@ -208,10 +208,6 @@
"archived": "已归档"
}
},
"gradeHead": {
"title": "年级备课",
"description": "查看本年级教师的备课"
},
"library": {
"title": "校内课案库",
"description": "浏览本校教师分享的优秀课案",
@@ -316,7 +312,12 @@
"revertConfirm": "确认回退到 v{versionNo}?将生成新版本。",
"revertSuccess": "已回退到 v{versionNo}",
"autoLabel": "自动版本",
"revertLabel": "回退到 v{versionNo}"
"revertLabel": "回退到 v{versionNo}",
"diff": {
"title": "版本 v{versionNo} 对比",
"summary": "新增 {added} 项,删除 {removed} 项,未变 {unchanged} 项",
"noChanges": "两个版本无差异"
}
},
"diff": {
"summary": "新增 {added} · 删除 {removed} · 修改 {modified}",
@@ -602,7 +603,15 @@
"unauthorized": "未授权操作",
"planIdRequired": "课案 ID 必填",
"classIdRequired": "班级 ID 必填",
"dateRequired": "日期必填"
"dateRequired": "日期必填",
"labelTooLong": "标签不能超过 100 个字符",
"versionNoInvalid": "版本号必须为正整数",
"nameRequired": "请输入名称",
"nameTooLong": "名称不能超过 100 个字符",
"queryTooLong": "搜索词不能超过 200 个字符",
"blockIdRequired": "节点 ID 必填",
"commentTooLong": "评论不能超过 500 个字符",
"reviewerRequired": "审核人 ID 必填"
},
"confirm": {
"archive": "确认归档此课案?",
@@ -638,100 +647,6 @@
"sat": "周六"
}
},
"review": {
"title": "审核工作流",
"submit": "提交审核",
"withdraw": "撤回提交",
"approve": "通过",
"reject": "驳回",
"submitConfirm": "确认提交审核?提交后不可编辑,等待教研组长审核。",
"withdrawConfirm": "确认撤回提交?撤回后将回到草稿状态。",
"approveConfirm": "确认通过此课案审核?",
"rejectConfirm": "确认驳回此课案?教师可修改后重新提交。",
"commentLabel": "审核意见",
"commentPlaceholder": "请输入审核意见(可选)",
"submitted": "已提交审核",
"approved": "审核通过",
"rejected": "审核驳回",
"withdrawn": "已撤回",
"reviewerLabel": "审核人:{name}",
"reviewAt": "审核时间:{time}",
"records": "审核记录",
"noRecords": "暂无审核记录",
"pendingTitle": "待审核课案",
"pendingEmpty": "暂无待审核课案",
"invalidTransition": "状态变更不合法",
"notSubmitted": "当前状态不可提交审核",
"notReviewable": "当前状态不可审核",
"submitSuccess": "已提交审核",
"withdrawSuccess": "已撤回提交",
"approveSuccess": "审核已通过",
"rejectSuccess": "已驳回"
},
"comment": {
"title": "协同评论",
"add": "发表评论",
"reply": "回复",
"placeholder": "输入评论内容...",
"empty": "暂无评论",
"resolve": "标记已解决",
"reopen": "重新打开",
"resolved": "已解决",
"unresolved": "未解决",
"delete": "删除",
"deleteConfirm": "确认删除此评论?子回复将一并删除。",
"edit": "编辑",
"unresolvedCount": "{count} 条未解决",
"blockLabel": "节点:{title}",
"createSuccess": "评论已发表",
"deleteSuccess": "已删除",
"resolveSuccess": "已标记为解决",
"createFailed": "发表评论失败",
"deleteFailed": "删除评论失败"
},
"formative": {
"title": "形成性评价",
"add": "添加互动组件",
"empty": "暂无互动组件",
"interactionType": {
"poll": "投票",
"quiz": "随堂测",
"exit_ticket": "出口票"
},
"promptLabel": "题目",
"promptPlaceholder": "输入互动题目...",
"optionsLabel": "选项",
"addOption": "+ 添加选项",
"correctAnswer": "正确答案",
"durationLabel": "时长(秒)",
"responses": "学生作答",
"noResponses": "暂无作答",
"stats": "统计",
"totalResponses": "{count} 人作答",
"correctRate": "正确率 {rate}%",
"avgDuration": "平均用时 {sec} 秒",
"createSuccess": "已添加",
"updateSuccess": "已更新",
"deleteSuccess": "已删除"
},
"substitute": {
"title": "代课教师",
"add": "添加代课",
"empty": "暂无代课记录",
"originalTeacher": "原任教师",
"substituteTeacher": "代课教师",
"startDate": "开始日期",
"endDate": "结束日期",
"status": {
"active": "生效中",
"expired": "已过期",
"cancelled": "已取消"
},
"createSuccess": "代课已安排",
"updateSuccess": "代课状态已更新",
"deleteSuccess": "代课已删除",
"cancelConfirm": "确认取消此代课安排?"
},
"schedule": {
"title": "安排课时",
"boundList": "已绑定课时",
@@ -750,30 +665,6 @@
"addSuccess": "已添加课时绑定",
"deleteSuccess": "已删除课时绑定"
},
"evaluation": {
"title": "AI 课案评估",
"run": "开始评估",
"running": "评估中...",
"empty": "暂无评估记录",
"overallScore": "综合评分",
"dimensions": {
"clarity": "目标清晰度",
"alignment": "课标对齐",
"engagement": "学生参与",
"differentiation": "分层设计",
"assessment": "评估设计"
},
"suggestions": "改进建议",
"noSuggestions": "暂无改进建议",
"createSuccess": "评估完成",
"suggestion": {
"addMoreNodeDetails": "建议在教学节点中补充更多细节",
"linkMoreStandards": "建议关联更多课标条目",
"addFormativeItems": "建议增加课堂互动组件",
"addPracticeBlock": "建议增加课堂练习环节",
"addHomeworkBlock": "建议明确课后作业要求"
}
},
"analytics": {
"title": "备课分析",
"teacherInvestment": "教师投入",
@@ -788,19 +679,74 @@
"noData": "暂无数据",
"loadFailed": "加载分析数据失败"
},
"standards": {
"title": "课标对标",
"link": "关联课标",
"unlink": "取消关联",
"linked": "已关联 {count} 条",
"empty": "暂无关联课标",
"searchPlaceholder": "搜索课标...",
"level": {
"national": "国家标准",
"curriculum": "课程标准",
"custom": "校本标准"
"v4": {
"tree": {
"title": "结构",
"addNode": "添加节点",
"onPaper": "纸上",
"countSuffix": "节点"
},
"linkSuccess": "已关联课标",
"unlinkSuccess": "已取消关联"
"paper": {
"textbookHeader": "教材正文 · 可编辑副本",
"expandedCount": "{count} 节点已展开",
"anchorHere": "锚定到节点"
},
"detail": {
"designIntent": "设计意图",
"qaDialog": "对话设计",
"addTurn": "添加一轮对话",
"turnTeacher": "师 · 提问",
"turnStudent": "生 · 回答",
"turnFollowup": "师 · 追问",
"expectedAnswer": "预期回答",
"aiAssist": "AI 协助",
"aiGenerateLayered": "生成分层提问",
"aiFillExpected": "补充预期回答",
"aiOptimizeFollowup": "优化追问",
"aiDifferentiation": "差异化建议",
"updated": "更新于 {time}",
"stageLabel": "教学阶段",
"differentiationLabel": "差异化",
"exerciseCount": "道练习",
"turnContentPlaceholder": "内容"
},
"contextMenu": {
"nodeOps": "节点操作",
"expandToPaper": "展开到正文",
"collapseFromPaper": "从正文收起",
"moveUp": "上移",
"moveDown": "下移",
"aiAssist": "AI 协助",
"aiGenerate": "生成本节点内容",
"aiOptimize": "优化表达",
"aiDifferentiation": "差异化建议",
"aiLayeredQuestions": "生成分层提问",
"copyNode": "复制节点",
"deleteNode": "删除节点",
"anchorToNode": "锚定到节点",
"insertPointAnchor": "插入点锚点",
"copied": "已复制节点",
"copyFailed": "复制失败:节点不存在",
"aiComingSoon": "AI 协助功能即将上线",
"aiRunning": "AI 正在处理...",
"aiSuccess": "AI 已更新节点内容",
"aiFailed": "AI 处理失败,请重试"
},
"interaction": {
"label": "师生互动",
"defaultTitle": "师生互动",
"round": "第 {n} 轮",
"roleTeacher": "师",
"roleStudent": "生"
},
"migration": {
"legacyAnchorTitle": "锚点格式升级提示",
"legacyAnchorBody": "此课案使用旧版锚点格式,部分锚点已失效,请重新锚定。",
"legacyAnchorDismiss": "知道了"
},
"heatmap": {
"legendTitle": "覆盖率图例:",
"noData": "无数据"
}
}
}