Files
NextEdu/src/modules/lesson-preparation/lib/rf-mappers.ts
SpecialX 20023e13fd feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff
- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
2026-07-03 10:25:21 +08:00

187 lines
5.6 KiB
TypeScript
Raw Blame History

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