Compare commits
39 Commits
872d5fb085
...
d1ad7a1f75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ad7a1f75 | ||
|
|
e962b67050 | ||
|
|
0780524c08 | ||
|
|
5ca2a76e3a | ||
|
|
0e4eccec21 | ||
|
|
e762490f19 | ||
|
|
5d306dc686 | ||
|
|
124ed97060 | ||
|
|
e29f30d278 | ||
|
|
6dd3b1fecd | ||
|
|
85121c49a3 | ||
|
|
8ff14fce1c | ||
|
|
24b8ae78c6 | ||
|
|
43f30f4013 | ||
|
|
40cba54ed4 | ||
|
|
a4c9eb02b4 | ||
|
|
05f9b4fd9d | ||
|
|
c7cbc86fd4 | ||
|
|
6fa712ad15 | ||
|
|
4db77b7d1e | ||
|
|
cbed4ef508 | ||
|
|
8583e3e387 | ||
|
|
9b0b7ef619 | ||
|
|
66a60213bf | ||
|
|
1b898fb6cf | ||
|
|
d7b15093f9 | ||
|
|
5c4db2fedc | ||
|
|
ccb2cf05df | ||
|
|
4f00e566df | ||
|
|
ef1be6d04f | ||
|
|
0f4e58d7e1 | ||
|
|
a856ab005d | ||
|
|
7b550d3b69 | ||
|
|
f7ea76cd60 | ||
|
|
8a7d0f69ca | ||
|
|
217b5b48e4 | ||
|
|
8fc798fbcf | ||
|
|
ccf1618b1c | ||
|
|
a1c283e10d |
@@ -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)。数据结构从 v1(blocks 数组)升级到 v2(nodes + edges 节点图),旧数据通过 `migrateV1ToV2()` 自动迁移。
|
||||
|
||||
> 架构变更(2026-06-23):数据结构从 v2 升级到 v3(课文锚点画布格式)。正文节点(textbook_content)作为画布中央核心,教学节点围绕正文组织。新增锚点系统(range/point 两种锚定方式)、占位符注入算法、11 种定制节点类型。旧数据通过 `migrateV2ToV3()` 自动迁移。
|
||||
|
||||
> 架构变更(2026-07-04,V4 纸感重构):抛弃 React Flow 画布,改用三栏布局(260px 结构树 + 1fr 纸感编辑器 + 380px 详情面板)。正文采用 Tiptap 富文本编辑器,锚点从字符串偏移升级为 Tiptap Mark(AnchorMark 区间锚/AnchorPoint 点锚),v3 旧锚点通过 AnchorMigrationBanner 提示失效。doc.version=4,editor-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 节点;V3:ctx 新增 `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`(V4:Tiptap 富文本编辑器)、`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)
|
||||
- ✅ 编辑器架构升级:NodeEditor(React Flow 画布)+ NodeEditPanel(侧边内容编辑面板)+ LessonNode(自定义节点组件),支持节点拖拽、连线、画布缩放
|
||||
- ✅ V4 纸感重构:三栏布局 StructureTree + PaperEditor + DetailPanel,正文采用 Tiptap 富文本编辑器,锚点升级为 Tiptap Mark(AnchorMark/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→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton,10 节点 + 正文节点)/ 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 + getNodeColor(V3 续审计:移除 `as { html?: string; ... }` 断言,改用 isRecord/getArrayLength/getString/getNumber 安全字段提取从 `unknown` 收窄) |
|
||||
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes(V3:移除冗余 `as` 断言,TypeScript 判别联合自动收窄)/ toRfEdges / fromRfEdges |
|
||||
| `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(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 + getNodeColor(V3 续审计:移除 `as { html?: string; ... }` 断言,改用 isRecord/getArrayLength/getString/getNumber 安全字段提取从 `unknown` 收窄) |
|
||||
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + BlockRenderer(V3:使用 `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 接口 + noopTracker;hooks:useLessonPlanContextSafe/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe |
|
||||
| `providers/lesson-plan-provider-setup.tsx` | **页面层 Provider 设置包装组件(V3 续新增)**:LessonPlanProviderSetup 自动注入默认数据服务(createDefaultDataService)和角色配置(TEACHER_ROLE_CONFIG),3 个 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` | **编辑器 slice(V4 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` | **编辑器 slice(V4 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` | **选中状态 slice(V4 P2-6 新增)**:Zustand `StateCreator`,包含 selectedNodeId 状态 + selectNode 方法 |
|
||||
| `hooks/version-slice.ts` | **版本与保存状态 slice(V4 P2-6 新增)**:Zustand `StateCreator`,包含 isDirty/isSaving/lastSavedAt 状态 + hydrate/markSaved/setSaving/replaceDoc 方法 |
|
||||
| `hooks/version-slice.ts` | **版本与保存状态 slice(V4 P2-6 新增)**:Zustand `StateCreator`,包含 isDirty/isSaving/lastSavedAt/saveError/isOnline 状态 + hydrate/markSaved/setSaving/replaceDoc/setSaveError/setOnline 方法 |
|
||||
| `hooks/history-slice.ts` | **撤销/重做 slice(V5-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 actions;V3 续审计: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` | **只读画布组件**(V3:MiniMap 使用 `getNodeColor` 替代硬编码颜色) |
|
||||
| `components/node-editor.tsx` | **节点图画布**(V3:MiniMap `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` | **只读视图**(V3:MiniMap 使用 `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 新增**:师生交互 Block(designIntent + 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 个 actions;V3 续审计:通过 `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-04,V4 纸感重构 — 抛弃 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 读取 localStorage(dismiss 状态按 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` 接入真实 action;i18n 新增 `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/aiFailed)(zh-CN + en 同步);P1 修复 en locale 下大面积报错:en `calendar` 节键名对齐 zh-CN(weekView/monthView/loadFailed + prev/next/eventMeta/weekDays 子节),en `analytics` 节补齐 totalPublished/totalSubmitted/totalStandardsLinked/loadFailed;P2 修复 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/gradeHead,analytics 因被 `app/(dashboard)/admin/curriculum-map` 引用而保留);`lib/i18n-errors.ts` 新增 `t.has(msg)` 运行时守卫,避免 `as` 断言绕过类型检查(键不存在时返回原消息而非抛出 MISSING_MESSAGE);zh-CN 与 en 顶层键集一致性验证通过(43 = 43);005_architecture_data.json 同步追加 `V4-I18N-1` auditFixes 条目;known-issues.md 追加 3 个 i18n 规则表(键缺失与中英文不同步 / dead 节清理规则 / 动态键翻译守卫)
|
||||
|
||||
---
|
||||
|
||||
## 2.27b standards(课标模块,2026-07-01 新增)
|
||||
|
||||
@@ -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% 透明度,选中时完整显示。数据模型 v3:LessonPlanDocument 含 textbookContentNodeId、nodes、edges、anchors 数组。anchor-injector 实现 Markdown 纯文本偏移映射与占位符注入。实时拖动(onNodeDrag 替代 onNodeDragStop)。v1→v2→v3 链式迁移。强制选定教材+章节才能备课(template-picker 四步流程)。保留模板、版本管理、知识点标注、题目创建/拉取、作业发布能力",
|
||||
"description": "教师备课模块(V4 纸感重构):抛弃 React Flow 画布,改用三栏布局(260px 结构树 + 1fr 纸感编辑器 + 380px 详情面板)。正文采用 Tiptap 富文本编辑器,锚点从字符串偏移升级为 Tiptap Mark(AnchorMark 区间锚/AnchorPoint 点锚),v3 旧锚点通过 AnchorMigrationBanner 提示失效。doc.version=4,editor-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.ts(past/future 栈,50 步上限,canUndo/canRedo/pushHistory/undo/redo/clearHistory 方法);editor-slice.ts 所有 mutation 方法(setTitle/addNode/updateNode/removeNode/updateTextbookContent/addAnchor/removeAnchor/updateAnchor/connect/disconnect/setEdges)包装 pushHistory,updateNodePosition 除外(拖拽过程中由调用方在 onDragStart 时推入);version-slice hydrate/replaceDoc 调用 clearHistory;lesson-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.ts(flattenLessonPlanForPrint 函数,将画布式 LessonPlanDocument 扁平化为 PrintableLessonPlan 线性结构,支持 detailed/concise 两种 variant,concise 仅含 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-image(Image.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-image;files/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-image(Image.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-image;files/types.ts FileTargetType 新增 'lesson_plan' 类型",
|
||||
"V4-PAPER-1": "H1 注册 interaction block:config/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 读取 localStorage(dismiss 状态按 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.ts;lesson-plan-readonly-view.tsx 重写为委托 LessonPlanMobileView(线性卡片视图)渲染所有视口,移除 React Flow 依赖",
|
||||
"V4-PAPER-5": "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)",
|
||||
"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 升级为 4: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 字符串偏移锚点)",
|
||||
"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 校验、失败返回 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 接入真实 action;i18n 新增 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-CN;P2 修复 7 处硬编码中文 + 新增 6 个 i18n 键;清理 7 个 dead 节(review/comment/formative/substitute/evaluation/standards/gradeHead,analytics 因被 admin/curriculum-map 引用保留);lib/i18n-errors.ts 新增 t.has() 守卫"
|
||||
}
|
||||
},
|
||||
"error-book": {
|
||||
|
||||
@@ -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` | 反思 Block(V3 续:select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) |',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 2.28 error-book(错题本模块)'
|
||||
].join('\r\n');
|
||||
|
||||
const newLines = [
|
||||
'| `components/blocks/reflection-block.tsx` | 反思 Block(V3 续: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 Actions(2026-07-01 新增)** |',
|
||||
'| `actions-review.ts` | **M3 审核工作流 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-comments.ts` | **M2 评论 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-formative.ts` | **M5 形成性评价 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-attachments.ts` | **M6 附件库 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-substitutes.ts` | **M12 代课教师 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-ai-evaluation.ts` | **M8 AI 评估 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-analytics.ts` | **M10 分析仪表盘 Server Actions(2026-07-01 新增)** |',
|
||||
'',
|
||||
'> 架构变更(2026-07-01,M1-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.ts,4 种互动类型',
|
||||
'> - **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.ts,3 个分析维度',
|
||||
'> - **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-access:getStandards / getStandardsTree / getStandardById / createStandard / updateStandard / deactivateStandard / searchStandards / getStandardsByPlanId / linkPlanToStandard / unlinkPlanFromStandard',
|
||||
'- Actions:10 个 Server Actions,全部使用 requirePermission() + ActionState<T>',
|
||||
'- Schema:createStandardSchema / updateStandardSchema / getStandardsParamsSchema / linkPlanToStandardSchema',
|
||||
'- Types:Standard / StandardTreeNode / LessonPlanStandardLink',
|
||||
'',
|
||||
'**权限点**:',
|
||||
'- STANDARD_READ(standard:read)',
|
||||
'- STANDARD_MANAGE(standard:manage)',
|
||||
'- STANDARD_LINK(standard: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);
|
||||
637
docs/mockups/editor-layout-comparison.html
Normal file
637
docs/mockups/editor-layout-comparison.html
Normal 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>
|
||||
938
docs/mockups/paper-detail-expanded.html
Normal file
938
docs/mockups/paper-detail-expanded.html
Normal 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>
|
||||
799
docs/mockups/paper-sticky-design.html
Normal file
799
docs/mockups/paper-sticky-design.html
Normal 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>
|
||||
882
docs/mockups/paper-sticky-expanded.html
Normal file
882
docs/mockups/paper-sticky-expanded.html
Normal 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
@@ -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`
|
||||
- 旧版本无法读取 v4(v4 是新创建分支,不回写 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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* V4:v3 锚点失效提示 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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%" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
{/* V4:v3 锚点失效提示 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
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
“{anchor.textPreview}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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.data(Record<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 YAGNI,V2 完整实现)
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 衬线
|
||||
* - 锚点:AnchorMark(range)+ AnchorPoint(point)
|
||||
* - 编辑时浮动工具条出现
|
||||
*/
|
||||
export function TextbookTiptapEditor({ content, readonly }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const updateTextbookContent = useLessonPlanEditor((s) => s.updateTextbookContent);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// V4:禁用一些冲突的特性
|
||||
codeBlock: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t("v4.paper.textbookHeader"),
|
||||
}),
|
||||
Image,
|
||||
AnchorMark,
|
||||
AnchorPoint,
|
||||
],
|
||||
content,
|
||||
editable: !readonly,
|
||||
// SSR 必填:避免 hydration mismatches(Tiptap 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 键(实际翻译由组件层完成)
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
56
src/modules/lesson-preparation/hooks/expanded-slice.ts
Normal file
56
src/modules/lesson-preparation/hooks/expanded-slice.ts
Normal 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 }),
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
111
src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
Normal file
111
src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
Normal 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.data;patch 字段由 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 };
|
||||
}
|
||||
250
src/modules/lesson-preparation/lib/ai-node-assist.ts
Normal file
250
src/modules/lesson-preparation/lib/ai-node-assist.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
133
src/modules/lesson-preparation/lib/anchor-mark.ts
Normal file
133
src/modules/lesson-preparation/lib/anchor-mark.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
import { Node } from "@tiptap/core";
|
||||
|
||||
/**
|
||||
* V4 锚点系统:用 Tiptap Mark 内嵌锚点,替代 v3 的字符串偏移。
|
||||
*
|
||||
* - AnchorMark(range 锚点):包裹选中文本,渲染为高亮 + 底部细线 + 行内标签
|
||||
* - AnchorPoint(point 锚点):独立 Node,渲染为黑色小圆圈带数字
|
||||
*
|
||||
* 锚点数据存储在 Mark/Node 的 attributes 中,编辑时自动跟随文本移动。
|
||||
*/
|
||||
|
||||
export interface AnchorMarkAttributes {
|
||||
anchorId: string;
|
||||
nodeId: string;
|
||||
type: "range";
|
||||
}
|
||||
|
||||
export const AnchorMark = Mark.create({
|
||||
name: "anchor",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
anchorId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-anchor-id"),
|
||||
renderHTML: (attrs) => ({
|
||||
"data-anchor-id": attrs.anchorId,
|
||||
}),
|
||||
},
|
||||
nodeId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-node-id"),
|
||||
renderHTML: (attrs) => ({
|
||||
"data-node-id": attrs.nodeId,
|
||||
}),
|
||||
},
|
||||
type: {
|
||||
default: "range",
|
||||
parseHTML: (el) => el.getAttribute("data-anchor-type") ?? "range",
|
||||
renderHTML: (attrs) => ({
|
||||
"data-anchor-type": attrs.type,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "span[data-anchor-id]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
class: "lp-anchor-range",
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
// 不自动添加任何快捷键
|
||||
addKeyboardShortcuts() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* AnchorPoint:点锚点,作为独立 Node 插入文本流。
|
||||
* 渲染为带数字的黑色小圆圈。
|
||||
*/
|
||||
export const AnchorPoint = Node.create({
|
||||
name: "anchorPoint",
|
||||
|
||||
group: "inline",
|
||||
|
||||
inline: true,
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
anchorId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-anchor-id"),
|
||||
renderHTML: (attrs) => ({
|
||||
"data-anchor-id": attrs.anchorId,
|
||||
}),
|
||||
},
|
||||
nodeId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-node-id"),
|
||||
renderHTML: (attrs) => ({
|
||||
"data-node-id": attrs.nodeId,
|
||||
}),
|
||||
},
|
||||
label: {
|
||||
default: "•",
|
||||
parseHTML: (el) => el.getAttribute("data-label") ?? "•",
|
||||
renderHTML: (attrs) => ({
|
||||
"data-label": attrs.label,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "span[data-anchor-point]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-anchor-point": "true",
|
||||
class: "lp-anchor-point",
|
||||
contenteditable: "false",
|
||||
}),
|
||||
HTMLAttributes.label ?? "•",
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 孤立节点(无边)也要 setNode,dagre 会自动排列
|
||||
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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v3,无正文节点)----
|
||||
// 注意:此函数不创建正文节点,调用方应使用 buildDefaultSkeleton 创建完整 v3 文档
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v4,无正文节点)----
|
||||
// 注意:此函数不创建正文节点,调用方应使用 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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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` 替代。保留是为了向后兼容旧代码引用。
|
||||
|
||||
@@ -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 存入 data,fromRfEdges 从 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
// V4:NodeAnchor 简化(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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "无数据"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user