feat(flow): 添加动作队列步骤管理功能

- 新增 actionQueue.js 工具函数,包含步骤移动、复制、名称获取等功能
- 在 ActionQueueParams.vue 中实现步骤名称编辑和复制功能
- 添加步骤上下移动和删除操作的工具提示
- 实现步骤名称显示和索引编号的样式优化
- 在 ActionQueueSingleRun.vue 中添加步骤复制和移动功能
- 重构步骤数据结构,添加运行时键值和名称字段
- 更新样式以支持新的步骤头部布局和操作按钮
This commit is contained in:
lixiaolong 2026-08-13 17:16:42 +08:00
parent d3f344338a
commit 3d343812a9
3 changed files with 136 additions and 13 deletions

32
src/utils/actionQueue.js Normal file
View File

@ -0,0 +1,32 @@
const deepClone = (value) => JSON.parse(JSON.stringify(value))
export const getActionQueueStepName = (step, index) => {
const name = String(step?.stepName || '').trim()
return name || `步骤 ${index + 1}`
}
export const moveActionQueueStep = (steps, index, offset) => {
const targetIndex = index + offset
if (!Array.isArray(steps) || index < 0 || targetIndex < 0 || targetIndex >= steps.length) return false
const [step] = steps.splice(index, 1)
steps.splice(targetIndex, 0, step)
return true
}
export const copyActionQueueStep = (step, overrides = {}) => {
const copied = deepClone(step)
const originalName = String(step?.stepName || '').trim()
return {
...copied,
...overrides,
stepName: `${originalName || '未命名步骤'} - 副本`,
}
}
export const toActionQueueExecutionSteps = (steps) => (Array.isArray(steps) ? steps : []).map((step, index) => ({
stepId: `step_${index + 1}`,
type: step.type,
...(step.deviceId ? { deviceId: step.deviceId } : {}),
timeoutMs: Number(step.timeoutMs || 0),
params: deepClone(step.params || {}),
}))

View File

