CMVR-BID/src/views/flow/index.vue
lixiaolong e3a2761683 fix(flow): 解决非机器人节点携带机器人执行目标导致的后端误判问题
- 非机器人节点不再携带 executionTarget 属性,避免后端将空对象误判为无效角色引用
- 添加 FLOW_NODE_RESOURCE_OWNER 和 USER_RESOURCE_OWNER 常量用于资源归属管理
- 为流程节点创建的设备插槽添加 managedBy 字段标识资源归属
- 移除未被任何节点引用的自动注册角色定义和设备使用
- 为用户手动创建的角色和设备插槽标记为 USER_RESOURCE_OWNER 归属
- 在节点更新时自动清理未使用的节点资源,优化资源管理
2026-08-11 17:16:27 +08:00

2038 lines
62 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
* 文件说明:流程设计器主页面,负责编辑、校验、试运行、执行跟踪和快捷操作。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div class="page">
<el-container class="page__container">
<el-header v-if="!flowInfoData.isLog">
<div class="flow-heading">
<div class="flow-heading__name">{{ flowInfoData.name || "未命名流程" }}</div>
<div class="flow-status" :class="`flow-status--${flowState}`">
<span class="flow-status__dot"></span>
<span>{{ stateMap[flowState] }}</span>
</div>
</div>
<div class="btn__container">
<div class="btn flow-actions">
<el-tag v-if="hasRunResult && ['testRunError', 'testRunFinish'].includes(flowState)" :type="flowState === 'testRunFinish' ? 'success' : 'danger'">
{{ totalRunningTime }}ms
</el-tag>
<el-button v-if="hasRunResult" @click="clearRun">
<el-icon><Delete /></el-icon>清除结果
</el-button>
<el-button :type="groupSelectionPending ? 'primary' : 'default'" @click="group">
<el-icon><CollectionTag /></el-icon>{{ groupSelectionPending ? "取消分组" : "分组" }}
</el-button>
<el-button v-if="flowState !== 'testRunning'" @click="testRun">
<el-icon><VideoPlay /></el-icon>试运行
</el-button>
<el-button v-if="flowState === 'testRunning'" @click="flowPauseFn">
<el-icon><VideoPause /></el-icon>暂停
</el-button>
<el-button v-if="flowState === 'pause'" @click="flowResumeFn">
<el-icon><VideoPlay /></el-icon>恢复
</el-button>
<el-button v-if="['testRunning', 'pause'].includes(flowState)" @click="flowStopFn">
<el-icon><CircleClose /></el-icon>终止
</el-button>
</div>
<div class="btn publish-actions">
<el-tooltip
class="box-item"
effect="dark"
content="试运行成功后才可以发布"
placement="top"
v-if="flowState !== 'testRunFinish'"
>
<el-button type="primary" :disabled="true"><el-icon><Upload /></el-icon>发布</el-button>
</el-tooltip>
<el-button v-else @click="deployFlow" type="primary"><el-icon><Upload /></el-icon>发布</el-button>
<el-popover
:width="260"
@show="showNodeRelationship"
popper-class="node-relationship-network-popover"
>
<template #reference>
<el-button circle title="节点关系" aria-label="节点关系"><el-icon><Share /></el-icon></el-button>
</template>
<div>
<div class="title">节点关系网</div>
<div class="fit">
<el-tooltip
class="box-item"
effect="dark"
content="适应屏幕"
placement="bottom"
>
<el-button size="small" circle @click="fitView"
><el-icon><Location /></el-icon
></el-button>
</el-tooltip>
</div>
<el-tree
style="max-width: 600px"
:data="treeData"
:props="defaultProps"
:default-expand-all="true"
@node-click="handleTreeNodeClick"
/>
</div>
</el-popover>
</div>
</div>
</el-header>
<el-container class="container">
<el-aside v-if="!flowStore.disableForm">
<Aside />
</el-aside>
<el-main>
<div class="canvas-toolbar">
<el-tooltip v-if="!flowStore.disableForm" content="撤销" placement="bottom">
<el-button aria-label="撤销" @click="lf.undo()"><el-icon><RefreshLeft /></el-icon></el-button>
</el-tooltip>
<el-tooltip v-if="!flowStore.disableForm" content="重做" placement="bottom">
<el-button aria-label="重做" @click="lf.redo()"><el-icon><RefreshRight /></el-icon></el-button>
</el-tooltip>
<span v-if="!flowStore.disableForm" class="canvas-toolbar__divider"></span>
<el-tooltip content="适应视图" placement="bottom">
<el-button aria-label="适应视图" @click="fitView"><el-icon><FullScreen /></el-icon></el-button>
</el-tooltip>
</div>
<VueFlow
id="main-flow"
class="flow__container"
ref="flowContainerRef"
:min-zoom="0.1"
:max-zoom="3"
:node-drag-threshold="0"
:nodes-draggable="!flowStore.disableForm"
:nodes-connectable="!flowStore.disableForm"
:edges-updatable="!flowStore.disableForm"
:elements-selectable="true"
:elevate-nodes-on-select="false"
:delete-key-code="null"
:multi-selection-key-code="['Control', 'Meta']"
:selection-key-code="flowStore.disableForm ? null : true"
:pan-on-drag="flowStore.disableForm ? true : [1, 2]"
:zoom-on-double-click="false"
:default-edge-options="defaultEdgeOptions"
@connect="handleConnect"
@node-double-click="handleNodeDoubleClick"
@node-drag-start="handleNodeDragStart"
@node-drag="handleNodeDrag"
@node-drag-stop="handleNodeDragStop"
@nodes-change="handleNodesChange"
@edges-change="handleEdgesChange"
@edge-click="handleEdgeClick"
@pane-click="handlePaneClick"
@selection-end="handleSelectionEnd"
@dragover="dragover"
@drop="drop"
>
<template #node-flow-node="nodeProps">
<FlowNode v-bind="nodeProps" />
</template>
<template #edge-flow-edge="edgeProps">
<FlowEdge v-bind="edgeProps" />
</template>
<Background color="#d9dee8" :gap="24" :size="1" />
<Controls position="bottom-right" />
</VueFlow>
</el-main>
</el-container>
</el-container>
<TestRun
:drawer="isOpen"
@close="isOpen = false"
@changeState="changeState"
:flowId="flowInfoData.itemId"
/>
<ParamsDrawer
v-if="showParamsDrawer"
:drawer="true"
:data="paramsDrawerData"
@close="showParamsDrawer = false"
/>
</div>
<el-dialog v-model="visible" width="500" class="single-node-dialog" append-to-body>
<template #header="{ close, titleId, titleClass }">
<div class="my-header">
<h4 :id="titleId" :class="titleClass">执行{{ nodeName }}节点</h4>
</div>
</template>
<div v-if="nodeAction === 'AGV_MOVE_TO_STATION'" class="single-node-station-tools">
<el-button
type="primary"
plain
:loading="stationLoading"
:disabled="!singleNodeRobotId || !singleNodeDeviceId"
@click="loadSingleNodeStations"
>加载站点列表</el-button>
<span>{{ singleNodeRobotId && singleNodeDeviceId ? '加载后从站点下拉框选择' : '请先选择机器人和设备' }}</span>
</div>
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="executeForm"
label-position="top"
label-width="auto"
:disabled="flowStore.disableForm"
>
<div v-for="(field, index) in singleNodeFields" :key="field.key">
<el-row class="single-node-param-row">
<el-form-item
class="single-node-param-name"
:label="index === 0 ? '参数名' : ''"
>
<el-input
:disabled="field.param?.disabled || false"
v-model="field.param.name"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
class="single-node-param-value"
:label="index === 0 ? '参数值' : ''"
>
<el-input-number
v-if="field.param.componentType === 'number'"
v-model="field.param.input"
:min="0"
:max="field.param.max || Infinity"
:step="field.param.step || 1"
:step-strictly="true"
placeholder="请输入"
clearable
/>
<el-select
v-model="field.param.input"
v-else-if="field.param.componentType === 'select'"
:disabled="field.param.deviceLocked"
@change="field.param.name === 'robotId' && handleSingleNodeRobotChange(field.param.input)"
>
<el-option
v-for="item in field.param.selectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-else
v-model="field.param.input"
autosize
type="textarea"
resize="none"
placeholder="请输入"
clearable
/>
<div v-if="field.param.name === 'deviceId' && field.param.deviceLocked" class="device-lock-hint">
已自动匹配当前机器人设备
</div>
</el-form-item>
</el-row>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="singleNodeExecuting" @click="execute"> 确定 </el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch, onMounted, onUnmounted, nextTick } from "vue";
import { MarkerType, VueFlow, useVueFlow } from "@vue-flow/core";
import { v4 as uuid } from "uuid";
import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import FlowNode from "./vue-flow/FlowNode.vue";
import FlowEdge from "./vue-flow/FlowEdge.vue";
import { createFlowFacade } from "./vue-flow/adapter";
import { useFlowEvents } from "./composables/useFlowEvents";
import TestRun from "./components/TestRun.vue";
import ParamsDrawer from "./components/ParamsDrawer.vue";
import Aside from "./components/Aside.vue";
import { ElMessage } from "element-plus";
import { useFlowStore } from "@/store/modules/flow";
import { emitter } from "@/utils/eventBus";
import { convertToTree } from "@/utils/flow";
import {
actionRequiresDevice,
createDefaultRobotRole,
ensureNodeExecutionResources,
getResourceRoles,
mergeCopiedResourceRoles,
normalizeFlowResourceSchema,
pruneUnusedNodeResources,
validateFlowResources,
} from "@/utils/flowResources";
import {
flowDeploy,
flowView,
flowPause,
flowResume,
flowStop,
flowAction,
getAgvStations,
} from "@/api/flow/flow";
import { getDetect } from "@/api/test/detect";
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import {
CircleClose,
CollectionTag,
Delete,
FullScreen,
Location,
RefreshLeft,
RefreshRight,
Share,
Upload,
VideoPause,
VideoPlay,
} from "@element-plus/icons-vue";
const flowContainerRef = ref(null);
const lfRef = ref(null);
const vueFlow = useVueFlow("main-flow");
const lf = createFlowFacade(vueFlow);
const loopChildDragContexts = new Map();
const { onFlowEvent } = useFlowEvents();
window.lf = lf;
const defaultEdgeOptions = {
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 },
};
/**
* 处理 dragover 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const dragover = (e) => {
e.preventDefault();
};
/**
* 处理 drop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const drop = (e) => {
let node = e.dataTransfer.getData("flowNode");
if (!node) return;
node = JSON.parse(node);
const point = lf.getPointByClient(e.clientX, e.clientY);
const loopNode = findLoopAtPoint(point.canvasOverlayPosition);
if (["stopLoop", "subEnd", "subStart", "currentLoop"].includes(node.type) && !loopNode) {
ElMessage.warning("此节点只能拖拽到循环节点的循环体中");
return;
}
const { type, ...other } = node;
const newNode = lf.addNode({
type,
x: point.canvasOverlayPosition.x,
y: point.canvasOverlayPosition.y,
properties: {
...other,
},
});
const normalizedResources = ensureNodeExecutionResources(lf.getGraphData(), newNode.id);
if (normalizedResources.start) {
lf.setProperties(normalizedResources.start.id, normalizedResources.start.properties);
}
if (normalizedResources.node) {
lf.setProperties(normalizedResources.node.id, normalizedResources.node.properties);
}
if (loopNode && !["start", "end", "loop", "selectArea"].includes(newNode.type)) {
lf.addToGroup(loopNode.id, newNode.id, point.canvasOverlayPosition);
}
if (
!["selectArea", "stopLoop", "subStart", "subEnd"].includes(newNode.type)
) {
showParamsDrawer.value = true;
paramsDrawerData.value = lf.getGraphData().nodes.find((item) => item.id === newNode.id) || newNode;
}
};
/**
* 处理 handleConnect 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} connection 连接信息
*/
const handleConnect = (connection) => {
if (!connection.source || !connection.target) return;
const edge = lf.addEdge({
type: "bezier",
sourceNodeId: connection.source,
targetNodeId: connection.target,
sourceAnchorId: connection.sourceHandle || `${connection.source}_1`,
targetAnchorId: connection.targetHandle || `${connection.target}_3`,
});
lf.emit("edge:add", { data: edge });
};
/**
* 处理 handleNodeDoubleClick 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node } 调用方传入的 { node } 参数
*/
const handleNodeDoubleClick = ({ node }) => {
const data = lf.getGraphData().nodes.find((item) => item.id === node.id);
if (!data || ["selectArea", "stopLoop", "subStart", "subEnd", "end"].includes(data.type)) return;
showParamsDrawer.value = true;
paramsDrawerData.value = data;
};
/**
* 查找 findLoopAtPoint 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} point 画布坐标
*/
const findLoopAtPoint = (point) => {
return (vueFlow.getNodes.value || []).find((candidate) => {
if (candidate.data?.flowType !== "loop") 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);
return point.x >= groupPosition.x + 24
&& point.x <= groupPosition.x + groupWidth - 24
&& point.y >= groupPosition.y + 112
&& point.y <= groupPosition.y + groupHeight - 24;
});
};
/**
* 查找 findContainingLoop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} node 当前流程节点
*/
const findContainingLoop = (node) => {
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 });
};
/**
* 处理 eventClientPoint 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
const eventClientPoint = (event) => {
const pointer = event?.touches?.[0] || event?.changedTouches?.[0] || event;
if (!Number.isFinite(pointer?.clientX) || !Number.isFinite(pointer?.clientY)) return null;
return { x: pointer.clientX, y: pointer.clientY };
};
/**
* 处理 handleNodeDragStart 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node 调用方传入的 { node 参数
* @param {*} event } 调用方传入的 event } 参数
*/
const handleNodeDragStart = ({ node, event }) => {
const drag = lf.prepareLoopChildDrag(node.id);
const clientPoint = eventClientPoint(event);
if (!drag || !clientPoint) return;
const pointer = lf.getPointByClient(clientPoint.x, clientPoint.y).canvasOverlayPosition;
loopChildDragContexts.set(node.id, {
groupId: drag.groupId,
offset: { x: pointer.x - drag.position.x, y: pointer.y - drag.position.y },
});
};
/**
* 更新 updateLoopChildDrag 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node 调用方传入的 { node 参数
* @param {*} event } 调用方传入的 event } 参数
*/
const updateLoopChildDrag = ({ node, event }) => {
const drag = loopChildDragContexts.get(node.id);
const clientPoint = eventClientPoint(event);
if (!drag || !clientPoint) return;
const pointer = lf.getPointByClient(clientPoint.x, clientPoint.y).canvasOverlayPosition;
lf.layoutLoopDuringDrag(drag.groupId, node.id, {
x: pointer.x - drag.offset.x,
y: pointer.y - drag.offset.y,
});
};
/**
* 处理 handleNodeDrag 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} payload 事件数据
*/
const handleNodeDrag = (payload) => updateLoopChildDrag(payload);
/**
* 处理 handleNodeDragStop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} payload 事件数据
*/
const handleNodeDragStop = (payload) => {
const { node } = payload;
updateLoopChildDrag(payload);
loopChildDragContexts.delete(node.id);
const vueNode = vueFlow.findNode(node.id);
if (
vueNode
&& !vueNode.parentNode
&& !["start", "end", "loop", "selectArea"].includes(vueNode.data?.flowType)
) {
const loopNode = findContainingLoop(vueNode);
if (loopNode) lf.addToGroup(loopNode.id, vueNode.id);
}
lf.markChanged();
};
/**
* 处理 handleNodesChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} changes 变更集合
*/
const handleNodesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type) || (change.type === "position" && change.dragging === false))) {
lf.markChanged();
}
};
/**
* 处理 handleEdgesChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} changes 变更集合
*/
const handleEdgesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type))) lf.markChanged();
};
/**
* 处理 handleEdgeClick 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { event 调用方传入的 { event 参数
* @param {*} edge } 调用方传入的 edge } 参数
*/
const handleEdgeClick = ({ event, edge }) => {
event?.stopPropagation?.();
lf.selectElementById(edge.id);
};
/**
* 处理 handlePaneClick 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const handlePaneClick = () => lf.clearSelectElements();
const flowStore = useFlowStore();
const isOpen = ref(false);
const stateMap = {
unpublished: "未发布",
testRunning: "试运行中",
testRunFinish: "试运行成功",
testRunError: "试运行失败",
release: "已发布",
updateData: "修改中",
pause: "已暂停",
stop: "已终止",
};
const flowState = ref("unpublished");
const flowInfoData = ref({
itemId: "",
instId: "",
taskId: "",
isLog: false,
name: "流程",
});
/**
* 试运行
*/
const testRun = async () => {
// 1、验证画布内所有的表单通过验证
const { nodes, edges } = lf.getGraphData();
const isolatedNodes = nodes.filter((node) => {
const nodeId = node.id;
// 检查是否存在关联边
return !edges.some(
(edge) => edge.sourceNodeId === nodeId || edge.targetNodeId === nodeId
);
});
const gl = isolatedNodes.filter((item) => {
return !item.properties?.parentId && item.type !== "selectArea";
});
if (gl && gl.length > 0) {
ElMessage.warning("存在孤立的节点,请检查!");
return;
}
if (nodeErrorList.value.length > 0) {
ElMessage.warning("存在参数错误的节点,请检查!");
return;
}
const resourceValidation = validateFlowResources({ nodes, edges });
if (resourceValidation.errors.length > 0) {
const error = resourceValidation.errors[0];
ElMessage.warning(error.message);
if (error.nodeId) {
lf.selectElementById(error.nodeId);
lf.focusOn({ id: error.nodeId });
}
return;
}
isOpen.value = true;
};
// 流程暂停
/**
* 处理 flowPauseFn 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const flowPauseFn = async () => {
const res = await flowPause(instId.value);
if (res.code === 200) {
flowState.value = "pause";
ElMessage.success(res.msg);
}
};
const totalRunningTime = ref(0);
const hasRunResult = ref(false);
let flowPollTimer = null;
let flowDisposed = false;
/**
* 清理 clearFlowPollTimer 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const clearFlowPollTimer = () => {
if (flowPollTimer !== null) {
window.clearTimeout(flowPollTimer);
flowPollTimer = null;
}
};
/**
* 调度 scheduleFlowView 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} currentInstId 当前流程实例 ID
* @param {*} taskId 任务 ID
* @param {*} delay 延迟毫秒数
*/
const scheduleFlowView = (currentInstId, taskId = null, delay = 2000) => {
clearFlowPollTimer();
flowPollTimer = window.setTimeout(() => {
flowPollTimer = null;
if (!flowDisposed) loopFlowView(currentInstId, taskId);
}, delay);
};
/**
* 处理 flowResumeFn 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const flowResumeFn = async () => {
const res = await flowResume(instId.value);
if (res.code === 200) {
flowState.value = "testRunning";
clearFlowPollTimer();
loopFlowView(instId.value);
ElMessage.success(res.msg);
}
};
/**
* 处理 flowStopFn 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const flowStopFn = async () => {
const res = await flowStop(instId.value);
if (res.code === 200) {
clearFlowPollTimer();
flowState.value = "stop";
hasRunResult.value = true;
flowStore.updateDisableForm(false);
ElMessage.success(res.msg);
}
};
const initFlowData = {
nodes: [
{
type: "start",
x: 300,
y: 100,
properties: {
name: "Start",
action: "start",
schemaVersion: 2,
resourceRoles: [createDefaultRobotRole()],
inputParams: [],
},
},
{
type: "end",
x: 1300,
y: 700,
properties: {
name: "End",
action: "end",
},
},
],
// 边
edges: [],
};
const hasSameEdge = (edge, ignoredId) => lf.getGraphData().edges.some((item) => (
item.id !== ignoredId
&& item.sourceNodeId === edge.sourceNodeId
&& item.targetNodeId === edge.targetNodeId
&& item.sourceAnchorId === edge.sourceAnchorId
&& item.targetAnchorId === edge.targetAnchorId
));
const validEdge = (data) => {
// 校验思路:先确认端点存在,再按节点类型限制方向;最后结合锚点名称和现有连线判重。
// 任一阶段失败都会删除候选连线,避免非法边进入可保存的画布数据。
const sourceModel = lf.getNodeModelById(data.sourceNodeId);
const targetModel = lf.getNodeModelById(data.targetNodeId);
if (!sourceModel || !targetModel) {
lf.deleteEdge(data.id);
return;
}
const sourceAnchorArr = sourceModel.anchors;
const [sourceName] = sourceAnchorArr
.filter((item) => {
return item.id === data.sourceAnchorId;
})
.map((item) => {
return item.name;
});
const targetAnchorArr = targetModel.anchors;
const [targetName] = targetAnchorArr
.filter((item) => {
return item.id === data.targetAnchorId;
})
.map((item) => {
return item.name;
});
if (!sourceName || !targetName) {
lf.deleteEdge(data.id);
return;
}
if (sourceName === "right" && targetName === "left") {
if (hasSameEdge(data, data.id)) {
lf.deleteEdge(data.id);
}
return;
} else if (
(sourceName === "right" && targetName === "right") ||
(sourceName === "left" && targetName === "left")
) {
lf.deleteEdge(data.id);
} else if (sourceName === "left" && targetName === "right") {
const sourceNodeId = data.targetNodeId;
const sourceAnchorId = data.targetAnchorId;
const targetNodeId = data.sourceNodeId;
const targetAnchorId = data.sourceAnchorId;
lf.deleteEdge(data.id);
if (hasSameEdge({ sourceNodeId, targetNodeId, sourceAnchorId, targetAnchorId })) return;
lf.addEdge({
type: "bezier",
sourceNodeId,
targetNodeId,
sourceAnchorId,
targetAnchorId,
});
}
};
const instId = ref(null);
const changeState = (value) => {
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
isOpen.value = false;
flowStore.updateDisableForm(true);
flowState.value = value.type;
hasRunResult.value = false;
instId.value = value.instId;
clearFlowPollTimer();
loopFlowView(value.instId);
};
const loopFlowView = async (instId, taskId = null) => {
// 轮询思路:优先使用任务 ID 查询指定执行,否则查询实例最新状态,再统一映射节点状态。
// 仅在非日志模式下根据终态停止或续排定时器,日志回放保持为一次性快照。
const params = {
itemId: flowInfoData.value.itemId,
instId,
};
if (taskId) {
params.taskId = taskId;
}
const res = await flowView(params);
if (flowDisposed) return;
if (res.code === 200) {
const arr = res.data || [];
if (arr.length > 0) hasRunResult.value = true;
arr.forEach((item) => {
emitter.emit("changeNodeState", item);
});
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
if (
arr[arr.length - 1]?.nodeType !== "END" &&
!["FAILED", "STOPPED"].includes(arr[arr.length - 1]?.status)
) {
scheduleFlowView(instId);
} else if (
arr[arr.length - 1]?.nodeType === "END" &&
arr[arr.length - 1]?.status === "SUCCESS"
) {
if (!flowInfoData.value.isLog) {
flowStore.updateDisableForm(false);
flowState.value = "testRunFinish";
totalRunningTime.value = arr[arr.length - 1].endTime - arr[0].startTime
}
} else {
if (!flowInfoData.value.isLog) {
if (["PAUSED", "STOPPED"].includes(arr[arr.length - 1]?.status)) {
flowState.value = arr[arr.length - 1]?.status === "PAUSED" ? "pause" : "stop";
} else {
flowState.value = "testRunError";
totalRunningTime.value = arr[arr.length - 1].endTime - arr[0].startTime
flowStore.updateDisableForm(false);
}
}
}
}
};
let oldFlowData = {};
/**
* 发布
*/
const deployFlow = async () => {
if (flowState.value !== "testRunFinish") {
ElMessage.warning("试运行成功后才可以发布");
return;
}
try {
const res = await flowDeploy({ id: flowInfoData.value.itemId });
if (res.code === 200) {
ElMessage.success(res.msg);
flowState.value = "release";
} else {
ElMessage.error(res.msg);
}
} catch (error) {
ElMessage.error("部署操作产生异常");
}
};
const loadFlowData = async (id, lf) => {
try {
const res = await getDetect(id);
if (res.data && res.data.flowData) {
const flowData = normalizeFlowResourceSchema(JSON.parse(res.data.flowData));
lf.render(flowData);
nextTick(() => {
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
lf.fitView();
});
} else {
// 渲染数据 data
lf.render(initFlowData);
oldFlowData = initFlowData;
nextTick(() => lf.zoom(0.8, [850, 450]));
}
} catch (err) {
lf.render(initFlowData);
oldFlowData = initFlowData;
nextTick(() => lf.zoom(0.8, [850, 450]));
}
};
const registerBeforeUnload = () => {
window.addEventListener("beforeunload", handleBeforeUnload);
};
const handleBeforeUnload = (_event) => {
if (flowState.value === "pause" && !flowInfoData.value.isLog) {
flowStopFn();
}
};
const justLoadFlow = () => {
const url = new URL(window.location.href);
const locationParams = Object.fromEntries(url.searchParams.entries());
if (locationParams?.itemId) {
flowState.value = locationParams.state === "0" ? "release" : "unpublished";
flowInfoData.value = {
itemId: locationParams.itemId,
instId: locationParams?.instId || "",
taskId: locationParams?.taskId || "",
isLog: locationParams?.isLog || false,
name: decodeURIComponent(locationParams.name),
};
if (flowInfoData.value.isLog) {
flowStore.updateDisableForm(true);
scheduleFlowView(flowInfoData.value.instId, flowInfoData.value.taskId, 1000);
}
}
};
let flowInitialized = false;
const initFlow = () => {
if (flowInitialized) return;
flowInitialized = true;
lfRef.value = lf;
loadFlowData(flowInfoData.value.itemId, lf);
lf.on("edge:add", ({ data }) => validEdge(data));
lf.on("history:change", () => {
if (flowStore.disableForm) return;
const flowData = lf.getGraphData();
if (JSON.stringify(oldFlowData) !== JSON.stringify(flowData)) {
flowState.value = "updateData";
}
});
};
const groupSelectionPending = ref(false);
const createGroup = (selectedNodes) => {
const nodes = selectedNodes.filter((item) => (
!["selectArea", "loop"].includes(item.type)
&& !lf.getNodeModelById(item.id)?.parentId
));
if (nodes.length < 2) {
ElMessage.warning("请至少选择两个未分组节点");
groupSelectionPending.value = false;
return;
}
const INSET = { left: 52, top: 120, right: 52, bottom: 52 };
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const children = [];
nodes.forEach((item) => {
const model = lf.getNodeModelById(item.id);
const width = model?.width || 372;
const height = model?.height || 180;
const left = Number.isFinite(model?.left) ? model.left : item.x - width / 2;
const top = Number.isFinite(model?.top) ? model.top : item.y - height / 2;
minX = Math.min(minX, left);
minY = Math.min(minY, top);
maxX = Math.max(maxX, left + width);
maxY = Math.max(maxY, top + height);
children.push(item.id);
});
const width = maxX - minX + INSET.left + INSET.right;
const height = maxY - minY + INSET.top + INSET.bottom;
lf.groupNodes({
type: "selectArea",
x: minX - INSET.left + width / 2,
y: minY - INSET.top + height / 2,
properties: {
name: "分组",
action: "NONE",
width,
height,
},
}, children);
lf.clearSelectElements();
groupSelectionPending.value = false;
};
const group = () => {
if (groupSelectionPending.value) {
groupSelectionPending.value = false;
lf.clearSelectElements();
return;
}
const { nodes } = lf.getSelectElements();
if (nodes.length) createGroup(nodes);
else groupSelectionPending.value = true;
};
const handleSelectionEnd = () => {
if (!groupSelectionPending.value) return;
createGroup(lf.getSelectElements().nodes);
};
const treeData = ref(null);
const defaultProps = {
children: "children",
label: "name",
};
const showNodeRelationship = () => {
const { nodes } = lf.getGraphData();
treeData.value = convertToTree(nodes);
};
const handleTreeNodeClick = (node) => {
lf.focusOn({
id: node.id,
});
lf.selectElementById(node.id);
};
const fitView = () => {
lf.fitView();
};
const nodeName = ref("");
const visible = ref(false);
const formData = reactive({
nodeParams: [],
});
const rules = reactive({});
const executeForm = ref();
const nodeAction = ref("");
const singleNodeExecuting = ref(false);
const activeSingleNodeId = ref("");
// 单节点机器人选择按节点缓存;新节点默认沿用最近一次选择。
const singleNodeRobotCache = new Map();
let lastSingleNodeRobotId = "";
const stationLoading = ref(false);
const singleNodeRobotOptions = ref([]);
const flattenSingleNodeParams = (params, parentPath = [], fields = []) => {
(params || []).forEach((param, index) => {
const name = String(param?.name || "").trim();
const path = [...parentPath, name || `param-${index}`];
if (Array.isArray(param?.children)) {
flattenSingleNodeParams(param.children, path, fields);
return;
}
if (!name && (param?.input === "" || param?.input === null || param?.input === undefined)) return;
fields.push({ key: `${path.join(".")}-${index}`, param });
});
return fields;
};
const singleNodeFields = computed(() => flattenSingleNodeParams(formData.nodeParams));
const buildSingleNodePayload = (params) => {
const payload = {};
(params || []).forEach((param) => {
const name = String(param?.name || "").trim();
if (!name) return;
payload[name] = Array.isArray(param.children)
? buildSingleNodePayload(param.children)
: param.input;
});
return payload;
};
const validateSingleNodeParams = () => {
for (const { param } of singleNodeFields.value) {
if (!String(param?.name || "").trim()) {
ElMessage.warning("请输入参数名");
return false;
}
if (param.input === "" || param.input === null || param.input === undefined) {
ElMessage.warning(`请输入参数 ${param.name} 的值`);
return false;
}
}
return true;
};
const singleNodeRobotId = computed(() =>
formData.nodeParams.find((item) => item.name === "robotId")?.input
);
const singleNodeDeviceId = computed(() =>
formData.nodeParams.find((item) => item.name === "deviceId")?.input
);
// 在执行、关闭弹窗或切换节点前记录当前机器人选择。
const rememberSingleNodeRobot = () => {
if (!activeSingleNodeId.value || !singleNodeRobotId.value) return;
lastSingleNodeRobotId = singleNodeRobotId.value;
singleNodeRobotCache.set(activeSingleNodeId.value, singleNodeRobotId.value);
};
watch(visible, (value) => {
if (!value) rememberSingleNodeRobot();
});
const handleSingleNodeRobotChange = async (robotId) => {
const deviceParam = formData.nodeParams.find((item) => item.name === "deviceId");
if (!deviceParam) return;
const originalDeviceId = deviceParam.input;
const originalOptions = [...(deviceParam.selectOptions || [])];
const originalLocked = Boolean(deviceParam.deviceLocked);
const stationParam = formData.nodeParams.find((item) => item.name === "stationId");
if (!robotId) return;
try {
const deviceKind = getDeviceKindForAction(nodeAction.value);
const response = await getRobotDevicesByRobotId(robotId,
deviceKind ? { deviceKind } : {});
const devices = Array.isArray(response.data) ? response.data : [];
if (!devices.length) {
deviceParam.input = originalDeviceId;
deviceParam.selectOptions = originalOptions;
deviceParam.deviceLocked = originalLocked;
ElMessage.warning("当前机器人没有匹配的在线设备,已保留原设备");
return;
}
if (stationParam) {
stationParam.input = "";
stationParam.selectOptions = [];
}
deviceParam.componentType = "select";
deviceParam.selectOptions = toDeviceOptions(devices);
const currentDevice = devices.find((device) => device.deviceId === originalDeviceId);
deviceParam.input = (currentDevice || devices[0]).deviceId;
deviceParam.deviceLocked = devices.length === 1;
} catch (error) {
deviceParam.input = originalDeviceId;
deviceParam.selectOptions = originalOptions;
deviceParam.deviceLocked = originalLocked;
console.error("加载机器人设备失败:", error);
ElMessage.error("加载机器人设备失败");
}
};
/**
* 处理 stationOptions 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} stations 站点集合
*/
const stationOptions = (stations) => (Array.isArray(stations) ? stations : [])
.filter((station) => station?.id !== undefined && station?.id !== null && String(station.id).trim())
.map((station) => {
const id = String(station.id).trim();
const description = station.description ? String(station.description).trim() : "";
return { value: id, label: description ? `${id} - ${description}` : id };
});
/**
* 加载 loadSingleNodeStations 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const loadSingleNodeStations = async () => {
stationLoading.value = true;
try {
const response = await getAgvStations({
robotId: singleNodeRobotId.value,
deviceId: singleNodeDeviceId.value,
});
const options = stationOptions(response?.data);
const stationParam = formData.nodeParams.find((item) => item.name === "stationId");
if (!stationParam) return;
stationParam.selectOptions = options;
stationParam.stationRobotId = singleNodeRobotId.value;
stationParam.stationDeviceId = singleNodeDeviceId.value;
if (!options.some((item) => item.value === stationParam.input)) stationParam.input = "";
if (options.length === 0) ElMessage.warning("当前AGV没有可用站点");
} catch (error) {
console.error("加载AGV站点列表失败:", error);
ElMessage.error("加载AGV站点列表失败");
} finally {
stationLoading.value = false;
}
};
/**
* 加载 loadSingleNodeRobots 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const loadSingleNodeRobots = async () => {
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' });
singleNodeRobotOptions.value = (response.rows || []).map(item => ({
label: `${item.robotName || item.robotId} (${item.robotId})`,
value: item.robotId,
}));
};
/**
* 处理 singleNodeExecution 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const singleNodeExecution = async (e) => {
rememberSingleNodeRobot();
const { name, nodeType, nodeParams, action, nodeId } = e.detail;
const executionParams = JSON.parse(JSON.stringify(nodeParams || []))
.filter((param) => !['robotId', 'deviceId'].includes(param.name));
const normalizedAction = String(action || "").toUpperCase();
const effectiveNodeType = nodeType || (getDeviceKindForAction(normalizedAction) ? "EDGE" : "");
activeSingleNodeId.value = nodeId || `${normalizedAction}:${name || ""}`;
nodeName.value = name;
visible.value = true;
nodeAction.value = normalizedAction;
singleNodeExecuting.value = false;
stationLoading.value = false;
if (effectiveNodeType === "EDGE") {
await loadSingleNodeRobots();
const routingParams = [
{
disabled: true,
input: "",
name: "robotId",
type: "input",
componentType: "select",
selectOptions: singleNodeRobotOptions.value,
},
];
if (actionRequiresDevice(normalizedAction)) {
routingParams.push({
disabled: true,
input: "",
name: "deviceId",
type: "input",
componentType: "select",
selectOptions: [],
});
}
formData.nodeParams = [...routingParams, ...executionParams];
const cachedRobotId = singleNodeRobotCache.get(activeSingleNodeId.value) || lastSingleNodeRobotId;
if (cachedRobotId) {
formData.nodeParams[0].input = cachedRobotId;
await handleSingleNodeRobotChange(cachedRobotId);
}
} else {
formData.nodeParams = executionParams;
}
if (nodeAction.value === "AGV_MOVE_TO_STATION") {
const stationParam = formData.nodeParams.find((item) => item.name === "stationId");
if (stationParam) {
stationParam.input = "";
stationParam.selectOptions = [];
}
}
};
/**
* 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const execute = async () => {
if (nodeAction.value === "AGV_MOVE_TO_STATION") {
const stationParam = formData.nodeParams.find((item) => item.name === "stationId");
const validStation = stationParam?.selectOptions?.some((item) => item.value === stationParam.input);
const correctSource = stationParam?.stationRobotId === singleNodeRobotId.value
&& stationParam?.stationDeviceId === singleNodeDeviceId.value;
if (!validStation || !correctSource) {
ElMessage.warning("请按当前机器人和设备重新加载并选择站点");
return;
}
}
if (!validateSingleNodeParams()) return;
rememberSingleNodeRobot();
singleNodeExecuting.value = true;
try {
const res = await flowAction({
action: nodeAction.value,
payload: buildSingleNodePayload(formData.nodeParams),
});
if (res.code === 200) {
ElMessage.success("执行成功");
} else {
ElMessage.error(res.msg);
}
} catch (error) {
console.error("单节点执行失败:", error);
} finally {
singleNodeExecuting.value = false;
}
};
const showParamsDrawer = ref(false);
const paramsDrawerData = ref({});
/**
* 清理 clearRun 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const clearRun = () => {
const { nodes } = lf.getGraphData();
nodes.forEach((item) => {
const node = lf.getNodeModelById(item.id);
if (node.closePopover) {
node.closePopover();
}
});
hasRunResult.value = false;
totalRunningTime.value = 0;
}
const NODE_PASTE_OFFSET = 40;
const NON_COPYABLE_NODE_TYPES = new Set(["start", "end", "selectArea"]);
const FLOW_CLIPBOARD_TYPE = "cmvr-flow/nodes";
const FLOW_CLIPBOARD_VERSION = 1;
const FLOW_CLIPBOARD_MIME = "application/x-cmvr-flow-nodes";
const FLOW_CLIPBOARD_STORAGE_KEY = "cmvr-flow:clipboard";
const FLOW_EDITOR_INSTANCE_ID = uuid();
const NODE_REFERENCE_ARRAY_KEYS = new Set(["quote", "nameQuote"]);
let copiedPayload = null;
let lastPastedClipboardId = "";
let pasteCount = 0;
const EDITABLE_SELECTOR = [
"input",
"textarea",
"select",
"[contenteditable]:not([contenteditable='false'])",
".monaco-editor",
".el-input",
".el-textarea",
".el-select",
".el-cascader",
".el-input-number",
"[role='dialog']",
].join(",");
/**
* 判断 isEditableElement 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} element 调用方传入的 element 参数
*/
const isEditableElement = (element) => (
element instanceof Element && Boolean(element.closest(EDITABLE_SELECTOR))
);
/**
* 判断 isTextEditing 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
const isTextEditing = (event) => {
const eventPath = typeof event.composedPath === "function" ? event.composedPath() : [];
if (eventPath.some(isEditableElement)) return true;
if (isEditableElement(event.target) || isEditableElement(document.activeElement)) return true;
return Boolean(window.getSelection()?.toString());
};
/**
* 复制 copySelectedNodes 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
const copySelectedNodes = (event) => {
// 复制只保留选中节点及它们之间的内部连线,避免粘贴时重建指向未选节点的关系。
// 节点数据在此深拷贝,后续生成新 ID 时不会反向污染原画布对象。
if (flowStore.disableForm || isTextEditing(event)) return;
const selectedNodes = lf.getSelectElements().nodes;
if (!selectedNodes.length) return;
const graph = lf.getGraphData();
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
const selectedIds = new Set(selectedNodes.map((node) => node.id));
/**
* 新增 addChildren 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} nodeId 流程节点 ID
*/
const addChildren = (nodeId) => {
const node = nodeById.get(nodeId);
(node?.children || []).forEach((childId) => {
if (selectedIds.has(childId)) return;
selectedIds.add(childId);
addChildren(childId);
});
};
[...selectedIds].forEach(addChildren);
const copyableIds = new Set(
[...selectedIds].filter((id) => {
const node = nodeById.get(id);
return node && !NON_COPYABLE_NODE_TYPES.has(node.type);
}),
);
const nodes = graph.nodes.filter((node) => copyableIds.has(node.id));
if (!nodes.length) return;
const edges = graph.edges.filter((edge) => (
copyableIds.has(edge.sourceNodeId) && copyableIds.has(edge.targetNodeId)
));
const payload = {
type: FLOW_CLIPBOARD_TYPE,
version: FLOW_CLIPBOARD_VERSION,
clipboardId: uuid(),
sourceEditorId: FLOW_EDITOR_INSTANCE_ID,
sourceFlowId: flowInfoData.value.itemId || null,
copiedAt: Date.now(),
resourceRoles: getResourceRoles(graph),
graph: JSON.parse(JSON.stringify({ nodes, edges })),
};
const serialized = JSON.stringify(payload);
event.preventDefault();
if (event.clipboardData) {
try {
event.clipboardData.setData(FLOW_CLIPBOARD_MIME, serialized);
} catch (error) {
console.debug("浏览器不支持自定义流程剪贴板类型,使用文本格式", error);
}
event.clipboardData.setData("text/plain", serialized);
}
copiedPayload = payload;
try {
localStorage.setItem(FLOW_CLIPBOARD_STORAGE_KEY, serialized);
} catch (error) {
console.debug("流程剪贴板本地缓存写入失败", error);
}
pasteCount = 0;
lastPastedClipboardId = "";
};
/**
* 解析并校验流程剪贴板内容,避免把普通 JSON 文本当作流程节点粘贴。
* @param {*} raw 剪贴板原始文本
*/
const parseFlowClipboardPayload = (raw) => {
if (!raw || typeof raw !== "string") return null;
try {
const payload = JSON.parse(raw);
if (
payload?.type !== FLOW_CLIPBOARD_TYPE
|| payload?.version !== FLOW_CLIPBOARD_VERSION
|| !Array.isArray(payload?.graph?.nodes)
|| !Array.isArray(payload?.graph?.edges)
|| payload.graph.nodes.length === 0
) {
return null;
}
return payload;
} catch {
return null;
}
};
/**
* 优先读取系统剪贴板;浏览器未暴露剪贴板数据时使用同源窗口共享缓存。
* @param {*} event 粘贴事件
*/
const readFlowClipboardPayload = (event) => {
const clipboard = event.clipboardData;
if (clipboard) {
const customText = clipboard.getData(FLOW_CLIPBOARD_MIME);
const plainText = clipboard.getData("text/plain");
const raw = customText || plainText;
// 系统剪贴板存在其他内容时,不应使用一份过期的本地流程缓存。
if (raw) return parseFlowClipboardPayload(raw);
}
if (copiedPayload) return copiedPayload;
try {
return parseFlowClipboardPayload(localStorage.getItem(FLOW_CLIPBOARD_STORAGE_KEY));
} catch {
return null;
}
};
/**
* 重映射 remapNodeAnchorId 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} anchorId 锚点 ID
* @param {*} oldNodeId 原节点 ID
* @param {*} newNodeId 新节点 ID
*/
const remapNodeAnchorId = (anchorId, oldNodeId, newNodeId) => {
if (!anchorId || !String(anchorId).startsWith(`${oldNodeId}_`)) return anchorId;
return `${newNodeId}${String(anchorId).slice(oldNodeId.length)}`;
};
/**
* 重映射 remapCopiedNodeReferences 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} value 待处理的新值
* @param {*} idMap 节点 ID 映射
* @param {*} parentKey 递归父键名
*/
const remapCopiedNodeReferences = (value, idMap, parentKey = "", options = {}) => {
if (Array.isArray(value)) {
const result = value.map((item) => remapCopiedNodeReferences(item, idMap, "", options));
if (NODE_REFERENCE_ARRAY_KEYS.has(parentKey) && result.length) {
if (idMap.has(result[0])) {
result[0] = idMap.get(result[0]);
} else if (options.dropExternalReferences) {
options.removedReferenceCount += 1;
return [];
}
}
return result;
}
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => (
[key, remapCopiedNodeReferences(item, idMap, key, options)]
)));
};
/**
* 跨窗口粘贴时将节点组移动到当前画布视口中心;当前窗口内复制仍使用固定偏移。
* @param {*} payload 流程剪贴板载荷
*/
const resolvePasteTranslation = (payload) => {
if (payload.sourceEditorId === FLOW_EDITOR_INSTANCE_ID) {
const offset = NODE_PASTE_OFFSET * pasteCount;
return { x: offset, y: offset };
}
const nodes = payload.graph.nodes;
const xs = nodes.map((node) => Number(node.x || 0));
const ys = nodes.map((node) => Number(node.y || 0));
const sourceCenter = {
x: (Math.min(...xs) + Math.max(...xs)) / 2,
y: (Math.min(...ys) + Math.max(...ys)) / 2,
};
const canvas = document.getElementById("main-flow");
if (!canvas) {
const offset = NODE_PASTE_OFFSET * pasteCount;
return { x: offset, y: offset };
}
const rect = canvas.getBoundingClientRect();
const target = lf.getPointByClient(rect.left + rect.width / 2, rect.top + rect.height / 2)
.canvasOverlayPosition;
const repeatedOffset = NODE_PASTE_OFFSET * (pasteCount - 1);
return {
x: target.x - sourceCenter.x + repeatedOffset,
y: target.y - sourceCenter.y + repeatedOffset,
};
};
/**
* 粘贴 pasteCopiedNodes 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
const pasteCopiedNodes = (event) => {
// 先为全部节点建立旧 ID 到新 ID 的映射,再重建父子引用、锚点和内部连线。
// 该顺序保证循环、分组和分支之间的交叉引用都能解析到新的目标 ID。
if (flowStore.disableForm || isTextEditing(event)) return;
const payload = readFlowClipboardPayload(event);
if (!payload) return;
const copiedGraph = payload.graph;
event.preventDefault();
if (lastPastedClipboardId !== payload.clipboardId) {
pasteCount = 0;
lastPastedClipboardId = payload.clipboardId;
}
pasteCount += 1;
const translation = resolvePasteTranslation(payload);
const graph = lf.getGraphData();
const existingNodeIds = new Set(graph.nodes.map((node) => node.id));
const idMap = new Map(copiedGraph.nodes.map((node) => (
[node.id, uuid().replace(/-/g, "")]
)));
const isSameEditor = payload.sourceEditorId === FLOW_EDITOR_INSTANCE_ID;
const isSameFlow = Boolean(payload.sourceFlowId)
&& payload.sourceFlowId === flowInfoData.value.itemId;
const referenceOptions = {
dropExternalReferences: !isSameFlow,
removedReferenceCount: 0,
};
const pastedNodes = copiedGraph.nodes.map((sourceNode) => {
const newId = idMap.get(sourceNode.id);
const properties = remapCopiedNodeReferences(
JSON.parse(JSON.stringify(sourceNode.properties || {})),
idMap,
"",
referenceOptions,
);
const parentId = properties.parentId;
if (idMap.has(parentId)) properties.parentId = idMap.get(parentId);
else if (!isSameEditor || !existingNodeIds.has(parentId)) delete properties.parentId;
if (sourceNode.type === "branch" && Array.isArray(properties.anchor)) {
properties.anchor = properties.anchor.map((anchor) => ({
...anchor,
id: remapNodeAnchorId(anchor.id, sourceNode.id, newId),
}));
}
const children = (sourceNode.children || [])
.filter((childId) => idMap.has(childId))
.map((childId) => idMap.get(childId));
return {
...JSON.parse(JSON.stringify(sourceNode)),
id: newId,
x: Number(sourceNode.x || 0) + translation.x,
y: Number(sourceNode.y || 0) + translation.y,
properties,
children,
};
});
const pastedEdges = copiedGraph.edges.map((sourceEdge) => {
const sourceNodeId = idMap.get(sourceEdge.sourceNodeId);
const targetNodeId = idMap.get(sourceEdge.targetNodeId);
return {
...JSON.parse(JSON.stringify(sourceEdge)),
id: `edge_${uuid().replace(/-/g, "")}`,
sourceNodeId,
targetNodeId,
sourceAnchorId: remapNodeAnchorId(
sourceEdge.sourceAnchorId,
sourceEdge.sourceNodeId,
sourceNodeId,
),
targetAnchorId: remapNodeAnchorId(
sourceEdge.targetAnchorId,
sourceEdge.targetNodeId,
targetNodeId,
),
};
});
const resourceMerge = mergeCopiedResourceRoles(graph, payload.resourceRoles || [], pastedNodes);
const graphNodesWithResources = graph.nodes.map((node) => (
node.id === resourceMerge.start?.id ? resourceMerge.start : node
));
lf.render({
nodes: [...graphNodesWithResources, ...pastedNodes],
edges: [...graph.edges, ...pastedEdges],
}, {
resetHistory: false,
replaceNodes: true,
preserveNodeSizes: true,
preserveNodePositions: true,
});
nextTick(() => {
lf.selectElementsByIds(pastedNodes.map((node) => node.id));
lf.markChanged();
if (referenceOptions.removedReferenceCount > 0) {
ElMessage.warning(
`已粘贴 ${pastedNodes.length} 个节点,${referenceOptions.removedReferenceCount} 个跨工作流参数引用已清除`,
);
}
if (resourceMerge.addedRoles || resourceMerge.addedSlots) {
ElMessage.success(`已同步 ${resourceMerge.addedRoles} 个机器人角色和 ${resourceMerge.addedSlots} 个设备用途`);
}
});
};
/**
* 处理 handleKeyboardShortcut 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
const handleKeyboardShortcut = (event) => {
if (isTextEditing(event)) return;
const isModifierKey = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (isModifierKey && key === "z") {
if (flowStore.disableForm) return;
event.preventDefault();
if (event.shiftKey) lf.redo();
else lf.undo();
return;
}
if (!["Backspace", "Delete"].includes(event.key) || flowStore.disableForm) return;
const { nodes, edges } = lf.getSelectElements();
if (nodes.some((node) => ["start", "end"].includes(node.type))) {
lf.clearSelectElements();
return;
}
if (nodes.length) {
event.preventDefault();
nodes.forEach((node) => {
lf.deleteNode(node.id);
emitter.emit("deleteNode", node.id);
});
return;
}
if (edges.length) {
event.preventDefault();
edges.forEach((edge) => lf.deleteEdge(edge.id));
}
};
const nodeErrorList = ref([]);
onMounted(() => {
justLoadFlow();
initFlow();
document.addEventListener("singleNodeExecution", singleNodeExecution);
registerBeforeUnload();
window.addEventListener("keydown", handleKeyboardShortcut);
window.addEventListener("copy", copySelectedNodes);
window.addEventListener("paste", pasteCopiedNodes);
onFlowEvent("openParamsDrawer", (node) => {
showParamsDrawer.value = true;
paramsDrawerData.value = node;
});
onFlowEvent("throwAnError", (data) => {
// 处理错误抛出逻辑
const { id, hasError, type } = data;
let element
if (type === 'loop') {
const tt = document.getElementsByClassName(`group__container ${id}`)[0];
if (tt?.parentElement) {
element = tt.parentElement;
} else {
element = tt
}
} else {
const tt = document.getElementsByClassName(`node__container ${id}`)[0]
if (tt?.parentElement) {
element = tt.parentElement;
} else {
element = tt
}
}
if (hasError) {
if (!nodeErrorList.value.includes(id)) {
nodeErrorList.value.push(id);
if (element) element.style.background = "#ff00001a";
}
} else {
nodeErrorList.value = nodeErrorList.value.filter((item) => item !== id);
if (element) element.style.background = "#fff";
}
});
onFlowEvent("deleteNode", (nodeId) => {
const index = nodeErrorList.value.findIndex((item) => item === nodeId);
if (index !== -1) {
nodeErrorList.value.splice(index, 1);
}
const cleanup = pruneUnusedNodeResources(lf.getGraphData());
if (cleanup.start && (cleanup.removedRoles || cleanup.removedSlots)) {
lf.setProperties(cleanup.start.id, cleanup.start.properties);
}
})
});
onUnmounted(() => {
flowDisposed = true;
clearFlowPollTimer();
loopChildDragContexts.clear();
if (window.lf === lf) delete window.lf;
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("keydown", handleKeyboardShortcut);
window.removeEventListener("copy", copySelectedNodes);
window.removeEventListener("paste", pasteCopiedNodes);
document.removeEventListener("singleNodeExecution", singleNodeExecution);
});
</script>
<style lang="scss" scoped>
.single-node-param-row {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
gap: 24px;
align-items: start;
}
.single-node-param-name,
.single-node-param-value {
width: 100%;
margin-right: 0;
margin-bottom: 16px;
:deep(.el-form-item__content),
:deep(.el-input),
:deep(.el-input-number),
:deep(.el-select) {
width: 220px;
}
}
.single-node-param-value :deep(.el-textarea__inner) {
min-height: 32px !important;
line-height: 20px;
}
.single-node-station-tools {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid #dfe6ef;
border-radius: 6px;
background: #f7f9fc;
color: #65758b;
font-size: 13px;
}
.device-lock-hint {
width: 100%;
margin-top: 6px;
color: var(--el-color-success);
font-size: 12px;
line-height: 18px;
}
.page {
width: 100%;
height: 100%;
overflow: hidden;
.page__container {
width: 100%;
height: 100%;
position: relative;
.el-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #e3e7ee;
background: #fff;
gap: 18px;
.flow-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.flow-heading__name {
overflow: hidden;
max-width: 320px;
color: #1f2937;
font-size: 16px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-status {
display: flex;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #dfe4ec;
border-radius: 5px;
background: #f8fafc;
color: #687386;
font-size: 11px;
white-space: nowrap;
}
.flow-status__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.flow-status--release,
.flow-status--testRunFinish {
border-color: #c8ead5;
background: #effaf3;
color: #1e7d49;
}
.flow-status--testRunning,
.flow-status--updateData {
border-color: #cbdcf8;
background: #f0f6ff;
color: #2f6fca;
}
.flow-status--testRunError,
.flow-status--stop {
border-color: #f1cecb;
background: #fff3f2;
color: #c33b32;
}
.flow-status--pause {
border-color: #ead9b8;
background: #fff9eb;
color: #9a6700;
}
.btn__container {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
.btn {
display: flex;
align-items: center;
gap: 8px;
.el-button + .el-button { margin-left: 0; }
}
.publish-actions {
padding-left: 10px;
border-left: 1px solid #e4e8ef;
}
}
}
.container {
height: calc(100% - 60px);
}
.el-main {
padding: 0;
position: relative;
overflow: hidden;
}
.el-aside {
margin: 0;
width: 272px;
padding: 0;
border-right: 1px solid #e3e7ee;
background: #f8fafc;
}
.canvas-toolbar {
display: flex;
position: absolute;
top: 14px;
left: 14px;
z-index: 20;
align-items: center;
padding: 4px;
border: 1px solid #dfe4ec;
border-radius: 7px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 6px 18px rgba(31, 41, 55, 0.1);
.el-button {
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: #566174;
}
.el-button + .el-button { margin-left: 2px; }
.el-button:hover {
background: #f1f5f9;
color: #1f2937;
}
}
.canvas-toolbar__divider {
width: 1px;
height: 18px;
margin: 0 4px;
background: #e1e6ed;
}
.flow__container {
width: 100%;
height: 100%;
cursor: pointer;
:deep(.vue-flow__node) {
border: 0;
background: transparent;
cursor: grab;
}
:deep(.vue-flow__node.dragging) { cursor: grabbing; }
:deep(.vue-flow__node .flow-node-shell),
:deep(.vue-flow__node .flow-node-content),
:deep(.vue-flow__node .node__container),
:deep(.vue-flow__node .right),
:deep(.vue-flow__node .editIcon),
:deep(.vue-flow__node .resultText) {
cursor: pointer;
}
:deep(.vue-flow__edge-path),
:deep(.vue-flow__connection-path) {
stroke: #ff6b35;
stroke-width: 2;
}
:deep(.vue-flow__pane) {
cursor: pointer;
}
:deep(.vue-flow__pane.dragging) {
cursor: grabbing;
}
:deep(.vue-flow__selection) {
border: 1px solid #2f80ed;
background: rgba(47, 128, 237, 0.1);
box-shadow: 0 0 0 1px rgba(47, 128, 237, 0.08);
}
:deep(.vue-flow__nodesselection-rect) {
border: 1px dashed rgba(47, 128, 237, 0.72);
background: rgba(47, 128, 237, 0.04);
}
:deep(.vue-flow__handle) {
cursor: crosshair;
}
:deep(.vue-flow__edge) {
cursor: pointer;
}
:deep(.vue-flow__edge-interaction) {
cursor: pointer;
pointer-events: stroke;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #1677ff;
stroke-width: 4;
filter: drop-shadow(0 0 3px rgba(22, 119, 255, 0.45));
}
:deep(.vue-flow__controls) {
overflow: hidden;
border: 1px solid #dfe4ec;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(20, 33, 61, 0.12);
}
}
}
}
</style>
<style lang="scss">
.node-relationship-network-popover {
position: relative;
.title {
font-size: 16px;
font-weight: bold;
text-align: center;
}
.fit {
position: absolute;
right: 20px;
top: 10px;
}
.el-tree {
margin-top: 20px;
max-height: 200px;
overflow-y: auto;
}
}
</style>