feat(flow): 添加动作队列节点拖拽和步骤提取功能
- 实现节点拖拽到动作队列的功能,支持将工作流节点转换为队列步骤 - 添加从动作队列中提取步骤还原为独立节点的功能,支持多种放置方式 - 新增动作队列相关的工具函数和验证逻辑 - 在流程图界面中添加队列拖拽视觉反馈效果 - 扩展动作队列参数面板,增加步骤提取操作选项 - 实现队列步骤与源节点之间的双向转换机制
This commit is contained in:
parent
7de5b5d093
commit
cad4184a2f
@ -1,5 +1,346 @@
|
|||||||
const deepClone = (value) => JSON.parse(JSON.stringify(value))
|
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) => {
|
export const getActionQueueStepName = (step, index) => {
|
||||||
const name = String(step?.stepName || '').trim()
|
const name = String(step?.stepName || '').trim()
|
||||||
return name || `步骤 ${index + 1}`
|
return name || `步骤 ${index + 1}`
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
:data="props.data"
|
:data="props.data"
|
||||||
@save-success="handleSaveSuccess"
|
@save-success="handleSaveSuccess"
|
||||||
@save-error="handleSaveError"
|
@save-error="handleSaveError"
|
||||||
|
@extract-step="handleExtractStep"
|
||||||
/>
|
/>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
@ -45,7 +46,7 @@ const props = defineProps({
|
|||||||
data: Object
|
data: Object
|
||||||
})
|
})
|
||||||
|
|
||||||
const emits = defineEmits(['close'])
|
const emits = defineEmits(['close', 'extract-step'])
|
||||||
const paramsComponentRef = ref(null)
|
const paramsComponentRef = ref(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -106,6 +107,8 @@ const handleSaveError = () => {
|
|||||||
emits('close')
|
emits('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExtractStep = (payload) => emits('extract-step', payload)
|
||||||
|
|
||||||
// 组件卸载时清理事件监听
|
// 组件卸载时清理事件监听
|
||||||
// 注意:emitter.off 应在各子组件中自行清理,父组件不再重复
|
// 注意:emitter.off 应在各子组件中自行清理,父组件不再重复
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -42,6 +42,16 @@
|
|||||||
<el-tooltip content="复制步骤" placement="top">
|
<el-tooltip content="复制步骤" placement="top">
|
||||||
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
|
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
|
<el-dropdown v-if="step.sourceNode" trigger="click" @command="(placement) => requestExtractStep(index, placement)">
|
||||||
|
<el-button :icon="MoreFilled" circle size="small" aria-label="移出队列" />
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="before">移到队列前并连接</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="after">移到队列后并连接</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="detached" divided>仅移出,不连线</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
<el-tooltip content="删除步骤" placement="top">
|
<el-tooltip content="删除步骤" placement="top">
|
||||||
<el-button :icon="Delete" circle size="small" type="danger" plain aria-label="删除步骤" @click="removeStep(index)" />
|
<el-button :icon="Delete" circle size="small" type="danger" plain aria-label="删除步骤" @click="removeStep(index)" />
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
@ -131,7 +141,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, Plus } from '@element-plus/icons-vue'
|
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, MoreFilled, Plus } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
|
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
|
||||||
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
|
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
|
||||||
@ -147,7 +157,7 @@ import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
|
|||||||
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep } from '@/utils/actionQueue'
|
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep } from '@/utils/actionQueue'
|
||||||
|
|
||||||
const props = defineProps({ data: { type: Object, required: true } })
|
const props = defineProps({ data: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['save-success', 'save-error'])
|
const emit = defineEmits(['save-success', 'save-error', 'extract-step'])
|
||||||
|
|
||||||
const actionTypes = [
|
const actionTypes = [
|
||||||
{ value: 'DELAY', label: '等待' },
|
{ value: 'DELAY', label: '等待' },
|
||||||
@ -238,6 +248,7 @@ const normalizeStep = (source = {}, index = 0) => {
|
|||||||
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
|
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
|
||||||
deviceSlotKey: source.deviceSlotKey || '',
|
deviceSlotKey: source.deviceSlotKey || '',
|
||||||
stationOptions: [],
|
stationOptions: [],
|
||||||
|
...(source.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(source.sourceNode)) } : {}),
|
||||||
params,
|
params,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -424,46 +435,66 @@ const parseJsonFields = (step) => {
|
|||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateAndSave = async () => {
|
const buildQueueDefinition = () => {
|
||||||
try {
|
if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色')
|
||||||
if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色')
|
if (!formData.steps.length) throw new Error('请添加至少一个动作步骤')
|
||||||
if (!formData.steps.length) throw new Error('请添加至少一个动作步骤')
|
const steps = formData.steps.map((step, index) => {
|
||||||
const steps = formData.steps.map((step, index) => {
|
if (!step.type) throw new Error(`步骤 ${index + 1} 未选择动作`)
|
||||||
if (!step.type) throw new Error(`步骤 ${index + 1} 未选择动作`)
|
if (deviceKindForType(step.type) && !step.deviceSlotKey) throw new Error(`步骤 ${index + 1} 未选择设备用途`)
|
||||||
if (deviceKindForType(step.type) && !step.deviceSlotKey) throw new Error(`步骤 ${index + 1} 未选择设备用途`)
|
const params = parseJsonFields(step)
|
||||||
const params = parseJsonFields(step)
|
fieldsForType(step.type).filter((field) => field.required !== false).forEach((field) => {
|
||||||
fieldsForType(step.type).filter((field) => field.required !== false).forEach((field) => {
|
if (params[field.key] === '' || params[field.key] == null) throw new Error(`步骤 ${index + 1} 的${field.label}不能为空`)
|
||||||
if (params[field.key] === '' || params[field.key] == null) throw new Error(`步骤 ${index + 1} 的${field.label}不能为空`)
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
stepId: `step_${index + 1}`,
|
|
||||||
stepName: String(step.stepName || '').trim() || `步骤 ${index + 1}`,
|
|
||||||
type: step.type,
|
|
||||||
timeoutMs: Number(step.timeoutMs || 0),
|
|
||||||
...(deviceKindForType(step.type) ? {
|
|
||||||
deviceBindingKey: step.deviceBindingKey,
|
|
||||||
deviceSlotKey: step.deviceSlotKey,
|
|
||||||
} : {}),
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
const executionTarget = {
|
return {
|
||||||
|
stepId: `step_${index + 1}`,
|
||||||
|
stepName: String(step.stepName || '').trim() || `步骤 ${index + 1}`,
|
||||||
|
type: step.type,
|
||||||
|
timeoutMs: Number(step.timeoutMs || 0),
|
||||||
|
...(deviceKindForType(step.type) ? {
|
||||||
|
deviceBindingKey: step.deviceBindingKey,
|
||||||
|
deviceSlotKey: step.deviceSlotKey,
|
||||||
|
} : {}),
|
||||||
|
...(step.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(step.sourceNode)) } : {}),
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
executionTarget: {
|
||||||
roleKey: formData.executionTarget.roleKey,
|
roleKey: formData.executionTarget.roleKey,
|
||||||
dispatchPolicy: 'ONE',
|
dispatchPolicy: 'ONE',
|
||||||
deviceBindings: steps.filter((step) => step.deviceBindingKey).map((step) => ({
|
deviceBindings: steps.filter((step) => step.deviceBindingKey).map((step) => ({
|
||||||
parameterName: step.deviceBindingKey,
|
parameterName: step.deviceBindingKey,
|
||||||
slotKey: step.deviceSlotKey,
|
slotKey: step.deviceSlotKey,
|
||||||
})),
|
})),
|
||||||
}
|
},
|
||||||
lf.setProperties(props.data.id, {
|
}
|
||||||
...props.data.properties,
|
}
|
||||||
executionTarget,
|
|
||||||
nodeParams: [
|
const persistQueueDefinition = ({ steps, executionTarget }) => {
|
||||||
{ name: 'actionId', type: 'input', input: formData.actionId, required: false },
|
lf.setProperties(props.data.id, {
|
||||||
{ name: 'totalTimeoutMs', type: 'input', componentType: 'number', input: formData.totalTimeoutMs, required: true },
|
...props.data.properties,
|
||||||
{ name: 'steps', type: 'input', input: steps, required: true },
|
executionTarget,
|
||||||
],
|
nodeParams: [
|
||||||
})
|
{ name: 'actionId', type: 'input', input: formData.actionId, required: false },
|
||||||
|
{ name: 'totalTimeoutMs', type: 'input', componentType: 'number', input: formData.totalTimeoutMs, required: true },
|
||||||
|
{ name: 'steps', type: 'input', input: steps, required: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestExtractStep = (index, placement) => {
|
||||||
|
try {
|
||||||
|
persistQueueDefinition(buildQueueDefinition())
|
||||||
|
emit('extract-step', { queueId: props.data.id, index, placement })
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateAndSave = async () => {
|
||||||
|
try {
|
||||||
|
persistQueueDefinition(buildQueueDefinition())
|
||||||
const cleanup = pruneUnusedNodeResources(lf.getGraphData())
|
const cleanup = pruneUnusedNodeResources(lf.getGraphData())
|
||||||
if (cleanup.start) {
|
if (cleanup.start) {
|
||||||
lf.setProperties(cleanup.start.id, {
|
lf.setProperties(cleanup.start.id, {
|
||||||
|
|||||||
@ -132,7 +132,10 @@
|
|||||||
@drop="drop"
|
@drop="drop"
|
||||||
>
|
>
|
||||||
<template #node-flow-node="nodeProps">
|
<template #node-flow-node="nodeProps">
|
||||||
<FlowNode v-bind="nodeProps" />
|
<FlowNode
|
||||||
|
v-bind="nodeProps"
|
||||||
|
:queue-drop-active="nodeProps.id === queueDropTargetId"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #edge-flow-edge="edgeProps">
|
<template #edge-flow-edge="edgeProps">
|
||||||
<FlowEdge v-bind="edgeProps" />
|
<FlowEdge v-bind="edgeProps" />
|
||||||
@ -156,6 +159,7 @@
|
|||||||
:drawer="true"
|
:drawer="true"
|
||||||
:data="paramsDrawerData"
|
:data="paramsDrawerData"
|
||||||
@close="showParamsDrawer = false"
|
@close="showParamsDrawer = false"
|
||||||
|
@extract-step="handleExtractQueueStep"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -287,6 +291,10 @@ import {
|
|||||||
pruneUnusedNodeResources,
|
pruneUnusedNodeResources,
|
||||||
validateFlowResources,
|
validateFlowResources,
|
||||||
} from "@/utils/flowResources";
|
} from "@/utils/flowResources";
|
||||||
|
import {
|
||||||
|
appendNodeToActionQueue,
|
||||||
|
extractActionQueueStep,
|
||||||
|
} from "@/utils/actionQueue";
|
||||||
import {
|
import {
|
||||||
flowDeploy,
|
flowDeploy,
|
||||||
flowView,
|
flowView,
|
||||||
@ -318,6 +326,8 @@ const lfRef = ref(null);
|
|||||||
const vueFlow = useVueFlow("main-flow");
|
const vueFlow = useVueFlow("main-flow");
|
||||||
const lf = createFlowFacade(vueFlow);
|
const lf = createFlowFacade(vueFlow);
|
||||||
const loopChildDragContexts = new Map();
|
const loopChildDragContexts = new Map();
|
||||||
|
const actionQueueDragOrigins = new Map();
|
||||||
|
const queueDropTargetId = ref("");
|
||||||
const { onFlowEvent } = useFlowEvents();
|
const { onFlowEvent } = useFlowEvents();
|
||||||
window.lf = lf;
|
window.lf = lf;
|
||||||
|
|
||||||
@ -452,12 +462,71 @@ const eventClientPoint = (event) => {
|
|||||||
return { x: pointer.clientX, y: pointer.clientY };
|
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 对应的数据或交互;作用范围仅限当前组件或模块。
|
* 处理 handleNodeDragStart 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||||
* @param {*} { node 调用方传入的 { node 参数
|
* @param {*} { node 调用方传入的 { node 参数
|
||||||
* @param {*} event } 调用方传入的 event } 参数
|
* @param {*} event } 调用方传入的 event } 参数
|
||||||
*/
|
*/
|
||||||
const handleNodeDragStart = ({ node, 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 drag = lf.prepareLoopChildDrag(node.id);
|
||||||
const clientPoint = eventClientPoint(event);
|
const clientPoint = eventClientPoint(event);
|
||||||
if (!drag || !clientPoint) return;
|
if (!drag || !clientPoint) return;
|
||||||
@ -488,7 +557,10 @@ const updateLoopChildDrag = ({ node, event }) => {
|
|||||||
* 处理 handleNodeDrag 对应的数据或交互;作用范围仅限当前组件或模块。
|
* 处理 handleNodeDrag 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||||
* @param {*} payload 事件数据
|
* @param {*} payload 事件数据
|
||||||
*/
|
*/
|
||||||
const handleNodeDrag = (payload) => updateLoopChildDrag(payload);
|
const handleNodeDrag = (payload) => {
|
||||||
|
updateLoopChildDrag(payload);
|
||||||
|
queueDropTargetId.value = queueAtDragEvent(payload.node.id, payload.event)?.id || "";
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 handleNodeDragStop 对应的数据或交互;作用范围仅限当前组件或模块。
|
* 处理 handleNodeDragStop 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||||
@ -498,6 +570,14 @@ const handleNodeDragStop = (payload) => {
|
|||||||
const { node } = payload;
|
const { node } = payload;
|
||||||
updateLoopChildDrag(payload);
|
updateLoopChildDrag(payload);
|
||||||
loopChildDragContexts.delete(node.id);
|
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);
|
const vueNode = vueFlow.findNode(node.id);
|
||||||
if (
|
if (
|
||||||
vueNode
|
vueNode
|
||||||
@ -1295,6 +1375,20 @@ const execute = async () => {
|
|||||||
const showParamsDrawer = ref(false);
|
const showParamsDrawer = ref(false);
|
||||||
const paramsDrawerData = ref({});
|
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 对应的数据或交互;作用范围仅限当前组件或模块。
|
* 清理 clearRun 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||||
*/
|
*/
|
||||||
@ -1751,6 +1845,7 @@ onUnmounted(() => {
|
|||||||
flowDisposed = true;
|
flowDisposed = true;
|
||||||
clearFlowPollTimer();
|
clearFlowPollTimer();
|
||||||
loopChildDragContexts.clear();
|
loopChildDragContexts.clear();
|
||||||
|
actionQueueDragOrigins.clear();
|
||||||
if (window.lf === lf) delete window.lf;
|
if (window.lf === lf) delete window.lf;
|
||||||
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|||||||
@ -5,7 +5,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flow-node-shell"
|
class="flow-node-shell"
|
||||||
:class="[`flow-node-shell--${flowType}`, { 'flow-node-shell--selected': selected }]"
|
:class="[`flow-node-shell--${flowType}`, {
|
||||||
|
'flow-node-shell--selected': selected,
|
||||||
|
'flow-node-shell--queue-drop-active': queueDropActive,
|
||||||
|
}]"
|
||||||
>
|
>
|
||||||
<Handle
|
<Handle
|
||||||
v-if="showTarget"
|
v-if="showTarget"
|
||||||
@ -86,6 +89,7 @@ const props = defineProps({
|
|||||||
id: String,
|
id: String,
|
||||||
data: Object,
|
data: Object,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
|
queueDropActive: Boolean,
|
||||||
connectable: [Boolean, Number, String, Function],
|
connectable: [Boolean, Number, String, Function],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -259,6 +263,11 @@ onMounted(() => bindComponent(componentRef.value));
|
|||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&--queue-drop-active .flow-node-content {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.35), 0 10px 24px rgba(47, 128, 237, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
&--start,
|
&--start,
|
||||||
&--subStart { --node-accent: #239565; }
|
&--subStart { --node-accent: #239565; }
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user