From 0df5280ebf51d9a6210924d4af9c6879c8a0c6bd Mon Sep 17 00:00:00 2001 From: lixiaolong <702156524@qq.com> Date: Tue, 11 Aug 2026 16:50:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E9=87=8D=E6=9E=84=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E8=8A=82=E7=82=B9=E8=AE=BE=E5=A4=87=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除原有的 deviceId 参数绑定方式,改用统一的执行目标选择器 - 添加 RobotExecutionTarget 组件用于机器人角色和设备用途选择 - 更新节点参数验证逻辑,确保执行目标正确配置 - 优化流程资源管理,添加角色和设备槽位配置功能 - 改进复制粘贴功能,支持跨窗口节点复制和引用处理 - 重构设备选择对话框,集成到参数表单中统一管理 - 更新流程节点渲染,显示执行角色标签 - 添加流程资源验证和规范化处理逻辑 --- src/api/inspection/runTask.js | 7 +- src/api/intelligenceTest/runningTask.js | 7 +- src/views/flow/components/TestRun.vue | 583 +++++++++--------- .../components/params/BasicNodeParams.vue | 59 +- .../components/params/Device_universal.vue | 171 +---- .../flow/components/params/StartParams.vue | 360 ++++++----- src/views/flow/config.js | 47 +- src/views/flow/index.vue | 253 ++++++-- src/views/flow/vue-flow/FlowNode.vue | 23 + src/views/inspection/runTask/index.vue | 70 ++- .../intelligenceTest/runningTask/index.vue | 72 ++- src/views/is/project/evaluate/Run.vue | 66 +- src/views/test/configs/Run.vue | 72 ++- src/views/vi/testProject/evaluate/Run.vue | 65 +- 14 files changed, 1005 insertions(+), 850 deletions(-) diff --git a/src/api/inspection/runTask.js b/src/api/inspection/runTask.js index fb2c8b4..9b106ba 100644 --- a/src/api/inspection/runTask.js +++ b/src/api/inspection/runTask.js @@ -38,10 +38,11 @@ export function deleteRunTask(id) { }) } -export function startRunTask(id) { +export function startRunTask(id, data) { return request({ url: `/inspection/taskInstance/start/${id}`, - method: 'put' + method: 'put', + data }) } @@ -72,4 +73,4 @@ export function getTaskLogListApi(params) { method: 'get', params: params }) -} \ No newline at end of file +} diff --git a/src/api/intelligenceTest/runningTask.js b/src/api/intelligenceTest/runningTask.js index adfd01e..713d82b 100644 --- a/src/api/intelligenceTest/runningTask.js +++ b/src/api/intelligenceTest/runningTask.js @@ -32,10 +32,11 @@ export function deleteTaskinstance(ids) { }) } -export function startTaskinstance(id) { +export function startTaskinstance(id, data) { return request({ url: `/aima/taskinstance/start/${id}`, - method: 'put' + method: 'put', + data }) } @@ -66,4 +67,4 @@ export function getTaskLogListApi(params) { method: 'get', params: params }) -} \ No newline at end of file +} diff --git a/src/views/flow/components/TestRun.vue b/src/views/flow/components/TestRun.vue index db2adf1..a82fecb 100644 --- a/src/views/flow/components/TestRun.vue +++ b/src/views/flow/components/TestRun.vue @@ -1,328 +1,319 @@ - + + - diff --git a/src/views/flow/components/params/BasicNodeParams.vue b/src/views/flow/components/params/BasicNodeParams.vue index 3f302eb..ce031bb 100644 --- a/src/views/flow/components/params/BasicNodeParams.vue +++ b/src/views/flow/components/params/BasicNodeParams.vue @@ -3,6 +3,11 @@ * 作用范围:仅服务于流程设计器模块。 -->
- {{ robotActionButtonText }} + {{ robotActionButtonText }}
@@ -118,12 +123,14 @@ import { computed, ref, reactive, watch, nextTick } from 'vue' import { Plus, Minus } from '@element-plus/icons-vue' import FormItemRecursive from '../FormItemRecursive.vue' +import RobotExecutionTarget from './RobotExecutionTarget.vue' import { getInput } from '@/utils/flow' import { useQuote } from './useQuote.js' import { getArmStatus, getArmJointState, getAgvStatus, getAgvStations } from '@/api/flow/flow' import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot' import { ElMessage } from 'element-plus' import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice' +import { actionRequiresDevice } from '@/utils/flowResources' const props = defineProps({ data: Object @@ -133,7 +140,8 @@ const emit = defineEmits(['save-success', 'save-error']) const formData = reactive({ nodeParams: [], - outputParams: [] + outputParams: [], + executionTarget: {} }) const rules = reactive({}) @@ -197,6 +205,7 @@ const initData = () => { const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || [])) formData.nodeParams = nodeParams; formData.outputParams = outputParams; + formData.executionTarget = JSON.parse(JSON.stringify(props.data.properties.executionTarget || {})); } watch( @@ -220,6 +229,13 @@ const validateAndSave = async () => { if (outputFormRef.value) { await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false }) } + if (isRobotNode.value && !formData.executionTarget.roleKey) inputValid = false + if (isRobotNode.value + && actionRequiresDevice(props.data.properties.action) + && !formData.executionTarget.deviceSlotKey) { + inputValid = false + ElMessage.warning('请选择该节点使用的设备用途') + } // 保存到 lf lf.setProperties(props.data.id, { @@ -290,10 +306,9 @@ const testRule = (property) => { return [ { required: true, message: '请从站点列表中选择站点', trigger: 'change' }, { validator: () => { - const deviceId = formData.nodeParams.find(param => param.name === 'deviceId')?.input const selectedFromList = property.selectOptions?.some(option => option.value === property.input) - if (selectedFromList && property.stationDeviceId === deviceId) return Promise.resolve() - return Promise.reject(new Error('设备已变化,请重新加载站点列表')) + if (selectedFromList) return Promise.resolve() + return Promise.reject(new Error('请重新加载并选择站点')) }, trigger: 'change' } ] } @@ -331,10 +346,11 @@ const testRule = (property) => { const robotFormAction = ref() const stationListLoading = ref(false) const POSITION_ACTIONS = ['AGV_MOVE_TO_POINT', 'ARM_MOVE_TO_POINT', 'ARM_MOVE_TO_J'] -const hasBindableDevice = computed(() => - formData.nodeParams.some(param => param.name === 'deviceId') - && Boolean(getDeviceKindForAction(props.data.properties.action)) -) +const isRobotNode = computed(() => props.data.properties?.nodeType === 'EDGE') +const hasDeviceDebugAction = computed(() => ( + POSITION_ACTIONS.includes(props.data.properties.action) + || props.data.properties.action === 'AGV_MOVE_TO_STATION' +)) const robotActionButtonText = computed(() => { if (props.data.properties.action === 'AGV_MOVE_TO_STATION') return '选择机器人并加载站点' if (POSITION_ACTIONS.includes(props.data.properties.action)) return '选择机器人并获取位置' @@ -361,8 +377,7 @@ const submitRobotForm = () => { const testMap = { x: x, y: y, - theta: theta, - deviceId: robotForm.deviceId + theta: theta } // 将获取到的位置信息设置到 formData 中 formData.nodeParams.forEach(param => { @@ -390,15 +405,12 @@ const submitRobotForm = () => { throw new Error('AGV未返回可用站点') } const stationParam = formData.nodeParams.find(param => param.name === 'stationId') - const deviceParam = formData.nodeParams.find(param => param.name === 'deviceId') if (stationParam) { stationParam.selectOptions = stationOptions - stationParam.stationDeviceId = robotForm.deviceId if (!stationOptions.some(item => item.value === stationParam.input)) { stationParam.input = '' } } - if (deviceParam) deviceParam.input = robotForm.deviceId dialogVisible.value = false ElMessage.success(`已加载${stationOptions.length}个AGV站点`) } catch (error) { @@ -424,7 +436,6 @@ const submitRobotForm = () => { rx: rx, ry: ry, rz: rz, - deviceId: robotForm.deviceId } // 将获取到的位置信息设置到 formData 中 formData.nodeParams.forEach(param => { @@ -453,28 +464,13 @@ const submitRobotForm = () => { } dialogVisible.value = false const targetParam = formData.nodeParams.find(param => param.name === 'target') - const deviceParam = formData.nodeParams.find(param => param.name === 'deviceId') if (targetParam) targetParam.input = JSON.stringify(target) - if (deviceParam) deviceParam.input = robotForm.deviceId } } catch (error) { console.error('获取机械臂关节位置失败:', error) } } }) - } else { - robotFormRef.value.validate((valid) => { - if (!valid) return - const deviceParam = formData.nodeParams.find(param => param.name === 'deviceId') - if (deviceParam) { - deviceParam.input = robotForm.deviceId - deviceParam.componentType = 'select' - deviceParam.selectOptions = [...deviceOptions.value] - deviceParam.deviceLocked = deviceOptions.value.length === 1 - } - dialogVisible.value = false - ElMessage.success('机器人设备已匹配') - }) } } @@ -500,8 +496,7 @@ const toStationOptions = (stations) => (Array.isArray(stations) ? stations : []) const openRobotForm = async (action) => { dialogVisible.value = true robotFormAction.value = action - const currentDeviceId = formData.nodeParams.find(param => param.name === 'deviceId')?.input || '' - robotForm.deviceId = currentDeviceId + robotForm.deviceId = '' await loadRobotOptions() if (robotForm.robotId) await loadRobotDevices(robotForm.robotId) } diff --git a/src/views/flow/components/params/Device_universal.vue b/src/views/flow/components/params/Device_universal.vue index 70fe46b..6995dc3 100644 --- a/src/views/flow/components/params/Device_universal.vue +++ b/src/views/flow/components/params/Device_universal.vue @@ -3,6 +3,7 @@ * 作用范围:仅服务于流程设计器模块。 -->
-
-
-
执行设备
-
从机器人当前上报的全部在线设备中选择
-
- - 选择机器人设备 - -
- -
通用指令不限制设备类型,设备列表来自机器人最近一次 QUIC 在线上报。
- - - - - - - - - - -
仅有一个在线设备,已自动选择并锁定
-
-
- -
- diff --git a/src/views/flow/config.js b/src/views/flow/config.js index 0d7e9bc..bb1853c 100644 --- a/src/views/flow/config.js +++ b/src/views/flow/config.js @@ -107,9 +107,7 @@ export const collapseList = [ desc: "在获取相机照片时,需要先启动相机", action: 'CAMERA_START', nodeType: 'EDGE', - nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true } - ], + nodeParams: [], outputParams: [], outputType: 'json' }, @@ -120,9 +118,7 @@ export const collapseList = [ desc: "获取相机拍摄的照片", action: 'CAMERA_GETRGBIMAGE', nodeType: 'EDGE', - nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true } - ], + nodeParams: [], outputType: 'img', outputParams: [{ name: 'imageUrl', type: 'array', desc: '图片地址数组', children: [], disabled: true }] }, @@ -133,9 +129,7 @@ export const collapseList = [ desc: "获取相机照片后,关闭该相机", action: 'CAMERA_STOP', nodeType: 'EDGE', - nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true } - ], + nodeParams: [], outputParams: [], outputType: 'json' }, @@ -151,9 +145,7 @@ export const collapseList = [ desc: "开启摄像头,并开始录像", action: 'CAMERA_RECORDING_START', nodeType: 'EDGE', - nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true } - ], + nodeParams: [], outputParams: [], outputType: 'json' }, @@ -164,9 +156,7 @@ export const collapseList = [ desc: "停止录像,并关闭摄像头", action: 'CAMERA_RECORDING_STOP', nodeType: 'EDGE', - nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true } - ], + nodeParams: [], outputType: 'video', outputParams: [{ name: 'videoUrl', type: 'array', children: [], desc: '视频播放地址数组', disabled: true}] } @@ -314,7 +304,6 @@ function handler(params) { action: 'AGV_MOVE_TO_POINT', nodeType: 'EDGE', nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true }, { name: "x", type: "input", input: "", disabled: true }, { name: "y", type: "input", input: "", disabled: true }, { name: "theta", type: "input", input: "", disabled: true }, @@ -329,7 +318,6 @@ function handler(params) { action: 'AGV_MOVE_TO_STATION', nodeType: 'EDGE', nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true }, { name: "stationId", type: "input", input: "", componentType: 'select', selectOptions: [], forceSelect: true, disabled: true } ], outputType: 'json' @@ -342,7 +330,6 @@ function handler(params) { action: 'DEVICE_EXECUTE_JSON_COMMAND', nodeType: 'EDGE', nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], required: true, disabled: true }, { name: "requestJson", type: "input", input: `{ "a":123 }`, required: false, disabled: true }, @@ -358,7 +345,6 @@ function handler(params) { action: 'ARM_MOVE_TO_POINT', nodeType: 'EDGE', nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true }, { name: "x", type: "input", input: "", disabled: true }, { name: "y", type: "input", input: "", disabled: true }, { name: "z", type: "input", input: "", disabled: true }, @@ -378,7 +364,6 @@ function handler(params) { action: 'ARM_MOVE_TO_J', nodeType: 'EDGE', nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true }, { name: "target", type: "input", input: "[0, 0, 0, 0, 0, 0]", disabled: true }, { name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true }, { name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true }, @@ -457,9 +442,7 @@ function handler(params) { desc: "用于启动麦克风的节点", action: 'MICROPHONE_START', nodeType: 'EDGE', - nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true } - ], + nodeParams: [], outputType: 'json' }, { @@ -469,9 +452,7 @@ function handler(params) { desc: "用于停止麦克风的节点", action: 'MICROPHONE_STOP', nodeType: 'EDGE', - nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true } - ], + nodeParams: [], outputType: 'json' }, { @@ -482,7 +463,6 @@ function handler(params) { action: 'SPEAKER_PLAYAUDIO', nodeType: 'EDGE', nodeParams: [ - { name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }, { name: "audioPath", type: "input", input: "", disabled: true } ], outputType: 'json' @@ -636,13 +616,12 @@ function handler(params) { icon: audioSvg, name: "播放语料", type: "serviceNode", - desc: "用于播放语料的节点,需要输入语音路径(audioPath)和播放语音的设备id(deviceId)", + desc: "用于播放语料的节点,需要输入语音路径,执行设备由流程角色配置", action: 'VI_PLAY_CORPUS', nodeType: "EDGE", outputType: 'json', nodeParams: [ { name: 'audioPath', type: "input", input: "", disabled: true }, - { name: 'deviceId', type: "input", input: "", disabled: true }, { name: 'bioId', type: "input", input: "", disabled: true, required: false } ], } @@ -659,7 +638,6 @@ function handler(params) { action: 'TOUCH', nodeType: "EDGE", nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true }, { name: "touchParams", type: "input", input: "", disabled: true } ], outputType: 'json', @@ -671,9 +649,7 @@ function handler(params) { desc: "控制机器人说话", action: 'BIO_HEAD_SPEAK_START', nodeType: "EDGE", - nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true } - ], + nodeParams: [], outputType: 'json', }, { @@ -683,9 +659,7 @@ function handler(params) { desc: "停止机器人说话", action: 'BIO_HEAD_SPEAK_STOP', nodeType: "EDGE", - nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true } - ], + nodeParams: [], outputType: 'json', }, { @@ -696,7 +670,6 @@ function handler(params) { action: 'BIO_HEAD_SPECIAL_EXPRESSION', nodeType: "EDGE", nodeParams: [ - { name: "deviceId", type: "input", input: "", disabled: true }, { name: "expressKey", type: "input",componentType: 'select', selectOptions: expressOptions(), disabled: true }, ], outputType: 'json', diff --git a/src/views/flow/index.vue b/src/views/flow/index.vue index 3f2472c..c67cdf7 100644 --- a/src/views/flow/index.vue +++ b/src/views/flow/index.vue @@ -268,6 +268,15 @@ import { ElMessage } 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, + validateFlowResources, +} from "@/utils/flowResources"; import { flowDeploy, flowView, @@ -349,6 +358,13 @@ const drop = (e) => { ...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); } @@ -357,7 +373,7 @@ const drop = (e) => { !["selectArea", "stopLoop", "subStart", "subEnd"].includes(newNode.type) ) { showParamsDrawer.value = true; - paramsDrawerData.value = newNode; + paramsDrawerData.value = lf.getGraphData().nodes.find((item) => item.id === newNode.id) || newNode; } }; @@ -569,6 +585,17 @@ const testRun = async () => { 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; + } + isOpen.value = true; }; @@ -649,9 +676,9 @@ const initFlowData = { properties: { name: "Start", action: "start", - inputParams: [ - { name: "robotId", type: "string", input: "", disabled: true } - ], + schemaVersion: 2, + resourceRoles: [createDefaultRobotRole()], + inputParams: [], }, }, { @@ -824,7 +851,7 @@ const loadFlowData = async (id, lf) => { try { const res = await getDetect(id); if (res.data && res.data.flowData) { - const flowData = JSON.parse(res.data.flowData); + const flowData = normalizeFlowResourceSchema(JSON.parse(res.data.flowData)); lf.render(flowData); nextTick(() => { @@ -1075,19 +1102,9 @@ const handleSingleNodeRobotChange = async (robotId) => { } deviceParam.componentType = "select"; deviceParam.selectOptions = toDeviceOptions(devices); - if (deviceKind) { - const currentDevice = devices.find((device) => device.deviceId === originalDeviceId); - deviceParam.input = (currentDevice || devices[0]).deviceId; - deviceParam.deviceLocked = devices.length === 1; - return; - } - - const originalStillAvailable = devices.some((device) => device.deviceId === originalDeviceId); - deviceParam.input = originalDeviceId || ""; - deviceParam.deviceLocked = devices.length === 1 && originalStillAvailable; - if (originalDeviceId && !originalStillAvailable) { - ElMessage.warning("已保留通用指令原设备;该设备未出现在当前机器人的在线列表中"); - } + 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; @@ -1153,7 +1170,8 @@ const loadSingleNodeRobots = async () => { const singleNodeExecution = async (e) => { rememberSingleNodeRobot(); const { name, nodeType, nodeParams, action, nodeId } = e.detail; - const executionParams = JSON.parse(JSON.stringify(nodeParams || [])); + 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 || ""}`; @@ -1164,7 +1182,7 @@ const singleNodeExecution = async (e) => { stationLoading.value = false; if (effectiveNodeType === "EDGE") { await loadSingleNodeRobots(); - formData.nodeParams = [ + const routingParams = [ { disabled: true, input: "", @@ -1173,8 +1191,18 @@ const singleNodeExecution = async (e) => { componentType: "select", selectOptions: singleNodeRobotOptions.value, }, - ...executionParams, ]; + if (actionRequiresDevice(normalizedAction)) { + routingParams.push({ + disabled: true, + input: "", + name: "deviceId", + type: "input", + componentType: "select", + selectOptions: [], + }); + } + formData.nodeParams = [...routingParams, ...executionParams]; const cachedRobotId = singleNodeRobotCache.get(activeSingleNodeId.value) || lastSingleNodeRobotId; if (cachedRobotId) { formData.nodeParams[0].input = cachedRobotId; @@ -1246,7 +1274,14 @@ const clearRun = () => { const NODE_PASTE_OFFSET = 40; const NON_COPYABLE_NODE_TYPES = new Set(["start", "end", "selectArea"]); -let copiedGraph = { nodes: [], edges: [] }; +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 = [ @@ -1289,6 +1324,7 @@ const isTextEditing = (event) => { const copySelectedNodes = (event) => { // 复制只保留选中节点及它们之间的内部连线,避免粘贴时重建指向未选节点的关系。 // 节点数据在此深拷贝,后续生成新 ID 时不会反向污染原画布对象。 + if (flowStore.disableForm || isTextEditing(event)) return; const selectedNodes = lf.getSelectElements().nodes; if (!selectedNodes.length) return; @@ -1321,9 +1357,79 @@ const copySelectedNodes = (event) => { 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(); - copiedGraph = JSON.parse(JSON.stringify({ nodes, edges })); + 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; + } }; /** @@ -1343,20 +1449,57 @@ const remapNodeAnchorId = (anchorId, oldNodeId, newNodeId) => { * @param {*} idMap 节点 ID 映射 * @param {*} parentKey 递归父键名 */ -const remapCopiedNodeReferences = (value, idMap, parentKey = "") => { +const remapCopiedNodeReferences = (value, idMap, parentKey = "", options = {}) => { if (Array.isArray(value)) { - const result = value.map((item) => remapCopiedNodeReferences(item, idMap)); - if (parentKey === "quote" && result.length && idMap.has(result[0])) { - result[0] = idMap.get(result[0]); + 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)] + [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 触发操作的浏览器事件 @@ -1364,26 +1507,42 @@ const remapCopiedNodeReferences = (value, idMap, parentKey = "") => { const pasteCopiedNodes = (event) => { // 先为全部节点建立旧 ID 到新 ID 的映射,再重建父子引用、锚点和内部连线。 // 该顺序保证循环、分组和分支之间的交叉引用都能解析到新的目标 ID。 - if (!copiedGraph.nodes.length || flowStore.disableForm) return; + 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 offset = NODE_PASTE_OFFSET * pasteCount; + 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 (!existingNodeIds.has(parentId)) delete properties.parentId; + else if (!isSameEditor || !existingNodeIds.has(parentId)) delete properties.parentId; if (sourceNode.type === "branch" && Array.isArray(properties.anchor)) { properties.anchor = properties.anchor.map((anchor) => ({ @@ -1398,8 +1557,8 @@ const pasteCopiedNodes = (event) => { return { ...JSON.parse(JSON.stringify(sourceNode)), id: newId, - x: Number(sourceNode.x || 0) + offset, - y: Number(sourceNode.y || 0) + offset, + x: Number(sourceNode.x || 0) + translation.x, + y: Number(sourceNode.y || 0) + translation.y, properties, children, }; @@ -1426,8 +1585,13 @@ const pasteCopiedNodes = (event) => { }; }); + const resourceMerge = mergeCopiedResourceRoles(graph, payload.resourceRoles || [], pastedNodes); + const graphNodesWithResources = graph.nodes.map((node) => ( + node.id === resourceMerge.start?.id ? resourceMerge.start : node + )); + lf.render({ - nodes: [...graph.nodes, ...pastedNodes], + nodes: [...graphNodesWithResources, ...pastedNodes], edges: [...graph.edges, ...pastedEdges], }, { resetHistory: false, @@ -1438,6 +1602,14 @@ const pasteCopiedNodes = (event) => { 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} 个设备用途`); + } }); }; @@ -1450,15 +1622,6 @@ const handleKeyboardShortcut = (event) => { const isModifierKey = event.ctrlKey || event.metaKey; const key = event.key.toLowerCase(); - if (isModifierKey && key === "c") { - if (!flowStore.disableForm) copySelectedNodes(event); - return; - } - if (isModifierKey && key === "v") { - pasteCopiedNodes(event); - return; - } - if (isModifierKey && key === "z") { if (flowStore.disableForm) return; event.preventDefault(); @@ -1496,6 +1659,8 @@ onMounted(() => { document.addEventListener("singleNodeExecution", singleNodeExecution); registerBeforeUnload(); window.addEventListener("keydown", handleKeyboardShortcut); + window.addEventListener("copy", copySelectedNodes); + window.addEventListener("paste", pasteCopiedNodes); onFlowEvent("openParamsDrawer", (node) => { showParamsDrawer.value = true; @@ -1548,6 +1713,8 @@ onUnmounted(() => { window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("keydown", handleKeyboardShortcut); + window.removeEventListener("copy", copySelectedNodes); + window.removeEventListener("paste", pasteCopiedNodes); document.removeEventListener("singleNodeExecution", singleNodeExecution); }); diff --git a/src/views/flow/vue-flow/FlowNode.vue b/src/views/flow/vue-flow/FlowNode.vue index 3006758..e0d924d 100644 --- a/src/views/flow/vue-flow/FlowNode.vue +++ b/src/views/flow/vue-flow/FlowNode.vue @@ -32,6 +32,7 @@ @sync-anchors="syncBranchAnchors" @add-to-group="addToGroup" /> +
{{ executionLabel }}
+ + diff --git a/src/views/test/configs/Run.vue b/src/views/test/configs/Run.vue index d97366f..798ff2d 100644 --- a/src/views/test/configs/Run.vue +++ b/src/views/test/configs/Run.vue @@ -7,16 +7,9 @@ :rules="rules" label-width="auto" > - - - - - +
本次执行安排
+ +
运行参数
{ if (res.code === 200) { const data = parseConfig(res.rows) formData.value.tableData = data - handleGetTerminalGroup(); + const definitions = await Promise.all(data.map(async (item) => { + const response = await getDetect(item.itemId) + if (!response.data?.flowData) return [] + return getResourceRoles(normalizeFlowResourceSchema(JSON.parse(response.data.flowData))) + })) + roles.value = mergeResourceRoleDefinitions(definitions) } }; @@ -123,12 +125,23 @@ const confirm = () => { ruleFormRef.value .validate() .then(async () => { - const runParams = {} + const runParams = {} formData.value.tableData.forEach(item => { const data = formatTableData(item.config) runParams[item.itemId] = data }) - const res = await taskExecute({ runParams, taskId: props.currentRow.id, robotId: formData.value.robotId }) + const assignmentError = runtimeEditorRef.value?.validate() + if (assignmentError) { + ElMessage.warning(assignmentError) + return + } + const robotId = runtimeAssignments.value.flatMap(item => item.members).find(member => member.robotId)?.robotId || '' + const res = await taskExecute({ + runParams, + taskId: props.currentRow.id, + robotId, + runtimeAssignments: runtimeAssignments.value + }) if (res.code === 200) { ruleFormRef.value.resetFields(); ElMessage.success('执行成功') @@ -142,22 +155,6 @@ const confirm = () => { }); }; -const robotIdOptions = ref([]); - -/** 获取设备终端列表 */ -const handleGetTerminalGroup = async (row) => { - const res = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' }); - if (res.code === 200) { - robotIdOptions.value = res.rows?.map((item) => { - return { - label: `${item.robotName || item.robotId} (${item.robotId})`, - value: item.robotId, - }; - }); - } -}; - - watch( () => props.drawer, (newVal) => { @@ -171,3 +168,16 @@ onUnmounted(() => { emitter.off("contentChange"); }); + + diff --git a/src/views/vi/testProject/evaluate/Run.vue b/src/views/vi/testProject/evaluate/Run.vue index c67f85e..1874718 100644 --- a/src/views/vi/testProject/evaluate/Run.vue +++ b/src/views/vi/testProject/evaluate/Run.vue @@ -7,16 +7,9 @@ :rules="rules" label-width="auto" > - - - - - +
本次执行安排
+ +
运行参数
+ +