cmvr-iot-common/src/views/flow/vue-flow/adapter.js

1179 lines
44 KiB
JavaScript
Raw Normal View History

2026-08-25 10:15:37 +08:00
/**
* 文件说明:旧流程数据与 Vue Flow 状态、操作 API 之间的兼容适配层。
* 作用范围:仅服务于流程设计器模块。
*/
import { nextTick } from "vue";
import { v4 as uuid } from "uuid";
import { MarkerType } from "@vue-flow/core";
/**
* 克隆 clone 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} value 待处理的新值
*/
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
const GROUP_TYPES = new Set(["loop", "selectArea"]);
const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]);
const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]);
const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 };
const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 };
const LOOP_MIN_SIZE = { width: 520, height: 360 };
/**
* 处理 loopChildExtent 对应的数据或交互;作用范围仅限画布适配层。
*/
const loopChildExtent = () => ({
range: "parent",
padding: [
LOOP_BODY_INSET.top,
LOOP_BODY_INSET.right,
LOOP_BODY_INSET.bottom,
LOOP_BODY_INSET.left,
],
});
/**
* 编组 groupChildExtent 对应的数据或交互;作用范围仅限画布适配层。
*/
const groupChildExtent = () => ({
range: "parent",
padding: [
GROUP_BODY_INSET.top,
GROUP_BODY_INSET.right,
GROUP_BODY_INSET.bottom,
GROUP_BODY_INSET.left,
],
});
/**
* 处理 nodeSize 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
const nodeSize = (node) => {
// 分支节点采用统一的标准卡片尺寸,忽略旧流程数据中遗留的大尺寸记录。
if (node.type === "branch") return { width: 372, height: 180 };
const width = Number(node.properties?.width || node.width);
const height = Number(node.properties?.height || node.height);
if (Number.isFinite(width) && Number.isFinite(height)) {
return { width, height };
}
if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 };
if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) {
return { width: 328, height: 120 };
}
return { width: 372, height: 180 };
};
/**
* 处理 edgeId 对应的数据或交互;作用范围仅限画布适配层。
*/
const edgeId = () => `edge_${uuid().replace(/-/g, "")}`;
/**
* 处理 nodeId 对应的数据或交互;作用范围仅限画布适配层。
*/
const nodeId = () => uuid().replace(/-/g, "");
/**
* 判断一个节点是否可以挂到指定容器下。
*
* 实现思路:普通节点仍可进入 loop 或 selectArea;容器节点中只开放 loop 嵌套 loop,
* 避免顺带改变 selectArea 的编组语义。
*
* @param {string} parentType 父容器的流程节点类型
* @param {string} childType 待挂载节点的流程节点类型
* @returns {boolean} 是否允许建立该父子关系
*/
const canAttachToGroup = (parentType, childType) => {
if (!GROUP_TYPES.has(parentType)) return false;
if (!GROUP_TYPES.has(childType)) return true;
return parentType === "loop" && childType === "loop";
};
/**
* 收集流程节点的全部后代,用于移动和删除嵌套循环时保持整棵子树一致。
*
* 实现思路:同时读取 children 与 properties.parentId,兼容新旧流程数据可能只保存
* 其中一种关系的情况;visited 可阻止异常历史数据形成死循环。
*
* @param {{ nodes?: Array }} graph 后端格式的流程图
* @param {string} rootId 根节点 ID
* @returns {Set<string>} 不包含根节点自身的全部后代 ID
*/
const collectGraphDescendantIds = (graph, rootId) => {
const childrenByParent = new Map();
(graph.nodes || []).forEach((node) => {
const childIds = new Set(node.children || []);
if (childIds.size) childrenByParent.set(node.id, childIds);
});
(graph.nodes || []).forEach((node) => {
const parentId = node.properties?.parentId;
if (!parentId) return;
const childIds = childrenByParent.get(parentId) || new Set();
childIds.add(node.id);
childrenByParent.set(parentId, childIds);
});
const descendants = new Set();
const pending = [...(childrenByParent.get(rootId) || [])];
while (pending.length) {
const childId = pending.pop();
if (!childId || childId === rootId || descendants.has(childId)) continue;
descendants.add(childId);
pending.push(...(childrenByParent.get(childId) || []));
}
return descendants;
};
/**
* 计算 Vue Flow 节点的父子深度,保证父节点始终排在后代之前挂载。
*
* @param {*} node Vue Flow 节点
* @param {Map<string, *>} nodeLookup 节点索引
* @returns {number} 根节点为 0 的层级深度
*/
const nodeHierarchyDepth = (node, nodeLookup) => {
let depth = 0;
let current = node;
const visited = new Set([node.id]);
while (current?.parentNode) {
if (visited.has(current.parentNode)) break;
visited.add(current.parentNode);
current = nodeLookup.get(current.parentNode);
if (!current) break;
depth += 1;
}
return depth;
};
2026-08-25 10:15:37 +08:00
/**
* 处理 toVueNode 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
* @param {*} sizeOverride 调用方传入的 sizeOverride 参数
* @param {*} positionOverride 调用方传入的 positionOverride 参数
*/
const toVueNode = (node, sizeOverride, positionOverride) => {
const normalized = clone(node);
normalized.id ||= nodeId();
normalized.properties ||= {};
const overrideWidth = Number(sizeOverride?.width);
const overrideHeight = Number(sizeOverride?.height);
const size = Number.isFinite(overrideWidth) && overrideWidth > 0
&& Number.isFinite(overrideHeight) && overrideHeight > 0
? { width: overrideWidth, height: overrideHeight }
: nodeSize(normalized);
const center = {
x: Number(normalized.x) || 0,
y: Number(normalized.y) || 0,
};
const overrideX = Number(positionOverride?.x);
const overrideY = Number(positionOverride?.y);
const position = Number.isFinite(overrideX) && Number.isFinite(overrideY)
? { x: overrideX, y: overrideY }
: {
x: center.x - size.width / 2,
y: center.y - size.height / 2,
};
const dimensions = GROUP_TYPES.has(normalized.type)
? { width: size.width, height: size.height }
: { width: size.width };
return {
id: normalized.id,
type: "flow-node",
position,
...dimensions,
draggable: normalized.draggable !== false,
selectable: true,
deletable: !SOURCE_ONLY_TYPES.has(normalized.type) && normalized.type !== "end",
connectable: true,
zIndex: GROUP_TYPES.has(normalized.type) ? 0 : 2,
data: {
flowType: normalized.type,
properties: normalized.properties,
children: Array.isArray(normalized.children) ? normalized.children : [],
text: normalized.text,
backend: normalized,
originalCenter: center,
originalPosition: position,
size,
},
};
};
/**
* 处理 toVueEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} edge 调用方传入的 edge 参数
*/
const toVueEdge = (edge) => {
const normalized = clone(edge);
normalized.id ||= edgeId();
return {
id: normalized.id,
source: normalized.sourceNodeId,
target: normalized.targetNodeId,
sourceHandle: normalized.sourceAnchorId || null,
targetHandle: normalized.targetAnchorId || null,
type: "flow-edge",
animated: true,
selectable: true,
focusable: true,
deletable: true,
interactionWidth: 32,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#ff6b35",
width: 20,
height: 20,
},
style: { stroke: "#ff6b35", strokeWidth: 2 },
data: { backend: normalized },
};
};
/**
* 将后端绝对坐标节点转换为 Vue Flow 的多层父子节点结构。
*
* 实现思路:先冻结所有节点的绝对坐标,再建立父子关系并换算相对坐标,避免父循环
* 已转成相对坐标后影响更深层节点。循环尺寸按“最深层优先”向外扩展,确保外层循环
* 能包含已经完成尺寸计算的内层循环。
*
* @param {Array} nodes Vue Flow 节点列表,初始 position 均为画布绝对坐标
* @returns {Array} 父节点排在子节点之前的节点列表
2026-08-25 10:15:37 +08:00
*/
const attachChildrenToGroups = (nodes) => {
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
const declaredParents = new Map();
const absolutePositions = new Map(nodes.map((node) => [node.id, { ...node.position }]));
2026-08-25 10:15:37 +08:00
nodes.filter((node) => GROUP_TYPES.has(node.data.flowType)).forEach((group) => {
(group.data.children || []).forEach((childId) => {
if (!declaredParents.has(childId)) declaredParents.set(childId, group.id);
});
});
nodes.forEach((child) => {
const parentId = child.data.properties?.parentId || declaredParents.get(child.id);
const parent = nodeLookup.get(parentId);
if (!parent || !canAttachToGroup(parent.data.flowType, child.data.flowType)) return;
// 历史数据可能包含错误的循环父子引用;沿父链检查可避免 Vue Flow 收到环形层级。
const visited = new Set([child.id]);
let ancestor = parent;
while (ancestor) {
if (visited.has(ancestor.id)) return;
visited.add(ancestor.id);
const ancestorParentId = ancestor.data.properties?.parentId || declaredParents.get(ancestor.id);
ancestor = nodeLookup.get(ancestorParentId);
}
2026-08-25 10:15:37 +08:00
const isLoop = parent.data.flowType === "loop";
const childPosition = absolutePositions.get(child.id) || child.position;
const parentPosition = absolutePositions.get(parentId) || parent.position;
2026-08-25 10:15:37 +08:00
const relativePosition = {
x: isLoop
? Math.max(childPosition.x - parentPosition.x, LOOP_BODY_INSET.left)
: childPosition.x - parentPosition.x,
2026-08-25 10:15:37 +08:00
y: isLoop
? Math.max(childPosition.y - parentPosition.y, LOOP_BODY_INSET.top)
: childPosition.y - parentPosition.y,
2026-08-25 10:15:37 +08:00
};
child.parentNode = parentId;
child.extent = isLoop ? loopChildExtent() : groupChildExtent();
child.expandParent = false;
child.position = relativePosition;
// 边统一位于 zIndex 4。嵌套循环若仍使用普通子节点的 zIndex 5,其白色容器背景会
// 遮住循环体内新建的边,看起来像“无法连线”;容器置于边下方,普通节点仍置于边上方。
child.zIndex = GROUP_TYPES.has(child.data.flowType) ? 1 : 5;
2026-08-25 10:15:37 +08:00
child.data.properties = { ...child.data.properties, parentId };
parent.data.children = Array.from(new Set([...(parent.data.children || []), child.id]));
});
nodes
.filter((node) => node.data.flowType === "loop")
.sort((left, right) => nodeHierarchyDepth(right, nodeLookup) - nodeHierarchyDepth(left, nodeLookup))
.forEach((group) => {
const children = nodes.filter((node) => node.parentNode === group.id);
if (!children.length) return;
const requiredWidth = Math.max(...children.map((child) => (
child.position.x + Number(child.data.size?.width || 0) + LOOP_BODY_INSET.right
)));
const requiredHeight = Math.max(...children.map((child) => (
child.position.y + Number(child.data.size?.height || 0) + LOOP_BODY_INSET.bottom
)));
const width = Math.max(Number(group.data.size?.width || 0), LOOP_MIN_SIZE.width, requiredWidth);
const height = Math.max(Number(group.data.size?.height || 0), LOOP_MIN_SIZE.height, requiredHeight);
group.width = width;
group.height = height;
group.data.size = { width, height };
group.data.properties = { ...group.data.properties, width, height };
});
2026-08-25 10:15:37 +08:00
return nodes.sort((left, right) => (
nodeHierarchyDepth(left, nodeLookup) - nodeHierarchyDepth(right, nodeLookup)
));
2026-08-25 10:15:37 +08:00
};
export const toVueFlowData = (graph = {}, options = {}) => {
const nodes = attachChildrenToGroups((graph.nodes || []).map((node) => (
toVueNode(node, options.nodeSizes?.get(node.id), options.nodePositions?.get(node.id))
)));
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
const edges = (graph.edges || []).map((edge) => {
const vueEdge = toVueEdge(edge);
const source = nodeLookup.get(vueEdge.source);
const target = nodeLookup.get(vueEdge.target);
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
return vueEdge;
});
return { nodes, edges };
};
export const createFlowFacade = (store) => {
const handlers = new Map();
const componentInstances = new Map();
const loopLayoutStates = new Map();
let historySuspended = false;
let history = [];
let historyIndex = -1;
/**
* 获取 getNodes 对应的数据或交互;作用范围仅限画布适配层。
*/
const getNodes = () => store.getNodes.value || [];
/**
* 获取 getEdges 对应的数据或交互;作用范围仅限画布适配层。
*/
const getEdges = () => store.getEdges.value || [];
/**
* 获取节点在整个画布中的绝对左上角坐标。
*
* 实现思路:Vue Flow 的子节点 position 相对直接父节点,嵌套循环需要沿完整父链累加;
* visited 用于防御异常数据中的父子环,避免保存或布局时无限递归。
*
* @param {*} node Vue Flow 节点
* @param {Set<string>} visited 已访问的节点 ID
* @returns {{x: number, y: number}} 画布绝对坐标
*/
const getAbsoluteNodePosition = (node, visited = new Set()) => {
const position = node?.position || node?.computedPosition || { x: 0, y: 0 };
if (!node?.parentNode || visited.has(node.id)) {
return { x: Number(position.x || 0), y: Number(position.y || 0) };
}
const nextVisited = new Set(visited);
nextVisited.add(node.id);
const parent = store.findNode(node.parentNode);
if (!parent) return { x: Number(position.x || 0), y: Number(position.y || 0) };
const parentPosition = getAbsoluteNodePosition(parent, nextVisited);
return {
x: parentPosition.x + Number(position.x || 0),
y: parentPosition.y + Number(position.y || 0),
};
};
/**
* 将 Vue Flow 节点序列化为后端使用的绝对坐标节点。
*
* 实现思路:先沿完整父链取得绝对位置,再恢复旧流程协议的中心点坐标;这样普通节点、
* 内层循环及更深层后代在保存和重新加载后都不会发生位置漂移。
*
2026-08-25 10:15:37 +08:00
* @param {*} node 当前流程节点
* @returns {*} 后端格式节点
2026-08-25 10:15:37 +08:00
*/
const backendNode = (node) => {
const result = clone(node.data.backend);
const size = {
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
};
const parent = node.parentNode ? store.findNode(node.parentNode) : null;
const absolutePosition = getAbsoluteNodePosition(node);
2026-08-25 10:15:37 +08:00
result.id = node.id;
result.type = node.data.flowType;
if (GROUP_TYPES.has(result.type)) {
result.x = absolutePosition.x + size.width / 2;
result.y = absolutePosition.y + size.height / 2;
} else {
const originalCenter = node.data.originalCenter || {
x: absolutePosition.x + size.width / 2,
y: absolutePosition.y + size.height / 2,
};
const originalPosition = node.data.originalPosition || absolutePosition;
result.x = originalCenter.x + absolutePosition.x - originalPosition.x;
result.y = originalCenter.y + absolutePosition.y - originalPosition.y;
}
result.properties = clone(node.data.properties || {});
if (parent?.data?.flowType === "selectArea") delete result.properties.parentId;
if (GROUP_TYPES.has(result.type)) {
result.properties.width = size.width;
result.properties.height = size.height;
}
if (node.data.children?.length || GROUP_TYPES.has(result.type)) {
result.children = [...(node.data.children || [])];
} else {
delete result.children;
}
return result;
};
/**
* 处理 backendEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} edge 调用方传入的 edge 参数
*/
const backendEdge = (edge) => ({
...clone(edge.data?.backend),
id: edge.id,
type: edge.data?.backend?.type || "bezier",
sourceNodeId: edge.source,
targetNodeId: edge.target,
sourceAnchorId: edge.sourceHandle || `${edge.source}_1`,
targetAnchorId: edge.targetHandle || `${edge.target}_3`,
});
/**
* 处理 emit 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} payload 事件数据
*/
const emit = (name, payload) => {
(handlers.get(name) || []).forEach((handler) => handler(payload));
};
/**
* 处理 recordHistory 对应的数据或交互;作用范围仅限画布适配层。
*/
const recordHistory = () => {
if (historySuspended) return;
const graph = facade.getGraphData();
const serialized = JSON.stringify(graph);
if (history[historyIndex]?.serialized === serialized) return;
history = history.slice(0, historyIndex + 1);
history.push({ serialized, graph: clone(graph) });
if (history.length > 100) history.shift();
historyIndex = history.length - 1;
};
/**
* 更新 changed 对应的数据或交互;作用范围仅限画布适配层。
*/
const changed = () => {
if (historySuspended) return;
recordHistory();
emit("history:change", { data: facade.getGraphData() });
};
/**
* 获取节点在画布绝对坐标系中的矩形。
*
2026-08-25 10:15:37 +08:00
* @param {*} node 当前流程节点
* @returns {{x: number, y: number, width: number, height: number}} 绝对矩形
2026-08-25 10:15:37 +08:00
*/
const getNodeRect = (node) => {
const position = getAbsoluteNodePosition(node);
2026-08-25 10:15:37 +08:00
return {
x: Number(position?.x || 0),
y: Number(position?.y || 0),
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
};
};
/**
* 获取 getLoopLayoutState 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} group 调用方传入的 group 参数
*/
const getLoopLayoutState = (group) => {
const current = getNodeRect(group);
const existing = loopLayoutStates.get(group.id);
/**
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} !existing 调用方传入的 !existing 参数
*/
if (!existing) {
const state = { base: { ...current }, last: { ...current } };
loopLayoutStates.set(group.id, state);
return state;
}
const sizeChanged = Math.abs(current.width - existing.last.width) > 1
|| Math.abs(current.height - existing.last.height) > 1;
/**
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} sizeChanged 调用方传入的 sizeChanged 参数
*/
if (sizeChanged) {
existing.base = { ...current };
} else {
const dx = current.x - existing.last.x;
const dy = current.y - existing.last.y;
existing.base.x += dx;
existing.base.y += dy;
}
existing.last = { ...current };
return existing;
};
/**
* 根据直接子节点的绝对矩形调整循环容器。
*
* 实现思路:布局计算统一使用画布绝对坐标;写回嵌套循环时,再减去父循环的绝对坐标
* 得到 Vue Flow 所需的相对 position,从而让同一算法同时适用于顶层和任意深度循环。
*
* @param {*} group 待调整的循环节点
* @param {string} activeChildId 正在拖动的直接子节点 ID
* @param {{x: number, y: number}} activePosition 正在拖动节点的绝对坐标
* @returns {boolean} 是否完成布局
2026-08-25 10:15:37 +08:00
*/
const layoutLoop = (group, activeChildId, activePosition) => {
const state = getLoopLayoutState(group);
const children = getNodes().filter((node) => node.parentNode === group.id);
if (!children.length) return false;
const snapshots = children.map((child) => {
const rect = getNodeRect(child);
const position = child.id === activeChildId && activePosition
? { x: activePosition.x, y: activePosition.y }
: { x: rect.x, y: rect.y };
return { child, position, width: rect.width, height: rect.height };
});
const contentLeft = Math.min(...snapshots.map(({ position }) => position.x));
const contentTop = Math.min(...snapshots.map(({ position }) => position.y));
const contentRight = Math.max(...snapshots.map(({ position, width }) => position.x + width));
const contentBottom = Math.max(...snapshots.map(({ position, height }) => position.y + height));
const left = Math.min(state.base.x, contentLeft - LOOP_BODY_INSET.left);
const top = Math.min(state.base.y, contentTop - LOOP_BODY_INSET.top);
const right = Math.max(
state.base.x + Math.max(state.base.width, LOOP_MIN_SIZE.width),
contentRight + LOOP_BODY_INSET.right,
);
const bottom = Math.max(
state.base.y + Math.max(state.base.height, LOOP_MIN_SIZE.height),
contentBottom + LOOP_BODY_INSET.bottom,
);
const width = right - left;
const height = bottom - top;
const properties = { ...group.data.properties, width, height };
const parent = group.parentNode ? store.findNode(group.parentNode) : null;
const parentPosition = parent ? getAbsoluteNodePosition(parent) : { x: 0, y: 0 };
const relativePosition = {
x: left - parentPosition.x,
y: top - parentPosition.y,
};
2026-08-25 10:15:37 +08:00
store.updateNode(group.id, {
position: relativePosition,
2026-08-25 10:15:37 +08:00
computedPosition: { ...group.computedPosition, x: left, y: top },
dimensions: { width, height },
width,
height,
});
store.updateNodeData(group.id, {
...group.data,
properties,
size: { width, height },
}, { replace: true });
snapshots.forEach(({ child, position }) => {
store.updateNode(child.id, {
position: { x: position.x - left, y: position.y - top },
computedPosition: { ...child.computedPosition, x: position.x, y: position.y },
extent: loopChildExtent(),
expandParent: false,
});
});
state.last = { x: left, y: top, width, height };
nextTick(() => store.updateNodeInternals([group.id, ...children.map((child) => child.id)]));
return true;
};
/**
* 从指定节点开始向上重新计算所有父循环尺寸。
*
* 实现思路:内层循环尺寸变化后,外层循环必须由内向外依次更新,否则外层可能裁剪
* 新增内容。visited 防止异常父子关系导致无限循环。
*
* @param {*} node 尺寸或位置已经变化的节点
*/
const layoutParentLoops = (node) => {
let parentId = node?.parentNode;
const visited = new Set();
while (parentId && !visited.has(parentId)) {
visited.add(parentId);
const parent = store.findNode(parentId);
if (!parent) break;
if (parent.data.flowType === "loop") layoutLoop(parent);
parentId = parent.parentNode;
}
};
2026-08-25 10:15:37 +08:00
/**
* 获取 getAnchors 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
const getAnchors = (node) => {
const type = node.data.flowType;
/**
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} type 目标类型
*/
if (type === "branch") {
return [
{ id: `${node.id}_entry`, name: "left" },
...(node.data.properties?.anchor || []).map((item) => ({ ...item, name: "right" })),
];
}
if (TARGET_ONLY_TYPES.has(type)) return [{ id: `${node.id}_3`, name: "left" }];
if (SOURCE_ONLY_TYPES.has(type)) return [{ id: `${node.id}_1`, name: "right" }];
if (type === "selectArea") return [];
return [
{ id: `${node.id}_1`, name: "right" },
{ id: `${node.id}_3`, name: "left" },
];
};
/**
* 处理 modelFor 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
const modelFor = (id) => {
const node = store.findNode(id);
if (!node) return null;
const model = {
id,
type: node.data.flowType,
get x() { return backendNode(node).x; },
get y() { return backendNode(node).y; },
get width() { return node.dimensions?.width || node.data.size?.width || 0; },
get height() { return node.dimensions?.height || node.data.size?.height || 0; },
get left() { return getNodeRect(node).x; },
get top() { return getNodeRect(node).y; },
get parentId() { return node.parentNode || null; },
get anchors() { return getAnchors(node); },
get children() { return new Set(node.data.children || []); },
set children(value) {
const children = Array.from(value || []);
store.updateNodeData(id, { ...node.data, children }, { replace: true });
changed();
},
get draggable() { return node.draggable !== false; },
set draggable(value) { store.updateNode(id, { draggable: Boolean(value) }); },
getProperties: () => facade.getProperties(id),
setProperties: (properties) => facade.setProperties(id, properties),
setComponentInstance: (instance) => componentInstances.set(id, instance),
closePopover: () => componentInstances.get(id)?.closePopover?.(),
updateSize: () => store.updateNodeInternals([id]),
setCustomProperties: () => store.updateNodeInternals([id]),
};
return model;
};
const facade = {
/**
* 订阅 on 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} handler 事件处理函数
*/
on(name, handler) {
const list = handlers.get(name) || [];
list.push(handler);
handlers.set(name, list);
return () => facade.off(name, handler);
},
/**
* 订阅 once 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} handler 事件处理函数
*/
once(name, handler) {
/**
* 处理 wrapped 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} payload 事件数据
*/
const wrapped = (payload) => {
facade.off(name, wrapped);
handler(payload);
};
facade.on(name, wrapped);
},
/**
* 取消订阅 off 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} handler 事件处理函数
*/
off(name, handler) {
if (!handler) handlers.delete(name);
else handlers.set(name, (handlers.get(name) || []).filter((item) => item !== handler));
},
emit,
markChanged: changed,
/**
* 获取 getGraphData 对应的数据或交互;作用范围仅限画布适配层。
*/
getGraphData() {
return {
nodes: getNodes().map(backendNode),
edges: getEdges().map(backendEdge),
};
},
/**
* 渲染 render 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} graph 调用方传入的 graph 参数
* @param {*} options 行为配置
*/
render(graph, options = {}) {
historySuspended = true;
loopLayoutStates.clear();
const nodeSizes = options.preserveNodeSizes === true
? new Map(getNodes().map((node) => [node.id, {
width: Number(node.dimensions?.width || node.width || node.data.size?.width || 0),
height: Number(node.dimensions?.height || node.height || node.data.size?.height || 0),
}]))
: options.nodeSizes;
const nodePositions = options.preserveNodePositions === true
? new Map(getNodes().map((node) => {
const rect = getNodeRect(node);
return [node.id, { x: rect.x, y: rect.y }];
}))
: options.nodePositions;
const data = toVueFlowData(graph, { nodeSizes, nodePositions });
/**
* 处理 finishRender 对应的数据或交互;作用范围仅限画布适配层。
*/
const finishRender = () => {
historySuspended = false;
if (options.resetHistory !== false) {
history = [];
historyIndex = -1;
recordHistory();
}
nextTick(() => store.updateNodeInternals());
};
/**
* 处理 applyData 对应的数据或交互;作用范围仅限画布适配层。
*/
const applyData = () => {
store.setNodes(data.nodes);
store.setEdges(data.edges);
finishRender();
};
if (options.replaceNodes === true) {
// Changing parentNode is structural in Vue Flow. Unmount in one tick
// and remount in the next so node wrappers and edges cannot retain
// different graph-node instances for the same id.
store.setEdges([]);
store.setNodes([]);
nextTick(applyData);
} else {
applyData();
}
},
/**
* 新增 addNode 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
addNode(node) {
const vueNode = toVueNode(node);
store.addNodes(vueNode);
changed();
return backendNode(vueNode);
},
/**
* 删除节点,并在删除循环时递归清理其完整循环体。
*
* 实现思路:嵌套循环可能包含多层后代,不能只删除 children 中的第一层;先从当前
* 图数据统一收集后代,再一次性删除节点与关联边,避免留下带失效 parentId 的孤儿节点。
*
* @param {string} id 目标元素 ID
2026-08-25 10:15:37 +08:00
*/
deleteNode(id) {
const target = store.findNode(id);
if (target?.data?.flowType === "selectArea") {
facade.ungroup(id);
return;
}
const parentId = target?.parentNode;
const descendantIds = target?.data?.flowType === "loop"
? collectGraphDescendantIds(facade.getGraphData(), id)
: new Set();
const childIds = [...descendantIds];
const removeChildren = childIds.length > 0;
2026-08-25 10:15:37 +08:00
const nodeIds = removeChildren ? [id, ...childIds] : id;
const removedIds = new Set(Array.isArray(nodeIds) ? nodeIds : [nodeIds]);
store.removeNodes(nodeIds, true, removeChildren);
getNodes().forEach((node) => {
if (node.data.children?.includes(id) && !removedIds.has(node.id)) {
store.updateNodeData(node.id, {
...node.data,
children: node.data.children.filter((childId) => childId !== id),
}, { replace: true });
}
});
removedIds.forEach((nodeIdToDelete) => {
componentInstances.delete(nodeIdToDelete);
loopLayoutStates.delete(nodeIdToDelete);
});
2026-08-25 10:15:37 +08:00
changed();
if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId));
},
/**
* 新增 addEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} edge 调用方传入的 edge 参数
*/
addEdge(edge) {
const vueEdge = toVueEdge(edge);
const source = store.findNode(vueEdge.source);
const target = store.findNode(vueEdge.target);
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
store.addEdges(vueEdge);
changed();
return backendEdge(vueEdge);
},
/**
* 删除 deleteEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
deleteEdge(id) {
store.removeEdges(id);
changed();
},
/**
* 删除 deleteEdgeByNodeId 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
deleteEdgeByNodeId(id) {
store.removeEdges(getEdges().filter((edge) => edge.source === id || edge.target === id));
changed();
},
/**
* 获取 getProperties 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
getProperties(id) {
return clone(store.findNode(id)?.data?.properties || {});
},
/**
* 设置 setProperties 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
* @param {*} properties 调用方传入的 properties 参数
*/
setProperties(id, properties) {
const node = store.findNode(id);
if (!node) return;
store.updateNodeData(id, { ...node.data, properties: clone(properties) }, { replace: true });
nextTick(() => store.updateNodeInternals([id]));
changed();
},
getNodeModelById: modelFor,
/**
* 获取 getSelectElements 对应的数据或交互;作用范围仅限画布适配层。
*/
getSelectElements() {
return {
nodes: (store.getSelectedNodes.value || []).map(backendNode),
edges: (store.getSelectedEdges.value || []).map(backendEdge),
};
},
/**
* 清理 clearSelectElements 对应的数据或交互;作用范围仅限画布适配层。
*/
clearSelectElements() {
store.removeSelectedElements();
},
/**
* 处理 selectElementById 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
selectElementById(id) {
store.removeSelectedElements();
if (!id) return;
const node = store.findNode(id);
if (node) store.addSelectedNodes([node]);
const edge = store.findEdge(id);
if (edge) store.addSelectedEdges([edge]);
},
/**
* 处理 selectElementsByIds 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} ids 调用方传入的 ids 参数
*/
selectElementsByIds(ids = []) {
store.removeSelectedElements();
const nodes = ids.map((id) => store.findNode(id)).filter(Boolean);
if (nodes.length) store.addSelectedNodes(nodes);
},
/**
* 处理 focusOn 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} { id } 调用方传入的 { id } 参数
*/
focusOn({ id }) {
const node = store.findNode(id);
if (!node) return;
const center = backendNode(node);
store.setCenter(center.x, center.y, { zoom: Math.max(store.viewport.value?.zoom || 1, 0.7), duration: 300 });
},
/**
* 处理 fitView 对应的数据或交互;作用范围仅限画布适配层。
*/
fitView() {
return store.fitView({ padding: 0.18, duration: 300 });
},
/**
* 处理 translateCenter 对应的数据或交互;作用范围仅限画布适配层。
*/
translateCenter() {
return facade.fitView();
},
/**
* 缩放 zoom 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} scale 缩放比例
* @param {*} center 缩放中心
*/
zoom(scale, center) {
if (Array.isArray(center) && center.length === 2) {
return store.setCenter(center[0], center[1], { zoom: scale, duration: 250 });
}
return store.zoomTo(scale, { duration: 250 });
},
/**
* 获取 getPointByClient 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} x 客户端横坐标
* @param {*} y 客户端纵坐标
*/
getPointByClient(x, y) {
const point = store.screenToFlowCoordinate({ x, y });
return { canvasOverlayPosition: point, domOverlayPosition: point };
},
/**
* 将节点或循环子树挂载到目标容器。
*
* 实现思路:普通节点保持原有编组行为;loop 只允许进入另一个 loop,并在修改前
* 检查目标是否属于自身后代,防止产生环形父子关系。移动循环时整棵子树按相同位移
* 平移,同时只断开循环根节点跨作用域的连线,保留循环体内部连线。
*
* @param {string} groupId 目标容器节点 ID
* @param {string} childId 待挂载节点 ID
* @param {{x: number, y: number}} dropCenter 拖放中心绝对坐标
* @returns {boolean} 是否成功接受本次挂载
2026-08-25 10:15:37 +08:00
*/
addToGroup(groupId, childId, dropCenter) {
const group = store.findNode(groupId);
const child = store.findNode(childId);
if (!group || !child || groupId === childId) return false;
if (!canAttachToGroup(group.data.flowType, child.data.flowType)) return false;
2026-08-25 10:15:37 +08:00
const graph = facade.getGraphData();
const groupData = graph.nodes.find((node) => node.id === groupId);
const childData = graph.nodes.find((node) => node.id === childId);
if (!groupData || !childData) return false;
const descendantIds = collectGraphDescendantIds(graph, childId);
if (descendantIds.has(groupId)) return false;
const previousParentId = child.parentNode || child.data?.properties?.parentId;
2026-08-25 10:15:37 +08:00
// 节点从循环体外进入循环体后,原连线的两端已处于不同执行作用域,必须全部断开。
// 在重新渲染父子结构前直接过滤图数据,可保证入边、出边和层级变更在同一次更新中完成。
const enteringLoop = group.data.flowType === "loop" && previousParentId !== groupId;
if (enteringLoop) {
graph.edges = graph.edges.filter((edge) => (
edge.sourceNodeId !== childId && edge.targetNodeId !== childId
));
}
const previousGroup = graph.nodes.find((node) => node.id === previousParentId);
if (previousGroup && previousGroup.id !== groupId) {
previousGroup.children = (previousGroup.children || []).filter((id) => id !== childId);
}
2026-08-25 10:15:37 +08:00
groupData.children = Array.from(new Set([...(groupData.children || []), childId]));
childData.properties = { ...(childData.properties || {}), parentId: groupId };
if (dropCenter) {
const dx = Number(dropCenter.x) - Number(childData.x || 0);
const dy = Number(dropCenter.y) - Number(childData.y || 0);
2026-08-25 10:15:37 +08:00
childData.x = dropCenter.x;
childData.y = dropCenter.y;
graph.nodes.forEach((node) => {
if (!descendantIds.has(node.id)) return;
node.x = Number(node.x || 0) + dx;
node.y = Number(node.y || 0) + dy;
});
2026-08-25 10:15:37 +08:00
}
facade.render(graph, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
store.updateNodeInternals([groupId, childId]);
changed();
});
return true;
2026-08-25 10:15:37 +08:00
},
/**
* 编组 groupNodes 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} groupNode 调用方传入的 groupNode 参数
* @param {*} childIds 子节点 ID 集合
*/
groupNodes(groupNode, childIds = []) {
// 先计算选中节点的外接矩形确定容器尺寸,再把子节点坐标转换为容器内相对坐标。
// 与分组外节点相连的边保持不变,此处仅更新节点层级和几何信息。
const graph = facade.getGraphData();
const selectedIds = new Set(childIds);
const children = graph.nodes.filter((node) => (
selectedIds.has(node.id)
&& !GROUP_TYPES.has(node.type)
&& !store.findNode(node.id)?.parentNode
));
if (!children.length) return null;
const group = clone(groupNode);
group.id ||= nodeId();
group.type = "selectArea";
group.properties = { ...(group.properties || {}), action: "NONE" };
group.children = children.map((node) => node.id);
children.forEach((node) => {
node.properties = { ...(node.properties || {}), parentId: group.id };
});
graph.nodes.push(group);
facade.render(graph, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
store.updateNodeInternals([group.id, ...group.children]);
changed();
});
return clone(group);
},
/**
* 处理 ungroup 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} groupId 分组节点 ID
*/
ungroup(groupId) {
const graph = facade.getGraphData();
const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea");
if (!group) return false;
const childIds = new Set([
...(group.children || []),
...graph.nodes.filter((node) => node.properties?.parentId === groupId).map((node) => node.id),
]);
graph.nodes = graph.nodes.filter((node) => node.id !== groupId);
graph.nodes.forEach((node) => {
if (!childIds.has(node.id)) return;
const properties = { ...(node.properties || {}) };
delete properties.parentId;
node.properties = properties;
});
facade.render(graph, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
store.updateNodeInternals([...childIds]);
changed();
});
return true;
},
/**
* 准备 prepareLoopChildDrag 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} childId 子节点 ID
*/
prepareLoopChildDrag(childId) {
const child = store.findNode(childId);
const group = child?.parentNode ? store.findNode(child.parentNode) : null;
if (!group || group.data.flowType !== "loop") return;
store.updateNode(childId, {
extent: loopChildExtent(),
expandParent: false,
});
getLoopLayoutState(group);
const rect = getNodeRect(child);
return { groupId: group.id, position: { x: rect.x, y: rect.y } };
},
/**
* 在拖拽循环体节点时同步调整当前循环及所有外层循环。
*
* @param {string} groupId 当前直接父循环 ID
* @param {string} childId 正在拖动的节点 ID
* @param {{x: number, y: number}} position 节点绝对坐标
2026-08-25 10:15:37 +08:00
*/
layoutLoopDuringDrag(groupId, childId, position) {
const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop" || !position) return;
if (layoutLoop(group, childId, position)) layoutParentLoops(group);
2026-08-25 10:15:37 +08:00
},
/**
* 按循环体内容调整当前循环及其外层循环尺寸。
*
* @param {string} groupId 待调整的循环 ID
* @param {{markChanged?: boolean}} options 是否记录一次流程变更
2026-08-25 10:15:37 +08:00
*/
resizeGroupToChildren(groupId, options = {}) {
// 循环容器沿用统一的内边距和最小尺寸约束,并按配置决定是否记录变更。
// 拖拽过程可关闭变更标记,避免高频移动产生多余历史记录。
const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop") return;
if (layoutLoop(group)) {
layoutParentLoops(group);
if (options.markChanged !== false) changed();
}
2026-08-25 10:15:37 +08:00
},
/**
* 移动 moveNodeBy 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
* @param {*} dx 横向位移
* @param {*} dy 纵向位移
*/
moveNodeBy(id, dx, dy) {
const node = store.findNode(id);
if (!node) return;
store.updateNode(id, {
position: {
x: node.position.x + dx,
y: node.position.y + dy,
},
});
changed();
},
/**
* 撤销 undo 对应的数据或交互;作用范围仅限画布适配层。
*/
undo() {
if (historyIndex <= 0) return false;
historyIndex -= 1;
const snapshot = clone(history[historyIndex].graph);
facade.render(snapshot, { resetHistory: false, replaceNodes: true });
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true;
},
/**
* 重做 redo 对应的数据或交互;作用范围仅限画布适配层。
*/
redo() {
if (historyIndex >= history.length - 1) return false;
historyIndex += 1;
const snapshot = clone(history[historyIndex].graph);
facade.render(snapshot, { resetHistory: false, replaceNodes: true });
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true;
},
/**
* 注册 registerComponentInstance 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
* @param {*} instance 组件实例
*/
registerComponentInstance(id, instance) {
if (instance) componentInstances.set(id, instance);
},
extension: {
selectionSelect: {
openSelectionSelect: () => emit("selection:open"),
closeSelectionSelect: () => emit("selection:close"),
},
},
};
return facade;
};