CMVR-IOT-UI/src/views/flow/components/params/BasicNodeParams.vue

602 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>
<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="hasBindableDevice" 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="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 { 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'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: []
})
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;
}
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 })
}
// 保存到 lf
lf.setProperties(props.data.id, {
...props.data.properties,
...formData
})
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 deviceId = formData.nodeParams.find(param => param.name === 'deviceId')?.input
const selectedFromList = property.selectOptions?.some(option => option.value === property.input)
if (selectedFromList && property.stationDeviceId === deviceId) 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 hasBindableDevice = computed(() =>
formData.nodeParams.some(param => param.name === 'deviceId')
&& Boolean(getDeviceKindForAction(props.data.properties.action))
)
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,
deviceId: robotForm.deviceId
}
// 将获取到的位置信息设置到 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')
const deviceParam = formData.nodeParams.find(param => param.name === 'deviceId')
if (stationParam) {
stationParam.selectOptions = stationOptions
stationParam.stationDeviceId = robotForm.deviceId
if (!stationOptions.some(item => item.value === stationParam.input)) {
stationParam.input = ''
}
}
if (deviceParam) deviceParam.input = robotForm.deviceId
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,
deviceId: robotForm.deviceId
}
// 将获取到的位置信息设置到 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')
const deviceParam = formData.nodeParams.find(param => param.name === 'deviceId')
if (targetParam) targetParam.input = JSON.stringify(target)
if (deviceParam) deviceParam.input = robotForm.deviceId
}
} catch (error) {
console.error('获取机械臂关节位置失败:', error)
}
}
})
} else {
robotFormRef.value.validate((valid) => {
if (!valid) return
const deviceParam = formData.nodeParams.find(param => param.name === 'deviceId')
if (deviceParam) {
deviceParam.input = robotForm.deviceId
deviceParam.componentType = 'select'
deviceParam.selectOptions = [...deviceOptions.value]
deviceParam.deviceLocked = deviceOptions.value.length === 1
}
dialogVisible.value = false
ElMessage.success('机器人设备已匹配')
})
}
}
/**
* 处理 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
const currentDeviceId = formData.nodeParams.find(param => param.name === 'deviceId')?.input || ''
robotForm.deviceId = currentDeviceId
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>