/** * 文件说明:旧流程数据与 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} 不包含根节点自身的全部后代 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} 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; }; /** * 处理 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} 父节点排在子节点之前的节点列表 */ 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 }])); 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); } const isLoop = parent.data.flowType === "loop"; const childPosition = absolutePositions.get(child.id) || child.position; const parentPosition = absolutePositions.get(parentId) || parent.position; const relativePosition = { x: isLoop ? Math.max(childPosition.x - parentPosition.x, LOOP_BODY_INSET.left) : childPosition.x - parentPosition.x, y: isLoop ? Math.max(childPosition.y - parentPosition.y, LOOP_BODY_INSET.top) : childPosition.y - parentPosition.y, }; 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; 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 }; }); return nodes.sort((left, right) => ( nodeHierarchyDepth(left, nodeLookup) - nodeHierarchyDepth(right, nodeLookup) )); }; 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} 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 节点序列化为后端使用的绝对坐标节点。 * * 实现思路:先沿完整父链取得绝对位置,再恢复旧流程协议的中心点坐标;这样普通节点、 * 内层循环及更深层后代在保存和重新加载后都不会发生位置漂移。 * * @param {*} node 当前流程节点 * @returns {*} 后端格式节点 */ 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); 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() }); }; /** * 获取节点在画布绝对坐标系中的矩形。 * * @param {*} node 当前流程节点 * @returns {{x: number, y: number, width: number, height: number}} 绝对矩形 */ const getNodeRect = (node) => { const position = getAbsoluteNodePosition(node); 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} 是否完成布局 */ 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, }; store.updateNode(group.id, { position: relativePosition, 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; } }; /** * 获取 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 */ 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; 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); }); 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} 是否成功接受本次挂载 */ 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; 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; // 节点从循环体外进入循环体后,原连线的两端已处于不同执行作用域,必须全部断开。 // 在重新渲染父子结构前直接过滤图数据,可保证入边、出边和层级变更在同一次更新中完成。 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); } 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); 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; }); } facade.render(graph, { resetHistory: false, replaceNodes: true, preserveNodeSizes: true, preserveNodePositions: true, }); nextTick(() => { store.updateNodeInternals([groupId, childId]); changed(); }); return true; }, /** * 编组 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 节点绝对坐标 */ layoutLoopDuringDrag(groupId, childId, position) { const group = store.findNode(groupId); if (!group || group.data.flowType !== "loop" || !position) return; if (layoutLoop(group, childId, position)) layoutParentLoops(group); }, /** * 按循环体内容调整当前循环及其外层循环尺寸。 * * @param {string} groupId 待调整的循环 ID * @param {{markChanged?: boolean}} options 是否记录一次流程变更 */ 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(); } }, /** * 移动 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; };