From 16481996b90f94dde823ac606ea23424711bb238 Mon Sep 17 00:00:00 2001 From: lixiaolong <702156524@qq.com> Date: Wed, 12 Aug 2026 17:57:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E6=B7=BB=E5=8A=A0=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E9=98=9F=E5=88=97=E8=8A=82=E7=82=B9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 actionQueue 节点类型,支持在边缘端按顺序连续执行多个动作 - 实现 ActionQueueParams 组件,提供执行设置和动作步骤配置界面 - 添加 ActionQueueSingleRun 组件,支持动作队列的单节点执行 - 更新 flowResources 工具函数,完善设备绑定和资源管理逻辑 - 集成动作队列到流程编辑器,支持参数配置和设备分配 - 优化节点渲染显示,支持多设备用途的角色展示 --- src/utils/flowResources.js | 59 +++- .../flow/components/ActionQueueSingleRun.vue | 111 +++++++ src/views/flow/components/ParamsDrawer.vue | 2 + .../components/params/ActionQueueParams.vue | 293 ++++++++++++++++++ src/views/flow/config.js | 19 ++ src/views/flow/index.vue | 44 ++- src/views/flow/nodes/function/actionQueue.vue | 25 ++ src/views/flow/vue-flow/FlowNode.vue | 4 + 8 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 src/views/flow/components/ActionQueueSingleRun.vue create mode 100644 src/views/flow/components/params/ActionQueueParams.vue create mode 100644 src/views/flow/nodes/function/actionQueue.vue diff --git a/src/utils/flowResources.js b/src/utils/flowResources.js index 9b0cb4e..a761b30 100644 --- a/src/utils/flowResources.js +++ b/src/utils/flowResources.js @@ -62,6 +62,7 @@ export const getResourceRoles = (graph = globalThis.lf?.getGraphData?.()) => ( ) const isRobotNode = (node) => node?.properties?.nodeType === 'EDGE' +export const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE' export const actionRequiresDevice = (action) => ( getDeviceKindForAction(action) != null || action === 'DEVICE_EXECUTE_JSON_COMMAND' ) @@ -139,7 +140,9 @@ export const normalizeFlowResourceSchema = (sourceGraph) => { } target.roleKey ||= defaultRole.roleKey target.dispatchPolicy ||= 'ONE' - target.deviceSlotKey ||= target.deviceBindings?.[0]?.slotKey || '' + const isActionQueue = properties.action === ACTION_QUEUE_ACTION + if (!isActionQueue) target.deviceSlotKey ||= target.deviceBindings?.[0]?.slotKey || '' + target.deviceBindings = Array.isArray(target.deviceBindings) ? target.deviceBindings : [] const targetRole = roles.find((role) => role.roleKey === target.roleKey) || defaultRole const deviceParam = properties.nodeParams?.find((param) => param.name === 'deviceId') @@ -147,11 +150,15 @@ export const normalizeFlowResourceSchema = (sourceGraph) => { const slot = findOrCreateLegacySlot(targetRole, node, legacySlotByIdentity, deviceParam?.input) if (slot) target.deviceSlotKey = slot.slotKey } - const referencedSlot = targetRole.deviceSlots.find((slot) => slot.slotKey === target.deviceSlotKey) - if (referencedSlot && !referencedSlot.managedBy) { - referencedSlot.managedBy = FLOW_NODE_RESOURCE_OWNER - } - delete target.deviceBindings + const referencedSlotKeys = new Set([ + target.deviceSlotKey, + ...target.deviceBindings.map((binding) => binding?.slotKey), + ].filter(Boolean)) + targetRole.deviceSlots.forEach((slot) => { + if (referencedSlotKeys.has(slot.slotKey) && !slot.managedBy) slot.managedBy = FLOW_NODE_RESOURCE_OWNER + }) + if (!isActionQueue) delete target.deviceBindings + if (isActionQueue) delete target.deviceSlotKey properties.nodeParams = (properties.nodeParams || []).filter((param) => param.name !== 'deviceId') properties.executionTarget = target }) @@ -182,9 +189,11 @@ export const pruneUnusedNodeResources = (sourceGraph) => { const target = node.properties?.executionTarget if (!target?.roleKey) return usedRoleKeys.add(target.roleKey) - if (!target.deviceSlotKey) return if (!usedSlotsByRole.has(target.roleKey)) usedSlotsByRole.set(target.roleKey, new Set()) - usedSlotsByRole.get(target.roleKey).add(target.deviceSlotKey) + if (target.deviceSlotKey) usedSlotsByRole.get(target.roleKey).add(target.deviceSlotKey) + ;(target.deviceBindings || []).forEach((binding) => { + if (binding?.slotKey) usedSlotsByRole.get(target.roleKey).add(binding.slotKey) + }) }) let removedSlots = 0 @@ -240,6 +249,13 @@ export const validateFlowResources = (sourceGraph) => { if (target.deviceSlotKey && !(role.deviceSlots || []).some((slot) => slot.slotKey === target.deviceSlotKey)) { errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的设备用途不存在` }) } + ;(target.deviceBindings || []).forEach((binding) => { + if (!binding?.parameterName || !binding?.slotKey) { + errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}存在未配置的队列设备` }) + } else if (!(role.deviceSlots || []).some((slot) => slot.slotKey === binding.slotKey)) { + errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的队列设备用途不存在` }) + } + }) if (nodeRequiresDevice(node) && !target.deviceSlotKey) { errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择设备用途` }) @@ -290,21 +306,34 @@ export const mergeCopiedResourceRoles = (targetGraph, sourceRoles = [], copiedNo targetRole ||= targetRoles[0] target.roleKey = targetRole.roleKey - if (target.deviceSlotKey && !targetRole.deviceSlots?.some((slot) => slot.slotKey === target.deviceSlotKey)) { - const sourceSlot = sourceRole?.deviceSlots?.find((slot) => slot.slotKey === target.deviceSlotKey) + const remapSlot = (slotKey) => { + if (!slotKey || targetRole.deviceSlots?.some((slot) => slot.slotKey === slotKey)) return slotKey + const sourceSlot = sourceRole?.deviceSlots?.find((slot) => slot.slotKey === slotKey) const sameSlot = targetRole.deviceSlots?.find((slot) => ( sourceSlot && slot.displayName === sourceSlot.displayName && Number(slot.deviceKind) === Number(sourceSlot.deviceKind) )) - if (sameSlot) { - target.deviceSlotKey = sameSlot.slotKey - return - } + if (sameSlot) return sameSlot.slotKey const newSlot = sourceSlot ? clone(sourceSlot) : createDeviceSlot(getDeviceKindForAction(node.properties?.action), targetRole.deviceSlots) newSlot.managedBy = FLOW_NODE_RESOURCE_OWNER delete newSlot.legacyDeviceId targetRole.deviceSlots.push(newSlot) - target.deviceSlotKey = newSlot.slotKey addedSlots += 1 + return newSlot.slotKey + } + target.deviceSlotKey = remapSlot(target.deviceSlotKey) + ;(target.deviceBindings || []).forEach((binding) => { + binding.slotKey = remapSlot(binding.slotKey) + }) + const bindingSlotByName = new Map((target.deviceBindings || []).map((binding) => ( + [binding.parameterName, binding.slotKey] + ))) + const queueStepsParam = node.properties?.nodeParams?.find((param) => param.name === 'steps') + if (Array.isArray(queueStepsParam?.input)) { + queueStepsParam.input.forEach((step) => { + if (bindingSlotByName.has(step?.deviceBindingKey)) { + step.deviceSlotKey = bindingSlotByName.get(step.deviceBindingKey) + } + }) } }) diff --git a/src/views/flow/components/ActionQueueSingleRun.vue b/src/views/flow/components/ActionQueueSingleRun.vue new file mode 100644 index 0000000..cc23bf8 --- /dev/null +++ b/src/views/flow/components/ActionQueueSingleRun.vue @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + 步骤 {{ index + 1 }} · {{ actionName(step.type) }} + + + + + + {{ stepSummary(step) }} + + + + + + + diff --git a/src/views/flow/components/ParamsDrawer.vue b/src/views/flow/components/ParamsDrawer.vue index ace2c79..5c76987 100644 --- a/src/views/flow/components/ParamsDrawer.vue +++ b/src/views/flow/components/ParamsDrawer.vue @@ -37,6 +37,7 @@ import SdAgentNodeParams from './params/SdAgentNodeParams.vue' import RecognizeNodeParams from './params/RecognizeNodeParams.vue' import DeviceUniversal from './params/DeviceUniversal.vue' import SwitchNodeParams from './params/SwitchNodeParams.vue' +import ActionQueueParams from './params/ActionQueueParams.vue' const props = defineProps({ drawer: Boolean, @@ -63,6 +64,7 @@ const currentComponent = computed(() => { if (type === 'sdAgent') return SdAgentNodeParams if (type === 'recognize') return RecognizeNodeParams if (['deviceUniversal', 'device_universal'].includes(type)) return DeviceUniversal + if (type === 'actionQueue') return ActionQueueParams if (type === 'branch') return SwitchNodeParams return null }) diff --git a/src/views/flow/components/params/ActionQueueParams.vue b/src/views/flow/components/params/ActionQueueParams.vue new file mode 100644 index 0000000..2a48ce9 --- /dev/null +++ b/src/views/flow/components/params/ActionQueueParams.vue @@ -0,0 +1,293 @@ + + + 执行设置 + + + + + + + + + + + + + + + + + 动作步骤 + 步骤会在边缘端严格按顺序执行。 + + 添加步骤 + + + + + 步骤 {{ index + 1 }} + + + + + + + + + + + + + + + + + 当前角色没有对应设备用途。 + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/flow/config.js b/src/views/flow/config.js index a75eb3d..9f47e60 100644 --- a/src/views/flow/config.js +++ b/src/views/flow/config.js @@ -337,6 +337,25 @@ function handler(params) { outputParams: [], outputType: 'json' }, + { + icon: planSvg, + name: "动作队列", + type: "actionQueue", + desc: "在边缘端按顺序连续执行多个动作", + action: 'SYSTEM_EXECUTE_ACTION_QUEUE', + nodeType: 'EDGE', + nodeParams: [ + { name: "actionId", type: "input", input: "", required: false }, + { name: "totalTimeoutMs", type: "input", componentType: 'number', input: 60000, required: true }, + { name: "steps", type: "input", input: [], required: true }, + ], + outputParams: [ + { name: 'actionId', type: 'string', desc: '动作队列标识' }, + { name: 'completedSteps', type: 'number', desc: '已完成步骤数' }, + { name: 'result', type: 'string', desc: '执行结果' }, + ], + outputType: 'json' + }, { icon: videoSvg, name: "机械臂", diff --git a/src/views/flow/index.vue b/src/views/flow/index.vue index 4753b93..7d45c71 100644 --- a/src/views/flow/index.vue +++ b/src/views/flow/index.vue @@ -159,7 +159,7 @@ /> - + 执行{{ nodeName }}节点 @@ -175,7 +175,15 @@ >加载站点列表 {{ singleNodeRobotId && singleNodeDeviceId ? '加载后从站点下拉框选择' : '请先选择机器人和设备' }} + { (params || []).forEach((param, index) => { const name = String(param?.name || "").trim(); @@ -1070,9 +1081,12 @@ const singleNodeDeviceId = computed(() => ); // 在执行、关闭弹窗或切换节点前记录当前机器人选择。 const rememberSingleNodeRobot = () => { - if (!activeSingleNodeId.value || !singleNodeRobotId.value) return; - lastSingleNodeRobotId = singleNodeRobotId.value; - singleNodeRobotCache.set(activeSingleNodeId.value, singleNodeRobotId.value); + 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) => { @@ -1186,6 +1200,12 @@ const singleNodeExecution = async (e) => { 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, @@ -1207,7 +1227,6 @@ const singleNodeExecution = async (e) => { }); } formData.nodeParams = [...routingParams, ...executionParams]; - const cachedRobotId = singleNodeRobotCache.get(activeSingleNodeId.value) || lastSingleNodeRobotId; if (cachedRobotId) { formData.nodeParams[0].input = cachedRobotId; await handleSingleNodeRobotChange(cachedRobotId); @@ -1228,6 +1247,21 @@ const singleNodeExecution = async (e) => { * 执行 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); diff --git a/src/views/flow/nodes/function/actionQueue.vue b/src/views/flow/nodes/function/actionQueue.vue new file mode 100644 index 0000000..c711e3b --- /dev/null +++ b/src/views/flow/nodes/function/actionQueue.vue @@ -0,0 +1,25 @@ + + + + {{ stepCount }} 个步骤 + + + + + + diff --git a/src/views/flow/vue-flow/FlowNode.vue b/src/views/flow/vue-flow/FlowNode.vue index dcadb0f..df7b186 100644 --- a/src/views/flow/vue-flow/FlowNode.vue +++ b/src/views/flow/vue-flow/FlowNode.vue @@ -80,6 +80,7 @@ import CurrentLoopNode from "../nodes/function/currentLoop.vue"; import SdAgentNode from "../nodes/function/sdAgent.vue"; import RecognizeNode from "../nodes/function/recognize.vue"; import DeviceUniversal from "../nodes/function/deviceUniversal.vue"; +import ActionQueue from "../nodes/function/actionQueue.vue"; const props = defineProps({ id: String, @@ -106,6 +107,7 @@ const componentMap = { recognize: RecognizeNode, deviceUniversal: DeviceUniversal, device_universal: DeviceUniversal, + actionQueue: ActionQueue, }; const flowStore = useFlowStore(); @@ -120,6 +122,8 @@ const executionLabel = computed(() => { const start = graph?.nodes?.find((node) => node.type === "start"); const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey); if (!role) return "未配置机器人角色"; + const bindings = properties.value.executionTarget?.deviceBindings || []; + if (bindings.length > 1) return `${role.displayName} · ${bindings.length} 个设备用途`; const slotKey = properties.value.executionTarget?.deviceSlotKey || properties.value.executionTarget?.deviceBindings?.[0]?.slotKey; const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey);