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 @@ + + + + @@ -131,7 +141,7 @@