CMVR-BID/src/views/flow/index.vue
lixiaolong 657b7b9338 feat(flow): 优化流程试运行功能
- 添加试运行准备状态指示器
- 在试运行按钮上增加加载状态和禁用逻辑
- 实现试运行前自动保存草稿功能
- 为保存草稿功能添加可选的成功消息参数
- 防止试运行和保存操作同时执行的冲突处理
2026-08-21 14:19:48 +08:00

2995 lines
100 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>
<el-tag v-if="publishedVersionNo" size="small" type="success" effect="plain">
当前发布 V{{ publishedVersionNo }}
</el-tag>
<el-tag v-if="versionPreviewMode" size="small" type="warning">
正在查看 V{{ previewingVersionNo }}
</el-tag>
</div>
<div v-if="!versionPreviewMode" 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="warning"
plain
:loading="restoreOperationalLoading"
:disabled="flowState === 'testRunning'"
@click="openRestoreOperationalDialog"
>
<el-icon><Warning /></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'"
:loading="trialPreparing"
:disabled="draftSaving"
@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-button
:loading="draftSaving"
:disabled="!draftDirty || ['testRunning', 'pause'].includes(flowState)"
@click="saveDraft"
>
<el-icon><DocumentChecked /></el-icon>保存
</el-button>
<el-button :disabled="['testRunning', 'pause'].includes(flowState)" @click="openVersionHistory">
<el-icon><Clock /></el-icon>发布记录
</el-button>
<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>
<div v-else class="btn__container">
<el-button type="primary" @click="exitVersionPreview">退出预览</el-button>
</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"
:queue-drop-active="nodeProps.id === queueDropTargetId"
/>
</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"
:flow-id="flowInfoData.itemId"
@close="showParamsDrawer = false"
@extract-step="handleExtractQueueStep"
/>
</div>
<el-dialog v-model="restoreOperationalDialogVisible" title="恢复运行状态" width="480" append-to-body>
<el-form label-position="top">
<el-form-item label="机器人">
<el-select
v-model="restoreOperationalRobotIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:loading="restoreRobotLoading"
placeholder="请选择需要恢复的在线机器人"
style="width: 100%"
>
<el-option
v-for="item in restoreRobotOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="restoreOperationalDialogVisible = false">取消</el-button>
<el-button
type="warning"
:loading="restoreOperationalLoading"
:disabled="restoreOperationalRobotIds.length === 0"
@click="confirmRestoreOperationalState"
>恢复运行状态</el-button>
</template>
</el-dialog>
<el-dialog v-model="visible" :width="nodeAction === 'SYSTEM_EXECUTE_ACTION_QUEUE' ? '620' : '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>
<ActionQueueSingleRun
v-if="nodeAction === 'SYSTEM_EXECUTE_ACTION_QUEUE'"
ref="actionQueueSingleRunRef"
:node-params="formData.nodeParams"
:robot-options="singleNodeRobotOptions"
:initial-robot-id="singleNodeInitialRobotId"
:executing-step-index="singleQueueStepExecutingIndex"
@execute-step="executeActionQueueStep"
/>
<el-form
v-else
: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="field.param.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-switch
v-model="field.param.input"
v-else-if="field.param.componentType === 'switch'"
inline-prompt
active-text="是"
inactive-text="否"
/>
<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>
<el-drawer v-model="versionDrawerVisible" title="发布记录" size="560px">
<div v-loading="versionLoading" class="version-history">
<el-empty v-if="!versionLoading && !versionList.length" description="暂无发布记录" />
<div v-for="version in versionList" :key="version.id" class="version-history__item">
<div class="version-history__main">
<div class="version-history__title">
<strong>V{{ version.versionNo }}</strong>
<el-tag v-if="version.id === publishedVersionId" size="small" type="success">当前发布</el-tag>
<el-tag v-if="version.sourceVersionId" size="small" type="warning">回退版本</el-tag>
</div>
<div class="version-history__meta">
{{ version.createBy || '未知用户' }} · {{ version.createTime || '-' }}
</div>
<div v-if="version.publishNote" class="version-history__note">{{ version.publishNote }}</div>
</div>
<div class="version-history__actions">
<el-button link type="primary" @click="previewVersion(version)">查看</el-button>
<el-button link type="primary" @click="restoreVersionAsDraft(version)">恢复为草稿</el-button>
<el-button link type="danger" :disabled="version.id === publishedVersionId" @click="rollbackVersion(version)">
回退
</el-button>
</div>
</div>
</div>
</el-drawer>
</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 ActionQueueSingleRun from "./components/ActionQueueSingleRun.vue";
import { ElMessage, ElMessageBox } 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 {
appendNodeToActionQueue,
extractActionQueueStep,
} from "@/utils/actionQueue";
import {
flowDeploy,
flowSaveDraft,
flowVersionList,
flowVersionDetail,
flowRestoreDraft,
flowRollbackVersion,
flowView,
flowPause,
flowResume,
flowStop,
flowAction,
getAgvStations,
} from "@/api/flow/flow";
import { getDetect } from "@/api/test/detect";
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { restoreOperationalState } from '@/api/device/system'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import { sanitizeFlowResourceDefinition } from '@/utils/flowResources'
import {
CircleClose,
Clock,
CollectionTag,
Delete,
DocumentChecked,
FullScreen,
Location,
RefreshLeft,
RefreshRight,
Share,
Upload,
VideoPause,
VideoPlay,
Warning,
} 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 actionQueueDragOrigins = new Map();
const queueDropTargetId = ref("");
const { onFlowEvent } = useFlowEvents();
window.lf = lf;
/**
* 恢复运行状态弹窗只面向在线机器人;局部失败时保留失败项,方便用户直接重试。
*/
const restoreOperationalDialogVisible = ref(false);
const restoreRobotLoading = ref(false);
const restoreOperationalLoading = ref(false);
const restoreRobotOptions = ref([]);
const restoreOperationalRobotIds = ref([]);
const openRestoreOperationalDialog = async () => {
restoreOperationalDialogVisible.value = true;
restoreRobotLoading.value = true;
try {
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' });
restoreRobotOptions.value = (response.rows || []).filter((robot) => robot.robotId).map((robot) => ({
value: robot.robotId,
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
}));
restoreOperationalRobotIds.value = restoreOperationalRobotIds.value.filter((robotId) => (
restoreRobotOptions.value.some((option) => option.value === robotId)
));
if (!restoreOperationalRobotIds.value.length && restoreRobotOptions.value.length === 1) {
restoreOperationalRobotIds.value = [restoreRobotOptions.value[0].value];
}
} catch (error) {
console.error("加载在线机器人失败:", error);
restoreRobotOptions.value = [];
} finally {
restoreRobotLoading.value = false;
}
};
const confirmRestoreOperationalState = async () => {
if (!restoreOperationalRobotIds.value.length) {
ElMessage.warning("请选择需要恢复的机器人");
return;
}
try {
await ElMessageBox.confirm(
`将恢复 ${restoreOperationalRobotIds.value.length} 台机器人的运行状态,确认继续?`,
"安全状态确认",
{ type: "warning", confirmButtonText: "确认清除", cancelButtonText: "取消" },
);
} catch {
return;
}
restoreOperationalLoading.value = true;
const robotIds = [...restoreOperationalRobotIds.value];
try {
// 并发恢复多台机器人,使用 allSettled 保留局部失败信息并提示用户重试失败项。
const results = await Promise.allSettled(robotIds.map((robotId) => restoreOperationalState({
robotId,
reason: "工作流设计器手动清除错误状态",
timeoutMs: 15000,
})));
const failedRobotIds = results.flatMap((result, index) => (
result.status === "rejected" ? [robotIds[index]] : []
));
if (!failedRobotIds.length) {
ElMessage.success(`已恢复 ${robotIds.length} 台机器人的运行状态`);
restoreOperationalDialogVisible.value = false;
restoreOperationalRobotIds.value = [];
} else {
const successCount = robotIds.length - failedRobotIds.length;
restoreOperationalRobotIds.value = failedRobotIds;
ElMessage.error(successCount
? `${successCount} 台成功,${failedRobotIds.length} 台失败,请重试失败项`
: "清除错误状态失败,请检查机器人状态");
}
} finally {
restoreOperationalLoading.value = false;
}
};
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"].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 };
};
const findActionQueueAtPoint = (point, draggedNodeId) => (
(vueFlow.getNodes.value || []).find((candidate) => {
if (candidate.id === draggedNodeId || candidate.data?.flowType !== "actionQueue") return false;
const position = candidate.computedPosition || candidate.position;
const width = Number(candidate.dimensions?.width || candidate.data?.size?.width || 372);
const height = Number(candidate.dimensions?.height || candidate.data?.size?.height || 180);
return point.x >= position.x
&& point.x <= position.x + width
&& point.y >= position.y
&& point.y <= position.y + height;
}) || null
);
const queueAtDragEvent = (nodeId, event) => {
const clientPoint = eventClientPoint(event);
if (!clientPoint) return null;
return findActionQueueAtPoint(
lf.getPointByClient(clientPoint.x, clientPoint.y).canvasOverlayPosition,
nodeId,
);
};
const renderQueueGraphChange = (graph, selectedNodeId) => {
const cleanup = pruneUnusedNodeResources(graph);
lf.render(cleanup.graph, { resetHistory: false, replaceNodes: true });
nextTick(() => {
lf.markChanged();
if (selectedNodeId) lf.selectElementById(selectedNodeId);
});
};
const restoreDraggedNodePosition = (nodeId) => {
const origin = actionQueueDragOrigins.get(nodeId);
if (!origin) return;
const graph = lf.getGraphData();
const node = graph.nodes.find((item) => item.id === nodeId);
if (!node) return;
node.x = origin.x;
node.y = origin.y;
lf.render(graph, { resetHistory: false, replaceNodes: true });
nextTick(() => lf.markChanged());
};
const moveNodeIntoActionQueue = (nodeId, queueId) => {
const result = appendNodeToActionQueue(lf.getGraphData(), nodeId, queueId);
if (!result.ok) {
restoreDraggedNodePosition(nodeId);
ElMessage.warning(result.message);
return false;
}
if (paramsDrawerData.value?.id === nodeId) showParamsDrawer.value = false;
nodeErrorList.value = nodeErrorList.value.filter((id) => id !== nodeId);
renderQueueGraphChange(result.graph, queueId);
ElMessage.success(`已将节点转换为队列第 ${result.step.stepId.replace("step_", "")} 步`);
return true;
};
/**
* 处理 handleNodeDragStart 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node 调用方传入的 { node 参数
* @param {*} event } 调用方传入的 event } 参数
*/
const handleNodeDragStart = ({ node, event }) => {
const graphNode = lf.getGraphData().nodes.find((item) => item.id === node.id);
if (graphNode) actionQueueDragOrigins.set(node.id, { x: graphNode.x, y: graphNode.y });
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);
queueDropTargetId.value = queueAtDragEvent(payload.node.id, payload.event)?.id || "";
};
/**
* 处理 handleNodeDragStop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} payload 事件数据
*/
const handleNodeDragStop = (payload) => {
const { node } = payload;
updateLoopChildDrag(payload);
loopChildDragContexts.delete(node.id);
const queueNode = queueAtDragEvent(node.id, payload.event);
queueDropTargetId.value = "";
if (queueNode) {
moveNodeIntoActionQueue(node.id, queueNode.id);
actionQueueDragOrigins.delete(node.id);
return;
}
actionQueueDragOrigins.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: "修改中",
draftSaved: "草稿已保存",
pause: "已暂停",
stop: "已终止",
};
const flowState = ref("unpublished");
const draftRevision = ref(0);
const draftDirty = ref(false);
const draftSaving = ref(false);
const publishedVersionId = ref("");
const publishedVersionNo = ref(null);
const versionDrawerVisible = ref(false);
const versionLoading = ref(false);
const versionList = ref([]);
const versionPreviewMode = ref(false);
const previewingVersionNo = ref(null);
const trialPreparing = ref(false);
let draftBeforeVersionPreview = null;
let oldFlowDataBeforeVersionPreview = null;
let disableFormBeforeVersionPreview = false;
const flowInfoData = ref({
itemId: "",
instId: "",
taskId: "",
isLog: false,
name: "流程",
});
/**
* 试运行
*/
const testRun = async () => {
if (trialPreparing.value || draftSaving.value) return;
// 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;
}
trialPreparing.value = true;
try {
const saved = await saveDraft({ successMessage: "已自动保存当前草稿" });
if (!saved) return;
isOpen.value = true;
} finally {
trialPreparing.value = false;
}
};
// 流程暂停
/**
* 处理 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 resolveSubFlowParentNodeId = (runtimeNodeId) => {
const markerIndexes = [runtimeNodeId.indexOf("__sf__"), runtimeNodeId.indexOf("__subflow__")]
.filter((index) => index > 0);
if (markerIndexes.length) {
return runtimeNodeId.slice(0, Math.min(...markerIndexes));
}
if (!runtimeNodeId.startsWith("sf_")) return null;
const separatorIndex = runtimeNodeId.lastIndexOf("_");
if (separatorIndex <= 3) return null;
const rootPrefix = runtimeNodeId.slice(3, separatorIndex);
const parentNode = lf.getGraphData().nodes?.find(
(node) => node.type === "subFlow" && String(node.id || "").startsWith(rootPrefix),
);
return parentNode?.id || null;
};
const parseRuntimeValue = (value) => {
if (!value) return {};
if (typeof value === "object") return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
};
const runtimeIterationPath = (item) => {
const value = item?.iterations ?? item?.iteration;
if (Array.isArray(value)) return value.filter(part => part !== null && part !== "");
if (value === null || value === undefined || value === "") return [];
return String(value).split(/[-.,/]/).map(part => part.trim()).filter(Boolean);
};
const emitLoopIterationOutputs = (logs) => {
const grouped = new Map();
logs.forEach((log) => {
const iterations = runtimeIterationPath(log);
if (!iterations.length || !log?.nodeId) return;
const key = String(log.nodeId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push({ log, iterations });
});
grouped.forEach((entries, nodeId) => {
const latest = entries[entries.length - 1].log;
const failed = entries.find(({ log }) => String(log?.status || "").toUpperCase() === "FAILED");
const running = entries.find(({ log }) => String(log?.status || "").toUpperCase() === "RUNNING");
const iterationOutputs = entries.map(({ log, iterations }) => ({
iteration: iterations.join("."),
status: log.status,
startTime: log.startTime,
endTime: log.endTime,
durationMs: log.endTime && log.startTime ? log.endTime - log.startTime : null,
input: parseRuntimeValue(log.paramsIn),
output: parseRuntimeValue(log.paramsOut),
error: String(log.status || "").toUpperCase() === "FAILED" ? (log.message || "") : "",
}));
emitter.emit("changeNodeState", {
...latest,
nodeId,
status: failed ? "FAILED" : running ? "RUNNING" : latest.status,
message: failed?.log?.message || latest.message,
startTime: entries[0].log.startTime,
endTime: latest.endTime,
paramsOut: JSON.stringify({
...parseRuntimeValue(latest.paramsOut),
iterationHistory: iterationOutputs,
}),
});
});
};
const runtimeBoolean = (value) => {
if (value === true || value === false) return value;
if (String(value).toLowerCase() === "true") return true;
if (String(value).toLowerCase() === "false") return false;
return null;
};
const decisionAnalysisOf = (log, output) => {
const action = String(log?.action || "").toUpperCase();
const analysisType = String(output?.analysisType || "").toUpperCase();
const isAnalysis = ["AUDIO_EVENT_CLASSIFY", "VIDEO_ANALYZE"].includes(action)
|| analysisType.includes("AUDIO")
|| analysisType.includes("VIDEO");
return {
isAnalysis,
passed: isAnalysis ? runtimeBoolean(output?.passed) : null,
};
};
const SUB_FLOW_ACTION_LABELS = Object.freeze({
START: "开始",
END: "结束",
SLEEP: "等待",
START_LOOP: "循环",
SUB_FLOW: "子流程",
CAMERA_RECORDING_START: "开始录像",
CAMERA_RECORDING_STOP: "结束录像",
CAMERA_GET_IMAGE: "获取图片",
MICROPHONE_START: "开始录音",
MICROPHONE_STOP: "结束录音",
AUDIO_EVENT_CLASSIFY: "声音事件检测",
VIDEO_ANALYZE: "视频分析",
IMAGE_ANALYZE: "图片分析",
ARM_MOVE_TO_POINT: "机械臂运动",
AGV_MOVE_TO_POINT: "底盘导航",
AGV_MOVE_TO_STATION: "站点导航",
SYSTEM_EXECUTE_ACTION_QUEUE: "执行动作队列",
DEVICE_EXECUTE_JSON_COMMAND: "通用设备指令",
});
const subFlowActionLabel = (log) => SUB_FLOW_ACTION_LABELS[String(log?.action || "").toUpperCase()]
|| String(log?.action || log?.nodeType || "内部节点");
const subFlowNodeName = (log) => {
const action = String(log?.action || "");
const match = String(log?.message || "").match(/^\[([^\]]+)]\s*执行/);
const messageName = match?.[1]?.trim();
if (messageName && messageName.toUpperCase() !== action.toUpperCase()) return messageName;
return subFlowActionLabel(log);
};
const emitSubFlowContainerOutputs = (logs) => {
const subFlowNodes = (lf.getGraphData().nodes || []).filter(node => node.type === "subFlow");
subFlowNodes.forEach((subFlowNode) => {
const nodeId = String(subFlowNode.id);
const parentLog = [...logs].reverse().find(log => String(log?.nodeId || "") === nodeId);
const internalLogs = logs.filter(log => resolveSubFlowParentNodeId(String(log?.nodeId || "")) === nodeId);
if (!parentLog && !internalLogs.length) return;
const grouped = new Map();
internalLogs.forEach((log) => {
const nodeType = String(log?.nodeType || "").toUpperCase();
if (["START", "END", "SUB_START", "SUB_END"].includes(nodeType)
&& String(log?.status || "").toUpperCase() !== "FAILED") return;
const iteration = runtimeIterationPath(log);
const key = iteration.length ? iteration.join(".") : "root";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(log);
});
const executions = [...grouped.entries()].map(([key, entries]) => {
const nodes = entries.map((log) => {
const output = parseRuntimeValue(log.paramsOut);
const analysis = decisionAnalysisOf(log, output);
return {
nodeId: log.nodeId,
nodeName: subFlowNodeName(log),
actionLabel: subFlowActionLabel(log),
action: log.action,
status: log.status,
startTime: log.startTime,
endTime: log.endTime,
durationMs: log.endTime && log.startTime ? log.endTime - log.startTime : null,
input: parseRuntimeValue(log.paramsIn),
output,
error: String(log?.status || "").toUpperCase() === "FAILED" ? (log.message || "") : "",
passed: analysis.passed,
isAnalysis: analysis.isAnalysis,
};
});
const failed = nodes.some(node => String(node.status || "").toUpperCase() === "FAILED");
const running = nodes.some(node => String(node.status || "").toUpperCase() === "RUNNING");
return {
key,
label: key === "root" ? "流程步骤" : `第 ${key} 次循环`,
status: failed ? "FAILED" : running ? "RUNNING" : "SUCCESS",
nodes,
};
}).filter(execution => execution.nodes.length);
const allNodes = executions.flatMap(execution => execution.nodes);
const analyses = allNodes.filter(node => node.isAnalysis);
const analysisStatus = analyses.some(node => node.passed === false || String(node.status || "").toUpperCase() === "FAILED")
? "FAILED"
: analyses.some(node => node.passed === null || String(node.status || "").toUpperCase() === "RUNNING")
? "RUNNING"
: analyses.length ? "PASSED" : null;
const failed = allNodes.find(node => String(node.status || "").toUpperCase() === "FAILED");
const running = allNodes.some(node => String(node.status || "").toUpperCase() === "RUNNING");
const parentStatus = String(parentLog?.status || "").toUpperCase();
const status = failed || parentStatus === "FAILED"
? "FAILED"
: running || parentStatus === "RUNNING" ? "RUNNING" : parentStatus || "SUCCESS";
const startTimes = [parentLog?.startTime, ...allNodes.map(node => node.startTime)].map(Number).filter(Number.isFinite);
const endTimes = [parentLog?.endTime, ...allNodes.map(node => node.endTime)].map(Number).filter(Number.isFinite);
const startTime = startTimes.length ? Math.min(...startTimes) : null;
const endTime = status === "RUNNING" || !endTimes.length ? null : Math.max(...endTimes);
const parentOutput = parseRuntimeValue(parentLog?.paramsOut);
emitter.emit("changeNodeState", {
...(parentLog || {}),
nodeId,
nodeType: "SUB_FLOW",
status,
startTime,
endTime,
paramsIn: parentLog?.paramsIn || "{}",
paramsOut: JSON.stringify({
subFlowExecution: {
name: subFlowNode.properties?.subFlow?.detectName || subFlowNode.properties?.name || "子流程",
versionNo: subFlowNode.properties?.subFlow?.versionNo,
status,
analysisStatus,
durationMs: startTime ? (endTime || Date.now()) - startTime : null,
nodeCount: allNodes.length,
executions,
output: parentOutput,
},
}),
message: failed
? `子流程内部节点[${failed.nodeName}]执行失败:${failed.error || "未知原因"}`
: parentLog?.message,
});
});
};
const emitLoopContainerOutputs = (logs) => {
const graph = lf.getGraphData();
const graphNodes = graph.nodes || [];
const nodeById = new Map(graphNodes.map(node => [String(node.id), node]));
graphNodes.filter(node => node.type === "loop").forEach((loopNode) => {
const childIds = new Set([
...(loopNode.children || []),
...graphNodes
.filter(node => String(node.properties?.parentId || "") === String(loopNode.id))
.map(node => node.id),
].map(String));
const loopHasDecisionAnalysis = [...childIds].some((childId) => {
const action = String(nodeById.get(childId)?.properties?.action || "").toUpperCase();
return ["AUDIO_EVENT_CLASSIFY", "VIDEO_ANALYZE"].includes(action);
});
const loopEntries = logs.filter(log => childIds.has(String(log?.nodeId || ""))
&& runtimeIterationPath(log).length);
if (!loopEntries.length) return;
const grouped = new Map();
loopEntries.forEach((log) => {
const iterations = runtimeIterationPath(log);
const key = iterations.join(".");
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push({ log, iterations });
});
const iterationHistory = [...grouped.entries()].map(([iteration, entries]) => {
const failed = entries.some(({ log }) => String(log?.status || "").toUpperCase() === "FAILED");
const running = entries.some(({ log }) => String(log?.status || "").toUpperCase() === "RUNNING");
const nodes = entries.map(({ log }) => {
const output = parseRuntimeValue(log.paramsOut);
const analysis = decisionAnalysisOf(log, output);
const definition = nodeById.get(String(log.nodeId));
return {
nodeId: log.nodeId,
nodeName: definition?.properties?.name || log.action || log.nodeType || log.nodeId,
status: log.status,
startTime: log.startTime,
endTime: log.endTime,
durationMs: log.endTime && log.startTime ? log.endTime - log.startTime : null,
input: parseRuntimeValue(log.paramsIn),
output,
error: String(log?.status || "").toUpperCase() === "FAILED" ? (log.message || "") : "",
isAnalysis: analysis.isAnalysis,
passed: analysis.passed,
};
});
const analyses = nodes.filter(node => node.isAnalysis);
const analysisStatus = analyses.some(node => String(node.status || "").toUpperCase() === "FAILED" || node.passed === false)
? "FAILED"
: analyses.some(node => String(node.status || "").toUpperCase() === "RUNNING" || node.passed === null)
? "RUNNING"
: analyses.length ? "PASSED" : loopHasDecisionAnalysis && running ? "RUNNING" : null;
const startTime = Math.min(...entries.map(({ log }) => Number(log.startTime)).filter(Number.isFinite));
const completedEndTimes = entries.map(({ log }) => Number(log.endTime)).filter(Number.isFinite);
const endTime = !running && completedEndTimes.length ? Math.max(...completedEndTimes) : null;
return {
iteration,
status: failed ? "FAILED" : running ? "RUNNING" : "SUCCESS",
analysisStatus,
startTime: Number.isFinite(startTime) ? startTime : null,
endTime,
durationMs: Number.isFinite(startTime) ? (endTime || Date.now()) - startTime : null,
nodes,
};
});
const controllerLog = [...logs].reverse().find(log => String(log?.nodeId || "") === String(loopNode.id));
const failedIteration = iterationHistory.find(item => item.status === "FAILED");
const runningIteration = iterationHistory.find(item => item.status === "RUNNING");
const loopStatus = failedIteration
? "FAILED"
: runningIteration || String(controllerLog?.status || "").toUpperCase() === "RUNNING"
? "RUNNING"
: String(controllerLog?.status || "SUCCESS").toUpperCase();
const startTime = controllerLog?.startTime || iterationHistory[0]?.startTime;
const endTime = controllerLog?.endTime || iterationHistory[iterationHistory.length - 1]?.endTime;
emitter.emit("changeNodeState", {
...(controllerLog || {}),
nodeId: loopNode.id,
nodeType: "LOOP",
status: loopStatus,
startTime,
endTime,
paramsIn: controllerLog?.paramsIn || "{}",
paramsOut: JSON.stringify({ iterationHistory }),
message: failedIteration ? `第 ${failedIteration.iteration} 次循环执行失败` : controllerLog?.message,
});
});
};
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) {
if (!flowInfoData.value.isLog) scheduleFlowView(instId, taskId);
return;
}
if (arr.length > 0) hasRunResult.value = true;
arr.forEach((item) => {
emitter.emit("changeNodeState", item);
const runtimeNodeId = String(item?.nodeId || "");
const parentNodeId = resolveSubFlowParentNodeId(runtimeNodeId);
if (parentNodeId && String(item?.status || "").toUpperCase() === "FAILED") {
emitter.emit("changeNodeState", {
...item,
nodeId: parentNodeId,
message: `子流程内部节点[${item.action || item.nodeType || "未知节点"}]执行失败:${item.message || "未知原因"}`,
});
}
});
emitLoopIterationOutputs(arr);
emitLoopContainerOutputs(arr);
emitSubFlowContainerOutputs(arr);
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
const first = arr[0];
const reversed = [...arr].reverse();
const mainEnd = reversed.find(
(item) =>
String(item?.nodeType || "").toUpperCase() === "END" &&
(item?.iteration == null ||
item.iteration === "" ||
(Array.isArray(item.iteration) && item.iteration.length === 0)),
);
const failed = reversed.find(
(item) => String(item?.status || "").toUpperCase() === "FAILED",
);
const last = arr[arr.length - 1];
const mainEndStatus = String(mainEnd?.status || "").toUpperCase();
const lastStatus = String(last?.status || "").toUpperCase();
if (failed) {
if (!flowInfoData.value.isLog) {
flowState.value = "testRunError";
totalRunningTime.value = (failed.endTime || Date.now()) - first.startTime;
flowStore.updateDisableForm(false);
}
} else if (mainEndStatus === "SUCCESS") {
if (!flowInfoData.value.isLog) {
flowStore.updateDisableForm(false);
flowState.value = "testRunFinish";
totalRunningTime.value = mainEnd.endTime - first.startTime;
}
} else if (["STOPPED", "PAUSED"].includes(lastStatus)) {
if (!flowInfoData.value.isLog) {
flowState.value = lastStatus === "PAUSED" ? "pause" : "stop";
}
} else if (!flowInfoData.value.isLog) {
// END is first persisted as RUNNING and updated to SUCCESS after the handler returns.
scheduleFlowView(instId, taskId);
}
}
};
let oldFlowData = {};
const getSavableFlowData = () => {
const graphData = lf.getGraphData();
graphData.nodes.forEach((node) => emitter.emit("contentChange", { id: node.id }));
const validation = validateFlowResources(lf.getGraphData());
if (validation.errors.length) {
throw new Error(validation.errors[0].message || "工作流资源配置不完整");
}
return sanitizeFlowResourceDefinition(validation.graph);
};
const saveDraft = async ({ successMessage = "草稿已保存" } = {}) => {
if (!flowInfoData.value.itemId) return false;
draftSaving.value = true;
try {
const flowData = getSavableFlowData();
const res = await flowSaveDraft({
id: flowInfoData.value.itemId,
flowData: JSON.stringify(flowData),
draftRevision: draftRevision.value,
});
if (res.code !== 200) throw new Error(res.msg || "保存失败");
draftRevision.value = res.data?.draftRevision ?? draftRevision.value + 1;
draftDirty.value = false;
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
if (flowState.value === "updateData") flowState.value = "draftSaved";
ElMessage.success(successMessage);
return true;
} catch (error) {
ElMessage.error(error?.message || "保存草稿失败");
return false;
} finally {
draftSaving.value = false;
}
};
const refreshVersionList = async () => {
versionLoading.value = true;
try {
const res = await flowVersionList(flowInfoData.value.itemId);
versionList.value = res.data || [];
} catch (error) {
ElMessage.error(error?.message || "加载发布记录失败");
} finally {
versionLoading.value = false;
}
};
const openVersionHistory = async () => {
versionDrawerVisible.value = true;
await refreshVersionList();
};
const previewVersion = async (version) => {
try {
const res = await flowVersionDetail(version.id);
const detail = res.data || version;
draftBeforeVersionPreview = JSON.parse(JSON.stringify(lf.getGraphData()));
oldFlowDataBeforeVersionPreview = JSON.parse(JSON.stringify(oldFlowData));
disableFormBeforeVersionPreview = flowStore.disableForm;
flowStore.updateDisableForm(true);
lf.render(normalizeFlowResourceSchema(JSON.parse(detail.flowData)));
previewingVersionNo.value = detail.versionNo;
versionPreviewMode.value = true;
versionDrawerVisible.value = false;
nextTick(() => lf.fitView());
} catch (error) {
ElMessage.error(error?.message || "加载发布版本失败");
}
};
const exitVersionPreview = () => {
if (draftBeforeVersionPreview) {
lf.render(draftBeforeVersionPreview);
oldFlowData = oldFlowDataBeforeVersionPreview || {};
}
flowStore.updateDisableForm(disableFormBeforeVersionPreview);
versionPreviewMode.value = false;
previewingVersionNo.value = null;
draftBeforeVersionPreview = null;
oldFlowDataBeforeVersionPreview = null;
nextTick(() => lf.fitView());
};
const restoreVersionAsDraft = async (version) => {
try {
await ElMessageBox.confirm(
`将 V${version.versionNo} 恢复为当前草稿?当前未保存修改会被覆盖,正式运行版本不会改变。`,
"恢复为草稿",
{ type: "warning" }
);
const res = await flowRestoreDraft(version.id, { draftRevision: draftRevision.value });
const item = res.data;
const graph = normalizeFlowResourceSchema(JSON.parse(item.flowData));
lf.render(graph);
draftRevision.value = item.draftRevision ?? draftRevision.value + 1;
draftDirty.value = false;
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
flowState.value = "draftSaved";
versionDrawerVisible.value = false;
nextTick(() => lf.fitView());
ElMessage.success(`已将 V${version.versionNo} 恢复为草稿`);
} catch (error) {
if (error !== "cancel" && error !== "close") {
ElMessage.error(error?.message || "恢复草稿失败");
}
}
};
const rollbackVersion = async (version) => {
try {
await ElMessageBox.confirm(
`立即回退到 V${version.versionNo}?系统会创建一个新的发布版本,正在运行的任务不受影响。`,
"回退发布版本",
{ type: "warning", confirmButtonText: "确认回退" }
);
const res = await flowRollbackVersion(version.id, {
publishNote: `回退到 V${version.versionNo}`,
});
publishedVersionId.value = res.data?.id || "";
publishedVersionNo.value = res.data?.versionNo || null;
flowState.value = "release";
await refreshVersionList();
ElMessage.success(`已发布回退版本 V${publishedVersionNo.value}`);
} catch (error) {
if (error !== "cancel" && error !== "close") {
ElMessage.error(error?.message || "回退版本失败");
}
}
};
/**
* 发布
*/
const deployFlow = async () => {
if (flowState.value !== "testRunFinish") {
ElMessage.warning("试运行成功后才可以发布");
return;
}
if (draftDirty.value) {
ElMessage.warning("请先保存当前草稿,再发布试运行通过的内容");
return;
}
try {
const prompt = await ElMessageBox.prompt("可填写本次发布说明", "发布工作流", {
inputPlaceholder: "例如:调整录像和视频分析参数",
confirmButtonText: "发布",
cancelButtonText: "取消",
});
const res = await flowDeploy({
id: flowInfoData.value.itemId,
draftRevision: draftRevision.value,
publishNote: prompt.value,
});
if (res.code === 200) {
ElMessage.success(res.msg);
publishedVersionId.value = res.data?.id || publishedVersionId.value;
publishedVersionNo.value = res.data?.versionNo || publishedVersionNo.value;
flowState.value = "release";
} else {
ElMessage.error(res.msg);
}
} catch (error) {
if (error !== "cancel" && error !== "close") {
ElMessage.error(error?.message || "发布操作产生异常");
}
}
};
const loadFlowData = async (id, lf) => {
try {
const res = await getDetect(id);
if (res.data && res.data.flowData) {
draftRevision.value = res.data.draftRevision ?? 0;
publishedVersionId.value = res.data.publishedVersionId || "";
publishedVersionNo.value = res.data.publishedVersionNo || null;
draftDirty.value = false;
const flowData = normalizeFlowResourceSchema(JSON.parse(res.data.flowData));
lf.render(flowData);
nextTick(() => {
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
if (
publishedVersionId.value &&
res.data.draftHash &&
res.data.publishedDraftHash &&
res.data.draftHash !== res.data.publishedDraftHash
) {
flowState.value = "draftSaved";
}
lf.fitView();
});
} else {
// 渲染数据 data
lf.render(initFlowData);
oldFlowData = initFlowData;
draftDirty.value = true;
flowState.value = "updateData";
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)) {
draftDirty.value = true;
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 singleQueueStepExecutingIndex = ref(-1);
const activeSingleNodeId = ref("");
// 单节点机器人选择按节点缓存;新节点默认沿用最近一次选择。
const singleNodeRobotCache = new Map();
let lastSingleNodeRobotId = "";
const stationLoading = ref(false);
const singleNodeRobotOptions = ref([]);
const actionQueueSingleRunRef = ref();
const singleNodeInitialRobotId = 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;
};
/**
* 单节点调试入口兼容旧版图片识别参数,把固定检测字段转换成 IMAGE_ANALYZE 的自由提示词协议。
*/
const normalizeImageAnalysisSingleNodeParams = (params) => {
const normalized = params || [];
const findValue = (name) => normalized.find((param) => param?.name === name)?.input;
if (!findValue('prompt')) {
const target = String(findValue('targetDescription') || '').trim();
const instruction = String(findValue('instruction') || '').trim();
const taskType = String(findValue('taskType') || '').toUpperCase();
const prompt = [target || instruction];
if (taskType === 'ICON_TEMPLATE_LOCATE') {
prompt.push('最后一张图片是参考图片,请在其他图片中查找对应目标并返回坐标。');
}
prompt.push('只返回分析结果,不输出通过、未通过或置信度。');
normalized.push({
name: 'prompt', type: 'input', input: prompt.filter(Boolean).join('\n'),
componentType: 'textarea', required: true, disabled: true,
});
}
for (let index = normalized.length - 1; index >= 0; index -= 1) {
if (['taskType', 'targetDescription', 'instruction'].includes(normalized[index]?.name)) {
normalized.splice(index, 1);
}
}
normalized.forEach((param) => {
// 单节点弹窗无法从上游引用取值,图片参数统一改成可粘贴多行地址的输入框。
if (!['imageUrl', 'referenceImageUrl'].includes(param?.name)) return;
param.type = 'input';
param.componentType = 'textarea';
param.input = Array.isArray(param.input) ? param.input.join('\n') : (param.input || '');
param.quote = '';
if (param.name === 'imageUrl') param.placeholder = '每行一个图片地址';
});
return normalized;
};
/**
* 执行端接收 imageUrl 数组;调试弹窗为了便于输入先用文本框承载,提交前再切回数组。
*/
const buildImageAnalysisSingleNodePayload = (params) => {
const payload = buildSingleNodePayload(params);
payload.imageUrl = String(payload.imageUrl || '')
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
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) && param.required !== false) {
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 = () => {
const selectedRobotId = nodeAction.value === "SYSTEM_EXECUTE_ACTION_QUEUE"
? actionQueueSingleRunRef.value?.getRobotId?.()
: singleNodeRobotId.value;
if (!activeSingleNodeId.value || !selectedRobotId) return;
lastSingleNodeRobotId = selectedRobotId;
singleNodeRobotCache.set(activeSingleNodeId.value, selectedRobotId);
};
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;
singleQueueStepExecutingIndex.value = -1;
stationLoading.value = false;
if (effectiveNodeType === "EDGE") {
await loadSingleNodeRobots();
const cachedRobotId = singleNodeRobotCache.get(activeSingleNodeId.value) || lastSingleNodeRobotId;
if (normalizedAction === "SYSTEM_EXECUTE_ACTION_QUEUE") {
formData.nodeParams = executionParams;
singleNodeInitialRobotId.value = cachedRobotId || "";
return;
}
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];
if (cachedRobotId) {
formData.nodeParams[0].input = cachedRobotId;
await handleSingleNodeRobotChange(cachedRobotId);
}
} else {
formData.nodeParams = normalizedAction === 'IMAGE_ANALYZE'
? normalizeImageAnalysisSingleNodeParams(executionParams)
: 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 === "SYSTEM_EXECUTE_ACTION_QUEUE") {
if (!actionQueueSingleRunRef.value?.validate?.()) return;
rememberSingleNodeRobot();
singleNodeExecuting.value = true;
try {
const res = await flowAction({ action: nodeAction.value, payload: actionQueueSingleRunRef.value.getPayload() });
if (res.code === 200) ElMessage.success("执行成功");
else ElMessage.error(res.msg);
} catch (error) {
console.error("动作队列单节点执行失败:", error);
} finally {
singleNodeExecuting.value = false;
}
return;
}
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: nodeAction.value === 'IMAGE_ANALYZE'
? buildImageAnalysisSingleNodePayload(formData.nodeParams)
: buildSingleNodePayload(formData.nodeParams),
});
if (res.code === 200) {
ElMessage.success("执行成功");
} else {
ElMessage.error(res.msg);
}
} catch (error) {
console.error("单节点执行失败:", error);
} finally {
singleNodeExecuting.value = false;
}
};
const executeActionQueueStep = async (index) => {
if (!actionQueueSingleRunRef.value?.validateStep?.(index)) return;
rememberSingleNodeRobot();
singleNodeExecuting.value = true;
singleQueueStepExecutingIndex.value = index;
try {
const res = await flowAction({
action: "SYSTEM_EXECUTE_ACTION_QUEUE",
payload: actionQueueSingleRunRef.value.getStepPayload(index),
});
if (res.code === 200) ElMessage.success("当前步骤执行成功");
else ElMessage.error(res.msg);
} catch (error) {
console.error("动作队列步骤执行失败:", error);
} finally {
singleNodeExecuting.value = false;
singleQueueStepExecutingIndex.value = -1;
}
};
const showParamsDrawer = ref(false);
const paramsDrawerData = ref({});
const handleExtractQueueStep = ({ queueId, index, placement }) => {
const result = extractActionQueueStep(lf.getGraphData(), queueId, index, placement);
if (!result.ok) {
ElMessage.warning(result.message);
return;
}
showParamsDrawer.value = false;
if (result.removedQueue) {
nodeErrorList.value = nodeErrorList.value.filter((id) => id !== queueId);
}
renderQueueGraphChange(result.graph, result.node.id);
ElMessage.success(result.removedQueue ? "已用原节点替换空队列" : "步骤已移出动作队列");
};
/**
* 清理 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();
actionQueueDragOrigins.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;
}
}
.version-history {
display: flex;
flex-direction: column;
gap: 10px;
}
.version-history__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 82px;
padding: 14px 0;
border-bottom: 1px solid #ebeef5;
}
.version-history__main {
min-width: 0;
flex: 1;
}
.version-history__title,
.version-history__actions {
display: flex;
align-items: center;
gap: 8px;
}
.version-history__meta,
.version-history__note {
margin-top: 6px;
color: #606266;
font-size: 13px;
line-height: 1.4;
}
.version-history__note {
overflow-wrap: anywhere;
}
</style>