CMVR-IOT-UI/src/views/flow/components/TestRun.vue

320 lines
11 KiB
Vue
Raw Normal View History

<!-- 流程试运行:分配机器人角色并收集业务运行参数。 -->
2026-07-28 16:26:07 +08:00
<template>
<el-drawer :model-value="props.drawer" title="试运行" size="min(620px, 96vw)" :before-close="handleClose">
<div class="section-heading">本次执行安排</div>
<div class="assignment-tip">流程只保存角色分工,以下机器人和设备仅用于本次运行。</div>
2026-08-07 14:38:16 +08:00
<div v-for="role in roles" :key="role.roleKey" class="assignment-row">
<div class="assignment-row__title">
<span>{{ role.displayName }}</span>
<el-tag v-if="role.required" size="small" type="info">必需</el-tag>
</div>
<el-form label-position="top">
<el-form-item label="执行机器人" required>
<el-select
:model-value="getMember(role).robotId"
filterable
:loading="robotLoading"
placeholder="请选择机器人"
style="width: 100%"
@change="(robotId) => handleRobotChange(role, robotId)"
>
<el-option v-for="item in robotOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-for="slot in role.deviceSlots" :key="slot.slotKey" :label="slot.displayName" :required="slot.required">
<el-select
v-model="getMember(role).devices[slot.slotKey]"
filterable
:loading="loadingRoleKey === role.roleKey"
:disabled="!getMember(role).robotId"
:placeholder="getMember(role).robotId ? '请选择设备' : '请先选择机器人'"
style="width: 100%"
>
<el-option
v-for="item in getSlotOptions(role, slot)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div v-if="getMember(role).robotId && !getSlotOptions(role, slot).length" class="assignment-error">
该机器人没有匹配的在线设备
</div>
</el-form-item>
</el-form>
</div>
<div class="section-heading section-heading--params">运行参数</div>
<el-form ref="ruleFormRef" :model="{ tableData }" label-width="auto">
<el-table :data="tableData" row-key="name" default-expand-all style="width: 100%; margin-bottom: 20px">
<el-table-column prop="name" label="参数">
<template #default="{ row }">
<span>{{ row.name }}</span><span v-if="row.required" class="required-mark">*</span>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }"><el-tag>{{ row.type }}</el-tag></template>
</el-table-column>
<el-table-column prop="value" label="值">
<template #default="{ row }">
<el-form-item
v-if="row.type !== 'object' && !row.type.includes('array')"
:prop="`tableData${row.propPath}value`"
:rules="row.required ? [{ required: true, message: '请填写参数', trigger: 'blur' }] : []"
>
<el-input v-if="row.type === 'string'" v-model="row.value" />
<el-input-number v-else-if="row.type === 'number'" v-model="row.value" :controls="false" />
<el-switch v-else-if="row.type === 'boolean'" v-model="row.value" />
</el-form-item>
<el-form-item
v-else-if="row.type !== 'object'"
:prop="`tableData${row.propPath}value`"
:rules="row.required ? [{ required: true, message: '请填写参数', trigger: 'blur' }] : []"
>
<el-input v-model="row.value" placeholder="数组格式,例如:[1,2,3]" />
</el-form-item>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!tableData.length" :image-size="52" description="没有需要填写的运行参数" />
2026-07-28 16:26:07 +08:00
</el-form>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" :loading="submitting" @click="confirm">开始运行</el-button>
</template>
</el-drawer>
2026-07-28 16:26:07 +08:00
</template>
2026-07-28 16:26:07 +08:00
<script setup>
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
2026-08-04 16:30:00 +08:00
import { flowExecuteTrial } from '@/api/flow/flow'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { emitter } from '@/utils/eventBus'
import { formatTableData, getStartNodeFormData } from '@/utils/flow'
import { toDeviceOptions } from '@/utils/robotDevice'
import {
getResourceRoles,
normalizeFlowResourceSchema,
sanitizeFlowResourceDefinition,
validateFlowResources,
} from '@/utils/flowResources'
2026-07-28 16:26:07 +08:00
const props = defineProps({ drawer: Boolean, flowId: String })
const emits = defineEmits(['close', 'changeState'])
2026-07-28 16:26:07 +08:00
const ruleFormRef = ref()
const tableData = ref([])
const roles = ref([])
const runtimeAssignments = ref([])
const robotOptions = ref([])
const devicesByRole = reactive({})
const robotLoading = ref(false)
const loadingRoleKey = ref('')
const submitting = ref(false)
const trialRunCache = new Map()
2026-07-28 16:26:07 +08:00
const clone = (value) => JSON.parse(JSON.stringify(value || []))
const cacheKey = () => props.flowId || '__new_flow__'
const mergeCachedValues = (source, cached) => {
const cachedByName = new Map((cached || []).map((item) => [item.name, item]))
return (source || []).map((item, index) => {
const cachedItem = cached?.[index]?.name === item.name ? cached[index] : cachedByName.get(item.name)
if (!cachedItem) return item
const merged = { ...item, value: cachedItem.value }
if (Array.isArray(item.children)) merged.children = mergeCachedValues(item.children, cachedItem.children)
return merged
})
}
const getAssignment = (role) => runtimeAssignments.value.find((item) => item.roleKey === role.roleKey)
const getMember = (role) => getAssignment(role).members[0]
const createAssignments = (cached = []) => roles.value.map((role) => {
const old = cached.find((item) => item.roleKey === role.roleKey)
return old || {
roleKey: role.roleKey,
members: [{
memberKey: `${role.roleKey}_1`,
robotId: role.legacyRobotId || '',
devices: Object.fromEntries((role.deviceSlots || []).map((slot) => [slot.slotKey, slot.legacyDeviceId || ''])),
}],
}
})
const remember = () => {
trialRunCache.set(cacheKey(), {
tableData: clone(tableData.value),
runtimeAssignments: clone(runtimeAssignments.value),
})
2026-07-28 16:26:07 +08:00
}
const loadRobotOptions = async () => {
robotLoading.value = true
try {
const response = await getRobotList({ pageNum: 1, pageSize: 1000 })
robotOptions.value = (response.rows || []).map((robot) => ({
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
value: robot.robotId,
}))
} finally {
robotLoading.value = false
}
}
2026-07-28 16:26:07 +08:00
const getSlotOptions = (role, slot) => toDeviceOptions((devicesByRole[role.roleKey] || []).filter((device) => (
slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind)
)))
2026-07-28 16:26:07 +08:00
const loadRoleDevices = async (role, preserveValues = true) => {
const member = getMember(role)
if (!member.robotId) {
devicesByRole[role.roleKey] = []
return
}
loadingRoleKey.value = role.roleKey
try {
const response = await getRobotDevicesByRobotId(member.robotId)
devicesByRole[role.roleKey] = Array.isArray(response.data) ? response.data : []
role.deviceSlots.forEach((slot) => {
const options = getSlotOptions(role, slot)
const currentValid = options.some((option) => option.value === member.devices[slot.slotKey])
if (!preserveValues || !currentValid) member.devices[slot.slotKey] = options.length === 1 ? options[0].value : ''
2026-07-28 16:26:07 +08:00
})
} catch (error) {
devicesByRole[role.roleKey] = []
ElMessage.error(`读取“${role.displayName}”的设备失败`)
} finally {
loadingRoleKey.value = ''
}
2026-07-28 16:26:07 +08:00
}
const handleRobotChange = async (role, robotId) => {
const member = getMember(role)
member.robotId = robotId
member.devices = Object.fromEntries(role.deviceSlots.map((slot) => [slot.slotKey, '']))
await loadRoleDevices(role, false)
}
const initialize = async () => {
const graph = normalizeFlowResourceSchema(globalThis.lf.getGraphData())
roles.value = getResourceRoles(graph)
const cached = trialRunCache.get(cacheKey())
tableData.value = mergeCachedValues(getStartNodeFormData(), cached?.tableData)
runtimeAssignments.value = createAssignments(cached?.runtimeAssignments)
await loadRobotOptions()
await Promise.all(roles.value.filter((role) => getMember(role).robotId).map((role) => loadRoleDevices(role)))
}
watch(() => props.drawer, (open) => { if (open) initialize() })
const handleClose = () => {
remember()
ruleFormRef.value?.clearValidate()
emits('close')
}
const validateAssignments = () => {
for (const role of roles.value) {
const member = getMember(role)
if (role.required && !member.robotId) return `请为“${role.displayName}”选择机器人`
for (const slot of role.deviceSlots || []) {
if (slot.required && !member.devices[slot.slotKey]) return `请为“${role.displayName}”选择“${slot.displayName}”`
}
}
return ''
}
const confirm = async () => {
const currentGraph = globalThis.lf.getGraphData()
const resourceValidation = validateFlowResources(currentGraph)
if (resourceValidation.errors.length) {
ElMessage.warning(resourceValidation.errors[0].message)
return
}
const assignmentError = validateAssignments()
if (assignmentError) {
ElMessage.warning(assignmentError)
return
}
const formValid = await ruleFormRef.value?.validate().catch(() => false)
if (formValid === false) return
remember()
submitting.value = true
try {
const formData = formatTableData(tableData.value)
resourceValidation.graph.nodes.forEach((node) => emitter.emit('contentChange', { id: node.id }))
const executionDefinition = sanitizeFlowResourceDefinition(globalThis.lf.getGraphData())
const firstRobotId = runtimeAssignments.value.flatMap((item) => item.members).find((member) => member.robotId)?.robotId || ''
const response = await flowExecuteTrial({
flowData: JSON.stringify(executionDefinition),
itemId: props.flowId,
robotId: firstRobotId,
runtimeAssignments: runtimeAssignments.value,
runParams: formData,
})
if (response.code !== 200) {
ElMessage.error(response.msg)
return
}
emits('changeState', { type: 'testRunning', instId: response.msg })
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.section-heading {
margin: 6px 0 10px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
2026-07-28 16:26:07 +08:00
}
.section-heading--params {
margin-top: 24px;
}
.assignment-tip {
margin-bottom: 14px;
padding: 10px 12px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
.assignment-row {
padding: 14px 0 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.assignment-row__title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
font-weight: 650;
}
.assignment-error,
.required-mark {
color: var(--el-color-danger);
}
.assignment-error {
margin-top: 5px;
font-size: 12px;
}
.required-mark {
margin-left: 6px;
}
</style>