CMVR-BID/src/views/flow/index.vue

2980 lines
99 KiB
Vue
Raw Normal View History

2026-08-07 14:38:16 +08:00
<!--
* 文件说明:流程设计器主页面,负责编辑、校验、试运行、执行跟踪和快捷操作。
* 作用范围:仅服务于流程设计器模块。
-->
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>
<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>
2026-07-28 16:26:07 +08:00
</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'">
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="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'" @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">
<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>
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>
<div v-else class="btn__container">
<el-button type="primary" @click="exitVersionPreview">退出预览</el-button>
</div>
2026-07-28 16:26:07 +08:00
</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="flowStore.disableForm ? null : true"
:pan-on-drag="flowStore.disableForm ? true : [1, 2]"
2026-07-29 11:43:07 +08:00
: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"
:queue-drop-active="nodeProps.id === queueDropTargetId"
/>
2026-07-29 11:43:07 +08:00
</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"
:flow-id="flowInfoData.itemId"
2026-07-28 16:26:07 +08:00
@close="showParamsDrawer = false"
@extract-step="handleExtractQueueStep"
2026-07-28 16:26:07 +08:00
/>
</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>
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>
<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"
/>
2026-07-28 16:26:07 +08:00
<el-form
v-else
2026-07-28 16:26:07 +08:00
: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">
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 ? '参数名' : ''"
>
<el-input
:disabled="field.param?.disabled || false"
v-model="field.param.name"
:placeholder="field.param.placeholder || '请输入'"
2026-07-28 16:26:07 +08:00
clearable
/>
</el-form-item>
<el-form-item
class="single-node-param-value"
2026-07-28 16:26:07 +08:00
:label="index === 0 ? '参数值' : ''"
>
<el-input-number
v-if="field.param.componentType === 'number'"
v-model="field.param.input"
2026-07-28 16:26:07 +08:00
:min="0"
:max="field.param.max || Infinity"
:step="field.param.step || 1"
2026-07-28 16:26:07 +08:00
: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)"
2026-07-28 16:26:07 +08:00
>
<el-option
v-for="item in field.param.selectOptions"
2026-07-28 16:26:07 +08:00
: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="否"
/>
2026-07-28 16:26:07 +08:00
<el-input
v-else
v-model="field.param.input"
2026-07-28 16:26:07 +08:00
autosize
type="textarea"
resize="none"
2026-07-28 16:26:07 +08:00
placeholder="请输入"
clearable
/>
<div v-if="field.param.name === 'deviceId' && field.param.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" :loading="singleNodeExecuting" @click="execute"> 确定 </el-button>
2026-07-28 16:26:07 +08:00
</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>
2026-07-28 16:26:07 +08:00
</template>
<script setup>
import { computed, reactive, ref, watch, onMounted, onUnmounted, nextTick } from "vue";
2026-07-29 11:43:07 +08:00
import { MarkerType, VueFlow, useVueFlow } from "@vue-flow/core";
import { v4 as uuid } from "uuid";
2026-07-29 11:43:07 +08:00
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 ActionQueueSingleRun from "./components/ActionQueueSingleRun.vue";
import { ElMessage, ElMessageBox } from "element-plus";
2026-07-28 16:26:07 +08:00
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";
2026-07-28 16:26:07 +08:00
import {
flowDeploy,
flowSaveDraft,
flowVersionList,
flowVersionDetail,
flowRestoreDraft,
flowRollbackVersion,
2026-07-28 16:26:07 +08:00
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 { 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";
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 actionQueueDragOrigins = new Map();
const queueDropTargetId = ref("");
const { onFlowEvent } = useFlowEvents();
2026-07-29 11:43:07 +08:00
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;
}
};
2026-07-29 11:43:07 +08:00
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
2026-08-07 14:38:16 +08:00
/**
* 处理 dragover 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
2026-07-28 16:26:07 +08:00
const dragover = (e) => {
e.preventDefault();
};
2026-08-07 14:38:16 +08:00
/**
* 处理 drop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
2026-07-28 16:26:07 +08:00
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,
},
});
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);
}
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 (
2026-08-07 15:27:59 +08:00
!["selectArea", "stopLoop", "subStart", "subEnd"].includes(newNode.type)
2026-07-28 16:26:07 +08:00
) {
showParamsDrawer.value = true;
paramsDrawerData.value = lf.getGraphData().nodes.find((item) => item.id === newNode.id) || newNode;
2026-07-28 16:26:07 +08:00
}
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleConnect 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} connection 连接信息
*/
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 });
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleNodeDoubleClick 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node } 调用方传入的 { node } 参数
*/
2026-07-29 11:43:07 +08:00
const handleNodeDoubleClick = ({ node }) => {
const data = lf.getGraphData().nodes.find((item) => item.id === node.id);
if (!data || ["selectArea", "stopLoop", "subStart", "subEnd"].includes(data.type)) return;
2026-07-29 11:43:07 +08:00
showParamsDrawer.value = true;
paramsDrawerData.value = data;
};
2026-08-07 14:38:16 +08:00
/**
* 查找 findLoopAtPoint 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} point 画布坐标
*/
2026-07-29 11:43:07 +08:00
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;
});
};
2026-08-07 14:38:16 +08:00
/**
* 查找 findContainingLoop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} node 当前流程节点
*/
2026-07-29 11:43:07 +08:00
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 });
};
2026-08-07 14:38:16 +08:00
/**
* 处理 eventClientPoint 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
2026-07-29 11:43:07 +08:00
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;
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleNodeDragStart 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node 调用方传入的 { node 参数
* @param {*} event } 调用方传入的 event } 参数
*/
2026-07-29 11:43:07 +08:00
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 });
2026-07-29 11:43:07 +08:00
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 },
});
};
2026-08-07 14:38:16 +08:00
/**
* 更新 updateLoopChildDrag 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { node 调用方传入的 { node 参数
* @param {*} event } 调用方传入的 event } 参数
*/
2026-07-29 11:43:07 +08:00
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,
});
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleNodeDrag 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} payload 事件数据
*/
const handleNodeDrag = (payload) => {
updateLoopChildDrag(payload);
queueDropTargetId.value = queueAtDragEvent(payload.node.id, payload.event)?.id || "";
};
2026-07-29 11:43:07 +08:00
2026-08-07 14:38:16 +08:00
/**
* 处理 handleNodeDragStop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} payload 事件数据
*/
2026-07-29 11:43:07 +08:00
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);
2026-07-29 11:43:07 +08:00
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();
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleNodesChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} changes 变更集合
*/
2026-07-29 11:43:07 +08:00
const handleNodesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type) || (change.type === "position" && change.dragging === false))) {
lf.markChanged();
}
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleEdgesChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} changes 变更集合
*/
2026-07-29 11:43:07 +08:00
const handleEdgesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type))) lf.markChanged();
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleEdgeClick 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} { event 调用方传入的 { event 参数
* @param {*} edge } 调用方传入的 edge } 参数
*/
2026-07-29 11:43:07 +08:00
const handleEdgeClick = ({ event, edge }) => {
event?.stopPropagation?.();
lf.selectElementById(edge.id);
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handlePaneClick 对应的数据或交互;作用范围仅限当前组件或模块。
*/
2026-07-29 11:43:07 +08:00
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: "修改中",
draftSaved: "草稿已保存",
2026-07-28 16:26:07 +08:00
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);
let draftBeforeVersionPreview = null;
let oldFlowDataBeforeVersionPreview = null;
let disableFormBeforeVersionPreview = false;
2026-07-28 16:26:07 +08:00
const flowInfoData = ref({
itemId: "",
instId: "",
taskId: "",
isLog: false,
name: "流程",
});
/**
* 试运行
*/
const testRun = async () => {
// 1、验证画布内所有的表单通过验证
const { nodes, edges } = lf.getGraphData();
const isolatedNodes = nodes.filter((node) => {
const nodeId = node.id;
// 检查是否存在关联边
return !edges.some(
(edge) => edge.sourceNodeId === nodeId || edge.targetNodeId === nodeId
);
});
const gl = isolatedNodes.filter((item) => {
return !item.properties?.parentId && item.type !== "selectArea";
});
if (gl && gl.length > 0) {
ElMessage.warning("存在孤立的节点,请检查!");
return;
}
if (nodeErrorList.value.length > 0) {
ElMessage.warning("存在参数错误的节点,请检查!");
return;
}
const resourceValidation = validateFlowResources({ nodes, edges });
if (resourceValidation.errors.length > 0) {
const error = resourceValidation.errors[0];
ElMessage.warning(error.message);
if (error.nodeId) {
lf.selectElementById(error.nodeId);
lf.focusOn({ id: error.nodeId });
}
return;
}
2026-07-28 16:26:07 +08:00
isOpen.value = true;
};
// 流程暂停
2026-08-07 14:38:16 +08:00
/**
* 处理 flowPauseFn 对应的数据或交互;作用范围仅限当前组件或模块。
*/
2026-07-28 16:26:07 +08:00
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;
2026-08-07 14:38:16 +08:00
/**
* 清理 clearFlowPollTimer 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const clearFlowPollTimer = () => {
if (flowPollTimer !== null) {
window.clearTimeout(flowPollTimer);
flowPollTimer = null;
}
};
2026-08-07 14:38:16 +08:00
/**
* 调度 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);
};
2026-07-28 16:26:07 +08:00
2026-08-07 14:38:16 +08:00
/**
* 处理 flowResumeFn 对应的数据或交互;作用范围仅限当前组件或模块。
*/
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);
}
};
2026-08-07 14:38:16 +08:00
/**
* 处理 flowStopFn 对应的数据或交互;作用范围仅限当前组件或模块。
*/
2026-07-28 16:26:07 +08:00
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",
schemaVersion: 2,
resourceRoles: [createDefaultRobotRole()],
inputParams: [],
2026-07-28 16:26:07 +08:00
},
},
{
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) => {
2026-08-07 14:38:16 +08:00
// 校验思路:先确认端点存在,再按节点类型限制方向;最后结合锚点名称和现有连线判重。
// 任一阶段失败都会删除候选连线,避免非法边进入可保存的画布数据。
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) => {
oldFlowData = JSON.parse(JSON.stringify(lf.getGraphData()));
2026-07-28 16:26:07 +08:00
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 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,
});
});
};
2026-07-28 16:26:07 +08:00
const loopFlowView = async (instId, taskId = null) => {
2026-08-07 14:38:16 +08:00
// 轮询思路:优先使用任务 ID 查询指定执行,否则查询实例最新状态,再统一映射节点状态。
// 仅在非日志模式下根据终态停止或续排定时器,日志回放保持为一次性快照。
2026-07-28 16:26:07 +08:00
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) {
if (!flowInfoData.value.isLog) scheduleFlowView(instId, taskId);
return;
}
if (arr.length > 0) hasRunResult.value = true;
2026-07-28 16:26:07 +08:00
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 || "未知原因"}`,
});
}
2026-07-28 16:26:07 +08:00
});
emitLoopIterationOutputs(arr);
emitLoopContainerOutputs(arr);
emitSubFlowContainerOutputs(arr);
2026-07-28 16:26:07 +08:00
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") {
2026-07-28 16:26:07 +08:00
if (!flowInfoData.value.isLog) {
flowStore.updateDisableForm(false);
flowState.value = "testRunFinish";
totalRunningTime.value = mainEnd.endTime - first.startTime;
2026-07-28 16:26:07 +08:00
}
} else if (["STOPPED", "PAUSED"].includes(lastStatus)) {
2026-07-28 16:26:07 +08:00
if (!flowInfoData.value.isLog) {
flowState.value = lastStatus === "PAUSED" ? "pause" : "stop";
2026-07-28 16:26:07 +08:00
}
} else if (!flowInfoData.value.isLog) {
// END is first persisted as RUNNING and updated to SUCCESS after the handler returns.
scheduleFlowView(instId, taskId);
2026-07-28 16:26:07 +08:00
}
}
};
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 () => {
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("草稿已保存");
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 || "回退版本失败");
}
}
};
2026-07-28 16:26:07 +08:00
/**
* 发布
*/
const deployFlow = async () => {
if (flowState.value !== "testRunFinish") {
ElMessage.warning("试运行成功后才可以发布");
return;
}
if (draftDirty.value) {
ElMessage.warning("请先保存当前草稿,再发布试运行通过的内容");
return;
}
2026-07-28 16:26:07 +08:00
try {
const prompt = await ElMessageBox.prompt("可填写本次发布说明", "发布工作流", {
inputPlaceholder: "例如:调整录像和视频分析参数",
confirmButtonText: "发布",
cancelButtonText: "取消",
});
const res = await flowDeploy({
id: flowInfoData.value.itemId,
draftRevision: draftRevision.value,
publishNote: prompt.value,
});
2026-07-28 16:26:07 +08:00
if (res.code === 200) {
ElMessage.success(res.msg);
publishedVersionId.value = res.data?.id || publishedVersionId.value;
publishedVersionNo.value = res.data?.versionNo || publishedVersionNo.value;
2026-07-28 16:26:07 +08:00
flowState.value = "release";
} else {
ElMessage.error(res.msg);
}
} catch (error) {
if (error !== "cancel" && error !== "close") {
ElMessage.error(error?.message || "发布操作产生异常");
}
2026-07-28 16:26:07 +08:00
}
};
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));
2026-07-28 16:26:07 +08:00
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";
}
2026-07-28 16:26:07 +08:00
lf.fitView();
});
} else {
// 渲染数据 data
lf.render(initFlowData);
oldFlowData = initFlowData;
draftDirty.value = true;
flowState.value = "updateData";
2026-07-28 16:26:07 +08:00
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) => {
2026-07-28 16:26:07 +08:00
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)) {
draftDirty.value = true;
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();
treeData.value = convertToTree(nodes);
2026-07-28 16:26:07 +08:00
};
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("加载机器人设备失败");
}
};
2026-08-07 14:38:16 +08:00
/**
* 处理 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 };
});
2026-08-07 14:38:16 +08:00
/**
* 加载 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;
}
};
2026-07-28 16:26:07 +08:00
2026-08-07 14:38:16 +08:00
/**
* 加载 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,
}));
};
2026-08-07 14:38:16 +08:00
/**
* 处理 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 || ""}`;
2026-07-28 16:26:07 +08:00
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 = [
2026-07-28 16:26:07 +08:00
{
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
},
];
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);
}
2026-07-28 16:26:07 +08:00
} 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 = [];
}
2026-07-28 16:26:07 +08:00
}
};
2026-08-07 14:38:16 +08:00
/**
* 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。
*/
2026-07-28 16:26:07 +08:00
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 {
2026-07-28 16:26:07 +08:00
const res = await flowAction({
action: nodeAction.value,
payload: nodeAction.value === 'IMAGE_ANALYZE'
? buildImageAnalysisSingleNodePayload(formData.nodeParams)
: buildSingleNodePayload(formData.nodeParams),
2026-07-28 16:26:07 +08:00
});
if (res.code === 200) {
ElMessage.success("执行成功");
} else {
ElMessage.error(res.msg);
}
} catch (error) {
console.error("单节点执行失败:", error);
} finally {
singleNodeExecuting.value = false;
2026-07-28 16:26:07 +08:00
}
};
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;
}
};
2026-07-28 16:26:07 +08:00
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 ? "已用原节点替换空队列" : "步骤已移出动作队列");
};
2026-08-07 14:38:16 +08:00
/**
* 清理 clearRun 对应的数据或交互;作用范围仅限当前组件或模块。
*/
2026-07-28 16:26:07 +08:00
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"]);
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 = "";
2026-08-04 10:08:57 +08:00
let pasteCount = 0;
2026-07-29 11:43:07 +08:00
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(",");
2026-08-07 14:38:16 +08:00
/**
* 判断 isEditableElement 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} element 调用方传入的 element 参数
*/
const isEditableElement = (element) => (
element instanceof Element && Boolean(element.closest(EDITABLE_SELECTOR))
);
2026-08-07 14:38:16 +08:00
/**
* 判断 isTextEditing 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
2026-08-04 10:08:57 +08:00
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());
2026-08-04 10:08:57 +08:00
};
2026-08-07 14:38:16 +08:00
/**
* 复制 copySelectedNodes 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
2026-08-04 10:08:57 +08:00
const copySelectedNodes = (event) => {
2026-08-07 14:38:16 +08:00
// 复制只保留选中节点及它们之间的内部连线,避免粘贴时重建指向未选节点的关系。
// 节点数据在此深拷贝,后续生成新 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));
2026-08-07 14:38:16 +08:00
/**
* 新增 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);
}),
2026-08-04 10:08:57 +08:00
);
const nodes = graph.nodes.filter((node) => copyableIds.has(node.id));
2026-08-04 10:08:57 +08:00
if (!nodes.length) return;
const edges = graph.edges.filter((edge) => (
copyableIds.has(edge.sourceNodeId) && copyableIds.has(edge.targetNodeId)
));
2026-08-04 10:08:57 +08:00
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);
2026-08-04 10:08:57 +08:00
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);
}
2026-08-04 10:08:57 +08:00
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;
}
2026-08-04 10:08:57 +08:00
};
2026-08-07 14:38:16 +08:00
/**
* 重映射 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)}`;
};
2026-08-07 14:38:16 +08:00
/**
* 重映射 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,
};
};
2026-08-07 14:38:16 +08:00
/**
* 粘贴 pasteCopiedNodes 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
2026-08-04 10:08:57 +08:00
const pasteCopiedNodes = (event) => {
2026-08-07 14:38:16 +08:00
// 先为全部节点建立旧 ID 到新 ID 的映射,再重建父子引用、锚点和内部连线。
// 该顺序保证循环、分组和分支之间的交叉引用都能解析到新的目标 ID。
if (flowStore.disableForm || isTextEditing(event)) return;
const payload = readFlowClipboardPayload(event);
if (!payload) return;
const copiedGraph = payload.graph;
2026-08-04 10:08:57 +08:00
event.preventDefault();
if (lastPastedClipboardId !== payload.clipboardId) {
pasteCount = 0;
lastPastedClipboardId = payload.clipboardId;
}
2026-08-04 10:08:57 +08:00
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,
);
2026-08-04 10:08:57 +08:00
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),
}));
}
2026-08-04 10:08:57 +08:00
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,
2026-08-04 10:08:57 +08:00
properties,
children,
};
});
2026-08-04 10:08:57 +08:00
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,
),
};
2026-08-04 10:08:57 +08:00
});
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} 个设备用途`);
}
});
2026-08-04 10:08:57 +08:00
};
2026-08-07 14:38:16 +08:00
/**
* 处理 handleKeyboardShortcut 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
*/
2026-08-04 10:08:57 +08:00
const handleKeyboardShortcut = (event) => {
if (isTextEditing(event)) return;
const isModifierKey = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
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);
window.addEventListener("copy", copySelectedNodes);
window.addEventListener("paste", pasteCopiedNodes);
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);
}
const cleanup = pruneUnusedNodeResources(lf.getGraphData());
if (cleanup.start && (cleanup.removedRoles || cleanup.removedSlots)) {
lf.setProperties(cleanup.start.id, cleanup.start.properties);
}
2026-07-28 16:26:07 +08:00
})
});
onUnmounted(() => {
flowDisposed = true;
clearFlowPollTimer();
2026-07-29 11:43:07 +08:00
loopChildDragContexts.clear();
actionQueueDragOrigins.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);
window.removeEventListener("copy", copySelectedNodes);
window.removeEventListener("paste", pasteCopiedNodes);
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
.single-node-param-row {
2026-08-05 09:53:23 +08:00
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) {
2026-08-05 09:53:23 +08:00
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;
}
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-08-05 09:53:23 +08:00
cursor: pointer;
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: grab;
2026-07-29 11:43:07 +08:00
}
2026-07-28 16:26:07 +08:00
:deep(.vue-flow__node.dragging) { cursor: grabbing; }
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) {
2026-08-05 09:53:23 +08:00
cursor: pointer;
2026-07-29 11:43:07 +08:00
}
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) {
2026-08-05 09:53:23 +08:00
cursor: pointer;
2026-07-29 11:43:07 +08:00
}
2026-07-28 16:26:07 +08:00
2026-07-29 11:43:07 +08:00
: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);
2026-07-29 11:43:07 +08:00
}
: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;
}
}
.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;
}
2026-07-28 16:26:07 +08:00
</style>