feat: 增强节点验证与保存逻辑,优化循环体节点的拖放与布局处理

This commit is contained in:
zhanghao 2026-09-20 13:37:23 +08:00
parent c81c10634b
commit 2d5266c497
6 changed files with 375 additions and 92 deletions

View File

@ -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

View File

@ -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(

View File

@ -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: [

View File

@ -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<string>} 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);

View File

@ -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);
}
};
/**

View File

@ -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<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;
};
/**
* 处理 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,27 +255,44 @@ 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) => {
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) => (
@ -215,7 +309,9 @@ const attachChildrenToGroups = (nodes) => {
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<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 节点序列化为后端使用的绝对坐标节点。
*
* 实现思路:先沿完整父链取得绝对位置,再恢复旧流程协议的中心点坐标;这样普通节点、
* 内层循环及更深层后代在保存和重新加载后都不会发生位置漂移。
*
* @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 对应的数据或交互;作用范围仅限画布适配层。