2026-08-12 17:57:54 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-08-13 09:40:16 +08:00
|
|
|
|
<el-collapse v-model="activeStepKeys" class="queue-steps">
|
|
|
|
|
|
<el-collapse-item v-for="(step, index) in formData.steps" :key="step.localKey" :name="step.localKey">
|
|
|
|
|
|
<template #title>
|
|
|
|
|
|
<div class="queue-step__header">
|
|
|
|
|
|
<div class="queue-step__title">
|
|
|
|
|
|
<span>步骤 {{ index + 1 }}</span>
|
|
|
|
|
|
<span class="queue-step__action">{{ actionLabel(step.type) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="queue-step__actions" @click.stop>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<div class="queue-step">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<div v-if="stepHelperLabel(step.type)" class="queue-step__helper">
|
|
|
|
|
|
<el-button type="primary" plain :icon="Location" @click="openStepHelper(step)">
|
|
|
|
|
|
{{ stepHelperLabel(step.type) }}
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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.key === 'stationId' && step.stationOptions?.length"
|
|
|
|
|
|
v-model="step.params[field.key]"
|
|
|
|
|
|
filterable
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-option v-for="option in step.stationOptions" :key="option.value" :label="option.label" :value="option.value" />
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
<el-select v-else-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>
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</div>
|
2026-08-13 09:40:16 +08:00
|
|
|
|
</el-collapse-item>
|
|
|
|
|
|
</el-collapse>
|
|
|
|
|
|
<el-empty v-if="!formData.steps.length" description="请添加至少一个动作步骤" :image-size="72" />
|
|
|
|
|
|
|
|
|
|
|
|
<el-dialog v-model="helperDialogVisible" :title="stepHelperLabel(helperStep?.type)" width="460" append-to-body>
|
|
|
|
|
|
<div class="queue-helper__tip">在线设备仅用于读取参数,不会写入流程定义。</div>
|
2026-08-12 17:57:54 +08:00
|
|
|
|
<el-form label-position="top">
|
2026-08-13 09:40:16 +08:00
|
|
|
|
<el-form-item label="机器人" required>
|
|
|
|
|
|
<el-select v-model="helperForm.robotId" filterable style="width: 100%" @change="loadHelperDevices">
|
|
|
|
|
|
<el-option v-for="item in helperRobotOptions" :key="item.value" :label="item.label" :value="item.value" />
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</el-select>
|
|
|
|
|
|
</el-form-item>
|
2026-08-13 09:40:16 +08:00
|
|
|
|
<el-form-item label="执行设备" required>
|
|
|
|
|
|
<el-select v-model="helperForm.deviceId" filterable :loading="helperDeviceLoading" style="width: 100%">
|
|
|
|
|
|
<el-option v-for="item in helperDeviceOptions" :key="item.value" :label="item.label" :value="item.value" />
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</el-select>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-form>
|
2026-08-13 09:40:16 +08:00
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="helperDialogVisible = false">取消</el-button>
|
|
|
|
|
|
<el-button type="primary" :loading="helperSubmitting" @click="applyStepHelper">确认读取</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { reactive, ref, watch } from 'vue'
|
2026-08-13 09:40:16 +08:00
|
|
|
|
import { ArrowDown, ArrowUp, Delete, Location, Plus } from '@element-plus/icons-vue'
|
2026-08-12 17:57:54 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
2026-08-13 09:40:16 +08:00
|
|
|
|
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
|
|
|
|
|
|
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
|
2026-08-12 17:57:54 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createDeviceSlot,
|
|
|
|
|
|
createResourceKey,
|
|
|
|
|
|
FLOW_NODE_RESOURCE_OWNER,
|
|
|
|
|
|
getResourceRoles,
|
|
|
|
|
|
getStartNode,
|
|
|
|
|
|
pruneUnusedNodeResources,
|
|
|
|
|
|
} from '@/utils/flowResources'
|
2026-08-13 09:40:16 +08:00
|
|
|
|
import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
|
2026-08-12 17:57:54 +08:00
|
|
|
|
|
|
|
|
|
|
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 },
|
2026-08-13 09:40:16 +08:00
|
|
|
|
ARM_MOVE_L: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0, frame: 'BASE', velocity: 2.1, acceleration: 2.2 },
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
}
|
2026-08-13 09:40:16 +08:00
|
|
|
|
const defaultTimeoutByType = {
|
|
|
|
|
|
DELAY: 5000,
|
|
|
|
|
|
ARM_MOVE_J: 10000,
|
|
|
|
|
|
ARM_MOVE_L: 3000,
|
|
|
|
|
|
AGV_NAVIGATE_TO_POSE: 60000,
|
|
|
|
|
|
AGV_NAVIGATE_TO_STATION: 60000,
|
|
|
|
|
|
AGV_FOLLOW_PATH: 120000,
|
|
|
|
|
|
}
|
2026-08-12 17:57:54 +08:00
|
|
|
|
|
|
|
|
|
|
const roles = ref([])
|
|
|
|
|
|
const formData = reactive({ actionId: '', totalTimeoutMs: 60000, steps: [], executionTarget: {} })
|
2026-08-13 09:40:16 +08:00
|
|
|
|
const activeStepKeys = ref([])
|
|
|
|
|
|
const helperDialogVisible = ref(false)
|
|
|
|
|
|
const helperStep = ref(null)
|
|
|
|
|
|
const helperRobotOptions = ref([])
|
|
|
|
|
|
const helperDeviceOptions = ref([])
|
|
|
|
|
|
const helperDeviceLoading = ref(false)
|
|
|
|
|
|
const helperSubmitting = ref(false)
|
|
|
|
|
|
const helperForm = reactive({ robotId: '', deviceId: '' })
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const fieldsForType = (type) => fieldsByType[type] || []
|
2026-08-13 09:40:16 +08:00
|
|
|
|
const actionLabel = (type) => actionTypes.find((item) => item.value === type)?.label || '未选择动作'
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const deviceKindForType = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
|
2026-08-13 09:40:16 +08:00
|
|
|
|
const stepHelperLabel = (type) => ({
|
|
|
|
|
|
ARM_MOVE_J: '读取当前关节位置',
|
|
|
|
|
|
ARM_MOVE_L: '读取当前末端位姿',
|
|
|
|
|
|
AGV_NAVIGATE_TO_POSE: '读取底盘当前位置',
|
|
|
|
|
|
AGV_NAVIGATE_TO_STATION: '加载底盘站点',
|
|
|
|
|
|
}[type] || '')
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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',
|
2026-08-13 09:40:16 +08:00
|
|
|
|
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
|
|
|
|
|
|
? defaultTimeoutByType[source.type || 'DELAY']
|
|
|
|
|
|
: Number(source.timeoutMs),
|
2026-08-12 17:57:54 +08:00
|
|
|
|
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
|
|
|
|
|
|
deviceSlotKey: source.deviceSlotKey || '',
|
2026-08-13 09:40:16 +08:00
|
|
|
|
stationOptions: [],
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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))
|
2026-08-13 09:40:16 +08:00
|
|
|
|
activeStepKeys.value = []
|
2026-08-12 17:57:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 09:40:16 +08:00
|
|
|
|
const addStep = () => {
|
|
|
|
|
|
const step = normalizeStep({}, formData.steps.length)
|
|
|
|
|
|
formData.steps.push(step)
|
|
|
|
|
|
activeStepKeys.value = [...activeStepKeys.value, step.localKey]
|
|
|
|
|
|
}
|
|
|
|
|
|
const removeStep = (index) => {
|
|
|
|
|
|
const [removed] = formData.steps.splice(index, 1)
|
|
|
|
|
|
activeStepKeys.value = activeStepKeys.value.filter((key) => key !== removed?.localKey)
|
|
|
|
|
|
}
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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] || {}) }
|
2026-08-13 09:40:16 +08:00
|
|
|
|
step.timeoutMs = defaultTimeoutByType[step.type] || 0
|
|
|
|
|
|
step.stationOptions = []
|
2026-08-12 17:57:54 +08:00
|
|
|
|
ensureStepSlot(step)
|
|
|
|
|
|
}
|
|
|
|
|
|
const handleRoleChange = () => formData.steps.forEach((step) => ensureStepSlot(step))
|
|
|
|
|
|
|
2026-08-13 09:40:16 +08:00
|
|
|
|
const loadHelperRobots = async () => {
|
|
|
|
|
|
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' })
|
|
|
|
|
|
helperRobotOptions.value = (response.rows || []).map((robot) => ({
|
|
|
|
|
|
value: robot.robotId,
|
|
|
|
|
|
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadHelperDevices = async (robotId) => {
|
|
|
|
|
|
helperForm.deviceId = ''
|
|
|
|
|
|
helperDeviceOptions.value = []
|
|
|
|
|
|
if (!robotId || !helperStep.value) return
|
|
|
|
|
|
helperDeviceLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await getRobotDevicesByRobotId(robotId, {
|
|
|
|
|
|
deviceKind: deviceKindForType(helperStep.value.type),
|
|
|
|
|
|
})
|
|
|
|
|
|
const devices = Array.isArray(response.data) ? response.data : []
|
|
|
|
|
|
helperDeviceOptions.value = toDeviceOptions(devices)
|
|
|
|
|
|
if (devices.length) helperForm.deviceId = devices[0].deviceId
|
|
|
|
|
|
else ElMessage.warning('当前机器人没有匹配的在线设备')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
helperDeviceLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openStepHelper = async (step) => {
|
|
|
|
|
|
helperStep.value = step
|
|
|
|
|
|
helperForm.deviceId = ''
|
|
|
|
|
|
helperDialogVisible.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadHelperRobots()
|
|
|
|
|
|
if (helperForm.robotId && helperRobotOptions.value.some((item) => item.value === helperForm.robotId)) {
|
|
|
|
|
|
await loadHelperDevices(helperForm.robotId)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载机器人设备失败:', error)
|
|
|
|
|
|
ElMessage.error('加载机器人设备失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stationOptions = (stations) => (Array.isArray(stations) ? stations : [])
|
|
|
|
|
|
.filter((station) => station?.id !== undefined && station?.id !== null && String(station.id).trim())
|
|
|
|
|
|
.map((station) => {
|
|
|
|
|
|
const value = String(station.id).trim()
|
|
|
|
|
|
const description = String(station.description || '').trim()
|
|
|
|
|
|
return { value, label: description ? `${value} - ${description}` : value }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const applyStepHelper = async () => {
|
|
|
|
|
|
const step = helperStep.value
|
|
|
|
|
|
if (!helperForm.robotId || !helperForm.deviceId) {
|
|
|
|
|
|
ElMessage.warning('请选择机器人和执行设备')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
helperSubmitting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const target = { robotId: helperForm.robotId, deviceId: helperForm.deviceId }
|
|
|
|
|
|
if (step.type === 'ARM_MOVE_J') {
|
|
|
|
|
|
const response = await getArmJointState(target)
|
|
|
|
|
|
const positions = response?.data?.position || response?.data?.positions
|
|
|
|
|
|
if (!Array.isArray(positions) || !positions.length) throw new Error('机械臂未返回有效的关节位置')
|
|
|
|
|
|
step.params.positions = JSON.stringify(positions)
|
|
|
|
|
|
} else if (step.type === 'ARM_MOVE_L') {
|
|
|
|
|
|
const response = await getArmStatus(target)
|
|
|
|
|
|
const pose = response?.data
|
|
|
|
|
|
if (!pose) throw new Error('机械臂未返回当前末端位姿')
|
|
|
|
|
|
;['x', 'y', 'z', 'rx', 'ry', 'rz'].forEach((key) => {
|
|
|
|
|
|
if (pose[key] !== undefined && pose[key] !== null) step.params[key] = Number(pose[key])
|
|
|
|
|
|
})
|
|
|
|
|
|
} else if (step.type === 'AGV_NAVIGATE_TO_POSE') {
|
|
|
|
|
|
const response = await getAgvStatus(target)
|
|
|
|
|
|
const pose = response?.data?.pose
|
|
|
|
|
|
if (!pose) throw new Error('底盘未返回当前位置')
|
|
|
|
|
|
;['x', 'y', 'theta'].forEach((key) => {
|
|
|
|
|
|
if (pose[key] !== undefined && pose[key] !== null) step.params[key] = Number(pose[key])
|
|
|
|
|
|
})
|
|
|
|
|
|
} else if (step.type === 'AGV_NAVIGATE_TO_STATION') {
|
|
|
|
|
|
const response = await getAgvStations(target)
|
|
|
|
|
|
const options = stationOptions(response?.data)
|
|
|
|
|
|
if (!options.length) throw new Error('底盘未返回可用站点')
|
|
|
|
|
|
step.stationOptions = options
|
|
|
|
|
|
if (!options.some((option) => option.value === step.params.stationId)) step.params.stationId = ''
|
|
|
|
|
|
ElMessage.success(`已加载${options.length}个站点`)
|
|
|
|
|
|
}
|
|
|
|
|
|
helperDialogVisible.value = false
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('读取动作参数失败:', error)
|
|
|
|
|
|
ElMessage.error(error?.message || '读取动作参数失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
helperSubmitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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; }
|
2026-08-13 09:40:16 +08:00
|
|
|
|
.queue-steps { border-top: 0; }
|
|
|
|
|
|
.queue-steps :deep(.el-collapse-item) { margin-bottom: 10px; border: 1px solid #dfe4ec; border-radius: 8px; overflow: hidden; }
|
|
|
|
|
|
.queue-steps :deep(.el-collapse-item__header) { height: 54px; padding: 0 12px; border-bottom: 0; }
|
|
|
|
|
|
.queue-steps :deep(.el-collapse-item__wrap) { border-top: 1px solid #edf0f4; border-bottom: 0; }
|
|
|
|
|
|
.queue-steps :deep(.el-collapse-item__content) { padding-bottom: 0; }
|
|
|
|
|
|
.queue-step { padding: 14px; background: #fff; }
|
|
|
|
|
|
.queue-step__header { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; color: #1f2937; font-weight: 650; }
|
|
|
|
|
|
.queue-step__title { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
|
|
|
|
|
.queue-step__action { overflow: hidden; color: #667085; font-size: 12px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
|
.queue-step__actions { display: flex; flex: 0 0 auto; margin-right: 8px; align-items: center; }
|
|
|
|
|
|
.queue-step__actions .el-button + .el-button { margin-left: 6px; }
|
|
|
|
|
|
.queue-step__helper { margin: -2px 0 16px; }
|
2026-08-12 17:57:54 +08:00
|
|
|
|
.queue-editor__warning { color: var(--el-color-warning); font-size: 12px; }
|
2026-08-13 09:40:16 +08:00
|
|
|
|
.queue-helper__tip { margin: -4px 0 16px; padding: 9px 12px; border-left: 3px solid var(--el-color-primary); background: var(--el-fill-color-light); color: #667085; font-size: 12px; line-height: 18px; }
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</style>
|