@ -11,8 +11,24 @@
<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>
<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">
<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>
<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"
@ -28,8 +44,10 @@
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowDown, ArrowUp, CopyDocument } from '@element-plus/icons-vue'
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { DEVICE_KIND } from '@/utils/robotDevice'
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue'
/**
* 组件输入配置,仅在当前单次动作队列运行表单内使用。
@ -58,6 +76,8 @@ const steps = ref([])
const devices = ref([])
/** 设备接口请求状态,仅控制当前组件设备选择框的加载效果。 */
const deviceLoading = ref(false)
let runtimeKeySequence = 0
const createRuntimeKey = () => `queue_runtime_${Date.now()}_${runtimeKeySequence += 1}`
/**
* 获取动作类型的展示名称。
@ -96,11 +116,26 @@ 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 : []))
steps.value = JSON.parse(JSON.stringify(Array.isArray(values.steps) ? values.steps : [])).map((step, index) => ({
...step,
stepName: getActionQueueStepName(step, index),
runtimeKey: createRuntimeKey(),
}))
robotId.value = props.initialRobotId || ''
if (robotId.value) await loadDevices(robotId.value)
}
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)
}
/**
* 加载指定机器人的设备并重建各步骤设备选择,影响当前组件的 devices 和 steps。
* 切换机器人后旧 deviceId 已不再可靠,因此先清空设备及所有步骤选择;无机器人时直接结束。
@ -144,7 +179,12 @@ const validate = () => {
}
/** 汇总当前表单数据为运行接口载荷;返回范围包含机器人、队列配置和全部步骤。 */
const getPayload = () => ({ robotId: robotId.value, actionId: actionId.value, totalTimeoutMs: totalTimeoutMs.value, steps: steps.value })
const getPayload = () => ({
robotId: robotId.value,
actionId: actionId.value,
totalTimeoutMs: totalTimeoutMs.value,
steps: toActionQueueExecutionSteps(steps.value),
})
/** 返回当前选中的机器人 ID,供父组件处理兼容字段或运行上下文。 */
const getRobotId = () => robotId.value
@ -164,11 +204,33 @@ defineExpose({ validate, getPayload, getRobotId })
border-radius: 8px;
}
.single-queue-step__title {
.single-queue-step__header {
display: flex;
min-width: 0;
margin-bottom: 10px;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.single-queue-step__title {
overflow: hidden;
color: #1f2937;
font-size: 13px;
font-weight: 650;
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;
}
.single-queue-step__params {

View File

@ -28,18 +28,31 @@
<template #title>
<div class="queue-step__header">
<div class="queue-step__title">
<span>步骤 {{ index + 1 }}</span>
<span class="queue-step__index">{{ index + 1 }}</span>
<span class="queue-step__name">{{ getActionQueueStepName(step, index) }}</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)" />
<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 === formData.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>
<el-tooltip content="删除步骤" placement="top">
<el-button :icon="Delete" circle size="small" type="danger" plain aria-label="删除步骤" @click="removeStep(index)" />
</el-tooltip>
</div>
</div>
</template>
<div class="queue-step">
<el-form label-position="top">
<el-form-item label="步骤名称">
<el-input v-model="step.stepName" maxlength="50" show-word-limit placeholder="例如:移动到取料位" />
</el-form-item>
<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" />
@ -118,7 +131,7 @@
<script setup>
import { reactive, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, Delete, Location, Plus } from '@element-plus/icons-vue'
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
@ -131,6 +144,7 @@ import {
pruneUnusedNodeResources,
} from '@/utils/flowResources'
import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep } from '@/utils/actionQueue'
const props = defineProps({ data: { type: Object, required: true } })
const emit = defineEmits(['save-success', 'save-error'])
@ -216,6 +230,7 @@ const normalizeStep = (source = {}, index = 0) => {
return {
localKey: source.localKey || createResourceKey('queue_step'),
stepId: source.stepId || `step_${index + 1}`,
stepName: String(source.stepName || '').trim() || `步骤 ${index + 1}`,
type: source.type || 'DELAY',
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
? defaultTimeoutByType[source.type || 'DELAY']
@ -278,8 +293,19 @@ const removeStep = (index) => {
activeStepKeys.value = activeStepKeys.value.filter((key) => key !== removed?.localKey)
}
const moveStep = (index, offset) => {
const [step] = formData.steps.splice(index, 1)
formData.steps.splice(index + offset, 0, step)
moveActionQueueStep(formData.steps, index, offset)
}
const copyStep = (index) => {
const source = formData.steps[index]
if (!source) return
const copied = copyActionQueueStep(source, {
localKey: createResourceKey('queue_step'),
stepId: `step_${formData.steps.length + 1}`,
deviceBindingKey: createResourceKey('queue_device'),
stationOptions: [],
})
formData.steps.splice(index + 1, 0, copied)
activeStepKeys.value = [...new Set([...activeStepKeys.value, copied.localKey])]
}
const handleStepTypeChange = (step) => {
step.params = { ...(defaultParams[step.type] || {}) }
@ -411,6 +437,7 @@ const validateAndSave = async () => {
})
return {
stepId: `step_${index + 1}`,
stepName: String(step.stepName || '').trim() || `步骤 ${index + 1}`,
type: step.type,
timeoutMs: Number(step.timeoutMs || 0),
...(deviceKindForType(step.type) ? {
@ -462,12 +489,14 @@ defineExpose({ validateAndSave })
.queue-editor__toolbar { display: flex; margin: 14px 0 12px; align-items: center; justify-content: space-between; }
.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__header) { min-height: 54px; height: auto; padding: 8px 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__index { display: inline-flex; width: 24px; height: 24px; flex: 0 0 24px; align-items: center; justify-content: center; border-radius: 50%; background: #eef4ff; color: var(--el-color-primary); font-size: 12px; }
.queue-step__name { overflow: hidden; max-width: 150px; text-overflow: ellipsis; white-space: nowrap; }
.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; }