CMVR-IOT-UI/src/views/flow/index.vue

1532 lines
44 KiB
Vue
Raw Normal View History

2026-07-28 16:26:07 +08:00
<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>
2026-07-28 16:26:07 +08:00
</div>
<div class="btn__container">
<div class="btn flow-actions">
<el-tag v-if="hasRunResult && ['testRunError', 'testRunFinish'].includes(flowState)" :type="flowState === 'testRunFinish' ? 'success' : 'danger'">
2026-07-28 16:26:07 +08:00
{{ 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>
2026-07-28 16:26:07 +08:00
</div>
<div class="btn publish-actions">
2026-07-28 16:26:07 +08:00
<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>
2026-07-28 16:26:07 +08:00
</el-tooltip>
<el-button v-else @click="deployFlow" type="primary"><el-icon><Upload /></el-icon></el-button>
2026-07-28 16:26:07 +08:00
<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>
2026-07-28 16:26:07 +08:00
</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>
2026-07-29 11:43:07 +08:00
<VueFlow
id="main-flow"
2026-07-28 16:26:07 +08:00
class="flow__container"
ref="flowContainerRef"
2026-07-29 11:43:07 +08:00
: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"
2026-07-29 11:43:07 +08:00
:delete-key-code="null"
:multi-selection-key-code="['Control', 'Meta']"
:selection-key-code="groupSelectionPending"
2026-07-29 11:43:07 +08:00
:pan-on-drag="!groupSelectionPending"
: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"
2026-07-28 16:26:07 +08:00
@dragover="dragover"
@drop="drop"
2026-07-29 11:43:07 +08:00
>
<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>
2026-07-28 16:26:07 +08:00
</el-main>
</el-container>
</el-container>
<TestRun
:drawer="isOpen"
@close="isOpen = false"
@changeState="changeState"
:flowId="flowInfoData.itemId"
/>
<ParamsDrawer
v-if="showParamsDrawer"
:drawer="true"
2026-07-28 16:26:07 +08:00
:data="paramsDrawerData"
@close="showParamsDrawer = false"
/>
</div>
<el-dialog v-model="visible" width="640" class="single-node-dialog" append-to-body>
2026-07-28 16:26:07 +08:00
<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>
2026-07-28 16:26:07 +08:00
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="executeForm"
label-position="top"
label-width="auto"
:disabled="flowStore.disableForm"
>
<div v-for="(property, index) in formData.nodeParams" :key="index">
<el-row class="single-node-param-row">
2026-07-28 16:26:07 +08:00
<el-form-item
class="single-node-param-name"
2026-07-28 16:26:07 +08:00
:label="index === 0 ? '参数名' : ''"
:prop="`nodeParams.${index}.name`"
:rules="[{ required: true, message: '请输入参数名', trigger: 'blur' }]"
>
<el-input
:disabled="property?.disabled || false"
v-model="property.name"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
class="single-node-param-value"
2026-07-28 16:26:07 +08:00
:label="index === 0 ? '参数值' : ''"
:prop="`nodeParams.${index}.input`"
:rules="[{ required: true, message: '请输入参数值', trigger: 'blur' }]"
>
<el-input-number
v-if="property.componentType === 'number'"
v-model="property.input"
:min="0"
2026-08-04 10:08:57 +08:00
:max="property.max || Infinity"
:step="property.step || 1"
2026-07-28 16:26:07 +08:00
:step-strictly="true"
placeholder="请输入"
clearable
/>
<el-select
v-model="property.input"
v-else-if="property.componentType === 'select'"
:disabled="property.deviceLocked"
@change="property.name === 'robotId' && handleSingleNodeRobotChange(property.input)"
2026-07-28 16:26:07 +08:00
>
<el-option
v-for="item in property.selectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-else
v-model="property.input"
autosize
type="textarea"
resize="none"
2026-07-28 16:26:07 +08:00
placeholder="请输入"
clearable
/>
<div v-if="property.name === 'deviceId' && property.deviceLocked" class="device-lock-hint">
已自动匹配当前机器人设备
</div>
2026-07-28 16:26:07 +08:00
</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" @click="execute"> 确定 </el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
2026-07-29 11:43:07 +08:00
import { computed, reactive, ref, onMounted, onUnmounted, nextTick } from "vue";
import { MarkerType, VueFlow, useVueFlow } from "@vue-flow/core";
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";
2026-07-28 16:26:07 +08:00
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";
2026-07-28 16:26:07 +08:00
import {
flowDeploy,
flowView,
flowPause,
flowResume,
flowStop,
flowAction,
getAgvStations,
2026-08-04 16:30:00 +08:00
} from "@/api/flow/flow";
2026-07-28 16:26:07 +08:00
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";
2026-07-28 16:26:07 +08:00
const flowContainerRef = ref(null);
const lfRef = ref(null);
2026-07-29 11:43:07 +08:00
const vueFlow = useVueFlow("main-flow");
const lf = createFlowFacade(vueFlow);
const loopChildDragContexts = new Map();
const { onFlowEvent } = useFlowEvents();
2026-07-29 11:43:07 +08:00
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 },
};
2026-07-28 16:26:07 +08:00
const dragover = (e) => {
e.preventDefault();
};
const drop = (e) => {
let node = e.dataTransfer.getData("flowNode");
2026-07-29 11:43:07 +08:00
if (!node) return;
2026-07-28 16:26:07 +08:00
node = JSON.parse(node);
2026-07-29 11:43:07 +08:00
const point = lf.getPointByClient(e.clientX, e.clientY);
const loopNode = findLoopAtPoint(point.canvasOverlayPosition);
if (["stopLoop", "subEnd", "subStart", "currentLoop"].includes(node.type) && !loopNode) {
2026-07-28 16:26:07 +08:00
ElMessage.warning("此节点只能拖拽到循环节点的循环体中");
return;
}
const { type, ...other } = node;
const newNode = lf.addNode({
type,
x: point.canvasOverlayPosition.x,
y: point.canvasOverlayPosition.y,
properties: {
...other,
},
});
2026-07-29 11:43:07 +08:00
if (loopNode && !["start", "end", "loop", "selectArea"].includes(newNode.type)) {
lf.addToGroup(loopNode.id, newNode.id, point.canvasOverlayPosition);
}
2026-07-28 16:26:07 +08:00
if (
!["selectArea", "branch", "stopLoop", "subStart", "subEnd"].includes(newNode.type)
) {
showParamsDrawer.value = true;
paramsDrawerData.value = newNode;
}
};
2026-07-29 11:43:07 +08:00
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 });
};
const handleNodeDoubleClick = ({ node }) => {
const data = lf.getGraphData().nodes.find((item) => item.id === node.id);
if (!data || ["selectArea", "branch", "stopLoop", "subStart", "subEnd", "end"].includes(data.type)) return;
showParamsDrawer.value = true;
paramsDrawerData.value = data;
};
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;
});
};
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 });
};
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 };
};
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 },
});
};
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,
});
};
const handleNodeDrag = (payload) => updateLoopChildDrag(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();
};
const handleNodesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type) || (change.type === "position" && change.dragging === false))) {
lf.markChanged();
}
};
const handleEdgesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type))) lf.markChanged();
};
const handleEdgeClick = ({ event, edge }) => {
event?.stopPropagation?.();
lf.selectElementById(edge.id);
};
const handlePaneClick = () => lf.clearSelectElements();
2026-07-28 16:26:07 +08:00
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;
}
isOpen.value = true;
};
// 流程暂停
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;
const clearFlowPollTimer = () => {
if (flowPollTimer !== null) {
window.clearTimeout(flowPollTimer);
flowPollTimer = null;
}
};
const scheduleFlowView = (currentInstId, taskId = null, delay = 2000) => {
clearFlowPollTimer();
flowPollTimer = window.setTimeout(() => {
flowPollTimer = null;
if (!flowDisposed) loopFlowView(currentInstId, taskId);
}, delay);
};
2026-07-28 16:26:07 +08:00
const flowResumeFn = async () => {
const res = await flowResume(instId.value);
if (res.code === 200) {
flowState.value = "testRunning";
clearFlowPollTimer();
2026-07-28 16:26:07 +08:00
loopFlowView(instId.value);
ElMessage.success(res.msg);
}
};
const flowStopFn = async () => {
const res = await flowStop(instId.value);
if (res.code === 200) {
clearFlowPollTimer();
2026-07-28 16:26:07 +08:00
flowState.value = "stop";
hasRunResult.value = true;
2026-07-28 16:26:07 +08:00
flowStore.updateDisableForm(false);
ElMessage.success(res.msg);
}
};
const initFlowData = {
nodes: [
{
type: "start",
x: 300,
y: 100,
properties: {
name: "Start",
action: "start",
},
},
{
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
));
2026-07-28 16:26:07 +08:00
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;
2026-07-28 16:26:07 +08:00
const [sourceName] = sourceAnchorArr
.filter((item) => {
return item.id === data.sourceAnchorId;
})
.map((item) => {
return item.name;
});
const targetAnchorArr = targetModel.anchors;
2026-07-28 16:26:07 +08:00
const [targetName] = targetAnchorArr
.filter((item) => {
return item.id === data.targetAnchorId;
})
.map((item) => {
return item.name;
});
if (!sourceName || !targetName) {
lf.deleteEdge(data.id);
return;
}
2026-07-28 16:26:07 +08:00
if (sourceName === "right" && targetName === "left") {
if (hasSameEdge(data, data.id)) lf.deleteEdge(data.id);
2026-07-28 16:26:07 +08:00
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;
2026-07-28 16:26:07 +08:00
lf.addEdge({
type: "bezier",
sourceNodeId,
targetNodeId,
sourceAnchorId,
targetAnchorId,
});
}
};
const instId = ref(null);
const changeState = (value) => {
const flowData = JSON.parse(JSON.stringify(lf.getGraphData()));
oldFlowData = flowData;
isOpen.value = false;
flowStore.updateDisableForm(true);
flowState.value = value.type;
hasRunResult.value = false;
2026-07-28 16:26:07 +08:00
instId.value = value.instId;
clearFlowPollTimer();
2026-07-28 16:26:07 +08:00
loopFlowView(value.instId);
};
const loopFlowView = async (instId, taskId = null) => {
const params = {
itemId: flowInfoData.value.itemId,
instId,
};
if (taskId) {
params.taskId = taskId;
}
const res = await flowView(params);
if (flowDisposed) return;
2026-07-28 16:26:07 +08:00
if (res.code === 200) {
const arr = res.data || [];
if (arr.length > 0) hasRunResult.value = true;
2026-07-28 16:26:07 +08:00
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);
2026-07-28 16:26:07 +08:00
} 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 = 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);
2026-07-28 16:26:07 +08:00
}
}
};
2026-07-29 11:43:07 +08:00
let flowInitialized = false;
2026-07-28 16:26:07 +08:00
const initFlow = () => {
2026-07-29 11:43:07 +08:00
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)) {
2026-07-28 16:26:07 +08:00
flowState.value = "updateData";
2026-07-29 11:43:07 +08:00
}
});
};
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
const groupSelectionPending = ref(false);
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
const createGroup = (selectedNodes) => {
const nodes = selectedNodes.filter((item) => (
!["selectArea", "loop"].includes(item.type)
&& !lf.getNodeModelById(item.id)?.parentId
));
if (nodes.length < 2) {
ElMessage.warning("请至少选择两个未分组节点");
2026-07-29 11:43:07 +08:00
groupSelectionPending.value = false;
return;
2026-07-28 16:26:07 +08:00
}
const INSET = { left: 52, top: 120, right: 52, bottom: 52 };
2026-07-29 11:43:07 +08:00
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const children = [];
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
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);
2026-07-29 11:43:07 +08:00
children.push(item.id);
2026-07-28 16:26:07 +08:00
});
2026-07-29 11:43:07 +08:00
const width = maxX - minX + INSET.left + INSET.right;
const height = maxY - minY + INSET.top + INSET.bottom;
lf.groupNodes({
2026-07-29 11:43:07 +08:00
type: "selectArea",
x: minX - INSET.left + width / 2,
y: minY - INSET.top + height / 2,
2026-07-29 11:43:07 +08:00
properties: {
name: "分组",
action: "NONE",
width,
height,
2026-07-29 11:43:07 +08:00
},
}, children);
2026-07-29 11:43:07 +08:00
lf.clearSelectElements();
groupSelectionPending.value = false;
};
const group = () => {
if (groupSelectionPending.value) {
groupSelectionPending.value = false;
lf.clearSelectElements();
return;
}
2026-07-29 11:43:07 +08:00
const { nodes } = lf.getSelectElements();
if (nodes.length) createGroup(nodes);
else groupSelectionPending.value = true;
};
const handleSelectionEnd = () => {
if (!groupSelectionPending.value) return;
createGroup(lf.getSelectElements().nodes);
2026-07-28 16:26:07 +08:00
};
const treeData = ref(null);
const defaultProps = {
children: "children",
label: "name",
};
const showNodeRelationship = () => {
const { nodes } = lf.getGraphData();
const outputData = convertToTree(nodes);
treeData.value = outputData;
};
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 stationLoading = ref(false);
const singleNodeRobotOptions = ref([]);
const singleNodeRobotId = computed(() =>
formData.nodeParams.find((item) => item.name === "robotId")?.input
);
const singleNodeDeviceId = computed(() =>
formData.nodeParams.find((item) => item.name === "deviceId")?.input
);
const handleSingleNodeRobotChange = async (robotId) => {
const deviceParam = formData.nodeParams.find((item) => item.name === "deviceId");
if (!deviceParam) return;
deviceParam.input = "";
deviceParam.deviceLocked = false;
deviceParam.componentType = "select";
deviceParam.selectOptions = [];
const stationParam = formData.nodeParams.find((item) => item.name === "stationId");
if (stationParam) {
stationParam.input = "";
stationParam.selectOptions = [];
}
if (!robotId) return;
try {
const deviceKind = getDeviceKindForAction(nodeAction.value);
const response = await getRobotDevicesByRobotId(robotId,
deviceKind ? { deviceKind } : {});
const devices = Array.isArray(response.data) ? response.data : [];
deviceParam.selectOptions = toDeviceOptions(devices);
if (devices.length > 0) {
deviceParam.input = devices[0].deviceId;
deviceParam.deviceLocked = devices.length === 1;
} else {
ElMessage.warning("当前机器人没有匹配的在线设备");
}
} catch (error) {
console.error("加载机器人设备失败:", error);
ElMessage.error("加载机器人设备失败");
}
};
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 };
});
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;
}
};
2026-07-28 16:26:07 +08:00
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,
}));
};
const singleNodeExecution = async (e) => {
2026-07-28 16:26:07 +08:00
const { name, nodeType, nodeParams, action } = e.detail;
const executionParams = JSON.parse(JSON.stringify(nodeParams || []));
2026-07-28 16:26:07 +08:00
nodeName.value = name;
visible.value = true;
nodeAction.value = action;
stationLoading.value = false;
2026-07-28 16:26:07 +08:00
if (nodeType === "EDGE") {
await loadSingleNodeRobots();
2026-07-28 16:26:07 +08:00
formData.nodeParams = [
{
disabled: true,
input: "",
name: "robotId",
2026-07-28 16:26:07 +08:00
type: "input",
componentType: "select",
selectOptions: singleNodeRobotOptions.value,
2026-07-28 16:26:07 +08:00
},
...executionParams,
2026-07-28 16:26:07 +08:00
];
} else {
formData.nodeParams = executionParams;
}
if (action === "AGV_MOVE_TO_STATION") {
const stationParam = formData.nodeParams.find((item) => item.name === "stationId");
if (stationParam) {
stationParam.input = "";
stationParam.selectOptions = [];
}
2026-07-28 16:26:07 +08:00
}
};
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;
}
}
2026-07-28 16:26:07 +08:00
const result = await executeForm.value.validate();
if (result) {
const obj = {};
formData.nodeParams.forEach((item) => {
obj[item.name] = item.input;
});
const res = await flowAction({
action: nodeAction.value,
payload: obj,
});
if (res.code === 200) {
ElMessage.success("执行成功");
} else {
ElMessage.error(res.msg);
}
}
};
const showParamsDrawer = ref(false);
const paramsDrawerData = ref({});
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;
2026-07-28 16:26:07 +08:00
}
2026-08-04 10:08:57 +08:00
const NODE_PASTE_OFFSET = 40;
const NON_COPYABLE_NODE_TYPES = new Set(["start", "end", "selectArea"]);
let copiedNodes = [];
let pasteCount = 0;
2026-07-29 11:43:07 +08:00
2026-08-04 10:08:57 +08:00
const isTextEditing = (event) => {
const element = event.target || document.activeElement;
return ["INPUT", "TEXTAREA", "SELECT"].includes(element?.tagName)
|| element?.isContentEditable;
};
const copySelectedNodes = (event) => {
const nodes = lf.getSelectElements().nodes.filter(
(node) => !NON_COPYABLE_NODE_TYPES.has(node.type),
);
if (!nodes.length) return;
event.preventDefault();
copiedNodes = JSON.parse(JSON.stringify(nodes));
pasteCount = 0;
};
const pasteCopiedNodes = (event) => {
if (!copiedNodes.length || flowStore.disableForm) return;
event.preventDefault();
pasteCount += 1;
const offset = NODE_PASTE_OFFSET * pasteCount;
const pastedNodes = copiedNodes.map((sourceNode) => {
const properties = JSON.parse(JSON.stringify(sourceNode.properties || {}));
const parentId = properties.parentId;
delete properties.parentId;
const newNode = lf.addNode({
type: sourceNode.type,
x: Number(sourceNode.x || 0) + offset,
y: Number(sourceNode.y || 0) + offset,
properties,
});
if (sourceNode.type === "branch" && Array.isArray(properties.anchor)) {
const oldElseId = `${sourceNode.id}_else`;
const newElseId = `${newNode.id}_else`;
const anchors = properties.anchor.map((anchor) => (
anchor.id === oldElseId ? { ...anchor, id: newElseId } : anchor
));
lf.setProperties(newNode.id, { ...properties, anchor: anchors });
}
if (parentId && lf.getNodeModelById(parentId)) {
lf.addToGroup(parentId, newNode.id, { x: newNode.x, y: newNode.y });
}
return newNode;
});
lf.clearSelectElements();
if (pastedNodes.length === 1) lf.selectElementById(pastedNodes[0].id);
};
const handleKeyboardShortcut = (event) => {
if (isTextEditing(event)) return;
const isModifierKey = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (isModifierKey && key === "c") {
if (!flowStore.disableForm) copySelectedNodes(event);
return;
}
if (isModifierKey && key === "v") {
pasteCopiedNodes(event);
return;
}
if (isModifierKey && key === "z") {
2026-07-29 11:43:07 +08:00
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));
}
};
2026-07-28 16:26:07 +08:00
const nodeErrorList = ref([]);
onMounted(() => {
justLoadFlow();
initFlow();
document.addEventListener("singleNodeExecution", singleNodeExecution);
registerBeforeUnload();
2026-08-04 10:08:57 +08:00
window.addEventListener("keydown", handleKeyboardShortcut);
2026-07-28 16:26:07 +08:00
onFlowEvent("openParamsDrawer", (node) => {
2026-07-28 16:26:07 +08:00
showParamsDrawer.value = true;
paramsDrawerData.value = node;
});
onFlowEvent("throwAnError", (data) => {
2026-07-28 16:26:07 +08:00
// 处理错误抛出逻辑
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);
2026-07-29 11:43:07 +08:00
if (element) element.style.background = "#ff00001a";
2026-07-28 16:26:07 +08:00
}
} else {
nodeErrorList.value = nodeErrorList.value.filter((item) => item !== id);
2026-07-29 11:43:07 +08:00
if (element) element.style.background = "#fff";
2026-07-28 16:26:07 +08:00
}
});
onFlowEvent("deleteNode", (nodeId) => {
2026-07-28 16:26:07 +08:00
const index = nodeErrorList.value.findIndex((item) => item === nodeId);
if (index !== -1) {
nodeErrorList.value.splice(index, 1);
}
})
});
onUnmounted(() => {
flowDisposed = true;
clearFlowPollTimer();
2026-07-29 11:43:07 +08:00
loopChildDragContexts.clear();
if (window.lf === lf) delete window.lf;
2026-07-28 16:26:07 +08:00
window.removeEventListener("beforeunload", handleBeforeUnload);
2026-08-04 10:08:57 +08:00
window.removeEventListener("keydown", handleKeyboardShortcut);
2026-07-28 16:26:07 +08:00
document.removeEventListener("singleNodeExecution", singleNodeExecution);
});
</script>
<style lang="scss" scoped>
2026-07-29 11:43:07 +08:00
$flow-canvas-cursor: url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20width=%2724%27%20height=%2724%27%20viewBox=%270%200%2024%2024%27%3E%3Cpath%20d=%27M4%202.5v16l4.2-4%203.1%207.1%203.4-1.5-3.1-7.1h5.8z%27%20fill=%27%23fff%27%20stroke=%27%231f2937%27%20stroke-width=%271.8%27%20stroke-linejoin=%27round%27/%3E%3C/svg%3E") 4 3, default;
.single-node-param-row {
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
gap: 16px;
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: 100%;
}
}
.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;
}
2026-07-28 16:26:07 +08:00
.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;
2026-07-29 11:43:07 +08:00
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;
}
2026-07-28 16:26:07 +08:00
.btn__container {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
2026-07-28 16:26:07 +08:00
.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;
2026-07-28 16:26:07 +08:00
}
}
}
.container {
height: calc(100% - 60px);
}
.el-main {
padding: 0;
position: relative;
overflow: hidden;
2026-07-28 16:26:07 +08:00
}
.el-aside {
margin: 0;
width: 272px;
2026-07-28 16:26:07 +08:00
padding: 0;
2026-07-29 11:43:07 +08:00
border-right: 1px solid #e3e7ee;
background: #f8fafc;
2026-07-28 16:26:07 +08:00
}
.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;
}
2026-07-28 16:26:07 +08:00
.flow__container {
width: 100%;
height: 100%;
2026-07-29 11:43:07 +08:00
cursor: $flow-canvas-cursor;
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
:deep(.vue-flow__node) {
border: 0;
background: transparent;
cursor: $flow-canvas-cursor;
}
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
: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: $flow-canvas-cursor;
}
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
:deep(.vue-flow__edge-path),
:deep(.vue-flow__connection-path) {
stroke: #ff6b35;
stroke-width: 2;
}
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
:deep(.vue-flow__pane) {
cursor: $flow-canvas-cursor;
}
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
:deep(.vue-flow__pane.dragging) {
cursor: $flow-canvas-cursor;
}
: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);
2026-07-28 16:26:07 +08:00
}
}
}
}
</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;
}
}
.el-dialog {
.el-input {
width: 200px;
}
.el-select {
width: 200px;
}
.el-textarea {
width: 200px;
}
}
</style>