CMVR-IOT-UI/src/views/flow/components/params/BasicNodeParams.vue
lixiaolong 311c8c5082 feat(flow): 增加AGV导航阻塞超时配置和图像分析功能增强
- 在AGV导航动作中添加blockedTimeoutMs参数配置,默认值为60000毫秒
- 实现图像分析多种模式:智能分析、描述找图、精确找图
- 增加图像分析异步执行开关功能
- 添加图像分析匹配结果相关输出参数:匹配分数、坐标、中心点等
- 完善图像分析参数验证和错误处理逻辑
- 在任务执行日志中增加失败原因显示功能
- 优化图像分析界面组件的交互和表单验证规则
2026-08-20 11:41:28 +08:00

599 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
* 文件说明:通用节点参数面板,配置输入输出及机器人动作调试。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<RobotExecutionTarget
v-if="isRobotNode"
:data="props.data"
v-model="formData.executionTarget"
/>
<el-collapse v-model="activeNames">
<!-- 输入参数 -->
<el-collapse-item name="1" icon-position="left">
<template #title>
输入参数
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'nodeParams')" class="addFormItem">
<el-icon :size="14">
<Plus />
</el-icon>
</el-button>
</template>
<div class="form__container">
<el-button type="primary" v-if="hasDeviceDebugAction" size="small" @click="openRobotForm(props.data.properties.action)">{{ robotActionButtonText }}</el-button>
<el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top"
label-width="auto">
<div v-for="(property, index) in formData.nodeParams" :key="index">
<el-row>
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`nodeParams.${index}.name`"
:rules="[{ required: true, message: '请输入参数名', trigger: 'blur' }]">
<el-input :disabled="property?.disabled || false" class="param-name"
v-model="property.name" placeholder="请输入" clearable />
</el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
<el-select v-model="property.type" class="param-type" :disabled="property?.forceSelect || false" @change="handleTypeChange(index, formData, property.type)">
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item v-if="property.type === 'input'"
:rules="testRule(property)"
:prop="`nodeParams.${index}.input`">
<el-input-number class="param-value" v-if="property.componentType === 'number'"
v-model="property.input" :min="property.min ?? 0" :max="property.max || Infinity"
:controls="property?.controls || true" :step-strictly="true" :step="property.step || 1" placeholder="请输入" clearable />
<el-select v-model="property.input" class="param-value"
v-else-if="property.componentType === 'select'">
<el-option v-for="item in property.selectOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
clearable />
</el-form-item>
<el-form-item v-if="property.type === 'quote'"
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
:prop="`nodeParams.${index}.quote`">
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
v-model="property.quote" :checkStrictly="true" :options="quoteOptions"
placeholder="请选择"
@visible-change="(visible) => visibleChange(visible, index, property.quote)"
@change="(value) => cascaderChange(value, index, formData, 'nodeParams')" />
</el-form-item>
</el-form-item>
<el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false"
@click="handleDelete(index)" class="deleteBtn" />
</el-row>
</div>
</el-form>
</div>
</el-collapse-item>
<!-- 输出参数 -->
<el-collapse-item name="2" icon-position="left">
<template #title>
输出参数
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'outputParams')"
class="addFormItem">
<el-icon :size="14">
<Plus />
</el-icon>
</el-button>
</template>
<div class="form__container">
<el-form :inline="true" :model="formData" label-position="top" label-width="auto" :rules="outputRules"
ref="outputFormRef">
<FormItemRecursive formType="output" :current-list="formData.outputParams" prop-path="outputParams"
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')" />
</el-form>
</div>
</el-collapse-item>
</el-collapse>
<el-dialog
v-model="dialogVisible"
:title="robotDialogTitle"
width="460"
>
<div class="robot-device-tip">设备来自机器人最近一次在线上报,单个匹配设备会自动锁定。</div>
<el-form ref="robotFormRef" :model="robotForm" :rules="robotFormRules" label-width="90px">
<el-form-item label="机器人" prop="robotId">
<el-select v-model="robotForm.robotId" filterable placeholder="请选择在线机器人"
style="width: 100%" @change="loadRobotDevices">
<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="执行设备" prop="deviceId">
<el-select v-model="robotForm.deviceId" filterable :loading="deviceLoading"
:disabled="deviceOptions.length === 1" placeholder="请先选择机器人" style="width: 100%">
<el-option v-for="item in deviceOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<div v-if="deviceOptions.length === 1" class="device-auto-hint">已自动匹配并锁定</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="stationListLoading" @click="submitRobotForm">确认</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, reactive, watch, nextTick } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue'
import RobotExecutionTarget from './RobotExecutionTarget.vue'
import { getInput } from '@/utils/flow'
import { useQuote } from './useQuote.js'
import { getArmStatus, getArmJointState, getAgvStatus, getAgvStations } from '@/api/flow/flow'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { ElMessage } from 'element-plus'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import { actionRequiresDevice } from '@/utils/flowResources'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: [],
executionTarget: {}
})
const rules = reactive({})
const outputRules = reactive({})
const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
// 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => {
formData.nodeParams.splice(index, 1)
}
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) {
currentLevel = currentLevel[fullPath[i]].children
}
const lastIndex = fullPath[fullPath.length - 1]
currentLevel.splice(lastIndex, 1)
}
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
formData.nodeParams = nodeParams;
formData.outputParams = outputParams;
formData.executionTarget = JSON.parse(JSON.stringify(props.data.properties.executionTarget || {}));
}
watch(
() => props.data,
() => {
initData()
},
{ immediate: true, deep: true }
)
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
let inputValid = true
let outputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
if (isRobotNode.value && !formData.executionTarget.roleKey) inputValid = false
if (isRobotNode.value
&& actionRequiresDevice(props.data.properties.action)
&& !formData.executionTarget.deviceSlotKey) {
inputValid = false
ElMessage.warning('请选择该节点使用的设备用途')
}
// 非机器人节点不应携带机器人执行目标,避免后端将空对象误判为无效角色引用。
const properties = {
...props.data.properties,
...formData
}
if (!isRobotNode.value) delete properties.executionTarget
lf.setProperties(props.data.id, properties)
if (inputValid && outputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
const dialogVisible = ref(false)
const robotFormRef = ref(null)
const robotFormRules = reactive({
robotId: [{ required: true, message: '请选择机器人', trigger: 'change' }],
deviceId: [{ required: true, message: '设备ID不能为空', trigger: 'blur' }]
})
const robotForm = reactive({
deviceId: '',
robotId: ''
})
const robotOptions = ref([])
const deviceOptions = ref([])
const deviceLoading = ref(false)
/**
* 加载 loadRobotOptions 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const loadRobotOptions = async () => {
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' })
robotOptions.value = (response.rows || []).map(robot => ({
value: robot.robotId,
label: `${robot.robotName || robot.robotId} (${robot.robotId})`
}))
}
/**
* 加载 loadRobotDevices 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} robotId 机器人 ID
*/
const loadRobotDevices = async (robotId) => {
robotForm.deviceId = ''
deviceOptions.value = []
if (!robotId) return
deviceLoading.value = true
try {
const response = await getRobotDevicesByRobotId(robotId, {
deviceKind: getDeviceKindForAction(robotFormAction.value)
})
const devices = Array.isArray(response.data) ? response.data : []
deviceOptions.value = toDeviceOptions(devices)
if (devices.length > 0) robotForm.deviceId = devices[0].deviceId
if (devices.length === 0) ElMessage.warning('当前机器人没有匹配的在线设备')
} finally {
deviceLoading.value = false
}
}
/**
* 测试 testRule 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} property 参数属性
*/
const testRule = (property) => {
if (property?.name === 'stationId' && props.data.properties.action === 'AGV_MOVE_TO_STATION') {
return [
{ required: true, message: '请从站点列表中选择站点', trigger: 'change' },
{ validator: () => {
const selectedFromList = property.selectOptions?.some(option => option.value === property.input)
if (selectedFromList) return Promise.resolve()
return Promise.reject(new Error('请重新加载并选择站点'))
}, trigger: 'change' }
]
}
if (property?.name === 'acceleration') {
return [
{ required: true, message: '请输入参数值', trigger: 'blur' },
{ validator: () => {
if (formData.nodeParams.find(param => param.name === 'acceleration')?.input > formData.nodeParams.find(param => param.name === 'velocity')?.input) {
return Promise.resolve()
} else {
return Promise.reject(new Error('加速度必须大于速度'))
}
}, trigger: 'change' }
]
}
if (property?.name === 'velocity') {
return [
{ required: true, message: '请输入参数值', trigger: 'blur' },
{ validator: () => {
if (formData.nodeParams.find(param => param.name === 'acceleration')?.input > formData.nodeParams.find(param => param.name === 'velocity')?.input) {
return Promise.resolve()
} else {
return Promise.reject(new Error('加速度必须大于速度'))
}
}, trigger: 'change' }
]
}
return [{ required: property?.required ?? true, message: '请输入参数值', trigger: 'blur' }]
}
const robotFormAction = ref()
const stationListLoading = ref(false)
const POSITION_ACTIONS = ['AGV_MOVE_TO_POINT', 'ARM_MOVE_TO_POINT', 'ARM_MOVE_TO_J']
const isRobotNode = computed(() => props.data.properties?.nodeType === 'EDGE')
const hasDeviceDebugAction = computed(() => (
POSITION_ACTIONS.includes(props.data.properties.action)
|| props.data.properties.action === 'AGV_MOVE_TO_STATION'
))
const robotActionButtonText = computed(() => {
if (props.data.properties.action === 'AGV_MOVE_TO_STATION') return '选择机器人并加载站点'
if (POSITION_ACTIONS.includes(props.data.properties.action)) return '选择机器人并获取位置'
return '选择机器人设备'
})
const robotDialogTitle = computed(() => {
if (robotFormAction.value === 'AGV_MOVE_TO_STATION') return '选择AGV并加载站点'
if (POSITION_ACTIONS.includes(robotFormAction.value)) return '选择机器人并读取当前位置'
return '选择机器人设备'
})
/**
* 处理 submitRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const submitRobotForm = () => {
if (robotFormAction.value === 'AGV_MOVE_TO_POINT') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
try {
const response = await getAgvStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
if (response && response.data) {
dialogVisible.value = false
const { x, y, theta } = response.data.pose
const testMap = {
x: x,
y: y,
theta: theta
}
// 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => {
if (testMap.hasOwnProperty(param.name)) {
param.input = testMap[param.name]
}
})
}
} catch (error) {
console.error('获取位置信息失败:', error)
}
}
})
} else if (robotFormAction.value === 'AGV_MOVE_TO_STATION') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
stationListLoading.value = true
try {
const response = await getAgvStations({
deviceId: robotForm.deviceId,
robotId: robotForm.robotId
})
const stationOptions = toStationOptions(response?.data)
if (stationOptions.length === 0) {
throw new Error('AGV未返回可用站点')
}
const stationParam = formData.nodeParams.find(param => param.name === 'stationId')
if (stationParam) {
stationParam.selectOptions = stationOptions
if (!stationOptions.some(item => item.value === stationParam.input)) {
stationParam.input = ''
}
}
dialogVisible.value = false
ElMessage.success(`已加载${stationOptions.length}个AGV站点`)
} catch (error) {
console.error('加载AGV站点列表失败:', error)
ElMessage.error(error?.message || '加载AGV站点列表失败')
} finally {
stationListLoading.value = false
}
}
})
} else if (robotFormAction.value === 'ARM_MOVE_TO_POINT') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
try {
const response = await getArmStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
if (response && response.data) {
dialogVisible.value = false
const { x, y, z, rx, ry, rz } = response.data
const testMap = {
x: x,
y: y,
z: z,
rx: rx,
ry: ry,
rz: rz,
}
// 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => {
if (testMap.hasOwnProperty(param.name)) {
param.input = testMap[param.name]
}
})
}
} catch (error) {
console.error('获取位置信息失败:', error)
}
}
})
} else if (robotFormAction.value === 'ARM_MOVE_TO_J') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
try {
const response = await getArmJointState({
deviceId: robotForm.deviceId,
robotId: robotForm.robotId
})
if (response && response.data) {
const target = response.data.position || response.data.positions
if (!Array.isArray(target) || target.length === 0) {
throw new Error('机械臂未返回有效的关节位置')
}
dialogVisible.value = false
const targetParam = formData.nodeParams.find(param => param.name === 'target')
if (targetParam) targetParam.input = JSON.stringify(target)
}
} catch (error) {
console.error('获取机械臂关节位置失败:', error)
}
}
})
}
}
/**
* 处理 toStationOptions 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} stations 站点集合
*/
const toStationOptions = (stations) => (Array.isArray(stations) ? stations : [])
.filter(station => station?.id !== undefined && station?.id !== null && String(station.id).trim())
.map(station => {
const id = String(station.id).trim()
const description = station.description ? String(station.description).trim() : ''
return {
value: id,
label: description ? `${id} - ${description}` : id
}
})
/**
* 打开 openRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} action 节点动作类型
*/
const openRobotForm = async (action) => {
dialogVisible.value = true
robotFormAction.value = action
robotForm.deviceId = ''
await loadRobotOptions()
if (robotForm.robotId) await loadRobotDevices(robotForm.robotId)
}
defineExpose({ validateAndSave })
</script>
<style lang="scss" scoped>
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
.robot-device-tip {
margin: -4px 0 18px;
padding: 10px 12px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 20px;
}
.device-auto-hint {
color: var(--el-color-success);
font-size: 12px;
}
</style>