2026-08-12 17:57:54 +08:00
|
|
|
|
<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>
|
2026-08-13 17:16:42 +08:00
|
|
|
|
<div v-for="(step, index) in steps" :key="step.runtimeKey" class="single-queue-step">
|
|
|
|
|
|
<div class="single-queue-step__header">
|
|
|
|
|
|
<div class="single-queue-step__title">{{ getActionQueueStepName(step, index) }} · {{ actionName(step.type) }}</div>
|
|
|
|
|
|
<div class="single-queue-step__actions">
|
2026-08-17 16:05:42 +08:00
|
|
|
|
<el-tooltip content="单独执行此步骤" placement="top">
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
:icon="VideoPlay"
|
|
|
|
|
|
circle
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
plain
|
|
|
|
|
|
aria-label="单独执行此步骤"
|
|
|
|
|
|
:loading="executingStepIndex === index"
|
|
|
|
|
|
:disabled="executingStepIndex >= 0 && executingStepIndex !== index"
|
|
|
|
|
|
@click="emit('execute-step', index)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</el-tooltip>
|
2026-08-13 17:16:42 +08:00
|
|
|
|
<el-tooltip content="上移一步" placement="top">
|
|
|
|
|
|
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
|
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
|
<el-tooltip content="下移一步" placement="top">
|
|
|
|
|
|
<el-button :icon="ArrowDown" circle size="small" :disabled="index === steps.length - 1" aria-label="下移一步" @click="moveStep(index, 1)" />
|
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
|
<el-tooltip content="复制步骤" placement="top">
|
|
|
|
|
|
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
|
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-form-item label="步骤名称">
|
|
|
|
|
|
<el-input v-model="step.stepName" maxlength="50" show-word-limit placeholder="例如:移动到取料位" />
|
|
|
|
|
|
</el-form-item>
|
2026-08-12 17:57:54 +08:00
|
|
|
|
<el-form-item v-if="deviceKind(step.type)" label="执行设备" required>
|
|
|
|
|
|
<el-select v-model="step.deviceId" filterable :loading="deviceLoading" style="width: 100%">
|
2026-08-13 10:23:55 +08:00
|
|
|
|
<el-option v-for="device in devicesForStep(step)" :key="device.deviceId"
|
2026-08-12 17:57:54 +08:00
|
|
|
|
:label="device.deviceName && device.deviceName !== device.deviceId ? `${device.deviceName} (${device.deviceId})` : device.deviceId"
|
2026-08-13 10:23:55 +08:00
|
|
|
|
:value="device.deviceId" />
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</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'
|
2026-08-17 16:05:42 +08:00
|
|
|
|
import { ArrowDown, ArrowUp, CopyDocument, VideoPlay } from '@element-plus/icons-vue'
|
2026-08-12 17:57:54 +08:00
|
|
|
|
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
|
|
|
|
|
|
import { DEVICE_KIND } from '@/utils/robotDevice'
|
2026-08-13 17:16:42 +08:00
|
|
|
|
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue'
|
2026-08-12 17:57:54 +08:00
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 组件输入配置,仅在当前单次动作队列运行表单内使用。
|
|
|
|
|
|
* nodeParams 提供流程节点参数,robotOptions 提供可选机器人,initialRobotId 用于初始化默认机器人。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
nodeParams: { type: Array, default: () => [] },
|
|
|
|
|
|
robotOptions: { type: Array, default: () => [] },
|
|
|
|
|
|
initialRobotId: { type: String, default: '' },
|
2026-08-17 16:05:42 +08:00
|
|
|
|
executingStepIndex: { type: Number, default: -1 },
|
2026-08-12 17:57:54 +08:00
|
|
|
|
})
|
2026-08-17 16:05:42 +08:00
|
|
|
|
const emit = defineEmits(['execute-step'])
|
2026-08-12 17:57:54 +08:00
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 动作类型到中文名称的映射,作用范围为当前组件的步骤标题展示。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const labels = {
|
|
|
|
|
|
DELAY: '等待', ARM_MOVE_J: '机械臂关节运动', ARM_MOVE_L: '机械臂直线运动',
|
|
|
|
|
|
AGV_NAVIGATE_TO_POSE: '底盘导航到坐标', AGV_NAVIGATE_TO_STATION: '底盘导航到站点', AGV_FOLLOW_PATH: '底盘按路径导航',
|
|
|
|
|
|
}
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 当前选中的机器人 ID,作用于设备查询、校验和最终运行请求。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const robotId = ref('')
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 本次动作队列标识;为空时由服务端自动生成,仅随当前运行请求提交。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const actionId = ref('')
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 整个动作队列的超时时间(毫秒),作用于当前运行请求。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const totalTimeoutMs = ref(60000)
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 当前节点解析出的动作步骤副本,组件内可补充 deviceId,不回写原始 props。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const steps = ref([])
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 当前机器人挂载的设备列表,用于为各动作步骤筛选可执行设备。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const devices = ref([])
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 设备接口请求状态,仅控制当前组件设备选择框的加载效果。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const deviceLoading = ref(false)
|
2026-08-13 17:16:42 +08:00
|
|
|
|
let runtimeKeySequence = 0
|
|
|
|
|
|
const createRuntimeKey = () => `queue_runtime_${Date.now()}_${runtimeKeySequence += 1}`
|
2026-08-13 10:23:55 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取动作类型的展示名称。
|
|
|
|
|
|
* 先查询本地中文映射,未配置的类型直接显示原值,避免未知动作显示为空。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const actionName = (type) => labels[type] || type
|
2026-08-13 10:23:55 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据动作名前缀判断所需设备类型。
|
|
|
|
|
|
* 当前组件只处理机械臂和底盘队列动作;等待等无设备动作返回 null。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const deviceKind = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
|
2026-08-13 10:23:55 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 筛选单个步骤可使用的设备,范围限定为当前所选机器人的在线设备结果。
|
|
|
|
|
|
* 将接口 deviceKind 与动作要求统一转为数值后比较,兼容字符串和数字类型。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const devicesForStep = (step) => devices.value.filter((device) => Number(device.deviceKind) === deviceKind(step.type))
|
2026-08-13 10:23:55 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成步骤参数摘要,仅用于当前组件的只读预览。
|
|
|
|
|
|
* 等待和站点导航使用易读文案,其余动作保留完整 JSON 信息。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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 || {})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 使用节点参数重新初始化表单,作用于首次渲染及输入属性变化。
|
|
|
|
|
|
* 步骤:1. 将参数数组转成按名称索引的对象;2. 设置基本字段并深拷贝步骤;
|
|
|
|
|
|
* 3. 应用默认机器人;4. 若存在机器人,则加载设备并为步骤建立默认设备选择。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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)
|
2026-08-13 17:16:42 +08:00
|
|
|
|
steps.value = JSON.parse(JSON.stringify(Array.isArray(values.steps) ? values.steps : [])).map((step, index) => ({
|
|
|
|
|
|
...step,
|
|
|
|
|
|
stepName: getActionQueueStepName(step, index),
|
|
|
|
|
|
runtimeKey: createRuntimeKey(),
|
|
|
|
|
|
}))
|
2026-08-12 17:57:54 +08:00
|
|
|
|
robotId.value = props.initialRobotId || ''
|
|
|
|
|
|
if (robotId.value) await loadDevices(robotId.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 17:16:42 +08:00
|
|
|
|
const moveStep = (index, offset) => {
|
|
|
|
|
|
moveActionQueueStep(steps.value, index, offset)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const copyStep = (index) => {
|
|
|
|
|
|
const source = steps.value[index]
|
|
|
|
|
|
if (!source) return
|
|
|
|
|
|
const copied = copyActionQueueStep(source, { runtimeKey: createRuntimeKey() })
|
|
|
|
|
|
steps.value.splice(index + 1, 0, copied)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 加载指定机器人的设备并重建各步骤设备选择,影响当前组件的 devices 和 steps。
|
|
|
|
|
|
* 切换机器人后旧 deviceId 已不再可靠,因此先清空设备及所有步骤选择;无机器人时直接结束。
|
|
|
|
|
|
* 接口返回后逐步按动作类型筛选设备,并默认选择第一个匹配项;finally 保证加载状态复位。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const loadDevices = async (value) => {
|
2026-08-13 10:23:55 +08:00
|
|
|
|
// 机器人发生变化时,旧设备可能属于另一台机器人,必须整体清除以避免误提交。
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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 : []
|
2026-08-13 10:23:55 +08:00
|
|
|
|
// 每个步骤独立按设备类型匹配;有候选项时预选首项,减少单一设备场景下的重复操作。
|
2026-08-12 17:57:54 +08:00
|
|
|
|
steps.value.forEach((step) => {
|
|
|
|
|
|
const options = devicesForStep(step)
|
|
|
|
|
|
if (options.length) step.deviceId = options[0].deviceId
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deviceLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 校验当前动作队列是否具备运行条件,供父组件在提交前调用。
|
|
|
|
|
|
* 依次检查机器人、队列非空及每个需要设备的步骤;首次失败即提示并终止,全部通过返回 true。
|
|
|
|
|
|
*/
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const validate = () => {
|
|
|
|
|
|
if (!robotId.value) return ElMessage.warning('请选择机器人'), false
|
|
|
|
|
|
if (!steps.value.length) return ElMessage.warning('动作队列不能为空'), false
|
2026-08-13 10:23:55 +08:00
|
|
|
|
// 等待类步骤无需设备;机械臂或底盘步骤必须存在与当前机器人匹配的 deviceId。
|
2026-08-12 17:57:54 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-13 10:23:55 +08:00
|
|
|
|
|
2026-08-17 16:05:42 +08:00
|
|
|
|
const validateStep = (index) => {
|
|
|
|
|
|
if (!robotId.value) return ElMessage.warning('请选择机器人'), false
|
|
|
|
|
|
const step = steps.value[index]
|
|
|
|
|
|
if (!step) return ElMessage.warning('未找到要执行的步骤'), false
|
|
|
|
|
|
if (deviceKind(step.type) && !step.deviceId) {
|
|
|
|
|
|
ElMessage.warning(`步骤 ${index + 1} 没有可用的执行设备`)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 汇总当前表单数据为运行接口载荷;返回范围包含机器人、队列配置和全部步骤。 */
|
2026-08-13 17:16:42 +08:00
|
|
|
|
const getPayload = () => ({
|
|
|
|
|
|
robotId: robotId.value,
|
|
|
|
|
|
actionId: actionId.value,
|
|
|
|
|
|
totalTimeoutMs: totalTimeoutMs.value,
|
|
|
|
|
|
steps: toActionQueueExecutionSteps(steps.value),
|
|
|
|
|
|
})
|
2026-08-13 10:23:55 +08:00
|
|
|
|
|
2026-08-17 16:05:42 +08:00
|
|
|
|
const getStepPayload = (index) => {
|
|
|
|
|
|
const step = steps.value[index]
|
|
|
|
|
|
const stepTimeoutMs = Math.max(0, Number(step?.timeoutMs || 0))
|
|
|
|
|
|
return {
|
|
|
|
|
|
robotId: robotId.value,
|
|
|
|
|
|
actionId: '',
|
|
|
|
|
|
totalTimeoutMs: Math.max(1000, stepTimeoutMs + 5000),
|
|
|
|
|
|
steps: toActionQueueExecutionSteps(step ? [step] : []),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 返回当前选中的机器人 ID,供父组件处理兼容字段或运行上下文。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
const getRobotId = () => robotId.value
|
|
|
|
|
|
|
2026-08-13 10:23:55 +08:00
|
|
|
|
/** 监听节点配置和默认机器人变化,深度变化时重建当前组件的表单及设备选项。 */
|
2026-08-12 17:57:54 +08:00
|
|
|
|
watch(() => [props.nodeParams, props.initialRobotId], initData, { immediate: true, deep: true })
|
2026-08-13 10:23:55 +08:00
|
|
|
|
|
|
|
|
|
|
/** 向父组件开放提交前校验、载荷读取和机器人 ID 读取能力。 */
|
2026-08-17 16:05:42 +08:00
|
|
|
|
defineExpose({ validate, validateStep, getPayload, getStepPayload, getRobotId })
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-13 10:23:55 +08:00
|
|
|
|
.single-queue-step {
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
border: 1px solid #dfe4ec;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 17:16:42 +08:00
|
|
|
|
.single-queue-step__header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
min-width: 0;
|
2026-08-13 10:23:55 +08:00
|
|
|
|
margin-bottom: 10px;
|
2026-08-13 17:16:42 +08:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.single-queue-step__title {
|
|
|
|
|
|
overflow: hidden;
|
2026-08-13 10:23:55 +08:00
|
|
|
|
color: #1f2937;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 650;
|
2026-08-13 17:16:42 +08:00
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.single-queue-step__actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.single-queue-step__actions :deep(.el-button + .el-button) {
|
|
|
|
|
|
margin-left: 0;
|
2026-08-13 10:23:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.single-queue-step__params {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
color: #7a8494;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 18px;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
2026-08-12 17:57:54 +08:00
|
|
|
|
</style>
|