Merge remote-tracking branch 'origin/lxl-dev' into lxl-dev

This commit is contained in:
lixiaolong 2026-08-20 13:36:47 +08:00
commit e136cf8a31
2 changed files with 124 additions and 0 deletions

View File

@ -1,11 +1,18 @@
<!--
* 文件说明:运行前资源分配编辑器,根据流程定义的机器人角色和设备用途,为本次运行生成 runtimeAssignments。
* 输入/输出:roles 描述资源需求,modelValue 承载已有分配;组件通过 v-model 回传当前选择。
* 重要约束:只加载在线机器人;AGV 角色可按 requiredMapId 过滤;同一检测项内部遵守设备复用规则。
-->
<template> <template>
<div class="runtime-assignments"> <div class="runtime-assignments">
<!-- 每个 role 对应一个运行时机器人角色,目前每个角色只分配一个 member。 -->
<div v-for="role in roles" :key="role.roleKey" class="runtime-role"> <div v-for="role in roles" :key="role.roleKey" class="runtime-role">
<div class="runtime-role__title"> <div class="runtime-role__title">
<span>{{ role.displayName }}</span> <span>{{ role.displayName }}</span>
<el-tag v-if="role.required" size="small" type="info">必需</el-tag> <el-tag v-if="role.required" size="small" type="info">必需</el-tag>
</div> </div>
<el-form label-position="top"> <el-form label-position="top">
<!-- 机器人选项会先限制为在线机器人;包含 AGV 用途时再按任务地图过滤。 -->
<el-form-item label="执行机器人" :required="role.required"> <el-form-item label="执行机器人" :required="role.required">
<el-select <el-select
:model-value="memberOf(role).robotId" :model-value="memberOf(role).robotId"
@ -19,6 +26,7 @@
<el-option v-for="item in robotOptionsOf(role)" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in robotOptionsOf(role)" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<!-- deviceSlots 是该角色所需的设备用途,例如底盘、相机、机械臂;选项依赖当前机器人。 -->
<el-form-item v-for="slot in role.deviceSlots" :key="slot.slotKey" :label="slot.displayName" :required="slot.required"> <el-form-item v-for="slot in role.deviceSlots" :key="slot.slotKey" :label="slot.displayName" :required="slot.required">
<el-select <el-select
v-model="memberOf(role).devices[slot.slotKey]" v-model="memberOf(role).devices[slot.slotKey]"
@ -31,12 +39,14 @@
> >
<el-option v-for="item in optionsOf(role, slot)" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in optionsOf(role, slot)" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
<!-- 已选择机器人但没有可选设备时给出局部提示,最终阻断仍由 validate 统一完成。 -->
<div v-if="memberOf(role).robotId && !optionsOf(role, slot).length" class="runtime-role__error"> <div v-if="memberOf(role).robotId && !optionsOf(role, slot).length" class="runtime-role__error">
该机器人没有匹配的在线设备 该机器人没有匹配的在线设备
</div> </div>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<!-- 列表加载失败和设备加载失败统一聚合为一个错误提示,避免多个异步错误同时占用表单空间。 -->
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon /> <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
<el-empty v-if="!roles.length" :image-size="52" description="当前任务不需要机器人" /> <el-empty v-if="!roles.length" :image-size="52" description="当前任务不需要机器人" />
</div> </div>
@ -47,6 +57,11 @@ import { computed, reactive, ref, watch } from 'vue'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot' import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { toDeviceOptions } from '@/utils/robotDevice' import { toDeviceOptions } from '@/utils/robotDevice'
/**
* roles 来自流程起点的 resourceRoles,描述本次任务需要哪些机器人角色和设备用途。
* modelValue 是父组件缓存或回显的 runtimeAssignments,本组件只维护一份可编辑副本并通过 update:modelValue 同步出去。
* robotQuery 用于叠加业务筛选条件;requiredMapId 用于有 AGV 用途时筛出位于同一地图的机器人。
*/
const props = defineProps({ const props = defineProps({
roles: { type: Array, default: () => [] }, roles: { type: Array, default: () => [] },
modelValue: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] },
@ -55,22 +70,39 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
// assignments 是表单直接修改的运行时分配副本,避免直接改父组件传入的 modelValue。
const assignments = ref([]) const assignments = ref([])
// robotOptions 保存在线机器人列表;devicesByRole 保存“角色 -> 当前机器人在线设备”的下拉数据。
const robotOptions = ref([]) const robotOptions = ref([])
const devicesByRole = reactive({}) const devicesByRole = reactive({})
// 三类 loading 分开维护,模板可精确显示机器人列表、自动分配或某个角色设备加载状态。
const robotLoading = ref(false) const robotLoading = ref(false)
const autoAssigning = ref(false) const autoAssigning = ref(false)
const loadingRoleKey = ref('') const loadingRoleKey = ref('')
const robotLoadError = ref('') const robotLoadError = ref('')
const deviceLoadErrors = reactive({}) const deviceLoadErrors = reactive({})
// 缓存按机器人 ID 发起的设备列表请求;多个角色尝试同一机器人时复用同一个 Promise。
const onlineDevicesByRobot = new Map() const onlineDevicesByRobot = new Map()
// 对外只展示一个当前最重要的加载错误,validate 也复用该聚合结果。
const loadError = computed(() => robotLoadError.value || Object.values(deviceLoadErrors).find(Boolean) || '') const loadError = computed(() => robotLoadError.value || Object.values(deviceLoadErrors).find(Boolean) || '')
const clone = (value) => JSON.parse(JSON.stringify(value || [])) const clone = (value) => JSON.parse(JSON.stringify(value || []))
// initialize 会保证每个 role 都有一个 assignment/member,因此模板和校验可以按 roleKey 直接取值。
const assignmentOf = (role) => assignments.value.find((item) => item.roleKey === role.roleKey) const assignmentOf = (role) => assignments.value.find((item) => item.roleKey === role.roleKey)
const memberOf = (role) => assignmentOf(role).members[0] const memberOf = (role) => assignmentOf(role).members[0]
const emitValue = () => emit('update:modelValue', clone(assignments.value)) const emitValue = () => emit('update:modelValue', clone(assignments.value))
/**
* 加载在线机器人列表。
*
* 这里不直接处理地图过滤,因为是否需要地图约束取决于具体 role 是否包含 AGV 设备用途;
* role 级过滤由 robotOptionsOf 统一计算。
*/
const loadRobots = async () => { const loadRobots = async () => {
robotLoading.value = true robotLoading.value = true
robotLoadError.value = '' robotLoadError.value = ''
@ -94,12 +126,23 @@ const loadRobots = async () => {
} }
} }
/**
* 返回某个角色可选的机器人。
*
* 只有包含移动底盘设备用途的角色才要求机器人位于任务地图,其他纯传感器/执行器角色不受 requiredMapId 限制。
*/
const robotOptionsOf = (role) => { const robotOptionsOf = (role) => {
const requiresTaskMap = (role.deviceSlots || []).some((slot) => Number(slot.deviceKind) === 1) const requiresTaskMap = (role.deviceSlots || []).some((slot) => Number(slot.deviceKind) === 1)
if (!props.requiredMapId || !requiresTaskMap) return robotOptions.value if (!props.requiredMapId || !requiresTaskMap) return robotOptions.value
return robotOptions.value.filter((robot) => String(robot.currentMapId || '') === String(props.requiredMapId)) return robotOptions.value.filter((robot) => String(robot.currentMapId || '') === String(props.requiredMapId))
} }
/**
* 获取某台机器人当前在线设备。
*
* 缓存的是请求 Promise,而不只是结果数组:并发自动分配多个角色时,相同机器人只会触发一次接口请求;
* 失败时清掉缓存,避免后续重试继续拿到已 rejected 的 Promise。
*/
const getOnlineDevices = async (robotId) => { const getOnlineDevices = async (robotId) => {
if (!onlineDevicesByRobot.has(robotId)) { if (!onlineDevicesByRobot.has(robotId)) {
const request = getRobotDevicesByRobotId(robotId).then((response) => ( const request = getRobotDevicesByRobotId(robotId).then((response) => (
@ -115,19 +158,28 @@ const getOnlineDevices = async (robotId) => {
} }
} }
// 设备复用规则按“检测项 + 第几次出现”隔离;没有检测项上下文时退回到任务级作用域。
const itemScopeOf = (role) => role.itemId == null const itemScopeOf = (role) => role.itemId == null
? 'task' ? 'task'
: `${role.itemId}:${role.itemOccurrence || 1}` : `${role.itemId}:${role.itemOccurrence || 1}`
/**
* 在不修改当前表单的前提下,为一个角色预演设备选择。
*
* committedUses 是前面角色已经确定占用的物理设备;函数复制一份 plannedUses 后再尝试写入,
* 只有整个角色的必需设备都能满足时才返回计划,避免失败候选污染后续匹配。
*/
const planRoleDevices = (role, robotId, devices, committedUses, preferredDevices = {}) => { const planRoleDevices = (role, robotId, devices, committedUses, preferredDevices = {}) => {
const plannedUses = new Map(committedUses) const plannedUses = new Map(committedUses)
const selectedDevices = {} const selectedDevices = {}
for (const slot of role.deviceSlots || []) { for (const slot of role.deviceSlots || []) {
// 先按设备类型筛选,deviceKind 为空表示该用途接受任意设备。
const matchingDevices = devices.filter((device) => ( const matchingDevices = devices.filter((device) => (
device?.deviceId device?.deviceId
&& (slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind)) && (slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind))
)) ))
const preferredId = preferredDevices[slot.slotKey] const preferredId = preferredDevices[slot.slotKey]
// 自动分配时优先保留用户已有选择;若该设备不可用,再按列表顺序寻找替代设备。
const orderedDevices = preferredId const orderedDevices = preferredId
? [ ? [
...matchingDevices.filter((device) => device.deviceId === preferredId), ...matchingDevices.filter((device) => device.deviceId === preferredId),
@ -135,6 +187,7 @@ const planRoleDevices = (role, robotId, devices, committedUses, preferredDevices
] ]
: matchingDevices : matchingDevices
const selected = orderedDevices.find((device) => { const selected = orderedDevices.find((device) => {
// 同一个检测项内部用 role/item/robot/device 组成物理占用键,确保复用判断落到真实设备。
const physicalKey = `${itemScopeOf(role)}:${robotId}:${device.deviceId}` const physicalKey = `${itemScopeOf(role)}:${robotId}:${device.deviceId}`
const existingUse = plannedUses.get(physicalKey) const existingUse = plannedUses.get(physicalKey)
return !existingUse || (slot.allowReuse && existingUse.allowReuse) return !existingUse || (slot.allowReuse && existingUse.allowReuse)
@ -150,6 +203,12 @@ const planRoleDevices = (role, robotId, devices, committedUses, preferredDevices
return { selectedDevices, plannedUses } return { selectedDevices, plannedUses }
} }
/**
* 根据当前在线机器人和设备,按角色顺序自动补齐可行分配。
*
* 该算法采用顺序贪心策略:每个角色选择第一台能满足全部必需设备的机器人;
* 找不到可行候选时清空该角色,交给用户手动选择或 validate 给出明确提示。
*/
const autoAssignRoles = async () => { const autoAssignRoles = async () => {
let committedUses = new Map() let committedUses = new Map()
autoAssigning.value = true autoAssigning.value = true
@ -190,6 +249,12 @@ const autoAssignRoles = async () => {
} }
} }
/**
* 生成某个设备用途的下拉选项。
*
* 同一角色内已经被其他用途占用、且不允许复用的设备会被过滤掉;
* 当前 slot 已选中的值会保留在选项中,避免选项刷新时把正在编辑的值瞬间隐藏。
*/
const optionsOf = (role, slot) => { const optionsOf = (role, slot) => {
const member = memberOf(role) const member = memberOf(role)
const unavailableDeviceIds = new Set((role.deviceSlots || []) const unavailableDeviceIds = new Set((role.deviceSlots || [])
@ -205,6 +270,12 @@ const optionsOf = (role, slot) => {
))) )))
} }
/**
* 按角色当前选择的机器人加载设备列表,并根据 preserve 决定是否尽量保留旧设备选择。
*
* 用户切换机器人时 preserve=false,设备用途全部重新选;初始化/自动刷新时 preserve=true,
* 已选设备仍然可用则保留,否则回退到当前第一个可用设备。
*/
const loadDevices = async (role, preserve = true) => { const loadDevices = async (role, preserve = true) => {
const member = memberOf(role) const member = memberOf(role)
if (!member.robotId) { if (!member.robotId) {
@ -230,6 +301,7 @@ const loadDevices = async (role, preserve = true) => {
} }
} }
// 机器人发生变化后,先清空该角色下所有设备选择,再基于新机器人加载并填充可用设备。
const changeRobot = async (role, robotId) => { const changeRobot = async (role, robotId) => {
const member = memberOf(role) const member = memberOf(role)
member.robotId = robotId member.robotId = robotId
@ -237,6 +309,12 @@ const changeRobot = async (role, robotId) => {
await loadDevices(role, false) await loadDevices(role, false)
} }
/**
* 根据最新 props 重建内部状态。
*
* 这里会清空设备缓存和加载错误,随后把 modelValue、旧版 legacyRobotId/legacyDeviceId 回填到 assignments;
* emitValue 先把规范化结构同步给父组件,再加载在线资源并执行自动匹配。
*/
const initialize = async () => { const initialize = async () => {
Object.keys(deviceLoadErrors).forEach((roleKey) => delete deviceLoadErrors[roleKey]) Object.keys(deviceLoadErrors).forEach((roleKey) => delete deviceLoadErrors[roleKey])
Object.keys(devicesByRole).forEach((roleKey) => delete devicesByRole[roleKey]) Object.keys(devicesByRole).forEach((roleKey) => delete devicesByRole[roleKey])
@ -266,6 +344,12 @@ const initialize = async () => {
await autoAssignRoles() await autoAssignRoles()
} }
/**
* 提交运行前的最终校验。
*
* 返回空字符串表示通过;返回中文错误文案表示父组件应阻断执行并提示用户。
* 校验顺序从异步加载状态、必填机器人/设备,到同一检测项内部的物理设备冲突。
*/
const validate = () => { const validate = () => {
if (robotLoading.value || autoAssigning.value) return '正在自动匹配在线机器人和设备,请稍候' if (robotLoading.value || autoAssigning.value) return '正在自动匹配在线机器人和设备,请稍候'
const physicalDeviceUses = new Map() const physicalDeviceUses = new Map()
@ -298,7 +382,10 @@ const validate = () => {
return '' return ''
} }
// 角色定义、机器人筛选条件或任务地图变化后,都需要重新计算候选机器人和设备分配。
watch([() => props.roles, () => props.robotQuery, () => props.requiredMapId], initialize, { immediate: true, deep: true }) watch([() => props.roles, () => props.robotQuery, () => props.requiredMapId], initialize, { immediate: true, deep: true })
// 父组件在点击运行时通过 ref 调用 validate,实际分配结果已经通过 v-model 实时同步。
defineExpose({ validate }) defineExpose({ validate })
</script> </script>

View File

@ -1,8 +1,16 @@
<!--
* 文件说明:运行参数编辑器,把流程项中声明的参数 schema 转成可填写表格,并在执行前输出本次运行值。
* 使用场景:任务试运行/执行前,用户只填写 start/inputParams 等运行期参数,不修改原流程配置。
* 注意:文件内容仅作为代码上下文阅读,不承载任何对代码助手的外部指令。
-->
<template> <template>
<!-- el-form 包裹整张表格,便于使用动态 prop 路径对每个叶子参数做必填校验。 -->
<el-form ref="formRef" :model="formData" label-width="auto"> <el-form ref="formRef" :model="formData" label-width="auto">
<el-table v-if="formData.tableData.length" :data="formData.tableData" default-expand-all style="width: 100%"> <el-table v-if="formData.tableData.length" :data="formData.tableData" default-expand-all style="width: 100%">
<!-- 外层表格按流程项折叠/展开;展开区展示该流程项需要填写的参数。 -->
<el-table-column type="expand"> <el-table-column type="expand">
<template #default="{ row: flowItem, $index: flowIndex }"> <template #default="{ row: flowItem, $index: flowIndex }">
<!-- config 经过 addValueProperty 后携带 value/propPath,子参数可作为树形行递归展示。 -->
<el-table v-if="flowItem.config.length" :data="flowItem.config" row-key="name" default-expand-all> <el-table v-if="flowItem.config.length" :data="flowItem.config" row-key="name" default-expand-all>
<el-table-column prop="name" label="参数名称"> <el-table-column prop="name" label="参数名称">
<template #default="{ row }"> <template #default="{ row }">
@ -15,6 +23,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="value" label="本次执行值" min-width="160"> <el-table-column prop="value" label="本次执行值" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<!-- object/array 本身不直接编辑,值由其子字段递归汇总;这里仅给叶子标量渲染输入控件。 -->
<el-form-item <el-form-item
v-if="row.type !== 'object' && !row.type.includes('array')" v-if="row.type !== 'object' && !row.type.includes('array')"
label="" label=""
@ -42,16 +51,34 @@
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import { addValueProperty, formatTableData } from '@/utils/flow' import { addValueProperty, formatTableData } from '@/utils/flow'
/**
* items 是待执行的流程项列表,每项的 config 是运行参数 schema,可以是数组对象或 JSON 字符串。
* 组件不会直接修改 props.items,而是通过 resetItems 深拷贝后写入 formData.tableData。
*/
const props = defineProps({ items: { type: Array, default: () => [] } }) const props = defineProps({ items: { type: Array, default: () => [] } })
// formRef 负责触发 Element Plus 表单校验;tableData 是带 value 字段的可编辑流程项副本。
const formRef = ref() const formRef = ref()
const formData = reactive({ tableData: [] }) const formData = reactive({ tableData: [] })
/**
* 把后端/流程配置中的参数 schema 转成表单可编辑结构。
*
* addValueProperty 会为每个参数补默认 value 和 propPath;propPath 用于拼出 el-form-item 的校验路径,
* 因此这里必须深拷贝 schema,避免把临时 value 写回原始流程项配置。
*/
const parseConfig = (config) => { const parseConfig = (config) => {
if (!config) return [] if (!config) return []
const schema = typeof config === 'string' ? JSON.parse(config) : config const schema = typeof config === 'string' ? JSON.parse(config) : config
return addValueProperty(JSON.parse(JSON.stringify(schema || []))) return addValueProperty(JSON.parse(JSON.stringify(schema || [])))
} }
/**
* 重置表格数据。
*
* orderNum 缺失时按当前数组顺序补齐,保证 UI 上始终有清晰的执行顺序;
* config 在这里统一解析,模板层只负责渲染,不再关心原始格式是字符串还是数组。
*/
const resetItems = (items) => { const resetItems = (items) => {
formData.tableData = (items || []).map((item, index) => ({ formData.tableData = (items || []).map((item, index) => ({
...item, ...item,
@ -60,8 +87,15 @@ const resetItems = (items) => {
})) }))
} }
// 对外暴露的校验方法:没有表单实例时视为通过;校验失败时统一返回 false。
const validate = async () => !formRef.value || formRef.value.validate().catch(() => false) const validate = async () => !formRef.value || formRef.value.validate().catch(() => false)
/**
* 组装本次运行参数。
*
* 同一个 itemId 可能在任务中出现多次,因此用 itemId:occurrence 作为 key;
* formatTableData 会把树形参数数组恢复为普通对象,供执行接口直接消费。
*/
const getRunParams = () => { const getRunParams = () => {
const occurrences = new Map() const occurrences = new Map()
const result = {} const result = {}
@ -74,7 +108,10 @@ const getRunParams = () => {
return result return result
} }
// 父组件切换任务项或刷新配置时,立即重建表格,deep 用于捕获 config 内部的变更。
watch(() => props.items, resetItems, { immediate: true, deep: true }) watch(() => props.items, resetItems, { immediate: true, deep: true })
// 父组件通过 ref 触发表单校验、读取运行参数,也可以在必要时主动重置表格。
defineExpose({ validate, getRunParams, resetItems }) defineExpose({ validate, getRunParams, resetItems })
</script> </script>