feat(flow): 添加动作队列节点功能

- 新增 actionQueue 节点类型,支持在边缘端按顺序连续执行多个动作
- 实现 ActionQueueParams 组件,提供执行设置和动作步骤配置界面
- 添加 ActionQueueSingleRun 组件,支持动作队列的单节点执行
- 更新 flowResources 工具函数,完善设备绑定和资源管理逻辑
- 集成动作队列到流程编辑器,支持参数配置和设备分配
- 优化节点渲染显示,支持多设备用途的角色展示
This commit is contained in:
lixiaolong 2026-08-12 17:57:54 +08:00
parent c99cd51d34
commit 16481996b9
8 changed files with 537 additions and 20 deletions

View File

@ -62,6 +62,7 @@ export const getResourceRoles = (graph = globalThis.lf?.getGraphData?.()) => (
) )
const isRobotNode = (node) => node?.properties?.nodeType === 'EDGE' const isRobotNode = (node) => node?.properties?.nodeType === 'EDGE'
export const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE'
export const actionRequiresDevice = (action) => ( export const actionRequiresDevice = (action) => (
getDeviceKindForAction(action) != null || action === 'DEVICE_EXECUTE_JSON_COMMAND' getDeviceKindForAction(action) != null || action === 'DEVICE_EXECUTE_JSON_COMMAND'
) )
@ -139,7 +140,9 @@ export const normalizeFlowResourceSchema = (sourceGraph) => {
} }
target.roleKey ||= defaultRole.roleKey target.roleKey ||= defaultRole.roleKey
target.dispatchPolicy ||= 'ONE' 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 targetRole = roles.find((role) => role.roleKey === target.roleKey) || defaultRole
const deviceParam = properties.nodeParams?.find((param) => param.name === 'deviceId') 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) const slot = findOrCreateLegacySlot(targetRole, node, legacySlotByIdentity, deviceParam?.input)
if (slot) target.deviceSlotKey = slot.slotKey if (slot) target.deviceSlotKey = slot.slotKey
} }
const referencedSlot = targetRole.deviceSlots.find((slot) => slot.slotKey === target.deviceSlotKey) const referencedSlotKeys = new Set([
if (referencedSlot && !referencedSlot.managedBy) { target.deviceSlotKey,
referencedSlot.managedBy = FLOW_NODE_RESOURCE_OWNER ...target.deviceBindings.map((binding) => binding?.slotKey),
} ].filter(Boolean))
delete target.deviceBindings 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.nodeParams = (properties.nodeParams || []).filter((param) => param.name !== 'deviceId')
properties.executionTarget = target properties.executionTarget = target
}) })
@ -182,9 +189,11 @@ export const pruneUnusedNodeResources = (sourceGraph) => {
const target = node.properties?.executionTarget const target = node.properties?.executionTarget
if (!target?.roleKey) return if (!target?.roleKey) return
usedRoleKeys.add(target.roleKey) usedRoleKeys.add(target.roleKey)
if (!target.deviceSlotKey) return
if (!usedSlotsByRole.has(target.roleKey)) usedSlotsByRole.set(target.roleKey, new Set()) 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 let removedSlots = 0
@ -240,6 +249,13 @@ export const validateFlowResources = (sourceGraph) => {
if (target.deviceSlotKey && !(role.deviceSlots || []).some((slot) => slot.slotKey === target.deviceSlotKey)) { if (target.deviceSlotKey && !(role.deviceSlots || []).some((slot) => slot.slotKey === target.deviceSlotKey)) {
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的设备用途不存在` }) 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) if (nodeRequiresDevice(node)
&& !target.deviceSlotKey) { && !target.deviceSlotKey) {
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择设备用途` }) errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择设备用途` })
@ -290,21 +306,34 @@ export const mergeCopiedResourceRoles = (targetGraph, sourceRoles = [], copiedNo
targetRole ||= targetRoles[0] targetRole ||= targetRoles[0]
target.roleKey = targetRole.roleKey target.roleKey = targetRole.roleKey
if (target.deviceSlotKey && !targetRole.deviceSlots?.some((slot) => slot.slotKey === target.deviceSlotKey)) { const remapSlot = (slotKey) => {
const sourceSlot = sourceRole?.deviceSlots?.find((slot) => slot.slotKey === target.deviceSlotKey) 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) => ( const sameSlot = targetRole.deviceSlots?.find((slot) => (
sourceSlot && slot.displayName === sourceSlot.displayName && Number(slot.deviceKind) === Number(sourceSlot.deviceKind) sourceSlot && slot.displayName === sourceSlot.displayName && Number(slot.deviceKind) === Number(sourceSlot.deviceKind)
)) ))
if (sameSlot) { if (sameSlot) return sameSlot.slotKey
target.deviceSlotKey = sameSlot.slotKey
return
}
const newSlot = sourceSlot ? clone(sourceSlot) : createDeviceSlot(getDeviceKindForAction(node.properties?.action), targetRole.deviceSlots) const newSlot = sourceSlot ? clone(sourceSlot) : createDeviceSlot(getDeviceKindForAction(node.properties?.action), targetRole.deviceSlots)
newSlot.managedBy = FLOW_NODE_RESOURCE_OWNER newSlot.managedBy = FLOW_NODE_RESOURCE_OWNER
delete newSlot.legacyDeviceId delete newSlot.legacyDeviceId
targetRole.deviceSlots.push(newSlot) targetRole.deviceSlots.push(newSlot)
target.deviceSlotKey = newSlot.slotKey
addedSlots += 1 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)
}
})
} }
}) })

View File

@ -0,0 +1,111 @@
<template>
<el-form label-position="top">
<el-form-item label="机器人" required>
<el-select v-model="robotId" filterable style="width: 100%" @change="loadDevices">
<el-option v-for="item in robotOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="队列总超时(毫秒)" required>
<el-input-number v-model="totalTimeoutMs" :min="1" :max="2147483647" style="width: 100%" />
</el-form-item>
<el-form-item label="动作标识(可选)">
<el-input v-model="actionId" placeholder="留空时自动生成" />
</el-form-item>
<div v-for="(step, index) in steps" :key="step.stepId || index" class="single-queue-step">
<div class="single-queue-step__title">步骤 {{ index + 1 }} · {{ actionName(step.type) }}</div>
<el-form-item v-if="deviceKind(step.type)" label="执行设备" required>
<el-select v-model="step.deviceId" filterable :loading="deviceLoading" style="width: 100%">
<el-option
v-for="device in devicesForStep(step)"
:key="device.deviceId"
:label="device.deviceName && device.deviceName !== device.deviceId ? `${device.deviceName} (${device.deviceId})` : device.deviceId"
:value="device.deviceId"
/>
</el-select>
</el-form-item>
<div class="single-queue-step__params">{{ stepSummary(step) }}</div>
</div>
</el-form>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { DEVICE_KIND } from '@/utils/robotDevice'
const props = defineProps({
nodeParams: { type: Array, default: () => [] },
robotOptions: { type: Array, default: () => [] },
initialRobotId: { type: String, default: '' },
})
const labels = {
DELAY: '等待', ARM_MOVE_J: '机械臂关节运动', ARM_MOVE_L: '机械臂直线运动',
AGV_NAVIGATE_TO_POSE: '底盘导航到坐标', AGV_NAVIGATE_TO_STATION: '底盘导航到站点', AGV_FOLLOW_PATH: '底盘按路径导航',
}
const robotId = ref('')
const actionId = ref('')
const totalTimeoutMs = ref(60000)
const steps = ref([])
const devices = ref([])
const deviceLoading = ref(false)
const actionName = (type) => labels[type] || type
const deviceKind = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
const devicesForStep = (step) => devices.value.filter((device) => Number(device.deviceKind) === deviceKind(step.type))
const stepSummary = (step) => {
if (step.type === 'DELAY') return `等待 ${step.params?.durationMs || 0} 毫秒`
if (step.type === 'AGV_NAVIGATE_TO_STATION') return `站点 ${step.params?.stationId || '-'}`
return JSON.stringify(step.params || {})
}
const initData = async () => {
const values = Object.fromEntries(props.nodeParams.map((item) => [item.name, item.input]))
actionId.value = values.actionId || ''
totalTimeoutMs.value = Number(values.totalTimeoutMs || 60000)
steps.value = JSON.parse(JSON.stringify(Array.isArray(values.steps) ? values.steps : []))
robotId.value = props.initialRobotId || ''
if (robotId.value) await loadDevices(robotId.value)
}
const loadDevices = async (value) => {
devices.value = []
steps.value.forEach((step) => { step.deviceId = '' })
if (!value) return
deviceLoading.value = true
try {
const response = await getRobotDevicesByRobotId(value)
devices.value = Array.isArray(response.data) ? response.data : []
steps.value.forEach((step) => {
const options = devicesForStep(step)
if (options.length) step.deviceId = options[0].deviceId
})
} finally {
deviceLoading.value = false
}
}
const validate = () => {
if (!robotId.value) return ElMessage.warning('请选择机器人'), false
if (!steps.value.length) return ElMessage.warning('动作队列不能为空'), false
for (let index = 0; index < steps.value.length; index += 1) {
const step = steps.value[index]
if (deviceKind(step.type) && !step.deviceId) {
ElMessage.warning(`步骤 ${index + 1} 没有可用的执行设备`)
return false
}
}
return true
}
const getPayload = () => ({ robotId: robotId.value, actionId: actionId.value, totalTimeoutMs: totalTimeoutMs.value, steps: steps.value })
const getRobotId = () => robotId.value
watch(() => [props.nodeParams, props.initialRobotId], initData, { immediate: true, deep: true })
defineExpose({ validate, getPayload, getRobotId })
</script>
<style scoped>
.single-queue-step { margin-bottom: 10px; padding: 12px; border: 1px solid #dfe4ec; border-radius: 8px; }
.single-queue-step__title { margin-bottom: 10px; color: #1f2937; font-size: 13px; font-weight: 650; }
.single-queue-step__params { overflow: hidden; color: #7a8494; font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@ -37,6 +37,7 @@ import SdAgentNodeParams from './params/SdAgentNodeParams.vue'
import RecognizeNodeParams from './params/RecognizeNodeParams.vue' import RecognizeNodeParams from './params/RecognizeNodeParams.vue'
import DeviceUniversal from './params/DeviceUniversal.vue' import DeviceUniversal from './params/DeviceUniversal.vue'
import SwitchNodeParams from './params/SwitchNodeParams.vue' import SwitchNodeParams from './params/SwitchNodeParams.vue'
import ActionQueueParams from './params/ActionQueueParams.vue'
const props = defineProps({ const props = defineProps({
drawer: Boolean, drawer: Boolean,
@ -63,6 +64,7 @@ const currentComponent = computed(() => {
if (type === 'sdAgent') return SdAgentNodeParams if (type === 'sdAgent') return SdAgentNodeParams
if (type === 'recognize') return RecognizeNodeParams if (type === 'recognize') return RecognizeNodeParams
if (['deviceUniversal', 'device_universal'].includes(type)) return DeviceUniversal if (['deviceUniversal', 'device_universal'].includes(type)) return DeviceUniversal
if (type === 'actionQueue') return ActionQueueParams
if (type === 'branch') return SwitchNodeParams if (type === 'branch') return SwitchNodeParams
return null return null
}) })

View File

@ -0,0 +1,293 @@
<template>
<div class="queue-editor">
<div class="queue-editor__heading">执行设置</div>
<el-form label-position="top">
<el-form-item label="由谁执行" required>
<el-select v-model="formData.executionTarget.roleKey" style="width: 100%" @change="handleRoleChange">
<el-option v-for="role in roles" :key="role.roleKey" :label="role.displayName" :value="role.roleKey" />
</el-select>
</el-form-item>
<el-form-item label="队列总超时(毫秒)" required>
<el-input-number v-model="formData.totalTimeoutMs" :min="1" :max="2147483647" style="width: 100%" />
</el-form-item>
<el-form-item label="动作标识(可选)">
<el-input v-model="formData.actionId" placeholder="留空时执行端自动生成" />
</el-form-item>
</el-form>
<div class="queue-editor__toolbar">
<div>
<div class="queue-editor__heading">动作步骤</div>
<div class="queue-editor__hint">步骤会在边缘端严格按顺序执行。</div>
</div>
<el-button type="primary" plain @click="addStep"><el-icon><Plus /></el-icon>添加步骤</el-button>
</div>
<div v-for="(step, index) in formData.steps" :key="step.localKey" class="queue-step">
<div class="queue-step__header">
<span>步骤 {{ index + 1 }}</span>
<div>
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" title="上移" @click="moveStep(index, -1)" />
<el-button :icon="ArrowDown" circle size="small" :disabled="index === formData.steps.length - 1" title="下移" @click="moveStep(index, 1)" />
<el-button :icon="Delete" circle size="small" type="danger" plain title="删除" @click="removeStep(index)" />
</div>
</div>
<el-form label-position="top">
<el-form-item label="动作" required>
<el-select v-model="step.type" style="width: 100%" @change="handleStepTypeChange(step)">
<el-option v-for="item in actionTypes" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-if="deviceKindForType(step.type)" label="使用设备" required>
<el-select v-model="step.deviceSlotKey" style="width: 100%">
<el-option
v-for="slot in matchingSlots(step.type)"
:key="slot.slotKey"
:label="slot.displayName"
:value="slot.slotKey"
/>
</el-select>
<div v-if="!matchingSlots(step.type).length" class="queue-editor__warning">当前角色没有对应设备用途。</div>
</el-form-item>
<el-form-item label="步骤超时(毫秒)">
<el-input-number v-model="step.timeoutMs" :min="0" :max="2147483647" style="width: 100%" />
</el-form-item>
<template v-for="field in fieldsForType(step.type)" :key="field.key">
<el-form-item :label="field.label" :required="field.required !== false">
<el-select v-if="field.options" v-model="step.params[field.key]" style="width: 100%">
<el-option v-for="option in field.options" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-input-number
v-else-if="field.number"
v-model="step.params[field.key]"
:min="field.min"
:max="field.max ?? Infinity"
:step="field.step || 1"
style="width: 100%"
/>
<el-input v-else v-model="step.params[field.key]" :type="field.multiline ? 'textarea' : 'text'" :rows="3" />
</el-form-item>
</template>
</el-form>
</div>
<el-empty v-if="!formData.steps.length" description="请添加至少一个动作步骤" :image-size="72" />
</div>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
createDeviceSlot,
createResourceKey,
FLOW_NODE_RESOURCE_OWNER,
getResourceRoles,
getStartNode,
pruneUnusedNodeResources,
} from '@/utils/flowResources'
import { DEVICE_KIND } from '@/utils/robotDevice'
const props = defineProps({ data: { type: Object, required: true } })
const emit = defineEmits(['save-success', 'save-error'])
const actionTypes = [
{ value: 'DELAY', label: '等待' },
{ value: 'ARM_MOVE_J', label: '机械臂关节运动' },
{ value: 'ARM_MOVE_L', label: '机械臂直线运动' },
{ value: 'AGV_NAVIGATE_TO_POSE', label: '底盘导航到坐标' },
{ value: 'AGV_NAVIGATE_TO_STATION', label: '底盘导航到站点' },
{ value: 'AGV_FOLLOW_PATH', label: '底盘按路径导航' },
]
const commonMotionFields = [
{ key: 'velocity', label: '速度', number: true, min: 0, step: 0.01, required: false },
{ key: 'acceleration', label: '加速度', number: true, min: 0, step: 0.01, required: false },
]
const fieldsByType = {
DELAY: [{ key: 'durationMs', label: '等待时长(毫秒)', number: true, min: 0 }],
ARM_MOVE_J: [{ key: 'positions', label: '关节位置(JSON 数组)', multiline: true }, ...commonMotionFields],
ARM_MOVE_L: [
...['x', 'y', 'z', 'rx', 'ry', 'rz'].map((key) => ({ key, label: key.toUpperCase(), number: true })),
{ key: 'frame', label: '坐标系', options: ['BASE', 'TOOL', 'WORLD', 'USER'].map((value) => ({ value, label: value })) },
...commonMotionFields,
],
AGV_NAVIGATE_TO_POSE: [
...['x', 'y', 'theta'].map((key) => ({ key, label: key === 'theta' ? '角度' : key.toUpperCase(), number: true })),
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
],
AGV_NAVIGATE_TO_STATION: [
{ key: 'stationId', label: '站点 ID' },
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
],
AGV_FOLLOW_PATH: [
{ key: 'path', label: '路径段(JSON 数组)', multiline: true },
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
],
}
const defaultParams = {
DELAY: { durationMs: 1000 },
ARM_MOVE_J: { positions: '[0, 0, 0, 0, 0, 0]', velocity: 0.1, acceleration: 0.2 },
ARM_MOVE_L: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0, frame: 'BASE', velocity: 0.1, acceleration: 0.2 },
AGV_NAVIGATE_TO_POSE: { x: 0, y: 0, theta: 0, maxSpeed: 0.5 },
AGV_NAVIGATE_TO_STATION: { stationId: '', maxSpeed: 0.5 },
AGV_FOLLOW_PATH: { path: '[]', maxSpeed: 0.5 },
}
const roles = ref([])
const formData = reactive({ actionId: '', totalTimeoutMs: 60000, steps: [], executionTarget: {} })
const fieldsForType = (type) => fieldsByType[type] || []
const deviceKindForType = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
const selectedRole = () => roles.value.find((role) => role.roleKey === formData.executionTarget.roleKey)
const matchingSlots = (type) => (selectedRole()?.deviceSlots || []).filter((slot) => Number(slot.deviceKind) === deviceKindForType(type))
const normalizeStep = (source = {}, index = 0) => {
const params = { ...(defaultParams[source.type || 'DELAY'] || {}), ...(source.params || {}) }
for (const key of ['positions', 'path']) {
if (Array.isArray(params[key])) params[key] = JSON.stringify(params[key])
}
return {
localKey: source.localKey || createResourceKey('queue_step'),
stepId: source.stepId || `step_${index + 1}`,
type: source.type || 'DELAY',
timeoutMs: Number(source.timeoutMs || 0),
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
deviceSlotKey: source.deviceSlotKey || '',
params,
}
}
const initData = () => {
roles.value = getResourceRoles()
const properties = props.data.properties || {}
const params = Object.fromEntries((properties.nodeParams || []).map((item) => [item.name, item.input]))
formData.actionId = params.actionId || ''
formData.totalTimeoutMs = Number(params.totalTimeoutMs || 60000)
formData.steps = (Array.isArray(params.steps) ? params.steps : []).map(normalizeStep)
formData.executionTarget = JSON.parse(JSON.stringify(properties.executionTarget || {}))
formData.executionTarget.roleKey ||= roles.value[0]?.roleKey || ''
formData.steps.forEach((step) => ensureStepSlot(step, false))
}
const persistRoles = () => {
const graph = lf.getGraphData()
const start = getStartNode(graph)
if (!start) return
lf.setProperties(start.id, { ...start.properties, resourceRoles: JSON.parse(JSON.stringify(roles.value)) })
}
const ensureStepSlot = (step, createWhenMissing = true) => {
const kind = deviceKindForType(step.type)
if (!kind) {
step.deviceSlotKey = ''
return
}
const role = selectedRole()
if (!role) return
const options = matchingSlots(step.type)
if (options.some((slot) => slot.slotKey === step.deviceSlotKey)) return
let slot = options[0]
if (!slot && createWhenMissing) {
slot = createDeviceSlot(kind, role.deviceSlots || [])
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
role.deviceSlots ||= []
role.deviceSlots.push(slot)
persistRoles()
}
step.deviceSlotKey = slot?.slotKey || ''
}
const addStep = () => formData.steps.push(normalizeStep({}, formData.steps.length))
const removeStep = (index) => formData.steps.splice(index, 1)
const moveStep = (index, offset) => {
const [step] = formData.steps.splice(index, 1)
formData.steps.splice(index + offset, 0, step)
}
const handleStepTypeChange = (step) => {
step.params = { ...(defaultParams[step.type] || {}) }
ensureStepSlot(step)
}
const handleRoleChange = () => formData.steps.forEach((step) => ensureStepSlot(step))
const parseJsonFields = (step) => {
const params = { ...step.params }
for (const key of ['positions', 'path']) {
if (typeof params[key] !== 'string') continue
try {
const parsed = JSON.parse(params[key])
if (!Array.isArray(parsed)) throw new Error()
params[key] = parsed
} catch {
throw new Error(`${fieldsForType(step.type).find((field) => field.key === key)?.label || key}格式不正确`)
}
}
return params
}
const validateAndSave = async () => {
try {
if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色')
if (!formData.steps.length) throw new Error('请添加至少一个动作步骤')
const steps = formData.steps.map((step, index) => {
if (!step.type) throw new Error(`步骤 ${index + 1} 未选择动作`)
if (deviceKindForType(step.type) && !step.deviceSlotKey) throw new Error(`步骤 ${index + 1} 未选择设备用途`)
const params = parseJsonFields(step)
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}不能为空`)
})
return {
stepId: `step_${index + 1}`,
type: step.type,
timeoutMs: Number(step.timeoutMs || 0),
...(deviceKindForType(step.type) ? {
deviceBindingKey: step.deviceBindingKey,
deviceSlotKey: step.deviceSlotKey,
} : {}),
params,
}
})
const executionTarget = {
roleKey: formData.executionTarget.roleKey,
dispatchPolicy: 'ONE',
deviceBindings: steps.filter((step) => step.deviceBindingKey).map((step) => ({
parameterName: step.deviceBindingKey,
slotKey: step.deviceSlotKey,
})),
}
lf.setProperties(props.data.id, {
...props.data.properties,
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 cleanup = pruneUnusedNodeResources(lf.getGraphData())
if (cleanup.start) {
lf.setProperties(cleanup.start.id, {
...cleanup.start.properties,
resourceRoles: cleanup.start.properties.resourceRoles,
})
}
emit('save-success')
} catch (error) {
ElMessage.warning(error.message)
emit('save-error')
}
}
watch(() => props.data, initData, { immediate: true, deep: true })
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.queue-editor { padding-top: 16px; }
.queue-editor__heading { color: #344054; font-size: 14px; font-weight: 650; }
.queue-editor__hint { margin-top: 3px; color: #7a8494; font-size: 12px; }
.queue-editor__toolbar { display: flex; margin: 14px 0 12px; align-items: center; justify-content: space-between; }
.queue-step { margin-bottom: 12px; padding: 14px; border: 1px solid #dfe4ec; border-radius: 8px; background: #fff; }
.queue-step__header { display: flex; margin-bottom: 12px; align-items: center; justify-content: space-between; color: #1f2937; font-weight: 650; }
.queue-step__header .el-button + .el-button { margin-left: 6px; }
.queue-editor__warning { color: var(--el-color-warning); font-size: 12px; }
</style>

View File

@ -337,6 +337,25 @@ function handler(params) {
outputParams: [], outputParams: [],
outputType: 'json' 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, icon: videoSvg,
name: "机械臂", name: "机械臂",

View File

@ -159,7 +159,7 @@
/> />
</div> </div>
<el-dialog v-model="visible" width="500" class="single-node-dialog" append-to-body> <el-dialog v-model="visible" :width="nodeAction === 'SYSTEM_EXECUTE_ACTION_QUEUE' ? '620' : '500'" class="single-node-dialog" append-to-body>
<template #header="{ close, titleId, titleClass }"> <template #header="{ close, titleId, titleClass }">
<div class="my-header"> <div class="my-header">
<h4 :id="titleId" :class="titleClass">执行{{ nodeName }}节点</h4> <h4 :id="titleId" :class="titleClass">执行{{ nodeName }}节点</h4>
@ -175,7 +175,15 @@
>加载站点列表</el-button> >加载站点列表</el-button>
<span>{{ singleNodeRobotId && singleNodeDeviceId ? '加载后从站点下拉框选择' : '请先选择机器人和设备' }}</span> <span>{{ singleNodeRobotId && singleNodeDeviceId ? '加载后从站点下拉框选择' : '请先选择机器人和设备' }}</span>
</div> </div>
<ActionQueueSingleRun
v-if="nodeAction === 'SYSTEM_EXECUTE_ACTION_QUEUE'"
ref="actionQueueSingleRunRef"
:node-params="formData.nodeParams"
:robot-options="singleNodeRobotOptions"
:initial-robot-id="singleNodeInitialRobotId"
/>
<el-form <el-form
v-else
:inline="true" :inline="true"
:model="formData" :model="formData"
:rules="rules" :rules="rules"
@ -264,6 +272,7 @@ import { useFlowEvents } from "./composables/useFlowEvents";
import TestRun from "./components/TestRun.vue"; import TestRun from "./components/TestRun.vue";
import ParamsDrawer from "./components/ParamsDrawer.vue"; import ParamsDrawer from "./components/ParamsDrawer.vue";
import Aside from "./components/Aside.vue"; import Aside from "./components/Aside.vue";
import ActionQueueSingleRun from "./components/ActionQueueSingleRun.vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useFlowStore } from "@/store/modules/flow"; import { useFlowStore } from "@/store/modules/flow";
import { emitter } from "@/utils/eventBus"; import { emitter } from "@/utils/eventBus";
@ -1024,6 +1033,8 @@ const singleNodeRobotCache = new Map();
let lastSingleNodeRobotId = ""; let lastSingleNodeRobotId = "";
const stationLoading = ref(false); const stationLoading = ref(false);
const singleNodeRobotOptions = ref([]); const singleNodeRobotOptions = ref([]);
const actionQueueSingleRunRef = ref();
const singleNodeInitialRobotId = ref("");
const flattenSingleNodeParams = (params, parentPath = [], fields = []) => { const flattenSingleNodeParams = (params, parentPath = [], fields = []) => {
(params || []).forEach((param, index) => { (params || []).forEach((param, index) => {
const name = String(param?.name || "").trim(); const name = String(param?.name || "").trim();
@ -1070,9 +1081,12 @@ const singleNodeDeviceId = computed(() =>
); );
// 在执行、关闭弹窗或切换节点前记录当前机器人选择。 // 在执行、关闭弹窗或切换节点前记录当前机器人选择。
const rememberSingleNodeRobot = () => { const rememberSingleNodeRobot = () => {
if (!activeSingleNodeId.value || !singleNodeRobotId.value) return; const selectedRobotId = nodeAction.value === "SYSTEM_EXECUTE_ACTION_QUEUE"
lastSingleNodeRobotId = singleNodeRobotId.value; ? actionQueueSingleRunRef.value?.getRobotId?.()
singleNodeRobotCache.set(activeSingleNodeId.value, singleNodeRobotId.value); : singleNodeRobotId.value;
if (!activeSingleNodeId.value || !selectedRobotId) return;
lastSingleNodeRobotId = selectedRobotId;
singleNodeRobotCache.set(activeSingleNodeId.value, selectedRobotId);
}; };
watch(visible, (value) => { watch(visible, (value) => {
@ -1186,6 +1200,12 @@ const singleNodeExecution = async (e) => {
stationLoading.value = false; stationLoading.value = false;
if (effectiveNodeType === "EDGE") { if (effectiveNodeType === "EDGE") {
await loadSingleNodeRobots(); await loadSingleNodeRobots();
const cachedRobotId = singleNodeRobotCache.get(activeSingleNodeId.value) || lastSingleNodeRobotId;
if (normalizedAction === "SYSTEM_EXECUTE_ACTION_QUEUE") {
formData.nodeParams = executionParams;
singleNodeInitialRobotId.value = cachedRobotId || "";
return;
}
const routingParams = [ const routingParams = [
{ {
disabled: true, disabled: true,
@ -1207,7 +1227,6 @@ const singleNodeExecution = async (e) => {
}); });
} }
formData.nodeParams = [...routingParams, ...executionParams]; formData.nodeParams = [...routingParams, ...executionParams];
const cachedRobotId = singleNodeRobotCache.get(activeSingleNodeId.value) || lastSingleNodeRobotId;
if (cachedRobotId) { if (cachedRobotId) {
formData.nodeParams[0].input = cachedRobotId; formData.nodeParams[0].input = cachedRobotId;
await handleSingleNodeRobotChange(cachedRobotId); await handleSingleNodeRobotChange(cachedRobotId);
@ -1228,6 +1247,21 @@ const singleNodeExecution = async (e) => {
* 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。 * 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。
*/ */
const execute = async () => { 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") { if (nodeAction.value === "AGV_MOVE_TO_STATION") {
const stationParam = formData.nodeParams.find((item) => item.name === "stationId"); const stationParam = formData.nodeParams.find((item) => item.name === "stationId");
const validStation = stationParam?.selectOptions?.some((item) => item.value === stationParam.input); const validStation = stationParam?.selectOptions?.some((item) => item.value === stationParam.input);

View File

@ -0,0 +1,25 @@
<template>
<div class="action-queue-node">
<ServiceNode ref="serviceNodeRef" v-bind="props" @content-change="$emit('contentChange')" />
<div class="action-queue-node__summary">{{ stepCount }} 个步骤</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import ServiceNode from '../common/serviceNode.vue'
const props = defineProps({ model: Object, properties: Object })
defineEmits(['contentChange'])
const serviceNodeRef = ref()
const stepCount = computed(() => {
const steps = props.properties?.nodeParams?.find((item) => item.name === 'steps')?.input
return Array.isArray(steps) ? steps.length : 0
})
const closePopover = () => serviceNodeRef.value?.closePopover?.()
defineExpose({ closePopover })
</script>
<style scoped>
.action-queue-node__summary { margin-top: 7px; color: #667085; font-size: 11px; line-height: 16px; }
</style>

View File

@ -80,6 +80,7 @@ import CurrentLoopNode from "../nodes/function/currentLoop.vue";
import SdAgentNode from "../nodes/function/sdAgent.vue"; import SdAgentNode from "../nodes/function/sdAgent.vue";
import RecognizeNode from "../nodes/function/recognize.vue"; import RecognizeNode from "../nodes/function/recognize.vue";
import DeviceUniversal from "../nodes/function/deviceUniversal.vue"; import DeviceUniversal from "../nodes/function/deviceUniversal.vue";
import ActionQueue from "../nodes/function/actionQueue.vue";
const props = defineProps({ const props = defineProps({
id: String, id: String,
@ -106,6 +107,7 @@ const componentMap = {
recognize: RecognizeNode, recognize: RecognizeNode,
deviceUniversal: DeviceUniversal, deviceUniversal: DeviceUniversal,
device_universal: DeviceUniversal, device_universal: DeviceUniversal,
actionQueue: ActionQueue,
}; };
const flowStore = useFlowStore(); const flowStore = useFlowStore();
@ -120,6 +122,8 @@ const executionLabel = computed(() => {
const start = graph?.nodes?.find((node) => node.type === "start"); const start = graph?.nodes?.find((node) => node.type === "start");
const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey); const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey);
if (!role) return "未配置机器人角色"; if (!role) return "未配置机器人角色";
const bindings = properties.value.executionTarget?.deviceBindings || [];
if (bindings.length > 1) return `${role.displayName} · ${bindings.length} 个设备用途`;
const slotKey = properties.value.executionTarget?.deviceSlotKey const slotKey = properties.value.executionTarget?.deviceSlotKey
|| properties.value.executionTarget?.deviceBindings?.[0]?.slotKey; || properties.value.executionTarget?.deviceBindings?.[0]?.slotKey;
const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey); const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey);