feat(flow): 优化动作队列参数界面并添加状态标准化功能

- 将动作步骤界面改为折叠面板形式提升用户体验
- 添加动作类型标签显示和步骤帮助功能
- 实现机器人设备参数读取助手对话框
- 为不同动作类型设置默认超时时间
- 统一任务状态数据类型为字符串格式
- 修复多处状态比较的类型匹配问题
This commit is contained in:
lixiaolong 2026-08-13 09:40:16 +08:00
parent 3b820be743
commit 3ccf3e2c00
3 changed files with 259 additions and 63 deletions

View File

@ -23,15 +23,22 @@
<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">
<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>
<div>
<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)">
@ -49,12 +56,25 @@
</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.options" v-model="step.params[field.key]" style="width: 100%">
<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
@ -70,14 +90,38 @@
</template>
</el-form>
</div>
</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>
<el-form label-position="top">
<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" />
</el-select>
</el-form-item>
<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" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="helperDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="helperSubmitting" @click="applyStepHelper">确认读取</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, Delete, Plus } from '@element-plus/icons-vue'
import { ArrowDown, ArrowUp, 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'
import {
createDeviceSlot,
createResourceKey,
@ -86,7 +130,7 @@ import {
getStartNode,
pruneUnusedNodeResources,
} from '@/utils/flowResources'
import { DEVICE_KIND } from '@/utils/robotDevice'
import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
const props = defineProps({ data: { type: Object, required: true } })
const emit = defineEmits(['save-success', 'save-error'])
@ -128,16 +172,39 @@ const fieldsByType = {
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 },
ARM_MOVE_L: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0, frame: 'BASE', velocity: 2.1, acceleration: 2.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 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,
}
const roles = ref([])
const formData = reactive({ actionId: '', totalTimeoutMs: 60000, steps: [], executionTarget: {} })
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: '' })
const fieldsForType = (type) => fieldsByType[type] || []
const actionLabel = (type) => actionTypes.find((item) => item.value === type)?.label || '未选择动作'
const deviceKindForType = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
const stepHelperLabel = (type) => ({
ARM_MOVE_J: '读取当前关节位置',
ARM_MOVE_L: '读取当前末端位姿',
AGV_NAVIGATE_TO_POSE: '读取底盘当前位置',
AGV_NAVIGATE_TO_STATION: '加载底盘站点',
}[type] || '')
const selectedRole = () => roles.value.find((role) => role.roleKey === formData.executionTarget.roleKey)
const matchingSlots = (type) => (selectedRole()?.deviceSlots || []).filter((slot) => Number(slot.deviceKind) === deviceKindForType(type))
@ -150,9 +217,12 @@ const normalizeStep = (source = {}, index = 0) => {
localKey: source.localKey || createResourceKey('queue_step'),
stepId: source.stepId || `step_${index + 1}`,
type: source.type || 'DELAY',
timeoutMs: Number(source.timeoutMs || 0),
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
? defaultTimeoutByType[source.type || 'DELAY']
: Number(source.timeoutMs),
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
deviceSlotKey: source.deviceSlotKey || '',
stationOptions: [],
params,
}
}
@ -167,6 +237,7 @@ const initData = () => {
formData.executionTarget = JSON.parse(JSON.stringify(properties.executionTarget || {}))
formData.executionTarget.roleKey ||= roles.value[0]?.roleKey || ''
formData.steps.forEach((step) => ensureStepSlot(step, false))
activeStepKeys.value = []
}
const persistRoles = () => {
@ -197,18 +268,121 @@ const ensureStepSlot = (step, createWhenMissing = true) => {
step.deviceSlotKey = slot?.slotKey || ''
}
const addStep = () => formData.steps.push(normalizeStep({}, formData.steps.length))
const removeStep = (index) => formData.steps.splice(index, 1)
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)
}
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] || {}) }
step.timeoutMs = defaultTimeoutByType[step.type] || 0
step.stationOptions = []
ensureStepSlot(step)
}
const handleRoleChange = () => formData.steps.forEach((step) => ensureStepSlot(step))
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
}
}
const parseJsonFields = (step) => {
const params = { ...step.params }
for (const key of ['positions', 'path']) {
@ -286,8 +460,18 @@ defineExpose({ validateAndSave })
.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-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; }
.queue-editor__warning { color: var(--el-color-warning); font-size: 12px; }
.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; }
</style>

View File

@ -211,6 +211,11 @@ const rules = reactive({
const taskList = ref([]);
const runTaskList = ref([]);
const normalizeTaskStatus = (tasks = []) => tasks.map(item => ({
...item,
status: String(item.status),
}))
const getTaskData = () => {
getTaskList({
pageNum: 1,
@ -259,8 +264,9 @@ const getList = () => {
statusList: ['1', '3', '5'],
}).then((res) => {
if (res.code === 200) {
runTaskList.value = res.rows;
runingTaskList.value = res.rows.filter(item => item.status !== '5')
const tasks = normalizeTaskStatus(res.rows)
runTaskList.value = tasks;
runingTaskList.value = tasks.filter(item => item.status !== '5')
for (let i = 0; i < runTaskList.value.length; i++) {
getPointByTaskId(runTaskList.value[i].taskId)
getTaskLogList(runTaskList.value[i].id)
@ -324,7 +330,7 @@ const queryParams = reactive({
* @param task
*/
const startTask = async (task) => {
if (task.status === '3') {
if (String(task.status) === '3') {
let res = await resumeRunTask(task.id);
if (res.code === 200) {
ElMessage.success("任务已恢复");
@ -436,7 +442,7 @@ const getHistoryList = () => {
pageSize: queryParams.pageSize,
}).then((res) => {
if (res.code === 200) {
tableDataList.value = res.rows;
tableDataList.value = normalizeTaskStatus(res.rows);
total.value = res.total;
}
loading.value = false;

View File

@ -233,6 +233,11 @@ const phoneList = ref([]);
const runTaskList = ref([]);
const normalizeTaskStatus = (tasks = []) => tasks.map(item => ({
...item,
status: String(item.status),
}))
const getTaskData = () => {
getTaskList({
pageNum: 1,
@ -301,8 +306,9 @@ const getList = () => {
statusList: ['1', '3', '5'],
}).then((res) => {
if (res.code === 200) {
runTaskList.value = res.rows;
runingTaskList.value = res.rows.filter(item => item.status !== '5')
const tasks = normalizeTaskStatus(res.rows)
runTaskList.value = tasks;
runingTaskList.value = tasks.filter(item => item.status !== '5')
for (let i = 0; i < runTaskList.value.length; i++) {
getCaseByTaskId(runTaskList.value[i].taskId)
getTaskLogList(runTaskList.value[i].id)
@ -366,7 +372,7 @@ const queryParams = reactive({
* @param task
*/
const startTask = async (task) => {
if (task.status === '3') {
if (String(task.status) === '3') {
let res = await resumeTaskinstance(task.id);
if (res.code === 200) {
ElMessage.success("任务已恢复");
@ -477,7 +483,7 @@ const getHistoryList = () => {
pageSize: queryParams.pageSize,
}).then((res) => {
if (res.code === 200) {
tableDataList.value = res.rows;
tableDataList.value = normalizeTaskStatus(res.rows);
total.value = res.total;
}
loading.value = false;