CMVR-IOT-UI/src/views/flow/index.vue
zhanghao d733ea8525 feat: 增强流资源管理和节点执行组件
- 增加了检测流资源中引用循环的功能,以防止循环依赖。
- 改进了流引用的验证,以确保节点输出中不存在循环依赖。
- 增强了资源定义清理功能,以便在执行前移除过时的设备ID。
- 已实现复制资源角色的合并,以优化现有资源的优先级并避免重复。
- 更新了LoopIterationResults组件,以管理运行中迭代的自动扩展。
- 增强了NodeState和NodeTitle组件,以支持循环和子流结果的更宽弹出框显示。
- 优化了ParamsDrawer和SubFlowExecutionResults组件,以处理动态参数更新和执行结果。
- 改进了TaskExecutionLog,以统一输出处理并增强用户对日志的可见性。
- 更新了各种参数组件,以简化输入处理并提升用户体验。
- 在代码中添加了全面的注释和文档,以提高可维护性和易理解性。
2026-08-21 15:01:16 +08:00

3042 lines
102 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);
};
/**
* 从后端运行日志中的内部节点 ID 反推出所属子流程节点。
* 后端存在新旧两类 ID 协议,这里统一归并到外层子流程节点,便于节点状态面板展示内部执行详情。
* @param {string} runtimeNodeId 运行日志中的节点 ID
*/
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;
};
/**
* 运行日志中的入参/出参可能是 JSON 字符串,也可能已经是对象;统一转成对象供结果面板消费。
* @param {*} value 运行日志字段值
*/
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 {};
}
};
/**
* 兼容数组、单值和分隔字符串三种循环层级格式,返回可用于分组展示的循环路径。
* @param {*} item 运行日志条目
*/
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);
};
/**
* 把同一节点在不同循环次数里的日志汇总成 iterationHistory,写回对应节点状态弹窗。
* 这样单个子节点既保留最新状态,也能展开查看每次循环的输入、输出和错误。
* @param {Array} logs 本次轮询返回的运行日志
*/
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,
}),
});
});
};
/**
* 将后端可能返回的布尔字符串转为三态结果;null 表示无法判断,避免误报“未通过”。
* @param {*} value 待转换值
*/
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;
};
/**
* 识别需要参与通过/未通过汇总的音视频分析节点,并提取业务判定结果。
* @param {*} log 运行日志条目
* @param {*} output 已解析的节点输出
*/
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 || "内部节点");
/**
* 优先从日志消息中提取用户配置的节点名;取不到时再退回到动作枚举文案。
* @param {*} log 子流程内部运行日志
*/
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);
};
/**
* 将子流程内部节点日志聚合到外层子流程节点,生成分组后的执行明细和整体分析结论。
* 子流程可能包含循环,因此内部日志按循环路径拆分;普通步骤统一放到 root 分组。
* @param {Array} logs 本次轮询返回的运行日志
*/
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);
// 有任一分析节点失败即认为子流程分析未通过;仍在执行或未返回判定时保持 RUNNING。
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,
});
});
};
/**
* 汇总循环容器下所有子节点的分次执行结果,供循环节点自身的状态弹窗展示。
* 同时计算每次循环的执行状态和分析判定,便于用户快速定位失败的循环轮次。
* @param {Array} logs 本次轮询返回的运行日志
*/
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));
// 如果循环体包含判定型分析节点,即使当前轮次尚未返回 passed,也需要显示“分析中”。
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>