feat(flow): 重构流程节点设备绑定机制

- 移除原有的 deviceId 参数绑定方式,改用统一的执行目标选择器
- 添加 RobotExecutionTarget 组件用于机器人角色和设备用途选择
- 更新节点参数验证逻辑,确保执行目标正确配置
- 优化流程资源管理,添加角色和设备槽位配置功能
- 改进复制粘贴功能,支持跨窗口节点复制和引用处理
- 重构设备选择对话框,集成到参数表单中统一管理
- 更新流程节点渲染,显示执行角色标签
- 添加流程资源验证和规范化处理逻辑
This commit is contained in:
lixiaolong 2026-08-11 16:50:30 +08:00
parent 40bd866f91
commit 0df5280ebf
14 changed files with 1005 additions and 850 deletions

View File

@ -38,10 +38,11 @@ export function deleteRunTask(id) {
})
}
export function startRunTask(id) {
export function startRunTask(id, data) {
return request({
url: `/inspection/taskInstance/start/${id}`,
method: 'put'
method: 'put',
data
})
}

View File

@ -32,10 +32,11 @@ export function deleteTaskinstance(ids) {
})
}
export function startTaskinstance(id) {
export function startTaskinstance(id, data) {
return request({
url: `/aima/taskinstance/start/${id}`,
method: 'put'
method: 'put',
data
})
}

View File

@ -1,328 +1,319 @@
<!--
* 文件说明:流程试运行抽屉,收集运行参数并绑定机器人设备。
* 作用范围:仅服务于流程设计器模块。
-->
<!-- 流程试运行:分配机器人角色并收集业务运行参数。 -->
<template>
<el-drawer
:model-value="props.drawer"
title="试运行"
:before-close="handleClose"
>
<el-form
ref="ruleFormRef"
style="max-width: 600px"
:model="{ tableData }"
:rules="rules"
label-width="auto"
>
<div class="binding-panel" :class="{ 'binding-panel--warning': bindingSummary.missing > 0 }">
<div class="binding-panel__title">
<span class="binding-panel__dot"></span>
机器人设备自动匹配
<el-drawer :model-value="props.drawer" title="试运行" size="min(620px, 96vw)" :before-close="handleClose">
<div class="section-heading">本次执行安排</div>
<div class="assignment-tip">流程只保存角色分工,以下机器人和设备仅用于本次运行。</div>
<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>
<div class="binding-panel__text" v-if="bindingLoading">正在读取机器人在线设备...</div>
<div class="binding-panel__text" v-else-if="bindingSummary.robotId">
已匹配 {{ bindingSummary.matched }} 个设备节点
<span v-if="bindingSummary.missing">,{{ bindingSummary.missing }} 个节点未匹配并保留原值</span>
<span v-if="bindingSummary.untouched">,{{ bindingSummary.untouched }} 个手动设备节点保持不变</span>
</div>
<div class="binding-panel__text" v-else>选择机器人后,将按节点类型自动填充该机器人上报的在线设备。</div>
</div>
<el-table
:data="tableData"
style="width: 100%; margin-bottom: 20px"
row-key="name"
default-expand-all
<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-table-column prop="name" label="入参名称">
<template #default="{row}">
<span>{{ row.name }}</span>
<span v-if="row.required" style="color: #f56c6c; margin-left: 8px;">*</span>
</template>
</el-table-column>
<el-table-column prop="type" label="入参类型">
<template #default="scope">
<el-tag>{{ scope.row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="value" label="入参值">
<template #default="{ row, column, $index }">
<el-form-item v-if="row.name === 'robotId'" label="" :prop="`tableData${row.propPath}value`" :rules="row.required ? [{ required: true, message: '请选择执行机器人', trigger: 'change' }] : []">
<el-select v-model="row.value" filterable style="width: 100%" @change="handleRobotChange">
<el-option
v-for="item in robotIdOptions"
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>
<el-form-item v-else-if="row.type !== 'object' && !row.type.includes('array')" label="" :prop="`tableData${row.propPath}value`" :rules="row.required ? [{ required: true, message: '请输入变量名', trigger: 'blur' }] : []">
<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-if="row.type === 'number'" :controls="false" v-model="row.value" />
<el-switch v-if="row.type === 'boolean'" 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' && row.type.includes('array')" label="" :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
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="没有需要填写的运行参数" />
</el-form>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" :loading="bindingLoading" :disabled="bindingLoading" @click="confirm">确定</el-button>
<el-button type="primary" :loading="submitting" @click="confirm">开始运行</el-button>
</template>
</el-drawer>
</template>
<script setup>
import { ref, watch } from 'vue'
import { getStartNodeFormData, formatTableData } from '@/utils/flow'
// import { Plus, Minus } from '@element-plus/icons-vue'
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { flowExecuteTrial } from '@/api/flow/flow'
import { emitter } from '@/utils/eventBus';
import { ElMessage } from 'element-plus';
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
const props = defineProps({
drawer: Boolean,
flowId: String
})
const ruleFormRef = ref()
const rules = ref({});
const tableData = ref([])
// 按流程缓存整套试运行参数,关闭或执行后再次打开时继续使用。
const trialRunCache = new Map()
const bindingLoading = ref(false)
/**
* 处理 emptyBindingSummary 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} robotId 机器人 ID
*/
const emptyBindingSummary = (robotId = '') => ({ robotId, matched: 0, missing: 0, untouched: 0 })
const bindingSummary = ref(emptyBindingSummary())
import { emitter } from '@/utils/eventBus'
import { formatTableData, getStartNodeFormData } from '@/utils/flow'
import { toDeviceOptions } from '@/utils/robotDevice'
import {
getResourceRoles,
normalizeFlowResourceSchema,
sanitizeFlowResourceDefinition,
validateFlowResources,
} from '@/utils/flowResources'
const props = defineProps({ drawer: Boolean, flowId: String })
const emits = defineEmits(['close', 'changeState'])
/**
* 克隆 cloneData 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} data 待处理的数据
*/
const cloneData = (data) => JSON.parse(JSON.stringify(data || []))
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()
const clone = (value) => JSON.parse(JSON.stringify(value || []))
const cacheKey = () => props.flowId || '__new_flow__'
// 根据参数层级恢复缓存值,同时兼容流程参数结构发生增删的情况。
/**
* 合并 mergeCachedValues 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} source 源数据
* @param {*} cached 缓存数据
*/
const mergeCachedValues = (source, cached) => {
const cachedByName = new Map((cached || []).map(item => [item.name, item]))
const cachedByName = new Map((cached || []).map((item) => [item.name, item]))
return (source || []).map((item, index) => {
const cachedAtIndex = cached?.[index]
const cachedItem = cachedAtIndex?.name === item.name ? cachedAtIndex : cachedByName.get(item.name)
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)
}
if (Array.isArray(item.children)) merged.children = mergeCachedValues(item.children, cachedItem.children)
return merged
})
}
/**
* 处理 cacheKey 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const cacheKey = () => props.flowId || '__new_flow__'
const getAssignment = (role) => runtimeAssignments.value.find((item) => item.roleKey === role.roleKey)
const getMember = (role) => getAssignment(role).members[0]
// 深拷贝快照,避免后续表单编辑直接修改缓存内容。
/**
* 缓存 rememberTrialRunParams 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const rememberTrialRunParams = () => {
if (!tableData.value.length) return
trialRunCache.set(cacheKey(), cloneData(tableData.value))
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),
})
}
/**
* 处理 handleClose 对应的数据或交互;作用范围仅限当前组件或模块。
*/
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
}
}
const getSlotOptions = (role, slot) => toDeviceOptions((devicesByRole[role.roleKey] || []).filter((device) => (
slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind)
)))
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 : ''
})
} catch (error) {
devicesByRole[role.roleKey] = []
ElMessage.error(`读取“${role.displayName}”的设备失败`)
} finally {
loadingRoleKey.value = ''
}
}
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 = () => {
rememberTrialRunParams()
remember()
ruleFormRef.value?.clearValidate()
emits('close')
}
watch(() => props.drawer,
(newVal) => {
if (newVal) {
const data = getStartNodeFormData()
const cached = trialRunCache.get(cacheKey())
tableData.value = mergeCachedValues(data, cached)
const robotId = tableData.value.find(item => item.name === 'robotId')?.value || ''
bindingSummary.value = emptyBindingSummary(robotId)
loadRobotOptions()
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}”`
}
}
)
/**
* 确认 confirm 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const confirm = () => {
rememberTrialRunParams()
lf.fitView()
ruleFormRef.value.validate().then(async () => {
const formData = formatTableData(tableData.value)
const { nodes } = lf.getGraphData()
nodes.forEach(item => {
emitter.emit('contentChange', { id: item.id})
})
const flowData = JSON.stringify(lf.getGraphData())
const { robotId, ...runParams } = formData
const res = await flowExecuteTrial({
flowData,
itemId: props.flowId,
robotId,
runParams
})
if (res.code === 200) {
emits('changeState', {
type: 'testRunning',
instId: res.msg
})
} else {
ElMessage.error(res.msg)
}
})
.catch(() => {})
return ''
}
const robotIdOptions = ref([])
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
/**
* 处理 handleRobotChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} robotId 机器人 ID
*/
const handleRobotChange = async (robotId) => {
// 先拉取机器人设备,再按每个设备节点声明的设备类型筛选候选项。
// 仅在候选唯一时自动回填;无候选或多候选均交由用户确认,避免绑定错误设备。
bindingSummary.value = emptyBindingSummary(robotId || '')
if (!robotId) return
bindingLoading.value = true
remember()
submitting.value = true
try {
const response = await getRobotDevicesByRobotId(robotId)
const devices = Array.isArray(response.data) ? response.data : []
let matched = 0
let missing = 0
let untouched = 0
const missingNodes = []
const { nodes } = lf.getGraphData()
nodes.forEach((node) => {
if (node.properties?.nodeType !== 'EDGE') return
const nodeParams = JSON.parse(JSON.stringify(node.properties.nodeParams || []))
const robotParam = nodeParams.find(param => param.name === 'robotId')
if (robotParam) robotParam.input = robotId
const deviceParam = nodeParams.find(param => param.name === 'deviceId')
if (!deviceParam) {
if (robotParam) lf.setProperties(node.id, { ...node.properties, nodeParams })
return
}
const kind = getDeviceKindForAction(node.properties.action)
if (!kind) {
untouched += 1
if (robotParam) lf.setProperties(node.id, { ...node.properties, nodeParams })
return
}
const candidates = devices.filter(device => Number(device.deviceKind) === kind)
if (!candidates.length) {
missing += 1
missingNodes.push(node.properties.name || node.id)
if (robotParam) lf.setProperties(node.id, { ...node.properties, nodeParams })
return
}
const currentCandidate = candidates.find(device => device.deviceId === deviceParam.input)
deviceParam.input = (currentCandidate || candidates[0]).deviceId
deviceParam.componentType = 'select'
deviceParam.selectOptions = toDeviceOptions(candidates)
deviceParam.deviceLocked = candidates.length === 1
lf.setProperties(node.id, { ...node.properties, nodeParams })
matched += 1
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,
})
bindingSummary.value = { robotId, matched, missing, untouched }
if (missingNodes.length) {
ElMessage.warning(`以下节点没有匹配的在线设备,已保留原值:${missingNodes.join('、')}`)
} else if (matched > 0) {
ElMessage.success(`已自动匹配 ${matched} 个设备节点`)
if (response.code !== 200) {
ElMessage.error(response.msg)
return
}
} catch (error) {
bindingSummary.value = emptyBindingSummary(robotId)
console.error('自动匹配机器人设备失败:', error)
ElMessage.error('读取机器人设备失败')
emits('changeState', { type: 'testRunning', instId: response.msg })
} finally {
bindingLoading.value = false
submitting.value = false
}
}
/** 获取在线机器人列表 */
const loadRobotOptions = async () => {
// 试运行不需要要有连接状态为连接的,因为可以选择未连接的机器人进行试运行来保存流程数据
// , connectStatus: '1'
const res = await getRobotList({ pageNum: 1, pageSize: 1000 })
if (res.code === 200) {
robotIdOptions.value = res.rows?.map((item) => {
return {
label: `${item.robotName || item.robotId} (${item.robotId})`,
value: item.robotId
}
})
}
}
</script>
<style lang="scss" scoped>
.binding-panel {
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid #d7e5f7;
border-radius: 6px;
background: #f5f9ff;
<style scoped lang="scss">
.section-heading {
margin: 6px 0 10px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
}
.binding-panel--warning {
border-color: #f3d19e;
background: #fdf6ec;
.section-heading--params {
margin-top: 24px;
}
.binding-panel__title {
display: flex;
align-items: center;
gap: 8px;
color: #24364b;
font-weight: 600;
}
.binding-panel__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #409eff;
}
.binding-panel__text {
margin-top: 6px;
color: #64748b;
.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>

View File

@ -3,6 +3,11 @@
* 作用范围:仅服务于流程设计器模块。
-->
<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">
@ -15,7 +20,7 @@
</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-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">
@ -118,12 +123,14 @@
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
@ -133,7 +140,8 @@ const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: []
outputParams: [],
executionTarget: {}
})
const rules = reactive({})
@ -197,6 +205,7 @@ const initData = () => {
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(
@ -220,6 +229,13 @@ const validateAndSave = async () => {
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('请选择该节点使用的设备用途')
}
// 保存到 lf
lf.setProperties(props.data.id, {
@ -290,10 +306,9 @@ const testRule = (property) => {
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('设备已变化,请重新加载站点列表'))
if (selectedFromList) return Promise.resolve()
return Promise.reject(new Error('请重新加载并选择站点'))
}, trigger: 'change' }
]
}
@ -331,10 +346,11 @@ const testRule = (property) => {
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 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 '选择机器人并获取位置'
@ -361,8 +377,7 @@ const submitRobotForm = () => {
const testMap = {
x: x,
y: y,
theta: theta,
deviceId: robotForm.deviceId
theta: theta
}
// 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => {
@ -390,15 +405,12 @@ const submitRobotForm = () => {
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) {
@ -424,7 +436,6 @@ const submitRobotForm = () => {
rx: rx,
ry: ry,
rz: rz,
deviceId: robotForm.deviceId
}
// 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => {
@ -453,28 +464,13 @@ const submitRobotForm = () => {
}
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('机器人设备已匹配')
})
}
}
@ -500,8 +496,7 @@ const toStationOptions = (stations) => (Array.isArray(stations) ? stations : [])
const openRobotForm = async (action) => {
dialogVisible.value = true
robotFormAction.value = action
const currentDeviceId = formData.nodeParams.find(param => param.name === 'deviceId')?.input || ''
robotForm.deviceId = currentDeviceId
robotForm.deviceId = ''
await loadRobotOptions()
if (robotForm.robotId) await loadRobotDevices(robotForm.robotId)
}

View File

@ -3,6 +3,7 @@
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<RobotExecutionTarget :data="props.data" v-model="formData.executionTarget" />
<el-collapse v-model="activeNames">
<el-collapse v-model="activeNames">
<el-collapse-item name="1" icon-position="left">
@ -20,15 +21,6 @@
</el-tooltip>
</template>
<div class="form__container">
<div class="device-binding-toolbar">
<div>
<div class="device-binding-toolbar__title">执行设备</div>
<div class="device-binding-toolbar__desc">从机器人当前上报的全部在线设备中选择</div>
</div>
<el-button type="primary" plain size="small" @click="openDeviceDialog">
选择机器人设备
</el-button>
</div>
<el-form
:inline="true"
:model="formData"
@ -203,51 +195,16 @@
</el-collapse>
</el-collapse>
<el-dialog v-model="deviceDialogVisible" title="选择通用指令执行设备" width="520" append-to-body>
<div class="device-dialog-tip">通用指令不限制设备类型,设备列表来自机器人最近一次 QUIC 在线上报。</div>
<el-form ref="deviceFormRef" :model="deviceForm" :rules="deviceFormRules" label-width="86px">
<el-form-item label="机器人" prop="robotId">
<el-select
v-model="deviceForm.robotId"
filterable
:loading="robotLoading"
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="deviceForm.deviceId"
filterable
:loading="deviceLoading"
:disabled="!deviceForm.robotId || 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="deviceDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmDevice">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue'
import RobotExecutionTarget from './RobotExecutionTarget.vue'
import * as monaco from 'monaco-editor';
import { getInput } from '@/utils/flow'
import { useQuote } from './useQuote.js'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { toDeviceOptions } from '@/utils/robotDevice'
import { ElMessage } from 'element-plus'
const props = defineProps({
@ -258,7 +215,8 @@ const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: []
outputParams: [],
executionTarget: {}
})
const codeRef = ref()
@ -347,83 +305,6 @@ const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
const deviceDialogVisible = ref(false)
const deviceFormRef = ref()
const robotLoading = ref(false)
const deviceLoading = ref(false)
const robotOptions = ref([])
const deviceOptions = ref([])
const deviceForm = reactive({ robotId: '', deviceId: '' })
const deviceFormRules = {
robotId: [{ required: true, message: '请选择机器人', trigger: 'change' }],
deviceId: [{ required: true, message: '请选择执行设备', trigger: 'change' }]
}
/**
* 加载 loadRobotOptions 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const loadRobotOptions = async () => {
robotLoading.value = true
try {
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})`
}))
} finally {
robotLoading.value = false
}
}
/**
* 加载 loadRobotDevices 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} robotId 机器人 ID
*/
const loadRobotDevices = async (robotId) => {
deviceForm.deviceId = ''
deviceOptions.value = []
if (!robotId) return
deviceLoading.value = true
try {
const response = await getRobotDevicesByRobotId(robotId)
const devices = Array.isArray(response.data) ? response.data : []
deviceOptions.value = toDeviceOptions(devices)
if (devices.length) deviceForm.deviceId = devices[0].deviceId
if (!devices.length) ElMessage.warning('当前机器人没有在线设备')
} finally {
deviceLoading.value = false
}
}
/**
* 打开 openDeviceDialog 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const openDeviceDialog = async () => {
deviceDialogVisible.value = true
deviceForm.robotId = ''
deviceForm.deviceId = ''
deviceOptions.value = []
await loadRobotOptions()
}
/**
* 确认 confirmDevice 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const confirmDevice = async () => {
const valid = await deviceFormRef.value?.validate().catch(() => false)
if (!valid) return
const deviceParam = formData.nodeParams.find(item => item.name === 'deviceId')
if (!deviceParam) return
deviceParam.type = 'input'
deviceParam.input = deviceForm.deviceId
deviceParam.componentType = 'select'
deviceParam.selectOptions = [...deviceOptions.value]
deviceParam.required = true
deviceParam.deviceLocked = deviceOptions.value.length === 1
deviceDialogVisible.value = false
ElMessage.success('执行设备已选择')
}
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
@ -479,6 +360,7 @@ const initData = () => {
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 || {}));
const codeParams = props.data.properties.nodeParams.find(item => item.name === 'requestJson')
if (codeParams) {
setTimeout(() => {
@ -512,6 +394,10 @@ const validateAndSave = async () => {
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
if (!formData.executionTarget.roleKey || !formData.executionTarget.deviceSlotKey) {
inputValid = false
ElMessage.warning('请选择执行角色和设备用途')
}
// 保存到 lf
lf.setProperties(props.data.id, {
@ -616,45 +502,6 @@ onBeforeUnmount(() => {
}
}
.device-binding-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid #d9e5f4;
border-radius: 6px;
background: #f6f9fd;
}
.device-binding-toolbar__title {
color: #26364a;
font-size: 14px;
font-weight: 600;
}
.device-binding-toolbar__desc,
.device-dialog-tip,
.device-auto-hint {
color: #6b7a90;
font-size: 12px;
line-height: 20px;
}
.device-dialog-tip {
margin-bottom: 16px;
padding: 10px 12px;
border-radius: 6px;
background: #f5f7fa;
}
.device-auto-hint {
width: 100%;
margin-top: 4px;
color: var(--el-color-success);
}
.code__container {
display: flex;
position: relative;

View File

@ -1,207 +1,227 @@
<!--
* 文件说明:开始节点参数面板,配置流程入口参数。
* 作用范围:仅服务于流程设计器模块。
-->
<!-- 开始节点维护流程级机器人角色和业务输入参数。 -->
<template>
<el-collapse v-model="activeNames">
<el-collapse-item name="1" icon-position="left">
<el-collapse-item name="roles" title="机器人角色">
<div class="role-intro">节点按角色分工,试运行时再安排具体机器人和设备。</div>
<div v-for="(role, roleIndex) in formData.resourceRoles" :key="role.roleKey" class="role-row">
<div class="role-row__header">
<el-input v-model="role.displayName" maxlength="30" placeholder="例如:巡检机器人" />
<el-button
circle
:icon="Delete"
:disabled="formData.resourceRoles.length === 1"
aria-label="删除机器人角色"
@click="deleteRole(roleIndex)"
/>
</div>
<div class="role-row__section">
<div class="role-row__label">设备用途</div>
<div v-if="!role.deviceSlots.length" class="empty-tip">当前角色暂不需要设备</div>
<div v-for="(slot, slotIndex) in role.deviceSlots" :key="slot.slotKey" class="device-row">
<el-input v-model="slot.displayName" maxlength="30" placeholder="例如:前方摄像头" />
<el-select v-model="slot.deviceKind" placeholder="设备类型">
<el-option v-for="item in deviceKinds" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button circle :icon="Minus" aria-label="删除设备用途" @click="deleteDeviceSlot(role, slotIndex)" />
</div>
<el-button text type="primary" :icon="Plus" @click="addDeviceSlot(role)">添加设备用途</el-button>
</div>
</div>
<el-button type="primary" plain :icon="Plus" @click="addRole">添加机器人角色</el-button>
</el-collapse-item>
<el-collapse-item name="inputs">
<template #title>
输入参数
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'inputParams')" class="addFormItem">
<el-icon :size="14">
<Plus />
</el-icon>
<el-button circle size="small" class="addFormItem" @click="(event) => addFormItem(event, 'inputParams')">
<el-icon :size="14"><Plus /></el-icon>
</el-button>
</template>
<el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top"
label-width="auto">
<FormItemRecursive formType="input" :current-list="formData.inputParams" prop-path="inputParams"
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
@delete-item="(path) => deleteTopLevelItem(path, 'inputParams')" />
<el-form
ref="dynamicFormRef"
:inline="true"
:model="formData"
label-position="top"
label-width="auto"
>
<FormItemRecursive
formType="input"
:current-list="formData.inputParams"
prop-path="inputParams"
:depth="0"
:is-first-level="true"
:endDepth="2"
:parent-path="[]"
@delete-item="(path) => deleteTopLevelItem(path, 'inputParams')"
/>
</el-form>
</el-collapse-item>
</el-collapse>
</template>
<script setup>
import { ref, reactive, watch, nextTick } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import { reactive, ref, watch } from 'vue'
import { Delete, Minus, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import FormItemRecursive from '../FormItemRecursive.vue'
import { getInput } from '@/utils/flow'
import { useQuote } from './useQuote.js'
const props = defineProps({
data: Object
})
import {
createDefaultRobotRole,
createDeviceSlot,
createResourceKey,
normalizeFlowResourceSchema,
} from '@/utils/flowResources'
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
inputParams: [],
})
const deviceKinds = [
{ label: '移动底盘', value: 1 },
{ label: '机械臂', value: 2 },
{ label: '仿生头', value: 4 },
{ label: '摄像头', value: 5 },
{ label: '麦克风', value: 9 },
{ label: '机器人本体', value: 12 },
{ label: '扬声器', value: 13 },
]
const rules = reactive({})
const formData = reactive({ resourceRoles: [], inputParams: [] })
const dynamicFormRef = ref()
const activeNames = ref(['roles', 'inputs'])
const activeNames = ref(['1', '2'])
// 添加表单项
/**
* 新增 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)
const addRole = () => {
formData.resourceRoles.push({
...createDefaultRobotRole(),
roleKey: createResourceKey('robot_role'),
displayName: `机器人角色${formData.resourceRoles.length + 1}`,
})
}
// 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => {
formData.nodeParams.splice(index, 1)
const addDeviceSlot = (role) => {
role.deviceSlots.push(createDeviceSlot(null, role.deviceSlots))
}
const referencedNodes = (predicate) => globalThis.lf.getGraphData().nodes.filter((node) => predicate(node.properties?.executionTarget))
const deleteRole = (index) => {
const role = formData.resourceRoles[index]
const references = referencedNodes((target) => target?.roleKey === role.roleKey)
if (references.length) {
ElMessage.warning(`“${role.displayName}”仍被 ${references.length} 个节点使用,请先调整这些节点`)
return
}
formData.resourceRoles.splice(index, 1)
}
const deleteDeviceSlot = (role, index) => {
const slot = role.deviceSlots[index]
const references = referencedNodes((target) => (
target?.roleKey === role.roleKey
&& (target.deviceSlotKey === slot.slotKey
|| target.deviceBindings?.some((binding) => binding.slotKey === slot.slotKey))
))
if (references.length) {
ElMessage.warning(`“${slot.displayName}”仍被 ${references.length} 个节点使用,请先调整这些节点`)
return
}
role.deviceSlots.splice(index, 1)
}
const addFormItem = (event, type) => {
event.stopPropagation()
formData[type].push({ name: '', type: 'string', required: false, children: [], input: '' })
}
// 删除顶层输出参数(递归删除通过 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
for (let index = 0; index < fullPath.length - 1; index += 1) {
currentLevel = currentLevel[fullPath[index]].children
}
const lastIndex = fullPath[fullPath.length - 1]
currentLevel.splice(lastIndex, 1)
currentLevel.splice(fullPath[fullPath.length - 1], 1)
}
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const inputParams = JSON.parse(JSON.stringify(props.data.properties.inputParams || []))
formData.inputParams = inputParams;
const graph = normalizeFlowResourceSchema(globalThis.lf.getGraphData())
const start = graph.nodes.find((node) => node.id === props.data.id)
formData.resourceRoles = JSON.parse(JSON.stringify(start?.properties?.resourceRoles || [createDefaultRobotRole()]))
formData.inputParams = JSON.parse(JSON.stringify(start?.properties?.inputParams || []))
}
watch(
() => props.data,
() => {
initData()
},
{ immediate: true, deep: true }
)
watch(() => props.data.id, initData, { immediate: true })
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
const names = formData.resourceRoles.map((role) => role.displayName.trim())
const invalidRole = formData.resourceRoles.some((role) => !role.displayName.trim())
const duplicateRole = new Set(names).size !== names.length
const invalidSlot = formData.resourceRoles.some((role) => role.deviceSlots.some((slot) => (
!slot.displayName.trim() || slot.deviceKind == null
)))
if (invalidRole || duplicateRole || invalidSlot) {
ElMessage.warning(invalidRole ? '请填写机器人角色名称' : duplicateRole ? '机器人角色名称不能重复' : '请完整填写设备用途和类型')
emit('save-error')
return
}
let inputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
// 保存到 lf
lf.setProperties(props.data.id, {
globalThis.lf.setProperties(props.data.id, {
...props.data.properties,
...formData
schemaVersion: 2,
resourceRoles: JSON.parse(JSON.stringify(formData.resourceRoles)),
inputParams: JSON.parse(JSON.stringify(formData.inputParams)),
})
if (inputValid) {
emit('save-success')
} else {
emit('save-error')
}
emit(inputValid ? 'save-success' : 'save-error')
}
defineExpose({ validateAndSave })
</script>
<style lang="scss" scoped>
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
<style scoped lang="scss">
.role-intro {
margin-bottom: 14px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
.param-name {
width: 160px;
.role-row {
margin-bottom: 14px;
padding: 14px;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
}
.param-type {
width: 80px;
.role-row__header,
.device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.param-value {
width: 160px;
.role-row__section {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
.role-row__label {
margin-bottom: 8px;
color: var(--el-text-color-regular);
font-size: 13px;
font-weight: 600;
}
.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;
}
}
.device-row {
grid-template-columns: minmax(0, 1fr) 132px auto;
margin-bottom: 8px;
}
.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;
}
.empty-tip {
margin-bottom: 6px;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
</style>

View File

@ -107,9 +107,7 @@ export const collapseList = [
desc: "在获取相机照片时,需要先启动相机",
action: 'CAMERA_START',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }
],
nodeParams: [],
outputParams: [],
outputType: 'json'
},
@ -120,9 +118,7 @@ export const collapseList = [
desc: "获取相机拍摄的照片",
action: 'CAMERA_GETRGBIMAGE',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }
],
nodeParams: [],
outputType: 'img',
outputParams: [{ name: 'imageUrl', type: 'array<string>', desc: '图片地址数组', children: [], disabled: true }]
},
@ -133,9 +129,7 @@ export const collapseList = [
desc: "获取相机照片后,关闭该相机",
action: 'CAMERA_STOP',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }
],
nodeParams: [],
outputParams: [],
outputType: 'json'
},
@ -151,9 +145,7 @@ export const collapseList = [
desc: "开启摄像头,并开始录像",
action: 'CAMERA_RECORDING_START',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }
],
nodeParams: [],
outputParams: [],
outputType: 'json'
},
@ -164,9 +156,7 @@ export const collapseList = [
desc: "停止录像,并关闭摄像头",
action: 'CAMERA_RECORDING_STOP',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }
],
nodeParams: [],
outputType: 'video',
outputParams: [{ name: 'videoUrl', type: 'array<string>', children: [], desc: '视频播放地址数组', disabled: true}]
}
@ -314,7 +304,6 @@ function handler(params) {
action: 'AGV_MOVE_TO_POINT',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "x", type: "input", input: "", disabled: true },
{ name: "y", type: "input", input: "", disabled: true },
{ name: "theta", type: "input", input: "", disabled: true },
@ -329,7 +318,6 @@ function handler(params) {
action: 'AGV_MOVE_TO_STATION',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "stationId", type: "input", input: "", componentType: 'select', selectOptions: [], forceSelect: true, disabled: true }
],
outputType: 'json'
@ -342,7 +330,6 @@ function handler(params) {
action: 'DEVICE_EXECUTE_JSON_COMMAND',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], required: true, disabled: true },
{ name: "requestJson", type: "input", input: `{
"a":123
}`, required: false, disabled: true },
@ -358,7 +345,6 @@ function handler(params) {
action: 'ARM_MOVE_TO_POINT',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "x", type: "input", input: "", disabled: true },
{ name: "y", type: "input", input: "", disabled: true },
{ name: "z", type: "input", input: "", disabled: true },
@ -378,7 +364,6 @@ function handler(params) {
action: 'ARM_MOVE_TO_J',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "target", type: "input", input: "[0, 0, 0, 0, 0, 0]", disabled: true },
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true },
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true },
@ -457,9 +442,7 @@ function handler(params) {
desc: "用于启动麦克风的节点",
action: 'MICROPHONE_START',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }
],
nodeParams: [],
outputType: 'json'
},
{
@ -469,9 +452,7 @@ function handler(params) {
desc: "用于停止麦克风的节点",
action: 'MICROPHONE_STOP',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true }
],
nodeParams: [],
outputType: 'json'
},
{
@ -482,7 +463,6 @@ function handler(params) {
action: 'SPEAKER_PLAYAUDIO',
nodeType: 'EDGE',
nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: [], disabled: true },
{ name: "audioPath", type: "input", input: "", disabled: true }
],
outputType: 'json'
@ -636,13 +616,12 @@ function handler(params) {
icon: audioSvg,
name: "播放语料",
type: "serviceNode",
desc: "用于播放语料的节点,需要输入语音路径(audioPath)和播放语音的设备id(deviceId)",
desc: "用于播放语料的节点,需要输入语音路径,执行设备由流程角色配置",
action: 'VI_PLAY_CORPUS',
nodeType: "EDGE",
outputType: 'json',
nodeParams: [
{ name: 'audioPath', type: "input", input: "", disabled: true },
{ name: 'deviceId', type: "input", input: "", disabled: true },
{ name: 'bioId', type: "input", input: "", disabled: true, required: false }
],
}
@ -659,7 +638,6 @@ function handler(params) {
action: 'TOUCH',
nodeType: "EDGE",
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "touchParams", type: "input", input: "", disabled: true }
],
outputType: 'json',
@ -671,9 +649,7 @@ function handler(params) {
desc: "控制机器人说话",
action: 'BIO_HEAD_SPEAK_START',
nodeType: "EDGE",
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true }
],
nodeParams: [],
outputType: 'json',
},
{
@ -683,9 +659,7 @@ function handler(params) {
desc: "停止机器人说话",
action: 'BIO_HEAD_SPEAK_STOP',
nodeType: "EDGE",
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true }
],
nodeParams: [],
outputType: 'json',
},
{
@ -696,7 +670,6 @@ function handler(params) {
action: 'BIO_HEAD_SPECIAL_EXPRESSION',
nodeType: "EDGE",
nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "expressKey", type: "input",componentType: 'select', selectOptions: expressOptions(), disabled: true },
],
outputType: 'json',

View File

@ -268,6 +268,15 @@ import { ElMessage } from "element-plus";
import { useFlowStore } from "@/store/modules/flow";
import { emitter } from "@/utils/eventBus";
import { convertToTree } from "@/utils/flow";
import {
actionRequiresDevice,
createDefaultRobotRole,
ensureNodeExecutionResources,
getResourceRoles,
mergeCopiedResourceRoles,
normalizeFlowResourceSchema,
validateFlowResources,
} from "@/utils/flowResources";
import {
flowDeploy,
flowView,
@ -349,6 +358,13 @@ const drop = (e) => {
...other,
},
});
const normalizedResources = ensureNodeExecutionResources(lf.getGraphData(), newNode.id);
if (normalizedResources.start) {
lf.setProperties(normalizedResources.start.id, normalizedResources.start.properties);
}
if (normalizedResources.node) {
lf.setProperties(normalizedResources.node.id, normalizedResources.node.properties);
}
if (loopNode && !["start", "end", "loop", "selectArea"].includes(newNode.type)) {
lf.addToGroup(loopNode.id, newNode.id, point.canvasOverlayPosition);
}
@ -357,7 +373,7 @@ const drop = (e) => {
!["selectArea", "stopLoop", "subStart", "subEnd"].includes(newNode.type)
) {
showParamsDrawer.value = true;
paramsDrawerData.value = newNode;
paramsDrawerData.value = lf.getGraphData().nodes.find((item) => item.id === newNode.id) || newNode;
}
};
@ -569,6 +585,17 @@ const testRun = async () => {
return;
}
const resourceValidation = validateFlowResources({ nodes, edges });
if (resourceValidation.errors.length > 0) {
const error = resourceValidation.errors[0];
ElMessage.warning(error.message);
if (error.nodeId) {
lf.selectElementById(error.nodeId);
lf.focusOn({ id: error.nodeId });
}
return;
}
isOpen.value = true;
};
@ -649,9 +676,9 @@ const initFlowData = {
properties: {
name: "Start",
action: "start",
inputParams: [
{ name: "robotId", type: "string", input: "", disabled: true }
],
schemaVersion: 2,
resourceRoles: [createDefaultRobotRole()],
inputParams: [],
},
},
{
@ -824,7 +851,7 @@ const loadFlowData = async (id, lf) => {
try {
const res = await getDetect(id);
if (res.data && res.data.flowData) {
const flowData = JSON.parse(res.data.flowData);
const flowData = normalizeFlowResourceSchema(JSON.parse(res.data.flowData));
lf.render(flowData);
nextTick(() => {
@ -1075,19 +1102,9 @@ const handleSingleNodeRobotChange = async (robotId) => {
}
deviceParam.componentType = "select";
deviceParam.selectOptions = toDeviceOptions(devices);
if (deviceKind) {
const currentDevice = devices.find((device) => device.deviceId === originalDeviceId);
deviceParam.input = (currentDevice || devices[0]).deviceId;
deviceParam.deviceLocked = devices.length === 1;
return;
}
const originalStillAvailable = devices.some((device) => device.deviceId === originalDeviceId);
deviceParam.input = originalDeviceId || "";
deviceParam.deviceLocked = devices.length === 1 && originalStillAvailable;
if (originalDeviceId && !originalStillAvailable) {
ElMessage.warning("已保留通用指令原设备;该设备未出现在当前机器人的在线列表中");
}
} catch (error) {
deviceParam.input = originalDeviceId;
deviceParam.selectOptions = originalOptions;
@ -1153,7 +1170,8 @@ const loadSingleNodeRobots = async () => {
const singleNodeExecution = async (e) => {
rememberSingleNodeRobot();
const { name, nodeType, nodeParams, action, nodeId } = e.detail;
const executionParams = JSON.parse(JSON.stringify(nodeParams || []));
const executionParams = JSON.parse(JSON.stringify(nodeParams || []))
.filter((param) => !['robotId', 'deviceId'].includes(param.name));
const normalizedAction = String(action || "").toUpperCase();
const effectiveNodeType = nodeType || (getDeviceKindForAction(normalizedAction) ? "EDGE" : "");
activeSingleNodeId.value = nodeId || `${normalizedAction}:${name || ""}`;
@ -1164,7 +1182,7 @@ const singleNodeExecution = async (e) => {
stationLoading.value = false;
if (effectiveNodeType === "EDGE") {
await loadSingleNodeRobots();
formData.nodeParams = [
const routingParams = [
{
disabled: true,
input: "",
@ -1173,8 +1191,18 @@ const singleNodeExecution = async (e) => {
componentType: "select",
selectOptions: singleNodeRobotOptions.value,
},
...executionParams,
];
if (actionRequiresDevice(normalizedAction)) {
routingParams.push({
disabled: true,
input: "",
name: "deviceId",
type: "input",
componentType: "select",
selectOptions: [],
});
}
formData.nodeParams = [...routingParams, ...executionParams];
const cachedRobotId = singleNodeRobotCache.get(activeSingleNodeId.value) || lastSingleNodeRobotId;
if (cachedRobotId) {
formData.nodeParams[0].input = cachedRobotId;
@ -1246,7 +1274,14 @@ const clearRun = () => {
const NODE_PASTE_OFFSET = 40;
const NON_COPYABLE_NODE_TYPES = new Set(["start", "end", "selectArea"]);
let copiedGraph = { nodes: [], edges: [] };
const FLOW_CLIPBOARD_TYPE = "cmvr-flow/nodes";
const FLOW_CLIPBOARD_VERSION = 1;
const FLOW_CLIPBOARD_MIME = "application/x-cmvr-flow-nodes";
const FLOW_CLIPBOARD_STORAGE_KEY = "cmvr-flow:clipboard";
const FLOW_EDITOR_INSTANCE_ID = uuid();
const NODE_REFERENCE_ARRAY_KEYS = new Set(["quote", "nameQuote"]);
let copiedPayload = null;
let lastPastedClipboardId = "";
let pasteCount = 0;
const EDITABLE_SELECTOR = [
@ -1289,6 +1324,7 @@ const isTextEditing = (event) => {
const copySelectedNodes = (event) => {
// 复制只保留选中节点及它们之间的内部连线,避免粘贴时重建指向未选节点的关系。
// 节点数据在此深拷贝,后续生成新 ID 时不会反向污染原画布对象。
if (flowStore.disableForm || isTextEditing(event)) return;
const selectedNodes = lf.getSelectElements().nodes;
if (!selectedNodes.length) return;
@ -1321,9 +1357,79 @@ const copySelectedNodes = (event) => {
copyableIds.has(edge.sourceNodeId) && copyableIds.has(edge.targetNodeId)
));
const payload = {
type: FLOW_CLIPBOARD_TYPE,
version: FLOW_CLIPBOARD_VERSION,
clipboardId: uuid(),
sourceEditorId: FLOW_EDITOR_INSTANCE_ID,
sourceFlowId: flowInfoData.value.itemId || null,
copiedAt: Date.now(),
resourceRoles: getResourceRoles(graph),
graph: JSON.parse(JSON.stringify({ nodes, edges })),
};
const serialized = JSON.stringify(payload);
event.preventDefault();
copiedGraph = JSON.parse(JSON.stringify({ nodes, edges }));
if (event.clipboardData) {
try {
event.clipboardData.setData(FLOW_CLIPBOARD_MIME, serialized);
} catch (error) {
console.debug("浏览器不支持自定义流程剪贴板类型,使用文本格式", error);
}
event.clipboardData.setData("text/plain", serialized);
}
copiedPayload = payload;
try {
localStorage.setItem(FLOW_CLIPBOARD_STORAGE_KEY, serialized);
} catch (error) {
console.debug("流程剪贴板本地缓存写入失败", error);
}
pasteCount = 0;
lastPastedClipboardId = "";
};
/**
* 解析并校验流程剪贴板内容,避免把普通 JSON 文本当作流程节点粘贴。
* @param {*} raw 剪贴板原始文本
*/
const parseFlowClipboardPayload = (raw) => {
if (!raw || typeof raw !== "string") return null;
try {
const payload = JSON.parse(raw);
if (
payload?.type !== FLOW_CLIPBOARD_TYPE
|| payload?.version !== FLOW_CLIPBOARD_VERSION
|| !Array.isArray(payload?.graph?.nodes)
|| !Array.isArray(payload?.graph?.edges)
|| payload.graph.nodes.length === 0
) {
return null;
}
return payload;
} catch {
return null;
}
};
/**
* 优先读取系统剪贴板;浏览器未暴露剪贴板数据时使用同源窗口共享缓存。
* @param {*} event 粘贴事件
*/
const readFlowClipboardPayload = (event) => {
const clipboard = event.clipboardData;
if (clipboard) {
const customText = clipboard.getData(FLOW_CLIPBOARD_MIME);
const plainText = clipboard.getData("text/plain");
const raw = customText || plainText;
// 系统剪贴板存在其他内容时,不应使用一份过期的本地流程缓存。
if (raw) return parseFlowClipboardPayload(raw);
}
if (copiedPayload) return copiedPayload;
try {
return parseFlowClipboardPayload(localStorage.getItem(FLOW_CLIPBOARD_STORAGE_KEY));
} catch {
return null;
}
};
/**
@ -1343,20 +1449,57 @@ const remapNodeAnchorId = (anchorId, oldNodeId, newNodeId) => {
* @param {*} idMap 节点 ID 映射
* @param {*} parentKey 递归父键名
*/
const remapCopiedNodeReferences = (value, idMap, parentKey = "") => {
const remapCopiedNodeReferences = (value, idMap, parentKey = "", options = {}) => {
if (Array.isArray(value)) {
const result = value.map((item) => remapCopiedNodeReferences(item, idMap));
if (parentKey === "quote" && result.length && idMap.has(result[0])) {
const result = value.map((item) => remapCopiedNodeReferences(item, idMap, "", options));
if (NODE_REFERENCE_ARRAY_KEYS.has(parentKey) && result.length) {
if (idMap.has(result[0])) {
result[0] = idMap.get(result[0]);
} else if (options.dropExternalReferences) {
options.removedReferenceCount += 1;
return [];
}
}
return result;
}
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => (
[key, remapCopiedNodeReferences(item, idMap, key)]
[key, remapCopiedNodeReferences(item, idMap, key, options)]
)));
};
/**
* 跨窗口粘贴时将节点组移动到当前画布视口中心;当前窗口内复制仍使用固定偏移。
* @param {*} payload 流程剪贴板载荷
*/
const resolvePasteTranslation = (payload) => {
if (payload.sourceEditorId === FLOW_EDITOR_INSTANCE_ID) {
const offset = NODE_PASTE_OFFSET * pasteCount;
return { x: offset, y: offset };
}
const nodes = payload.graph.nodes;
const xs = nodes.map((node) => Number(node.x || 0));
const ys = nodes.map((node) => Number(node.y || 0));
const sourceCenter = {
x: (Math.min(...xs) + Math.max(...xs)) / 2,
y: (Math.min(...ys) + Math.max(...ys)) / 2,
};
const canvas = document.getElementById("main-flow");
if (!canvas) {
const offset = NODE_PASTE_OFFSET * pasteCount;
return { x: offset, y: offset };
}
const rect = canvas.getBoundingClientRect();
const target = lf.getPointByClient(rect.left + rect.width / 2, rect.top + rect.height / 2)
.canvasOverlayPosition;
const repeatedOffset = NODE_PASTE_OFFSET * (pasteCount - 1);
return {
x: target.x - sourceCenter.x + repeatedOffset,
y: target.y - sourceCenter.y + repeatedOffset,
};
};
/**
* 粘贴 pasteCopiedNodes 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
@ -1364,26 +1507,42 @@ const remapCopiedNodeReferences = (value, idMap, parentKey = "") => {
const pasteCopiedNodes = (event) => {
// 先为全部节点建立旧 ID 到新 ID 的映射,再重建父子引用、锚点和内部连线。
// 该顺序保证循环、分组和分支之间的交叉引用都能解析到新的目标 ID。
if (!copiedGraph.nodes.length || flowStore.disableForm) return;
if (flowStore.disableForm || isTextEditing(event)) return;
const payload = readFlowClipboardPayload(event);
if (!payload) return;
const copiedGraph = payload.graph;
event.preventDefault();
if (lastPastedClipboardId !== payload.clipboardId) {
pasteCount = 0;
lastPastedClipboardId = payload.clipboardId;
}
pasteCount += 1;
const offset = NODE_PASTE_OFFSET * pasteCount;
const translation = resolvePasteTranslation(payload);
const graph = lf.getGraphData();
const existingNodeIds = new Set(graph.nodes.map((node) => node.id));
const idMap = new Map(copiedGraph.nodes.map((node) => (
[node.id, uuid().replace(/-/g, "")]
)));
const isSameEditor = payload.sourceEditorId === FLOW_EDITOR_INSTANCE_ID;
const isSameFlow = Boolean(payload.sourceFlowId)
&& payload.sourceFlowId === flowInfoData.value.itemId;
const referenceOptions = {
dropExternalReferences: !isSameFlow,
removedReferenceCount: 0,
};
const pastedNodes = copiedGraph.nodes.map((sourceNode) => {
const newId = idMap.get(sourceNode.id);
const properties = remapCopiedNodeReferences(
JSON.parse(JSON.stringify(sourceNode.properties || {})),
idMap,
"",
referenceOptions,
);
const parentId = properties.parentId;
if (idMap.has(parentId)) properties.parentId = idMap.get(parentId);
else if (!existingNodeIds.has(parentId)) delete properties.parentId;
else if (!isSameEditor || !existingNodeIds.has(parentId)) delete properties.parentId;
if (sourceNode.type === "branch" && Array.isArray(properties.anchor)) {
properties.anchor = properties.anchor.map((anchor) => ({
@ -1398,8 +1557,8 @@ const pasteCopiedNodes = (event) => {
return {
...JSON.parse(JSON.stringify(sourceNode)),
id: newId,
x: Number(sourceNode.x || 0) + offset,
y: Number(sourceNode.y || 0) + offset,
x: Number(sourceNode.x || 0) + translation.x,
y: Number(sourceNode.y || 0) + translation.y,
properties,
children,
};
@ -1426,8 +1585,13 @@ const pasteCopiedNodes = (event) => {
};
});
const resourceMerge = mergeCopiedResourceRoles(graph, payload.resourceRoles || [], pastedNodes);
const graphNodesWithResources = graph.nodes.map((node) => (
node.id === resourceMerge.start?.id ? resourceMerge.start : node
));
lf.render({
nodes: [...graph.nodes, ...pastedNodes],
nodes: [...graphNodesWithResources, ...pastedNodes],
edges: [...graph.edges, ...pastedEdges],
}, {
resetHistory: false,
@ -1438,6 +1602,14 @@ const pasteCopiedNodes = (event) => {
nextTick(() => {
lf.selectElementsByIds(pastedNodes.map((node) => node.id));
lf.markChanged();
if (referenceOptions.removedReferenceCount > 0) {
ElMessage.warning(
`已粘贴 ${pastedNodes.length} 个节点,${referenceOptions.removedReferenceCount} 个跨工作流参数引用已清除`,
);
}
if (resourceMerge.addedRoles || resourceMerge.addedSlots) {
ElMessage.success(`已同步 ${resourceMerge.addedRoles} 个机器人角色和 ${resourceMerge.addedSlots} 个设备用途`);
}
});
};
@ -1450,15 +1622,6 @@ const handleKeyboardShortcut = (event) => {
const isModifierKey = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (isModifierKey && key === "c") {
if (!flowStore.disableForm) copySelectedNodes(event);
return;
}
if (isModifierKey && key === "v") {
pasteCopiedNodes(event);
return;
}
if (isModifierKey && key === "z") {
if (flowStore.disableForm) return;
event.preventDefault();
@ -1496,6 +1659,8 @@ onMounted(() => {
document.addEventListener("singleNodeExecution", singleNodeExecution);
registerBeforeUnload();
window.addEventListener("keydown", handleKeyboardShortcut);
window.addEventListener("copy", copySelectedNodes);
window.addEventListener("paste", pasteCopiedNodes);
onFlowEvent("openParamsDrawer", (node) => {
showParamsDrawer.value = true;
@ -1548,6 +1713,8 @@ onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("keydown", handleKeyboardShortcut);
window.removeEventListener("copy", copySelectedNodes);
window.removeEventListener("paste", pasteCopiedNodes);
document.removeEventListener("singleNodeExecution", singleNodeExecution);
});
</script>

View File

@ -32,6 +32,7 @@
@sync-anchors="syncBranchAnchors"
@add-to-group="addToGroup"
/>
<div v-if="executionLabel" class="flow-node-executor">{{ executionLabel }}</div>
</div>
<template v-if="flowType === 'branch'">
@ -111,6 +112,18 @@ const vueFlow = useVueFlow("main-flow");
const componentRef = ref();
const flowType = computed(() => props.data.flowType);
const properties = computed(() => props.data.properties || {});
const executionLabel = computed(() => {
const roleKey = properties.value.executionTarget?.roleKey;
if (!roleKey) return "";
const graph = window.lf?.getGraphData?.();
const start = graph?.nodes?.find((node) => node.type === "start");
const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey);
if (!role) return "未配置机器人角色";
const slotKey = properties.value.executionTarget?.deviceSlotKey
|| properties.value.executionTarget?.deviceBindings?.[0]?.slotKey;
const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey);
return slot ? `${role.displayName} · ${slot.displayName}` : role.displayName;
});
const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode);
const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value));
const childNodeCount = computed(() => (
@ -285,6 +298,16 @@ onMounted(() => bindComponent(componentRef.value));
box-sizing: border-box;
}
.flow-node-executor {
overflow: hidden;
margin-top: 8px;
color: #667085;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-node-card {
width: 100%;
height: auto;

View File

@ -143,6 +143,14 @@
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body>
<taskLog :list="logDialogData" :height="300" />
</el-dialog>
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="560px" append-to-body>
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="runtimeRoles" />
<template #footer>
<el-button @click="startDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="startSubmitting" @click="confirmStartTask">开始执行</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
@ -157,10 +165,20 @@ import { addRunTask, getRunTaskList, startRunTask, pauseRunTask, stopRunTask, re
import { ElMessage, ElMessageBox } from "element-plus";
import { formatDate } from "@/utils/index";
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
import { getFlowParamsApi } from '@/api/device/terminal'
import { getDetect } from '@/api/test/detect'
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import { getResourceRoles, mergeResourceRoleDefinitions, normalizeFlowResourceSchema } from '@/utils/flowResources'
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const socket = inject('ws');
const startDialogVisible = ref(false)
const startSubmitting = ref(false)
const pendingStartTask = ref(null)
const runtimeRoles = ref([])
const runtimeAssignments = ref([])
const runtimeEditorRef = ref()
const taskStatusMap = {
'0': '已完成',
@ -324,11 +342,53 @@ const startTask = async (task) => {
getList();
}
} else {
let res = await startRunTask(task.id);
if (res.code === 200) {
ElMessage.success("任务已开始");
getList();
const taskDefinition = taskList.value.find(item => item.id === task.taskId)
if (!taskDefinition?.taskConfigId) {
ElMessage.warning('当前任务没有关联流程编排')
return
}
const response = await getFlowParamsApi(taskDefinition.taskConfigId)
const definitions = await Promise.all((response.rows || []).map(async (item) => {
const flowResponse = await getDetect(item.itemId)
return flowResponse.data?.flowData
? getResourceRoles(normalizeFlowResourceSchema(JSON.parse(flowResponse.data.flowData)))
: []
}))
const roles = mergeResourceRoleDefinitions(definitions)
runtimeAssignments.value = roles.map((role, index) => ({
roleKey: role.roleKey,
members: [{
memberKey: `${role.roleKey}_1`,
robotId: index === 0 ? task.robotId || '' : '',
devices: {}
}]
}))
runtimeRoles.value = roles
pendingStartTask.value = task
startDialogVisible.value = true
}
}
const confirmStartTask = async () => {
const assignmentError = runtimeEditorRef.value?.validate()
if (assignmentError) {
ElMessage.warning(assignmentError)
return
}
startSubmitting.value = true
try {
const robotId = runtimeAssignments.value.flatMap(item => item.members).find(member => member.robotId)?.robotId || ''
const res = await startRunTask(pendingStartTask.value.id, {
robotId,
runtimeAssignments: runtimeAssignments.value
})
if (res.code === 200) {
ElMessage.success('任务已开始')
startDialogVisible.value = false
getList()
}
} finally {
startSubmitting.value = false
}
}

View File

@ -150,6 +150,15 @@
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body>
<taskLog :list="logDialogData" :height="300" />
</el-dialog>
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="560px" append-to-body>
<div class="start-dialog-tip">手机和车辆沿用任务配置,这里只安排执行流程的机器人和设备。</div>
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="runtimeRoles" />
<template #footer>
<el-button @click="startDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="startSubmitting" @click="confirmStartTask">开始执行</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
@ -174,10 +183,20 @@ import {
import { ElMessage, ElMessageBox } from "element-plus";
import { formatDate } from "@/utils/index";
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
import { getFlowParamsApi } from '@/api/device/terminal'
import { getDetect } from '@/api/test/detect'
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import { getResourceRoles, mergeResourceRoleDefinitions, normalizeFlowResourceSchema } from '@/utils/flowResources'
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const socket = inject('ws');
const startDialogVisible = ref(false)
const startSubmitting = ref(false)
const pendingStartTask = ref(null)
const runtimeRoles = ref([])
const runtimeAssignments = ref([])
const runtimeEditorRef = ref()
const taskStatusMap = {
'0': '已完成',
@ -343,11 +362,45 @@ const startTask = async (task) => {
getList();
}
} else {
let res = await startTaskinstance(task.id);
if (res.code === 200) {
ElMessage.success("任务已开始");
getList();
const taskDefinition = taskList.value.find(item => item.id === task.taskId)
if (!taskDefinition?.taskConfigId) {
ElMessage.warning('当前任务没有关联流程编排')
return
}
const response = await getFlowParamsApi(taskDefinition.taskConfigId)
const definitions = await Promise.all((response.rows || []).map(async (item) => {
const flowResponse = await getDetect(item.itemId)
return flowResponse.data?.flowData
? getResourceRoles(normalizeFlowResourceSchema(JSON.parse(flowResponse.data.flowData)))
: []
}))
runtimeRoles.value = mergeResourceRoleDefinitions(definitions)
runtimeAssignments.value = []
pendingStartTask.value = task
startDialogVisible.value = true
}
}
const confirmStartTask = async () => {
const assignmentError = runtimeEditorRef.value?.validate()
if (assignmentError) {
ElMessage.warning(assignmentError)
return
}
startSubmitting.value = true
try {
const robotId = runtimeAssignments.value.flatMap(item => item.members).find(member => member.robotId)?.robotId || ''
const res = await startTaskinstance(pendingStartTask.value.id, {
robotId,
runtimeAssignments: runtimeAssignments.value
})
if (res.code === 200) {
ElMessage.success('任务已开始')
startDialogVisible.value = false
getList()
}
} finally {
startSubmitting.value = false
}
}
@ -644,4 +697,13 @@ onUnmounted(() => {
}
}
}
.start-dialog-tip {
margin-bottom: 12px;
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;
}
</style>

View File

@ -7,16 +7,9 @@
:rules="rules"
label-width="auto"
>
<el-form-item label="执行机器人" prop="formData.robotId" :rules="[{ required: true, message: '请选择在线机器人', trigger: 'change' }]">
<el-select v-model="formData.robotId" style="width: 260px" placeholder="请选择在线机器人">
<el-option
v-for="item in robotIdOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="run-section-title">本次执行安排</div>
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="roles" />
<div class="run-section-title run-section-title--params">运行参数</div>
<el-table
:data="formData.tableData"
:border="parentBorder"
@ -74,8 +67,10 @@
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { addValueProperty, formatTableData } from "@/utils/flow";
import { getRobotList } from '@/api/inspection/robot'
import { getExecuteRunParams, projectExecute, executeProject } from "@/api/is/project/index.js";
import { getExecuteRunParams, projectExecute } from "@/api/is/project/index.js";
import { getDetect } from '@/api/test/detect'
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import { getResourceRoles, mergeResourceRoleDefinitions, normalizeFlowResourceSchema } from '@/utils/flowResources'
const props = defineProps({
@ -90,31 +85,24 @@ const ruleFormRef = ref();
const rules = ref({});
const formData = ref({
robotId: '',
tableData: []
})
const robotIdOptions = ref([]);
/** 获取设备终端列表 */
const handleGetTerminalGroup = async (row) => {
const res = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' });
if (res.code === 200) {
robotIdOptions.value = res.rows?.map((item) => {
return {
label: `${item.robotName || item.robotId} (${item.robotId})`,
value: item.robotId,
};
});
}
};
const roles = ref([])
const runtimeAssignments = ref([])
const runtimeEditorRef = ref()
const getProjectParams = async () => {
const res = await getExecuteRunParams({ projectId: props.projectId });
if (res.code === 200) {
const data = parseConfig(res.data)
formData.value.tableData = data
handleGetTerminalGroup();
const definitions = await Promise.all(data.map(async (item) => {
const response = await getDetect(item.itemId)
return response.data?.flowData
? getResourceRoles(normalizeFlowResourceSchema(JSON.parse(response.data.flowData)))
: []
}))
roles.value = mergeResourceRoleDefinitions(definitions)
}
};
@ -138,9 +126,18 @@ const confirm = () => {
const data = formatTableData(item.config)
runParams[item.itemId] = data
})
const res = await projectExecute({ runParams, projectId: props.projectId, robotId: formData.value.robotId })
// 后端临时应付检查添加,后续可删除下一行代码,解开上一行代码注释
// const res = await executeProject(props.functionIds)
const assignmentError = runtimeEditorRef.value?.validate()
if (assignmentError) {
ElMessage.warning(assignmentError)
return
}
const robotId = runtimeAssignments.value.flatMap(item => item.members).find(member => member.robotId)?.robotId || ''
const res = await projectExecute({
runParams,
projectId: props.projectId,
robotId,
runtimeAssignments: runtimeAssignments.value
})
if (res.code === 200) {
ruleFormRef.value.resetFields();
ElMessage.success('执行成功')
@ -168,3 +165,8 @@ watch(
}
);
</script>
<style scoped>
.run-section-title { margin: 4px 0 10px; font-size: 15px; font-weight: 650; }
.run-section-title--params { margin-top: 22px; }
</style>

