CMVR-IOT-UI/src/views/flow/components/params/ActionQueueParams.vue

294 lines
13 KiB
Vue
Raw Normal View History

<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>