From 2d5266c497b08d0947efbd3f3e378c52a683d9a0 Mon Sep 17 00:00:00 2001 From: zhanghao <774378400@qq.com> Date: Sun, 20 Sep 2026 13:37:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E4=B8=8E=E4=BF=9D=E5=AD=98=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=BE=AA=E7=8E=AF=E4=BD=93=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=9A=84=E6=8B=96=E6=94=BE=E4=B8=8E=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/params/BasicNodeParams.vue | 12 +- .../params/RobotExecutionTarget.vue | 15 + src/views/flow/config.js | 2 - src/views/flow/index.vue | 89 ++++- src/views/flow/nodes/function/loop.vue | 31 +- src/views/flow/vue-flow/adapter.js | 318 ++++++++++++++---- 6 files changed, 375 insertions(+), 92 deletions(-) diff --git a/src/views/flow/components/params/BasicNodeParams.vue b/src/views/flow/components/params/BasicNodeParams.vue index 46d82ec..ddedefa 100644 --- a/src/views/flow/components/params/BasicNodeParams.vue +++ b/src/views/flow/components/params/BasicNodeParams.vue @@ -217,7 +217,11 @@ watch( // 保存验证 /** - * 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。 + * 校验并保存通用节点的输入、输出和执行目标。 + * + * 实现思路:先运行 Element Plus 的输入输出规则,再校验独立执行目标组件中的角色和设备用途; + * 执行目标不属于当前两个 el-form,因此失败时需要主动给出提示,避免用户只看到节点标红却 + * 无法判断缺少哪个字段。无论成功与否都保留当前编辑值,便于用户继续修正。 */ const validateAndSave = async () => { let inputValid = true @@ -228,8 +232,10 @@ const validateAndSave = async () => { if (outputFormRef.value) { await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false }) } - if (isRobotNode.value && !formData.executionTarget.roleKey) inputValid = false - if (isRobotNode.value + if (isRobotNode.value && !formData.executionTarget.roleKey) { + inputValid = false + ElMessage.warning('请选择执行机器人角色') + } else if (isRobotNode.value && actionRequiresDevice(props.data.properties.action) && !formData.executionTarget.deviceSlotKey) { inputValid = false diff --git a/src/views/flow/components/params/RobotExecutionTarget.vue b/src/views/flow/components/params/RobotExecutionTarget.vue index b87c223..c17ae9c 100644 --- a/src/views/flow/components/params/RobotExecutionTarget.vue +++ b/src/views/flow/components/params/RobotExecutionTarget.vue @@ -79,6 +79,15 @@ const handleRoleChange = () => { emitTarget() } +/** + * 将节点保存的执行目标同步到表单,并补齐界面自动选中的默认角色。 + * + * 实现思路:下拉框在缺少有效 roleKey 时会展示第一个角色,因此必须把同一个默认值 + * 通过 v-model 回写给父表单,避免出现“界面已选择、实际待保存数据仍为空”的状态。 + * 仅在规范化后的关键字段与原值不同时触发更新,防止深度 watch 反复回写。 + * + * 注意:没有可用角色时保持空值,交由保存校验给出明确提示。 + */ const syncFromModel = () => { roles.value = getResourceRoles() const value = props.modelValue || {} @@ -88,6 +97,12 @@ const syncFromModel = () => { localTarget.dispatchPolicy = value.dispatchPolicy || 'ONE' deviceSlotKey.value = value.deviceSlotKey || value.deviceBindings?.[0]?.slotKey || '' selectDefaultSlot() + + const normalizedDeviceSlotKey = hasDeviceParameter.value ? deviceSlotKey.value : '' + const targetChanged = String(value.roleKey || '') !== String(localTarget.roleKey || '') + || String(value.dispatchPolicy || 'ONE') !== 'ONE' + || String(value.deviceSlotKey || '') !== String(normalizedDeviceSlotKey || '') + if (targetChanged) emitTarget() } watch( diff --git a/src/views/flow/config.js b/src/views/flow/config.js index 5cb4de2..062bc1c 100644 --- a/src/views/flow/config.js +++ b/src/views/flow/config.js @@ -624,7 +624,6 @@ function handler(params) { name: "视频智能分析", type: "mediaAnalysis", desc: "按分析场景理解测试录像并输出结构化结论", - show: __PROJECT_NAME__ === 'aima', action: "VIDEO_ANALYZE", nodeType: "LLM", nodeParams: [ @@ -658,7 +657,6 @@ function handler(params) { name: "通用图片分析", type: "imageAnalysis", desc: "按照提示词分析一张或多张图片并返回指定内容", - show: __PROJECT_NAME__ === 'aima', action: 'IMAGE_ANALYZE', nodeType: "LLM", nodeParams: [ diff --git a/src/views/flow/index.vue b/src/views/flow/index.vue index 84b7018..f87c03b 100644 --- a/src/views/flow/index.vue +++ b/src/views/flow/index.vue @@ -543,8 +543,12 @@ const dragover = (e) => { }; /** - * 处理 drop 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} e 触发操作的浏览器事件 + * 将组件库节点创建到画布,并在落点位于循环体时建立父子关系。 + * + * 实现思路:先按绝对坐标创建节点,再交给适配层完成相对坐标转换;loop 可以进入 loop, + * 但开始、结束和普通分组仍保持顶层语义。 + * + * @param {DragEvent} e 组件库拖放事件 */ const drop = (e) => { let node = e.dataTransfer.getData("flowNode"); @@ -572,7 +576,7 @@ const drop = (e) => { if (normalizedResources.node) { lf.setProperties(normalizedResources.node.id, normalizedResources.node.properties); } - if (loopNode && !["start", "end", "loop", "selectArea"].includes(newNode.type)) { + if (loopNode && !["start", "end", "selectArea"].includes(newNode.type)) { lf.addToGroup(loopNode.id, newNode.id, point.canvasOverlayPosition); } @@ -612,12 +616,39 @@ const handleNodeDoubleClick = ({ node }) => { }; /** - * 查找 findLoopAtPoint 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} point 画布坐标 + * 查找指定位置最内层且可作为目标的循环。 + * + * 实现思路:嵌套循环会同时命中多个容器,优先选择层级更深、面积更小的循环,确保节点 + * 落入用户视觉上指向的循环体;excludedNodeIds 用于排除被拖动循环及其后代,防止成环。 + * + * @param {{x: number, y: number}} point 画布绝对坐标 + * @param {Set} excludedNodeIds 不允许作为目标的循环 ID + * @returns {*} 命中的 Vue Flow 循环节点 */ -const findLoopAtPoint = (point) => { - return (vueFlow.getNodes.value || []).find((candidate) => { - if (candidate.data?.flowType !== "loop") return false; +const findLoopAtPoint = (point, excludedNodeIds = new Set()) => { + const nodes = vueFlow.getNodes.value || []; + const nodeLookup = new Map(nodes.map((node) => [node.id, node])); + /** + * 计算候选循环的嵌套深度,供重叠命中时优先选择最内层容器。 + * + * @param {*} node 候选 Vue Flow 节点 + * @returns {number} 根节点为 0 的父子深度 + */ + const depthOf = (node) => { + let depth = 0; + let parentId = node.parentNode; + const visited = new Set([node.id]); + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + const parent = nodeLookup.get(parentId); + if (!parent) break; + depth += 1; + parentId = parent.parentNode; + } + return depth; + }; + return nodes.filter((candidate) => { + if (candidate.data?.flowType !== "loop" || excludedNodeIds.has(candidate.id)) return false; const groupPosition = candidate.computedPosition || candidate.position; const groupWidth = Number(candidate.dimensions?.width || candidate.data?.size?.width || 0); const groupHeight = Number(candidate.dimensions?.height || candidate.data?.size?.height || 0); @@ -625,18 +656,42 @@ const findLoopAtPoint = (point) => { && point.x <= groupPosition.x + groupWidth - 24 && point.y >= groupPosition.y + 112 && point.y <= groupPosition.y + groupHeight - 24; - }); + }).sort((left, right) => { + const depthDifference = depthOf(right) - depthOf(left); + if (depthDifference) return depthDifference; + const leftArea = Number(left.dimensions?.width || left.data?.size?.width || 0) + * Number(left.dimensions?.height || left.data?.size?.height || 0); + const rightArea = Number(right.dimensions?.width || right.data?.size?.width || 0) + * Number(right.dimensions?.height || right.data?.size?.height || 0); + return leftArea - rightArea; + })[0]; }; /** - * 查找 findContainingLoop 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} node 当前流程节点 + * 查找节点中心所在的可用循环,同时排除自身和全部后代循环。 + * + * @param {*} node 当前 Vue Flow 节点 + * @returns {*} 可接收该节点的最内层循环 */ const findContainingLoop = (node) => { + const nodes = vueFlow.getNodes.value || []; + const excludedNodeIds = new Set([node.id]); + const pending = [node.id]; + while (pending.length) { + const parentId = pending.pop(); + nodes.forEach((candidate) => { + if (candidate.parentNode !== parentId || excludedNodeIds.has(candidate.id)) return; + excludedNodeIds.add(candidate.id); + pending.push(candidate.id); + }); + } const position = node.computedPosition || node.position; const width = Number(node.dimensions?.width || node.data?.size?.width || 0); const height = Number(node.dimensions?.height || node.data?.size?.height || 0); - return findLoopAtPoint({ x: position.x + width / 2, y: position.y + height / 2 }); + return findLoopAtPoint( + { x: position.x + width / 2, y: position.y + height / 2 }, + excludedNodeIds, + ); }; /** @@ -750,8 +805,12 @@ const handleNodeDrag = (payload) => { }; /** - * 处理 handleNodeDragStop 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} payload 事件数据 + * 完成画布节点拖动,并在未挂载节点进入循环体时建立嵌套关系。 + * + * 实现思路:队列转换优先处理;其余顶层节点根据中心点寻找最内层循环。循环节点也参与 + * 命中,但目标筛选会排除自身和后代,避免产生循环父子关系。 + * + * @param {*} payload Vue Flow 节点拖动结束事件 */ const handleNodeDragStop = (payload) => { const { node } = payload; @@ -769,7 +828,7 @@ const handleNodeDragStop = (payload) => { if ( vueNode && !vueNode.parentNode - && !["start", "end", "loop", "selectArea"].includes(vueNode.data?.flowType) + && !["start", "end", "selectArea"].includes(vueNode.data?.flowType) ) { const loopNode = findContainingLoop(vueNode); if (loopNode) lf.addToGroup(loopNode.id, vueNode.id); diff --git a/src/views/flow/nodes/function/loop.vue b/src/views/flow/nodes/function/loop.vue index 702416b..e3a863b 100644 --- a/src/views/flow/nodes/function/loop.vue +++ b/src/views/flow/nodes/function/loop.vue @@ -53,6 +53,7 @@ import NodeState from "../../components/NodeState.vue"; import LoopIterationResults from "../../components/LoopIterationResults.vue"; import loop from "../../icon/loop.svg"; import { emitter } from "@/utils/eventBus"; +import { ensureNodeExecutionResources } from "@/utils/flowResources"; import { computed, onMounted, ref } from "vue"; import { useFlowEvents } from "../../composables/useFlowEvents"; import "vue3-json-viewer/dist/index.css"; @@ -126,8 +127,12 @@ const handleDragleave = (e) => { }; /** - * 处理 handleDrop 对应的数据或交互;作用范围仅限当前组件或模块。 - * @param {*} e 触发操作的浏览器事件 + * 将组件库节点直接创建到当前循环体。 + * + * 实现思路:允许 loop 作为子循环,其余容器限制保持不变;节点创建后先按顶层拖放相同的规则 + * 初始化执行角色与设备用途,再由 Vue Flow 适配层统一处理父子关系、坐标换算和环检测。 + * + * @param {DragEvent} e 组件库拖放事件 */ const handleDrop = (e) => { e.preventDefault(); @@ -137,7 +142,7 @@ const handleDrop = (e) => { let flowNode = e.dataTransfer.getData("flowNode"); if (!flowNode) return; flowNode = JSON.parse(flowNode); - if (["start", "end", "loop", "selectArea"].includes(flowNode.type)) return; + if (["start", "end", "selectArea"].includes(flowNode.type)) return; const point = lf.getPointByClient(e.clientX, e.clientY); const { type, ...other } = flowNode; const node = lf.addNode({ @@ -149,13 +154,25 @@ const handleDrop = (e) => { ...other, }, }); - if ( - !["selectArea", "branch", "stopLoop", "subStart", "subEnd"].includes(node.type) - ) { - emitter.emit("openParamsDrawer", node); + + // 循环体直接接管了 drop 事件,不会经过页面级 drop。 + // 因此必须在这里同步初始化资源,否则设备节点缺少 executionTarget,保存时会被校验拦截。 + const normalizedResources = ensureNodeExecutionResources(lf.getGraphData(), node.id); + if (normalizedResources.start) { + lf.setProperties(normalizedResources.start.id, normalizedResources.start.properties); + } + if (normalizedResources.node) { + lf.setProperties(normalizedResources.node.id, normalizedResources.node.properties); } lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition); + + if ( + !["selectArea", "branch", "stopLoop", "subStart", "subEnd"].includes(node.type) + ) { + const configuredNode = lf.getGraphData().nodes.find((item) => item.id === node.id) || node; + emitter.emit("openParamsDrawer", configuredNode); + } }; /** diff --git a/src/views/flow/vue-flow/adapter.js b/src/views/flow/vue-flow/adapter.js index c3c66e1..ae2b173 100644 --- a/src/views/flow/vue-flow/adapter.js +++ b/src/views/flow/vue-flow/adapter.js @@ -73,6 +73,78 @@ const edgeId = () => `edge_${uuid().replace(/-/g, "")}`; */ 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 当前流程节点 @@ -160,14 +232,19 @@ const toVueEdge = (edge) => { }; /** - * 关联 attachChildrenToGroups 对应的数据或交互;作用范围仅限画布适配层。 - * @param {*} nodes 调用方传入的 nodes 参数 + * 将后端绝对坐标节点转换为 Vue Flow 的多层父子节点结构。 + * + * 实现思路:先冻结所有节点的绝对坐标,再建立父子关系并换算相对坐标,避免父循环 + * 已转成相对坐标后影响更深层节点。循环尺寸按“最深层优先”向外扩展,确保外层循环 + * 能包含已经完成尺寸计算的内层循环。 + * + * @param {Array} nodes Vue Flow 节点列表,初始 position 均为画布绝对坐标 + * @returns {Array} 父节点排在子节点之前的节点列表 */ const attachChildrenToGroups = (nodes) => { - // 先建立节点索引和显式父子关系,再用旧数据中的 children 补齐缺失关系。 - // 最后统一换算相对坐标,避免父节点尚未就绪时提前计算造成位置漂移。 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) => { @@ -178,44 +255,63 @@ const attachChildrenToGroups = (nodes) => { nodes.forEach((child) => { const parentId = child.data.properties?.parentId || declaredParents.get(child.id); const parent = nodeLookup.get(parentId); - if (!parent || !GROUP_TYPES.has(parent.data.flowType) || GROUP_TYPES.has(child.data.flowType)) return; + 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(child.position.x - parent.position.x, LOOP_BODY_INSET.left) - : child.position.x - parent.position.x, + ? Math.max(childPosition.x - parentPosition.x, LOOP_BODY_INSET.left) + : childPosition.x - parentPosition.x, y: isLoop - ? Math.max(child.position.y - parent.position.y, LOOP_BODY_INSET.top) - : child.position.y - parent.position.y, + ? 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; - child.zIndex = 5; + // 边统一位于 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").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 }; - }); + 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) => Number(Boolean(left.parentNode)) - Number(Boolean(right.parentNode))); + return nodes.sort((left, right) => ( + nodeHierarchyDepth(left, nodeLookup) - nodeHierarchyDepth(right, nodeLookup) + )); }; export const toVueFlowData = (graph = {}, options = {}) => { @@ -251,8 +347,39 @@ export const createFlowFacade = (store) => { const getEdges = () => store.getEdges.value || []; /** - * 处理 backendNode 对应的数据或交互;作用范围仅限画布适配层。 + * 获取节点在整个画布中的绝对左上角坐标。 + * + * 实现思路: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); @@ -261,10 +388,7 @@ export const createFlowFacade = (store) => { height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0), }; const parent = node.parentNode ? store.findNode(node.parentNode) : null; - const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 }; - const absolutePosition = parent - ? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y } - : (node.position || node.computedPosition); + const absolutePosition = getAbsoluteNodePosition(node); result.id = node.id; result.type = node.data.flowType; if (GROUP_TYPES.has(result.type)) { @@ -340,15 +464,13 @@ export const createFlowFacade = (store) => { }; /** - * 获取 getNodeRect 对应的数据或交互;作用范围仅限画布适配层。 + * 获取节点在画布绝对坐标系中的矩形。 + * * @param {*} node 当前流程节点 + * @returns {{x: number, y: number, width: number, height: number}} 绝对矩形 */ const getNodeRect = (node) => { - const parent = node.parentNode ? store.findNode(node.parentNode) : null; - const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 }; - const position = parent - ? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y } - : (node.position || node.computedPosition); + const position = getAbsoluteNodePosition(node); return { x: Number(position?.x || 0), y: Number(position?.y || 0), @@ -393,10 +515,15 @@ export const createFlowFacade = (store) => { }; /** - * 布局 layoutLoop 对应的数据或交互;作用范围仅限画布适配层。 - * @param {*} group 调用方传入的 group 参数 - * @param {*} activeChildId 调用方传入的 activeChildId 参数 - * @param {*} activePosition 调用方传入的 activePosition 参数 + * 根据直接子节点的绝对矩形调整循环容器。 + * + * 实现思路:布局计算统一使用画布绝对坐标;写回嵌套循环时,再减去父循环的绝对坐标 + * 得到 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); @@ -427,9 +554,15 @@ export const createFlowFacade = (store) => { 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: { x: left, y: top }, + position: relativePosition, computedPosition: { ...group.computedPosition, x: left, y: top }, dimensions: { width, height }, width, @@ -453,6 +586,26 @@ export const createFlowFacade = (store) => { 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 当前流程节点 @@ -626,8 +779,12 @@ export const createFlowFacade = (store) => { return backendNode(vueNode); }, /** - * 删除 deleteNode 对应的数据或交互;作用范围仅限画布适配层。 - * @param {*} id 目标元素 ID + * 删除节点,并在删除循环时递归清理其完整循环体。 + * + * 实现思路:嵌套循环可能包含多层后代,不能只删除 children 中的第一层;先从当前 + * 图数据统一收集后代,再一次性删除节点与关联边,避免留下带失效 parentId 的孤儿节点。 + * + * @param {string} id 目标元素 ID */ deleteNode(id) { const target = store.findNode(id); @@ -636,8 +793,11 @@ export const createFlowFacade = (store) => { return; } const parentId = target?.parentNode; - const childIds = target?.data?.children || []; - const removeChildren = target?.data?.flowType === "loop" && childIds.length > 0; + 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); @@ -649,8 +809,10 @@ export const createFlowFacade = (store) => { }, { replace: true }); } }); - componentInstances.delete(id); - childIds.forEach((childId) => componentInstances.delete(childId)); + removedIds.forEach((nodeIdToDelete) => { + componentInstances.delete(nodeIdToDelete); + loopLayoutStates.delete(nodeIdToDelete); + }); changed(); if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId)); }, @@ -782,20 +944,29 @@ export const createFlowFacade = (store) => { return { canvasOverlayPosition: point, domOverlayPosition: point }; }, /** - * 新增 addToGroup 对应的数据或交互;作用范围仅限画布适配层。 - * @param {*} groupId 分组节点 ID - * @param {*} childId 子节点 ID - * @param {*} dropCenter 拖放中心坐标 + * 将节点或循环子树挂载到目标容器。 + * + * 实现思路:普通节点保持原有编组行为;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 || GROUP_TYPES.has(child.data.flowType)) return; - const previousParentId = child.parentNode || child.data?.properties?.parentId; + 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; + if (!groupData || !childData) return false; + const descendantIds = collectGraphDescendantIds(graph, childId); + if (descendantIds.has(groupId)) return false; + const previousParentId = child.parentNode || child.data?.properties?.parentId; // 节点从循环体外进入循环体后,原连线的两端已处于不同执行作用域,必须全部断开。 // 在重新渲染父子结构前直接过滤图数据,可保证入边、出边和层级变更在同一次更新中完成。 @@ -806,11 +977,22 @@ export const createFlowFacade = (store) => { )); } + 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, @@ -822,6 +1004,7 @@ export const createFlowFacade = (store) => { store.updateNodeInternals([groupId, childId]); changed(); }); + return true; }, /** * 编组 groupNodes 对应的数据或交互;作用范围仅限画布适配层。 @@ -909,27 +1092,32 @@ export const createFlowFacade = (store) => { return { groupId: group.id, position: { x: rect.x, y: rect.y } }; }, /** - * 布局 layoutLoopDuringDrag 对应的数据或交互;作用范围仅限画布适配层。 - * @param {*} groupId 分组节点 ID - * @param {*} childId 子节点 ID - * @param {*} position 调用方传入的 position 参数 + * 在拖拽循环体节点时同步调整当前循环及所有外层循环。 + * + * @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; - layoutLoop(group, childId, position); + if (layoutLoop(group, childId, position)) layoutParentLoops(group); }, /** - * 调整 resizeGroupToChildren 对应的数据或交互;作用范围仅限画布适配层。 - * @param {*} groupId 分组节点 ID - * @param {*} options 行为配置 + * 按循环体内容调整当前循环及其外层循环尺寸。 + * + * @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) && options.markChanged !== false) changed(); + if (layoutLoop(group)) { + layoutParentLoops(group); + if (options.markChanged !== false) changed(); + } }, /** * 移动 moveNodeBy 对应的数据或交互;作用范围仅限画布适配层。