forked from SmartBench/CMVR-IOT-UI
- 在动作队列参数组件中添加单独执行按钮,允许独立运行指定步骤 - 实现步骤验证和构建逻辑,支持单步执行的参数处理 - 更新媒体分析参数界面,添加高级设置和参数微调功能 - 增加视频分析的置信度、通过标准等配置选项 - 优化任务执行日志显示,添加分析结果的结构化展示 - 集成分页请求工具,改进日志加载性能和用户体验 - 添加执行步骤索引跟踪,确保执行状态正确管理 - 实现防重复ID机制,保证分页数据完整性 - 新增分析结果解析和可视化展示功能 - 扩展媒体分析类型的处理和结果显示
583 lines
26 KiB
Vue
583 lines
26 KiB
Vue
<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>
|
||
|
||
<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 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-tooltip content="单独执行此步骤" placement="top">
|
||
<el-button :icon="VideoPlay" circle size="small" type="primary" plain aria-label="单独执行此步骤" @click="runStep(index)" />
|
||
</el-tooltip>
|
||
<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-dropdown v-if="step.sourceNode" trigger="click" @command="(placement) => requestExtractStep(index, placement)">
|
||
<el-button :icon="MoreFilled" circle size="small" aria-label="移出队列" />
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="before">移到队列前并连接</el-dropdown-item>
|
||
<el-dropdown-item command="after">移到队列后并连接</el-dropdown-item>
|
||
<el-dropdown-item command="detached" divided>仅移出,不连线</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<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" />
|
||
</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>
|
||
</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, CopyDocument, Delete, Location, MoreFilled, Plus, VideoPlay } 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,
|
||
FLOW_NODE_RESOURCE_OWNER,
|
||
getResourceRoles,
|
||
getStartNode,
|
||
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', 'extract-step'])
|
||
|
||
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 },
|
||
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))
|
||
|
||
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}`,
|
||
stepName: String(source.stepName || '').trim() || `步骤 ${index + 1}`,
|
||
type: source.type || 'DELAY',
|
||
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
|
||
? defaultTimeoutByType[source.type || 'DELAY']
|
||
: Number(source.timeoutMs),
|
||
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
|
||
deviceSlotKey: source.deviceSlotKey || '',
|
||
stationOptions: [],
|
||
...(source.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(source.sourceNode)) } : {}),
|
||
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))
|
||
activeStepKeys.value = []
|
||
}
|
||
|
||
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 || ''
|
||
}
|
||
|
||
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) => {
|
||
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] || {}) }
|
||
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']) {
|
||
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 buildStandaloneStep = (step, index) => {
|
||
if (!step?.type) 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_1',
|
||
stepName: getActionQueueStepName(step, index),
|
||
type: step.type,
|
||
timeoutMs: Number(step.timeoutMs || 0),
|
||
params,
|
||
}
|
||
}
|
||
|
||
const runStep = (index) => {
|
||
try {
|
||
const step = formData.steps[index]
|
||
const executionStep = buildStandaloneStep(step, index)
|
||
const stepTimeoutMs = Math.max(0, Number(executionStep.timeoutMs || 0))
|
||
document.dispatchEvent(new CustomEvent('singleNodeExecution', {
|
||
bubbles: false,
|
||
cancelable: false,
|
||
detail: {
|
||
name: getActionQueueStepName(step, index),
|
||
nodeType: 'EDGE',
|
||
action: 'SYSTEM_EXECUTE_ACTION_QUEUE',
|
||
nodeId: `${props.data.id}:queue-step:${step.localKey || index}`,
|
||
nodeParams: [
|
||
{ name: 'actionId', type: 'input', input: '', required: false },
|
||
{ name: 'totalTimeoutMs', type: 'input', componentType: 'number', input: Math.max(1000, stepTimeoutMs + 5000), required: true },
|
||
{ name: 'steps', type: 'input', input: [executionStep], required: true },
|
||
],
|
||
},
|
||
}))
|
||
} catch (error) {
|
||
ElMessage.warning(error.message)
|
||
}
|
||
}
|
||
|
||
const buildQueueDefinition = () => {
|
||
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}`,
|
||
stepName: String(step.stepName || '').trim() || `步骤 ${index + 1}`,
|
||
type: step.type,
|
||
timeoutMs: Number(step.timeoutMs || 0),
|
||
...(deviceKindForType(step.type) ? {
|
||
deviceBindingKey: step.deviceBindingKey,
|
||
deviceSlotKey: step.deviceSlotKey,
|
||
} : {}),
|
||
...(step.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(step.sourceNode)) } : {}),
|
||
params,
|
||
}
|
||
})
|
||
return {
|
||
steps,
|
||
executionTarget: {
|
||
roleKey: formData.executionTarget.roleKey,
|
||
dispatchPolicy: 'ONE',
|
||
deviceBindings: steps.filter((step) => step.deviceBindingKey).map((step) => ({
|
||
parameterName: step.deviceBindingKey,
|
||
slotKey: step.deviceSlotKey,
|
||
})),
|
||
},
|
||
}
|
||
}
|
||
|
||
const persistQueueDefinition = ({ steps, executionTarget }) => {
|
||
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 requestExtractStep = (index, placement) => {
|
||
try {
|
||
persistQueueDefinition(buildQueueDefinition())
|
||
emit('extract-step', { queueId: props.data.id, index, placement })
|
||
} catch (error) {
|
||
ElMessage.warning(error.message)
|
||
}
|
||
}
|
||
|
||
const validateAndSave = async () => {
|
||
try {
|
||
persistQueueDefinition(buildQueueDefinition())
|
||
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; }
|
||
.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) { 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; }
|
||
.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>
|