From cad4184a2f31eff097dc2fbc15d6d70ace79a7f0 Mon Sep 17 00:00:00 2001
From: lixiaolong <702156524@qq.com>
Date: Fri, 14 Aug 2026 15:37:47 +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=E6=8B=96=E6=8B=BD?=
=?UTF-8?q?=E5=92=8C=E6=AD=A5=E9=AA=A4=E6=8F=90=E5=8F=96=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现节点拖拽到动作队列的功能,支持将工作流节点转换为队列步骤
- 添加从动作队列中提取步骤还原为独立节点的功能,支持多种放置方式
- 新增动作队列相关的工具函数和验证逻辑
- 在流程图界面中添加队列拖拽视觉反馈效果
- 扩展动作队列参数面板,增加步骤提取操作选项
- 实现队列步骤与源节点之间的双向转换机制
---
src/utils/actionQueue.js | 341 ++++++++++++++++++
src/views/flow/components/ParamsDrawer.vue | 5 +-
.../components/params/ActionQueueParams.vue | 101 ++++--
src/views/flow/index.vue | 99 ++++-
src/views/flow/vue-flow/FlowNode.vue | 11 +-
5 files changed, 518 insertions(+), 39 deletions(-)
diff --git a/src/utils/actionQueue.js b/src/utils/actionQueue.js
index 5911bfa..9ea3c2d 100644
--- a/src/utils/actionQueue.js
+++ b/src/utils/actionQueue.js
@@ -1,5 +1,346 @@
const deepClone = (value) => JSON.parse(JSON.stringify(value))
+const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE'
+const REFERENCE_KEYS = new Set(['quote', 'nameQuote'])
+const DEFAULT_TOTAL_TIMEOUT_BUFFER_MS = 5000
+
+const createKey = (prefix) => {
+ const random = globalThis.crypto?.randomUUID?.().replace(/-/g, '')
+ || `${Date.now()}${Math.random().toString(16).slice(2)}`
+ return `${prefix}_${random}`
+}
+
+const paramsToObject = (node) => Object.fromEntries(
+ (node?.properties?.nodeParams || []).map((param) => [param.name, param.input]),
+)
+
+const numberValue = (value, fallback = 0) => {
+ if (value === '' || value === null || value === undefined) return fallback
+ const result = Number(value)
+ return Number.isFinite(result) ? result : fallback
+}
+
+const arrayValue = (value) => {
+ if (Array.isArray(value)) return deepClone(value)
+ if (typeof value !== 'string') return value
+ try {
+ const result = JSON.parse(value)
+ return Array.isArray(result) ? result : value
+ } catch {
+ return value
+ }
+}
+
+const nodeToQueueStep = (node) => {
+ const action = node?.properties?.action
+ const params = paramsToObject(node)
+ const common = {
+ stepName: node?.properties?.name || '导入步骤',
+ sourceNode: deepClone(node),
+ }
+ if (node?.type === 'sleep' || String(action).toLowerCase() === 'sleep') {
+ const durationMs = numberValue(params.delayMs)
+ return {
+ ...common,
+ type: 'DELAY',
+ timeoutMs: Math.max(durationMs + 1000, 5000),
+ params: { durationMs },
+ }
+ }
+ if (action === 'AGV_MOVE_TO_POINT') {
+ return {
+ ...common,
+ type: 'AGV_NAVIGATE_TO_POSE',
+ timeoutMs: 60000,
+ params: {
+ x: numberValue(params.x),
+ y: numberValue(params.y),
+ theta: numberValue(params.theta),
+ maxSpeed: numberValue(params.maxSpeed, 0.5),
+ },
+ }
+ }
+ if (action === 'AGV_MOVE_TO_STATION') {
+ return {
+ ...common,
+ type: 'AGV_NAVIGATE_TO_STATION',
+ timeoutMs: 60000,
+ params: {
+ stationId: params.stationId ?? '',
+ maxSpeed: numberValue(params.maxSpeed, 0.5),
+ },
+ }
+ }
+ if (action === 'ARM_MOVE_TO_POINT') {
+ return {
+ ...common,
+ type: 'ARM_MOVE_L',
+ timeoutMs: 3000,
+ params: {
+ x: numberValue(params.x),
+ y: numberValue(params.y),
+ z: numberValue(params.z),
+ rx: numberValue(params.rx),
+ ry: numberValue(params.ry),
+ rz: numberValue(params.rz),
+ frame: params.frame || 'BASE',
+ velocity: numberValue(params.velocity, 2.1),
+ acceleration: numberValue(params.acceleration, 2.2),
+ },
+ }
+ }
+ if (action === 'ARM_MOVE_TO_J') {
+ return {
+ ...common,
+ type: 'ARM_MOVE_J',
+ timeoutMs: 10000,
+ params: {
+ positions: arrayValue(params.target),
+ velocity: numberValue(params.velocity, 0.1),
+ acceleration: numberValue(params.acceleration, 0.2),
+ },
+ }
+ }
+ return null
+}
+
+const validateConvertibleNodeParams = (node) => {
+ const action = node?.properties?.action
+ const params = paramsToObject(node)
+ const requiredByAction = {
+ AGV_MOVE_TO_POINT: ['x', 'y', 'theta'],
+ AGV_MOVE_TO_STATION: ['stationId'],
+ ARM_MOVE_TO_POINT: ['x', 'y', 'z', 'rx', 'ry', 'rz'],
+ ARM_MOVE_TO_J: ['target'],
+ }
+ const missing = (requiredByAction[action] || []).find((name) => (
+ params[name] === '' || params[name] === null || params[name] === undefined
+ ))
+ if (missing) return `节点[${node.properties?.name || node.id}]的参数 ${missing} 尚未配置`
+ if (action === 'ARM_MOVE_TO_J') {
+ const positions = arrayValue(params.target)
+ if (!Array.isArray(positions) || positions.length === 0) return '机械臂关节位置必须是非空 JSON 数组'
+ }
+ if ((node?.type === 'sleep' || String(action).toLowerCase() === 'sleep')
+ && numberValue(params.delayMs) < 0) return '睡眠时长不能小于 0'
+ return ''
+}
+
+const hasReference = (value, referencedNodeId, parentKey = '') => {
+ if (Array.isArray(value)) {
+ if (REFERENCE_KEYS.has(parentKey) && value[0] === referencedNodeId) return true
+ return value.some((item) => hasReference(item, referencedNodeId))
+ }
+ if (!value || typeof value !== 'object') return false
+ return Object.entries(value).some(([key, item]) => hasReference(item, referencedNodeId, key))
+}
+
+const hasDynamicInput = (node) => (node?.properties?.nodeParams || []).some((param) => (
+ param?.type === 'quote'
+ || (Array.isArray(param?.quote) && param.quote.length > 0)
+))
+
+const getNodeParam = (node, name, fallback) => (
+ node.properties?.nodeParams?.find((param) => param.name === name)?.input ?? fallback
+)
+
+const setNodeParam = (node, name, input, defaults = {}) => {
+ node.properties ||= {}
+ node.properties.nodeParams ||= []
+ const param = node.properties.nodeParams.find((item) => item.name === name)
+ if (param) param.input = input
+ else node.properties.nodeParams.push({ name, type: 'input', input, ...defaults })
+}
+
+const queueSteps = (queueNode) => {
+ const value = getNodeParam(queueNode, 'steps', [])
+ return Array.isArray(value) ? deepClone(value) : []
+}
+
+const edgeExists = (edges, sourceNodeId, targetNodeId) => edges.some((edge) => (
+ edge.sourceNodeId === sourceNodeId && edge.targetNodeId === targetNodeId
+))
+
+const createEdge = (sourceNodeId, targetNodeId, sourceAnchorId, targetAnchorId) => ({
+ id: createKey('edge'),
+ type: 'bezier',
+ sourceNodeId,
+ targetNodeId,
+ sourceAnchorId: sourceAnchorId || `${sourceNodeId}_1`,
+ targetAnchorId: targetAnchorId || `${targetNodeId}_3`,
+})
+
+const normalizeQueueStepIds = (steps) => steps.map((step, index) => ({
+ ...step,
+ stepId: `step_${index + 1}`,
+}))
+
+const updateQueueDefinition = (queueNode, steps) => {
+ const normalizedSteps = normalizeQueueStepIds(steps)
+ setNodeParam(queueNode, 'steps', normalizedSteps, { required: true })
+ const timeoutSum = normalizedSteps.reduce((sum, step) => sum + Math.max(0, numberValue(step.timeoutMs)), 0)
+ const currentTimeout = numberValue(getNodeParam(queueNode, 'totalTimeoutMs', 60000), 60000)
+ setNodeParam(
+ queueNode,
+ 'totalTimeoutMs',
+ Math.max(currentTimeout, timeoutSum + DEFAULT_TOTAL_TIMEOUT_BUFFER_MS),
+ { componentType: 'number', required: true },
+ )
+ const bindings = normalizedSteps.filter((step) => step.deviceBindingKey && step.deviceSlotKey).map((step) => ({
+ parameterName: step.deviceBindingKey,
+ slotKey: step.deviceSlotKey,
+ }))
+ queueNode.properties.executionTarget = {
+ ...(queueNode.properties.executionTarget || {}),
+ dispatchPolicy: 'ONE',
+ deviceBindings: bindings,
+ }
+}
+
+export const isNodeConvertibleToActionQueue = (node) => Boolean(nodeToQueueStep(node))
+
+/**
+ * Converts one workflow node into an edge action-queue step and rewrites its former
+ * linear position out of the graph. The source definition is retained for extraction.
+ */
+export const appendNodeToActionQueue = (sourceGraph, nodeId, queueId) => {
+ const graph = deepClone(sourceGraph || { nodes: [], edges: [] })
+ const node = graph.nodes.find((item) => item.id === nodeId)
+ const queueNode = graph.nodes.find((item) => item.id === queueId)
+ if (!node || !queueNode) return { ok: false, message: '未找到要移动的节点或动作队列' }
+ if (node.id === queueNode.id) return { ok: false, message: '动作队列不能放入自身' }
+ if (queueNode.properties?.action !== ACTION_QUEUE_ACTION) return { ok: false, message: '目标节点不是动作队列' }
+ const step = nodeToQueueStep(node)
+ if (!step) return { ok: false, message: `节点[${node.properties?.name || node.id}]暂不支持放入动作队列` }
+ const paramError = validateConvertibleNodeParams(node)
+ if (paramError) return { ok: false, message: paramError }
+ if (node.properties?.parentId !== queueNode.properties?.parentId) {
+ return { ok: false, message: '节点和动作队列必须位于同一个流程或循环体中' }
+ }
+ if (hasDynamicInput(node)) return { ok: false, message: '引用其他节点参数的节点暂不能放入动作队列' }
+ const referencedBy = graph.nodes.find((item) => item.id !== nodeId && hasReference(item.properties, nodeId))
+ if (referencedBy) {
+ return { ok: false, message: `节点[${referencedBy.properties?.name || referencedBy.id}]正在引用该节点的输出` }
+ }
+
+ const incoming = graph.edges.filter((edge) => edge.targetNodeId === nodeId)
+ const outgoing = graph.edges.filter((edge) => edge.sourceNodeId === nodeId)
+ if (incoming.length > 1 || outgoing.length > 1) {
+ return { ok: false, message: '存在分支连线的节点不能放入动作队列' }
+ }
+ if (incoming.length === 1
+ && outgoing.length === 1
+ && incoming[0].sourceNodeId === outgoing[0].targetNodeId) {
+ return { ok: false, message: '节点处于回路中,不能放入动作队列' }
+ }
+
+ const nodeRoleKey = node.properties?.executionTarget?.roleKey
+ const queueRoleKey = queueNode.properties?.executionTarget?.roleKey
+ if (nodeRoleKey && queueRoleKey && nodeRoleKey !== queueRoleKey) {
+ return { ok: false, message: '节点与动作队列使用的机器人角色不同' }
+ }
+ if (nodeRoleKey && !queueRoleKey) queueNode.properties.executionTarget = { roleKey: nodeRoleKey }
+
+ const deviceSlotKey = node.properties?.executionTarget?.deviceSlotKey
+ if (deviceSlotKey) {
+ step.deviceBindingKey = createKey('queue_device')
+ step.deviceSlotKey = deviceSlotKey
+ }
+ step.stepId = `step_${queueSteps(queueNode).length + 1}`
+ const steps = [...queueSteps(queueNode), step]
+ updateQueueDefinition(queueNode, steps)
+
+ graph.nodes = graph.nodes.filter((item) => item.id !== nodeId)
+ graph.nodes.forEach((item) => {
+ if (Array.isArray(item.children)) item.children = item.children.filter((childId) => childId !== nodeId)
+ })
+ graph.edges = graph.edges.filter((edge) => edge.sourceNodeId !== nodeId && edge.targetNodeId !== nodeId)
+
+ const predecessorId = incoming[0]?.sourceNodeId
+ const successorId = outgoing[0]?.targetNodeId
+ if (predecessorId && successorId && predecessorId !== successorId
+ && !edgeExists(graph.edges, predecessorId, successorId)) {
+ graph.edges.push(createEdge(
+ predecessorId,
+ successorId,
+ incoming[0].sourceAnchorId,
+ outgoing[0].targetAnchorId,
+ ))
+ }
+ return { ok: true, graph, queueId, step }
+}
+
+const remapNodeAnchors = (node, previousId, nextId) => {
+ const properties = node.properties || (node.properties = {})
+ if (!Array.isArray(properties.anchor)) return
+ properties.anchor = properties.anchor.map((anchor) => ({
+ ...anchor,
+ id: String(anchor.id || '').startsWith(`${previousId}_`)
+ ? `${nextId}${String(anchor.id).slice(previousId.length)}`
+ : anchor.id,
+ }))
+}
+
+/** Restores an imported queue step as a workflow node. */
+export const extractActionQueueStep = (sourceGraph, queueId, stepIndex, placement = 'detached') => {
+ const graph = deepClone(sourceGraph || { nodes: [], edges: [] })
+ const queueNode = graph.nodes.find((item) => item.id === queueId)
+ if (!queueNode) return { ok: false, message: '动作队列不存在' }
+ const steps = queueSteps(queueNode)
+ const step = steps[stepIndex]
+ if (!step) return { ok: false, message: '要移出的步骤不存在' }
+ if (!step.sourceNode) return { ok: false, message: '该步骤不是由工作流节点移入,无法还原为原节点' }
+
+ const node = deepClone(step.sourceNode)
+ const previousId = node.id
+ node.id = createKey('node')
+ remapNodeAnchors(node, previousId, node.id)
+ delete node.children
+ node.properties ||= {}
+ node.properties.parentId = queueNode.properties?.parentId
+ if (!node.properties.parentId) delete node.properties.parentId
+ const horizontalOffset = placement === 'before' ? -460 : 460
+ node.x = numberValue(queueNode.x) + (placement === 'detached' ? 0 : horizontalOffset)
+ node.y = numberValue(queueNode.y) + (placement === 'detached' ? 260 : 0)
+
+ const incoming = graph.edges.filter((edge) => edge.targetNodeId === queueId)
+ const outgoing = graph.edges.filter((edge) => edge.sourceNodeId === queueId)
+ if ((placement === 'before' && incoming.length > 1) || (placement === 'after' && outgoing.length > 1)) {
+ return { ok: false, message: '动作队列存在多条相关连线,请选择“仅移出,不连线”' }
+ }
+
+ graph.nodes.push(node)
+ if (steps.length === 1) {
+ graph.nodes = graph.nodes.filter((item) => item.id !== queueId)
+ graph.edges.forEach((edge) => {
+ if (edge.sourceNodeId === queueId) {
+ edge.sourceNodeId = node.id
+ edge.sourceAnchorId = `${node.id}_1`
+ }
+ if (edge.targetNodeId === queueId) {
+ edge.targetNodeId = node.id
+ edge.targetAnchorId = `${node.id}_3`
+ }
+ })
+ return { ok: true, graph, node, removedQueue: true }
+ }
+
+ steps.splice(stepIndex, 1)
+ updateQueueDefinition(queueNode, steps)
+ if (placement === 'before') {
+ graph.edges = graph.edges.filter((edge) => edge.targetNodeId !== queueId)
+ const previous = incoming[0]
+ if (previous) graph.edges.push(createEdge(previous.sourceNodeId, node.id, previous.sourceAnchorId, `${node.id}_3`))
+ graph.edges.push(createEdge(node.id, queueId, `${node.id}_1`, `${queueId}_3`))
+ } else if (placement === 'after') {
+ graph.edges = graph.edges.filter((edge) => edge.sourceNodeId !== queueId)
+ const next = outgoing[0]
+ graph.edges.push(createEdge(queueId, node.id, `${queueId}_1`, `${node.id}_3`))
+ if (next) graph.edges.push(createEdge(node.id, next.targetNodeId, `${node.id}_1`, next.targetAnchorId))
+ }
+ return { ok: true, graph, node, removedQueue: false }
+}
+
export const getActionQueueStepName = (step, index) => {
const name = String(step?.stepName || '').trim()
return name || `步骤 ${index + 1}`
diff --git a/src/views/flow/components/ParamsDrawer.vue b/src/views/flow/components/ParamsDrawer.vue
index 92088a6..6b307bf 100644
--- a/src/views/flow/components/ParamsDrawer.vue
+++ b/src/views/flow/components/ParamsDrawer.vue
@@ -20,6 +20,7 @@
:data="props.data"
@save-success="handleSaveSuccess"
@save-error="handleSaveError"
+ @extract-step="handleExtractStep"
/>
@@ -45,7 +46,7 @@ const props = defineProps({
data: Object
})
-const emits = defineEmits(['close'])
+const emits = defineEmits(['close', 'extract-step'])
const paramsComponentRef = ref(null)
/**
@@ -106,6 +107,8 @@ const handleSaveError = () => {
emits('close')
}
+const handleExtractStep = (payload) => emits('extract-step', payload)
+
// 组件卸载时清理事件监听
// 注意:emitter.off 应在各子组件中自行清理,父组件不再重复
diff --git a/src/views/flow/components/params/ActionQueueParams.vue b/src/views/flow/components/params/ActionQueueParams.vue
index 8dc5c2e..ac97d9d 100644
--- a/src/views/flow/components/params/ActionQueueParams.vue
+++ b/src/views/flow/components/params/ActionQueueParams.vue
@@ -42,6 +42,16 @@
+ requestExtractStep(index, placement)">
+
+
+
+ 移到队列前并连接
+ 移到队列后并连接
+ 仅移出,不连线
+
+
+
@@ -131,7 +141,7 @@