View File

@ -7,16 +7,9 @@
:rules="rules"
label-width="auto"
>
<el-form-item label="执行机器人" prop="formData.robotId" :rules="[{ required: true, message: '请选择在线机器人', trigger: 'change' }]">
<el-select v-model="formData.robotId" style="width: 260px" placeholder="请选择在线机器人">
<el-option
v-for="item in robotIdOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="run-section-title">本次执行安排</div>
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="roles" />
<div class="run-section-title run-section-title--params">运行参数</div>
<el-table
:data="formData.tableData"
:border="parentBorder"
@ -74,8 +67,10 @@ import { addValueProperty, formatTableData } from "@/utils/flow";
import { emitter } from "@/utils/eventBus";
import { ElMessage } from "element-plus";
import { getFlowParamsApi } from "@/api/device/terminal.js";
import { getRobotList } from '@/api/inspection/robot'
import { taskExecute } from "@/api/test/config"
import { getDetect } from '@/api/test/detect'
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import { getResourceRoles, mergeResourceRoleDefinitions, normalizeFlowResourceSchema } from '@/utils/flowResources'
const props = defineProps({
drawer: Boolean,
@ -88,9 +83,11 @@ const preserveExpanded = ref(false);
const ruleFormRef = ref();
const rules = ref({});
const formData = ref({
robotId: '',
tableData: []
})
const roles = ref([])
const runtimeAssignments = ref([])
const runtimeEditorRef = ref()
const emits = defineEmits(["close"]);
@ -104,7 +101,12 @@ const getFlowParams = async () => {
if (res.code === 200) {
const data = parseConfig(res.rows)
formData.value.tableData = data
handleGetTerminalGroup();
const definitions = await Promise.all(data.map(async (item) => {
const response = await getDetect(item.itemId)
if (!response.data?.flowData) return []
return getResourceRoles(normalizeFlowResourceSchema(JSON.parse(response.data.flowData)))
}))
roles.value = mergeResourceRoleDefinitions(definitions)
}
};
@ -128,7 +130,18 @@ const confirm = () => {
const data = formatTableData(item.config)
runParams[item.itemId] = data
})
const res = await taskExecute({ runParams, taskId: props.currentRow.id, robotId: formData.value.robotId })
const assignmentError = runtimeEditorRef.value?.validate()
if (assignmentError) {
ElMessage.warning(assignmentError)
return
}
const robotId = runtimeAssignments.value.flatMap(item => item.members).find(member => member.robotId)?.robotId || ''
const res = await taskExecute({
runParams,
taskId: props.currentRow.id,
robotId,
runtimeAssignments: runtimeAssignments.value
})
if (res.code === 200) {
ruleFormRef.value.resetFields();
ElMessage.success('执行成功')
@ -142,22 +155,6 @@ const confirm = () => {
});
};
const robotIdOptions = ref([]);
/** 获取设备终端列表 */
const handleGetTerminalGroup = async (row) => {
const res = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' });
if (res.code === 200) {
robotIdOptions.value = res.rows?.map((item) => {
return {
label: `${item.robotName || item.robotId} (${item.robotId})`,
value: item.robotId,
};
});
}
};
watch(
() => props.drawer,
(newVal) => {
@ -171,3 +168,16 @@ onUnmounted(() => {
emitter.off("contentChange");
});
</script>
<style scoped>
.run-section-title {
margin: 4px 0 10px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
}
.run-section-title--params {
margin-top: 22px;
}
</style>

View File

@ -7,16 +7,9 @@
:rules="rules"
label-width="auto"
>
<el-form-item label="执行机器人" prop="formData.robotId" :rules="[{ required: true, message: '请选择在线机器人', trigger: 'change' }]">
<el-select v-model="formData.robotId" style="width: 260px" placeholder="请选择在线机器人">
<el-option
v-for="item in robotIdOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="run-section-title">本次执行安排</div>
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="roles" />
<div class="run-section-title run-section-title--params">运行参数</div>
<el-table
:data="formData.tableData"
:border="parentBorder"
@ -71,11 +64,13 @@
</el-drawer>
</template>
<script setup>
import { ref, watch, onUnmounted } from "vue";
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { addValueProperty, formatTableData } from "@/utils/flow";
import { getRobotList } from '@/api/inspection/robot'
import { getExecuteRunParams, projectExecute } from "@/api/vi/project.js"
import { getDetect } from '@/api/test/detect'
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import { getResourceRoles, mergeResourceRoleDefinitions, normalizeFlowResourceSchema } from '@/utils/flowResources'
const props = defineProps({
drawer: Boolean,
@ -88,32 +83,24 @@ const ruleFormRef = ref();
const rules = ref({});
const formData = ref({
robotId: '',
tableData: []
})
const robotIdOptions = ref([]);
/** 获取设备终端列表 */
const handleGetTerminalGroup = async (row) => {
const res = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' });
if (res.code === 200) {
robotIdOptions.value = res.rows?.map((item) => {
return {
label: `${item.robotName || item.robotId} (${item.robotId})`,
value: item.robotId,
};
});
}
};
const roles = ref([])
const runtimeAssignments = ref([])
const runtimeEditorRef = ref()
const getProjectParams = async () => {
const res = await getExecuteRunParams(props.projectId);
if (res.code === 200) {
const data = parseConfig(res.data)
console.log('data', data)
formData.value.tableData = data
handleGetTerminalGroup();
const definitions = await Promise.all(data.map(async (item) => {
const response = await getDetect(item.itemId)
return response.data?.flowData
? getResourceRoles(normalizeFlowResourceSchema(JSON.parse(response.data.flowData)))
: []
}))
roles.value = mergeResourceRoleDefinitions(definitions)
}
};
@ -137,7 +124,18 @@ const confirm = () => {
const data = formatTableData(item.config)
runParams[item.itemId] = data
})
const res = await projectExecute({ runParams, projectId: props.projectId, robotId: formData.value.robotId })
const assignmentError = runtimeEditorRef.value?.validate()
if (assignmentError) {
ElMessage.warning(assignmentError)
return
}
const robotId = runtimeAssignments.value.flatMap(item => item.members).find(member => member.robotId)?.robotId || ''
const res = await projectExecute({
runParams,
projectId: props.projectId,
robotId,
runtimeAssignments: runtimeAssignments.value
})
if (res.code === 200) {
ruleFormRef.value.resetFields();
ElMessage.success('执行成功')
@ -165,3 +163,8 @@ watch(
}
);
</script>
<style scoped>
.run-section-title { margin: 4px 0 10px; font-size: 15px; font-weight: 650; }
.run-section-title--params { margin-top: 22px; }
</style>