- 增加了检测流资源中引用循环的功能,以防止循环依赖。 - 改进了流引用的验证,以确保节点输出中不存在循环依赖。 - 增强了资源定义清理功能,以便在执行前移除过时的设备ID。 - 已实现复制资源角色的合并,以优化现有资源的优先级并避免重复。 - 更新了LoopIterationResults组件,以管理运行中迭代的自动扩展。 - 增强了NodeState和NodeTitle组件,以支持循环和子流结果的更宽弹出框显示。 - 优化了ParamsDrawer和SubFlowExecutionResults组件,以处理动态参数更新和执行结果。 - 改进了TaskExecutionLog,以统一输出处理并增强用户对日志的可见性。 - 更新了各种参数组件,以简化输入处理并提升用户体验。 - 在代码中添加了全面的注释和文档,以提高可维护性和易理解性。
3042 lines
102 KiB
Vue
3042 lines
102 KiB
Vue
<!--
|
||
* 文件说明:流程设计器主页面,负责编辑、校验、试运行、执行跟踪和快捷操作。
|
||
* 作用范围:仅服务于流程设计器模块。
|
||
-->
|
||
<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>
|