- 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
187 lines
5.6 KiB
TypeScript
187 lines
5.6 KiB
TypeScript
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,
|
||
};
|
||
});
|
||
}
|