forked from SmartBench/CMVR-IOT-UI
feat(flow): 添加动作队列步骤管理功能
- 新增 actionQueue.js 工具函数,包含步骤移动、复制、名称获取等功能 - 在 ActionQueueParams.vue 中实现步骤名称编辑和复制功能 - 添加步骤上下移动和删除操作的工具提示 - 实现步骤名称显示和索引编号的样式优化 - 在 ActionQueueSingleRun.vue 中添加步骤复制和移动功能 - 重构步骤数据结构,添加运行时键值和名称字段 - 更新样式以支持新的步骤头部布局和操作按钮
This commit is contained in:
parent
d3f344338a
commit
3d343812a9
32
src/utils/actionQueue.js
Normal file
32
src/utils/actionQueue.js
Normal 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 || {}),
|
||||||
|
}))
|
||||||
@ -11,8 +11,24 @@
|
|||||||
<el-form-item label="动作标识(可选)">
|
<el-form-item label="动作标识(可选)">
|
||||||
<el-input v-model="actionId" placeholder="留空时自动生成" />
|
<el-input v-model="actionId" placeholder="留空时自动生成" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div v-for="(step, index) in steps" :key="step.stepId || index" class="single-queue-step">
|
<div v-for="(step, index) in steps" :key="step.runtimeKey" class="single-queue-step">
|
||||||
<div class="single-queue-step__title">步骤 {{ index + 1 }} · {{ actionName(step.type) }}</div>
|
<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-form-item v-if="deviceKind(step.type)" label="执行设备" required>
|
||||||
<el-select v-model="step.deviceId" filterable :loading="deviceLoading" style="width: 100%">
|
<el-select v-model="step.deviceId" filterable :loading="deviceLoading" style="width: 100%">
|
||||||
<el-option v-for="device in devicesForStep(step)" :key="device.deviceId"
|
<el-option v-for="device in devicesForStep(step)" :key="device.deviceId"
|
||||||
@ -28,8 +44,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowDown, ArrowUp, CopyDocument } from '@element-plus/icons-vue'
|
||||||
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
|
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
|
||||||
import { DEVICE_KIND } from '@/utils/robotDevice'
|
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 devices = ref([])
|
||||||
/** 设备接口请求状态,仅控制当前组件设备选择框的加载效果。 */
|
/** 设备接口请求状态,仅控制当前组件设备选择框的加载效果。 */
|
||||||
const deviceLoading = ref(false)
|
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]))
|
const values = Object.fromEntries(props.nodeParams.map((item) => [item.name, item.input]))
|
||||||
actionId.value = values.actionId || ''
|
actionId.value = values.actionId || ''
|
||||||
totalTimeoutMs.value = Number(values.totalTimeoutMs || 60000)
|
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 || ''
|
robotId.value = props.initialRobotId || ''
|
||||||
if (robotId.value) await loadDevices(robotId.value)
|
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。
|
* 加载指定机器人的设备并重建各步骤设备选择,影响当前组件的 devices 和 steps。
|
||||||
* 切换机器人后旧 deviceId 已不再可靠,因此先清空设备及所有步骤选择;无机器人时直接结束。
|
* 切换机器人后旧 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,供父组件处理兼容字段或运行上下文。 */
|
/** 返回当前选中的机器人 ID,供父组件处理兼容字段或运行上下文。 */
|
||||||
const getRobotId = () => robotId.value
|
const getRobotId = () => robotId.value
|
||||||
@ -164,11 +204,33 @@ defineExpose({ validate, getPayload, getRobotId })
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.single-queue-step__title {
|
.single-queue-step__header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-queue-step__title {
|
||||||
|
overflow: hidden;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
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 {
|
.single-queue-step__params {
|
||||||
|
|||||||
@ -28,18 +28,31 @@
|
|||||||
<template #title>
|
<template #title>
|
||||||
<div class="queue-step__header">
|
<div class="queue-step__header">
|
||||||
<div class="queue-step__title">
|
<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>
|
<span class="queue-step__action">{{ actionLabel(step.type) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-step__actions" @click.stop>
|
<div class="queue-step__actions" @click.stop>
|
||||||
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" title="上移" @click="moveStep(index, -1)" />
|
<el-tooltip content="上移一步" placement="top">
|
||||||
<el-button :icon="ArrowDown" circle size="small" :disabled="index === formData.steps.length - 1" title="下移" @click="moveStep(index, 1)" />
|
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
|
||||||
<el-button :icon="Delete" circle size="small" type="danger" plain title="删除" @click="removeStep(index)" />
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="queue-step">
|
<div class="queue-step">
|
||||||
<el-form label-position="top">
|
<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-form-item label="动作" required>
|
||||||
<el-select v-model="step.type" style="width: 100%" @change="handleStepTypeChange(step)">
|
<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-option v-for="item in actionTypes" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
@ -118,7 +131,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, watch } from 'vue'
|
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 { ElMessage } from 'element-plus'
|
||||||
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
|
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
|
||||||
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
|
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
|
||||||
@ -131,6 +144,7 @@ import {
|
|||||||
pruneUnusedNodeResources,
|
pruneUnusedNodeResources,
|
||||||
} from '@/utils/flowResources'
|
} from '@/utils/flowResources'
|
||||||
import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
|
import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
|
||||||
|
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep } from '@/utils/actionQueue'
|
||||||
|
|
||||||
const props = defineProps({ data: { type: Object, required: true } })
|
const props = defineProps({ data: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['save-success', 'save-error'])
|
const emit = defineEmits(['save-success', 'save-error'])
|
||||||
@ -216,6 +230,7 @@ const normalizeStep = (source = {}, index = 0) => {
|
|||||||
return {
|
return {
|
||||||
localKey: source.localKey || createResourceKey('queue_step'),
|
localKey: source.localKey || createResourceKey('queue_step'),
|
||||||
stepId: source.stepId || `step_${index + 1}`,
|
stepId: source.stepId || `step_${index + 1}`,
|
||||||
|
stepName: String(source.stepName || '').trim() || `步骤 ${index + 1}`,
|
||||||
type: source.type || 'DELAY',
|
type: source.type || 'DELAY',
|
||||||
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
|
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
|
||||||
? defaultTimeoutByType[source.type || 'DELAY']
|
? defaultTimeoutByType[source.type || 'DELAY']
|
||||||
@ -278,8 +293,19 @@ const removeStep = (index) => {
|
|||||||
activeStepKeys.value = activeStepKeys.value.filter((key) => key !== removed?.localKey)
|
activeStepKeys.value = activeStepKeys.value.filter((key) => key !== removed?.localKey)
|
||||||
}
|
}
|
||||||
const moveStep = (index, offset) => {
|
const moveStep = (index, offset) => {
|
||||||
const [step] = formData.steps.splice(index, 1)
|
moveActionQueueStep(formData.steps, index, offset)
|
||||||
formData.steps.splice(index + offset, 0, step)
|
}
|
||||||
|
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) => {
|
const handleStepTypeChange = (step) => {
|
||||||
step.params = { ...(defaultParams[step.type] || {}) }
|
step.params = { ...(defaultParams[step.type] || {}) }
|
||||||
@ -411,6 +437,7 @@ const validateAndSave = async () => {
|
|||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
stepId: `step_${index + 1}`,
|
stepId: `step_${index + 1}`,
|
||||||
|
stepName: String(step.stepName || '').trim() || `步骤 ${index + 1}`,
|
||||||
type: step.type,
|
type: step.type,
|
||||||
timeoutMs: Number(step.timeoutMs || 0),
|
timeoutMs: Number(step.timeoutMs || 0),
|
||||||
...(deviceKindForType(step.type) ? {
|
...(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-editor__toolbar { display: flex; margin: 14px 0 12px; align-items: center; justify-content: space-between; }
|
||||||
.queue-steps { border-top: 0; }
|
.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) { 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__wrap) { border-top: 1px solid #edf0f4; border-bottom: 0; }
|
||||||
.queue-steps :deep(.el-collapse-item__content) { padding-bottom: 0; }
|
.queue-steps :deep(.el-collapse-item__content) { padding-bottom: 0; }
|
||||||
.queue-step { padding: 14px; background: #fff; }
|
.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__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__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__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 { display: flex; flex: 0 0 auto; margin-right: 8px; align-items: center; }
|
||||||
.queue-step__actions .el-button + .el-button { margin-left: 6px; }
|
.queue-step__actions .el-button + .el-button { margin-left: 6px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user