forked from SmartBench/CMVR-IOT-UI
- 增加了检测流资源中引用循环的功能,以防止循环依赖。 - 改进了流引用的验证,以确保节点输出中不存在循环依赖。 - 增强了资源定义清理功能,以便在执行前移除过时的设备ID。 - 已实现复制资源角色的合并,以优化现有资源的优先级并避免重复。 - 更新了LoopIterationResults组件,以管理运行中迭代的自动扩展。 - 增强了NodeState和NodeTitle组件,以支持循环和子流结果的更宽弹出框显示。 - 优化了ParamsDrawer和SubFlowExecutionResults组件,以处理动态参数更新和执行结果。 - 改进了TaskExecutionLog,以统一输出处理并增强用户对日志的可见性。 - 更新了各种参数组件,以简化输入处理并提升用户体验。 - 在代码中添加了全面的注释和文档,以提高可维护性和易理解性。
518 lines
22 KiB
JavaScript
518 lines
22 KiB
JavaScript
import { getDeviceKindForAction } from '@/utils/robotDevice'
|
||
|
||
export const FLOW_SCHEMA_VERSION = 2
|
||
export const DEFAULT_ROLE_KEY = 'default_executor'
|
||
export const FLOW_NODE_RESOURCE_OWNER = 'FLOW_NODE'
|
||
export const USER_RESOURCE_OWNER = 'USER'
|
||
// 图片分析节点的输出契约会被节点配置、参数面板和旧流程迁移复用,集中定义避免字段漂移。
|
||
export const IMAGE_ANALYSIS_OUTPUT_PARAMS = Object.freeze([
|
||
{ name: 'result', type: 'object', desc: '按提示词返回的结果', children: [], disabled: true },
|
||
{ name: 'resultText', type: 'string', desc: '模型返回的原始文本', disabled: true },
|
||
{ name: 'matched', type: 'boolean', desc: '找图是否匹配', disabled: true },
|
||
{ name: 'matchScore', type: 'number', desc: '匹配分数或定位置信度', disabled: true },
|
||
{ name: 'coordinates', type: 'array<number>', desc: '原图坐标 [x1,y1,x2,y2]', children: [], disabled: true },
|
||
{ name: 'center', type: 'array<number>', desc: '目标中心点 [x,y]', children: [], disabled: true },
|
||
{ name: 'matchedDescription', type: 'string', desc: '模型实际定位的目标描述', disabled: true },
|
||
{ name: 'refinementMethod', type: 'string', desc: '坐标精修方式', disabled: true },
|
||
{ name: 'matchCount', type: 'number', desc: '匹配项数量', disabled: true },
|
||
{ name: 'matches', type: 'array<object>', desc: '全部匹配项', children: [], disabled: true },
|
||
{ name: 'selectedMatchIndex', type: 'number', desc: '当前选择的匹配序号', disabled: true },
|
||
])
|
||
|
||
const DEVICE_KIND_META = Object.freeze({
|
||
1: { key: 'agv', name: '移动底盘' },
|
||
2: { key: 'arm', name: '机械臂' },
|
||
4: { key: 'bio_head', name: '仿生头' },
|
||
5: { key: 'camera', name: '摄像头' },
|
||
9: { key: 'microphone', name: '麦克风' },
|
||
12: { key: 'robot', name: '机器人本体' },
|
||
13: { key: 'speaker', name: '扬声器' },
|
||
})
|
||
|
||
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
|
||
const normalizedText = (value) => String(value || '').trim()
|
||
const LEGACY_NODE_TYPE_ALIASES = Object.freeze({
|
||
device_universal: 'deviceUniversal',
|
||
})
|
||
|
||
export const createResourceKey = (prefix) => {
|
||
const random = globalThis.crypto?.randomUUID?.().replace(/-/g, '').slice(0, 10)
|
||
|| `${Date.now()}${Math.random().toString(16).slice(2, 8)}`
|
||
return `${prefix}_${random}`
|
||
}
|
||
|
||
export const createDefaultRobotRole = (legacyRobotId = '') => ({
|
||
roleKey: DEFAULT_ROLE_KEY,
|
||
resourceType: 'ROBOT',
|
||
displayName: '执行机器人',
|
||
required: true,
|
||
quantity: { min: 1, max: 1 },
|
||
deviceSlots: [],
|
||
...(legacyRobotId ? { legacyRobotId } : {}),
|
||
})
|
||
|
||
export const createDeviceSlot = (deviceKind, existingSlots = [], displayName = '') => {
|
||
const meta = DEVICE_KIND_META[Number(deviceKind)] || { key: 'device', name: '执行设备' }
|
||
const usedKeys = new Set(existingSlots.map((slot) => slot.slotKey))
|
||
let index = 1
|
||
let slotKey = meta.key
|
||
while (usedKeys.has(slotKey)) slotKey = `${meta.key}_${++index}`
|
||
const sameKindCount = existingSlots.filter((slot) => Number(slot.deviceKind) === Number(deviceKind)).length
|
||
return {
|
||
slotKey,
|
||
displayName: displayName || `${meta.name}${sameKindCount ? sameKindCount + 1 : ''}`,
|
||
deviceKind: deviceKind == null ? null : Number(deviceKind),
|
||
required: true,
|
||
allowReuse: false,
|
||
}
|
||
}
|
||
|
||
export const getStartNode = (graph = globalThis.lf?.getGraphData?.()) => (
|
||
graph?.nodes?.find((node) => node.type === 'start')
|
||
)
|
||
|
||
export const getResourceRoles = (graph = globalThis.lf?.getGraphData?.()) => (
|
||
clone(getStartNode(graph)?.properties?.resourceRoles || []) || []
|
||
)
|
||
|
||
const isRobotNode = (node) => node?.properties?.nodeType === 'EDGE'
|
||
export const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE'
|
||
export const actionRequiresDevice = (action) => (
|
||
getDeviceKindForAction(action) != null || action === 'DEVICE_EXECUTE_JSON_COMMAND'
|
||
)
|
||
export const nodeRequiresDevice = (node) => actionRequiresDevice(node?.properties?.action)
|
||
|
||
const resolveLegacyRobotId = (startProperties) => (
|
||
startProperties?.inputParams?.find((item) => item.name === 'robotId')?.input
|
||
|| startProperties?.robotId
|
||
|| ''
|
||
)
|
||
|
||
const findOrCreateLegacySlot = (role, node, legacySlotByIdentity, legacyDeviceId = '') => {
|
||
const kind = getDeviceKindForAction(node.properties?.action)
|
||
const normalizedDeviceId = normalizedText(legacyDeviceId)
|
||
const identity = `${role.roleKey}:${kind ?? 'any'}:${normalizedDeviceId || 'default'}`
|
||
if (legacySlotByIdentity.has(identity)) return legacySlotByIdentity.get(identity)
|
||
|
||
const slot = createDeviceSlot(kind, role.deviceSlots)
|
||
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||
if (normalizedDeviceId) slot.legacyDeviceId = normalizedDeviceId
|
||
role.deviceSlots.push(slot)
|
||
legacySlotByIdentity.set(identity, slot)
|
||
return slot
|
||
}
|
||
|
||
/**
|
||
* Loads old graphs as Schema V2 without mutating the source object. Physical IDs are
|
||
* retained only as migration hints and are consumed by the trial-run assignment form.
|
||
*/
|
||
export const normalizeFlowResourceSchema = (sourceGraph) => {
|
||
const graph = clone(sourceGraph || { nodes: [], edges: [] })
|
||
graph.nodes = Array.isArray(graph.nodes) ? graph.nodes : []
|
||
graph.edges = Array.isArray(graph.edges) ? graph.edges : []
|
||
graph.nodes.forEach((node) => {
|
||
node.type = LEGACY_NODE_TYPE_ALIASES[node.type] || node.type
|
||
// 早期图片分析图数据可能只保存节点类型,加载时补齐运行端识别所需的协议字段。
|
||
const properties = node.properties || (node.properties = {})
|
||
if (['AGV_MOVE_TO_POINT', 'AGV_MOVE_TO_STATION'].includes(properties.action)) {
|
||
properties.nodeParams = Array.isArray(properties.nodeParams) ? properties.nodeParams : []
|
||
if (!properties.nodeParams.some((param) => param?.name === 'blockedTimeoutMs')) {
|
||
properties.nodeParams.push({
|
||
name: 'blockedTimeoutMs',
|
||
type: 'input',
|
||
componentType: 'number',
|
||
input: 60000,
|
||
min: 1,
|
||
required: true,
|
||
disabled: true,
|
||
})
|
||
}
|
||
}
|
||
if (node.type === 'imageAnalysis') {
|
||
const properties = node.properties || (node.properties = {})
|
||
properties.action = 'IMAGE_ANALYZE'
|
||
properties.nodeType = 'LLM'
|
||
properties.outputType = 'json'
|
||
properties.outputParams = clone(IMAGE_ANALYSIS_OUTPUT_PARAMS)
|
||
}
|
||
// 视频分析新增 result/detailedOutput 后,打开旧流程时补齐输出字段,后续节点才能稳定引用。
|
||
if (node.properties?.action === 'VIDEO_ANALYZE') {
|
||
const outputParams = Array.isArray(node.properties.outputParams)
|
||
? node.properties.outputParams
|
||
: []
|
||
if (!outputParams.some((param) => param?.name === 'result')) {
|
||
outputParams.unshift({ name: 'result', type: 'string', desc: '分析结果', disabled: true })
|
||
}
|
||
if (!outputParams.some((param) => param?.name === 'detailedOutput')) {
|
||
outputParams.push({
|
||
name: 'detailedOutput', type: 'boolean', desc: '是否返回详细事件证据', disabled: true,
|
||
})
|
||
}
|
||
node.properties.outputParams = outputParams
|
||
}
|
||
})
|
||
const start = getStartNode(graph)
|
||
if (!start) return graph
|
||
|
||
const startProperties = start.properties || (start.properties = {})
|
||
const legacyRobotId = resolveLegacyRobotId(startProperties)
|
||
const roles = Array.isArray(startProperties.resourceRoles) && startProperties.resourceRoles.length
|
||
? startProperties.resourceRoles
|
||
: [createDefaultRobotRole(legacyRobotId)]
|
||
|
||
roles.forEach((role, index) => {
|
||
role.roleKey ||= index === 0 ? DEFAULT_ROLE_KEY : createResourceKey('robot_role')
|
||
role.resourceType = 'ROBOT'
|
||
role.displayName ||= `机器人角色${index + 1}`
|
||
role.required = role.required !== false
|
||
role.quantity ||= { min: 1, max: 1 }
|
||
role.deviceSlots = Array.isArray(role.deviceSlots) ? role.deviceSlots : []
|
||
})
|
||
|
||
startProperties.schemaVersion = FLOW_SCHEMA_VERSION
|
||
startProperties.resourceRoles = roles
|
||
startProperties.inputParams = (startProperties.inputParams || []).filter((item) => item.name !== 'robotId')
|
||
|
||
const defaultRole = roles[0]
|
||
const legacySlotByIdentity = new Map()
|
||
roles.forEach((role) => role.deviceSlots.forEach((slot) => {
|
||
const identity = `${role.roleKey}:${slot.deviceKind ?? 'any'}:${slot.legacyDeviceId || 'default'}`
|
||
legacySlotByIdentity.set(identity, slot)
|
||
}))
|
||
|
||
graph.nodes.forEach((node) => {
|
||
const properties = node.properties || (node.properties = {})
|
||
if (!isRobotNode(node)) {
|
||
delete properties.executionTarget
|
||
return
|
||
}
|
||
const target = properties.executionTarget || {
|
||
roleKey: defaultRole.roleKey,
|
||
dispatchPolicy: 'ONE',
|
||
}
|
||
target.roleKey ||= defaultRole.roleKey
|
||
target.dispatchPolicy ||= 'ONE'
|
||
const isActionQueue = properties.action === ACTION_QUEUE_ACTION
|
||
if (!isActionQueue) target.deviceSlotKey ||= target.deviceBindings?.[0]?.slotKey || ''
|
||
target.deviceBindings = Array.isArray(target.deviceBindings) ? target.deviceBindings : []
|
||
|
||
const targetRole = roles.find((role) => role.roleKey === target.roleKey) || defaultRole
|
||
const deviceParam = properties.nodeParams?.find((param) => param.name === 'deviceId')
|
||
if (nodeRequiresDevice(node) && !target.deviceSlotKey) {
|
||
const slot = findOrCreateLegacySlot(targetRole, node, legacySlotByIdentity, deviceParam?.input)
|
||
if (slot) target.deviceSlotKey = slot.slotKey
|
||
}
|
||
const referencedSlotKeys = new Set([
|
||
target.deviceSlotKey,
|
||
...target.deviceBindings.map((binding) => binding?.slotKey),
|
||
].filter(Boolean))
|
||
targetRole.deviceSlots.forEach((slot) => {
|
||
if (referencedSlotKeys.has(slot.slotKey) && !slot.managedBy) slot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||
})
|
||
if (!isActionQueue) delete target.deviceBindings
|
||
if (isActionQueue) delete target.deviceSlotKey
|
||
properties.nodeParams = (properties.nodeParams || []).filter((param) => param.name !== 'deviceId')
|
||
properties.executionTarget = target
|
||
})
|
||
|
||
return graph
|
||
}
|
||
|
||
export const ensureNodeExecutionResources = (sourceGraph, nodeId) => {
|
||
const graph = normalizeFlowResourceSchema(sourceGraph)
|
||
const node = graph.nodes.find((item) => item.id === nodeId)
|
||
return { graph, node, start: getStartNode(graph) }
|
||
}
|
||
|
||
/**
|
||
* Removes role definitions and device usages that were registered automatically
|
||
* for flow nodes and are no longer referenced by any remaining node.
|
||
*/
|
||
export const pruneUnusedNodeResources = (sourceGraph) => {
|
||
const graph = clone(sourceGraph || { nodes: [], edges: [] })
|
||
const start = getStartNode(graph)
|
||
if (!start?.properties?.resourceRoles) {
|
||
return { graph, start, removedRoles: 0, removedSlots: 0 }
|
||
}
|
||
|
||
const usedRoleKeys = new Set()
|
||
const usedSlotsByRole = new Map()
|
||
graph.nodes.filter(isRobotNode).forEach((node) => {
|
||
const target = node.properties?.executionTarget
|
||
if (!target?.roleKey) return
|
||
usedRoleKeys.add(target.roleKey)
|
||
if (!usedSlotsByRole.has(target.roleKey)) usedSlotsByRole.set(target.roleKey, new Set())
|
||
if (target.deviceSlotKey) usedSlotsByRole.get(target.roleKey).add(target.deviceSlotKey)
|
||
;(target.deviceBindings || []).forEach((binding) => {
|
||
if (binding?.slotKey) usedSlotsByRole.get(target.roleKey).add(binding.slotKey)
|
||
})
|
||
})
|
||
graph.nodes.filter((node) => node?.properties?.action === 'SUB_FLOW').forEach((node) => {
|
||
;(node.properties?.subFlow?.resourceRoles || []).forEach((role) => {
|
||
if (!role?.roleKey) return
|
||
usedRoleKeys.add(role.roleKey)
|
||
if (!usedSlotsByRole.has(role.roleKey)) usedSlotsByRole.set(role.roleKey, new Set())
|
||
;(role.deviceSlots || []).forEach((slot) => {
|
||
if (slot?.slotKey) usedSlotsByRole.get(role.roleKey).add(slot.slotKey)
|
||
})
|
||
})
|
||
})
|
||
|
||
let removedSlots = 0
|
||
start.properties.resourceRoles.forEach((role) => {
|
||
const usedSlotKeys = usedSlotsByRole.get(role.roleKey) || new Set()
|
||
role.deviceSlots = (role.deviceSlots || []).filter((slot) => {
|
||
const shouldRemove = slot.managedBy === FLOW_NODE_RESOURCE_OWNER && !usedSlotKeys.has(slot.slotKey)
|
||
if (shouldRemove) removedSlots += 1
|
||
return !shouldRemove
|
||
})
|
||
})
|
||
|
||
const beforeRoleCount = start.properties.resourceRoles.length
|
||
start.properties.resourceRoles = start.properties.resourceRoles.filter((role, index) => (
|
||
index === 0
|
||
|| role.managedBy !== FLOW_NODE_RESOURCE_OWNER
|
||
|| usedRoleKeys.has(role.roleKey)
|
||
))
|
||
|
||
return {
|
||
graph,
|
||
start,
|
||
removedRoles: beforeRoleCount - start.properties.resourceRoles.length,
|
||
removedSlots,
|
||
}
|
||
}
|
||
|
||
export const validateFlowResources = (sourceGraph) => {
|
||
const graph = normalizeFlowResourceSchema(sourceGraph)
|
||
const roles = getResourceRoles(graph)
|
||
const errors = validateFlowReferences(graph)
|
||
const roleKeys = new Set()
|
||
|
||
roles.forEach((role) => {
|
||
if (!normalizedText(role.displayName)) errors.push({ nodeId: getStartNode(graph)?.id, message: '机器人角色名称不能为空' })
|
||
if (roleKeys.has(role.roleKey)) errors.push({ nodeId: getStartNode(graph)?.id, message: `机器人角色标识重复:${role.roleKey}` })
|
||
roleKeys.add(role.roleKey)
|
||
const slotKeys = new Set()
|
||
;(role.deviceSlots || []).forEach((slot) => {
|
||
if (!normalizedText(slot.displayName)) errors.push({ nodeId: getStartNode(graph)?.id, message: `${role.displayName}存在未命名的设备用途` })
|
||
if (slotKeys.has(slot.slotKey)) errors.push({ nodeId: getStartNode(graph)?.id, message: `${role.displayName}的设备用途标识重复` })
|
||
slotKeys.add(slot.slotKey)
|
||
})
|
||
})
|
||
|
||
graph.nodes.filter(isRobotNode).forEach((node) => {
|
||
const target = node.properties?.executionTarget
|
||
const role = roles.find((item) => item.roleKey === target?.roleKey)
|
||
if (!role) {
|
||
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择有效的机器人角色` })
|
||
return
|
||
}
|
||
if (target.deviceSlotKey && !(role.deviceSlots || []).some((slot) => slot.slotKey === target.deviceSlotKey)) {
|
||
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的设备用途不存在` })
|
||
}
|
||
;(target.deviceBindings || []).forEach((binding) => {
|
||
if (!binding?.parameterName || !binding?.slotKey) {
|
||
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}存在未配置的队列设备` })
|
||
} else if (!(role.deviceSlots || []).some((slot) => slot.slotKey === binding.slotKey)) {
|
||
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}引用的队列设备用途不存在` })
|
||
}
|
||
})
|
||
if (nodeRequiresDevice(node)
|
||
&& !target.deviceSlotKey) {
|
||
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择设备用途` })
|
||
}
|
||
})
|
||
return { graph, errors }
|
||
}
|
||
|
||
const collectActiveQuotes = (value, result = []) => {
|
||
if (Array.isArray(value)) {
|
||
value.forEach((item) => collectActiveQuotes(item, result))
|
||
return result
|
||
}
|
||
if (!value || typeof value !== 'object') return result
|
||
if (value.type === 'quote' && Array.isArray(value.quote) && value.quote.length >= 3) {
|
||
result.push(value.quote)
|
||
}
|
||
if (value.nameType === 'quote' && Array.isArray(value.nameQuote) && value.nameQuote.length >= 3) {
|
||
result.push(value.nameQuote)
|
||
}
|
||
Object.values(value).forEach((item) => collectActiveQuotes(item, result))
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 对节点引用依赖做深度优先遍历,发现 A 引用 B、B 又回到 A 的循环链路。
|
||
* states: 1 表示当前递归栈中,2 表示已确认无环;命中 1 时即可截取循环路径。
|
||
*/
|
||
const findReferenceCycle = (dependencies) => {
|
||
const states = new Map()
|
||
const stack = []
|
||
const visit = (nodeId) => {
|
||
if (states.get(nodeId) === 2) return null
|
||
if (states.get(nodeId) === 1) {
|
||
const index = stack.indexOf(nodeId)
|
||
return [...stack.slice(Math.max(0, index)), nodeId]
|
||
}
|
||
states.set(nodeId, 1)
|
||
stack.push(nodeId)
|
||
for (const sourceId of dependencies.get(nodeId) || []) {
|
||
if (!dependencies.has(sourceId)) continue
|
||
const cycle = visit(sourceId)
|
||
if (cycle) return cycle
|
||
}
|
||
stack.pop()
|
||
states.set(nodeId, 2)
|
||
return null
|
||
}
|
||
for (const nodeId of dependencies.keys()) {
|
||
const cycle = visit(nodeId)
|
||
if (cycle) return cycle
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 校验已保存的引用配置和参数面板刚选择的引用配置。
|
||
* 当前只阻断循环引用,避免节点输出依赖后续又间接依赖自身,导致运行端无法拓扑执行。
|
||
*/
|
||
export const validateFlowReferences = (graph) => {
|
||
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
|
||
const nodeById = new Map(nodes.map((node) => [String(node.id), node]))
|
||
const referencesByNode = new Map()
|
||
const dependencies = new Map()
|
||
|
||
nodes.forEach((node) => {
|
||
const references = collectActiveQuotes(node.properties || [])
|
||
.map((quote) => quote.map((part) => String(part)))
|
||
referencesByNode.set(String(node.id), references)
|
||
dependencies.set(String(node.id), new Set(references.map((quote) => quote[0])))
|
||
})
|
||
|
||
const cycle = findReferenceCycle(dependencies)
|
||
if (cycle) {
|
||
const names = cycle.map((nodeId) => nodeById.get(nodeId)?.properties?.name || nodeId)
|
||
return [{
|
||
nodeId: cycle[0],
|
||
message: `节点参数存在循环引用:${names.join(' -> ')}`,
|
||
}]
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
/**
|
||
* 保存或发布前移除旧版物理机器人/设备 ID,只保留资源角色和设备用途定义。
|
||
* 运行时会重新选择实际设备,避免把历史测试设备固化进流程模板。
|
||
*/
|
||
export const sanitizeFlowResourceDefinition = (sourceGraph) => {
|
||
const graph = normalizeFlowResourceSchema(sourceGraph)
|
||
const start = getStartNode(graph)
|
||
;(start?.properties?.resourceRoles || []).forEach((role) => {
|
||
delete role.legacyRobotId
|
||
;(role.deviceSlots || []).forEach((slot) => delete slot.legacyDeviceId)
|
||
})
|
||
graph.nodes.filter(isRobotNode).forEach((node) => {
|
||
node.properties.nodeParams = (node.properties?.nodeParams || []).filter((param) => param.name !== 'deviceId')
|
||
})
|
||
return graph
|
||
}
|
||
|
||
/**
|
||
* 粘贴跨流程节点时合并其依赖的机器人角色和设备用途。
|
||
* 优先复用同标识或同名资源;找不到时才追加由流程节点托管的资源定义。
|
||
*/
|
||
export const mergeCopiedResourceRoles = (targetGraph, sourceRoles = [], copiedNodes = []) => {
|
||
const graph = normalizeFlowResourceSchema(targetGraph)
|
||
const start = getStartNode(graph)
|
||
const targetRoles = start.properties.resourceRoles
|
||
let addedRoles = 0
|
||
let addedSlots = 0
|
||
|
||
copiedNodes.filter(isRobotNode).forEach((node) => {
|
||
const target = node.properties?.executionTarget
|
||
if (!target?.roleKey) return
|
||
const sourceRole = sourceRoles.find((role) => role.roleKey === target.roleKey)
|
||
let targetRole = targetRoles.find((role) => role.roleKey === target.roleKey)
|
||
|| targetRoles.find((role) => sourceRole && role.displayName === sourceRole.displayName)
|
||
|
||
if (!targetRole && sourceRole) {
|
||
targetRole = clone(sourceRole)
|
||
targetRole.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||
delete targetRole.legacyRobotId
|
||
;(targetRole.deviceSlots || []).forEach((slot) => {
|
||
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||
delete slot.legacyDeviceId
|
||
})
|
||
targetRoles.push(targetRole)
|
||
addedRoles += 1
|
||
addedSlots += targetRole.deviceSlots?.length || 0
|
||
}
|
||
targetRole ||= targetRoles[0]
|
||
target.roleKey = targetRole.roleKey
|
||
|
||
// 设备用途可能只存在于来源流程,粘贴后需要映射到目标流程中可用的 slotKey。
|
||
const remapSlot = (slotKey) => {
|
||
if (!slotKey || targetRole.deviceSlots?.some((slot) => slot.slotKey === slotKey)) return slotKey
|
||
const sourceSlot = sourceRole?.deviceSlots?.find((slot) => slot.slotKey === slotKey)
|
||
const sameSlot = targetRole.deviceSlots?.find((slot) => (
|
||
sourceSlot && slot.displayName === sourceSlot.displayName && Number(slot.deviceKind) === Number(sourceSlot.deviceKind)
|
||
))
|
||
if (sameSlot) return sameSlot.slotKey
|
||
const newSlot = sourceSlot ? clone(sourceSlot) : createDeviceSlot(getDeviceKindForAction(node.properties?.action), targetRole.deviceSlots)
|
||
newSlot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||
delete newSlot.legacyDeviceId
|
||
targetRole.deviceSlots.push(newSlot)
|
||
addedSlots += 1
|
||
return newSlot.slotKey
|
||
}
|
||
target.deviceSlotKey = remapSlot(target.deviceSlotKey)
|
||
;(target.deviceBindings || []).forEach((binding) => {
|
||
binding.slotKey = remapSlot(binding.slotKey)
|
||
})
|
||
const bindingSlotByName = new Map((target.deviceBindings || []).map((binding) => (
|
||
[binding.parameterName, binding.slotKey]
|
||
)))
|
||
const queueStepsParam = node.properties?.nodeParams?.find((param) => param.name === 'steps')
|
||
// 动作队列步骤内保存的是设备绑定名,合并资源后同步回真实 slotKey,单步执行才能定位设备。
|
||
if (Array.isArray(queueStepsParam?.input)) {
|
||
queueStepsParam.input.forEach((step) => {
|
||
if (bindingSlotByName.has(step?.deviceBindingKey)) {
|
||
step.deviceSlotKey = bindingSlotByName.get(step.deviceBindingKey)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
return { graph, start, addedRoles, addedSlots }
|
||
}
|
||
|
||
export const getDeviceKindName = (deviceKind) => DEVICE_KIND_META[Number(deviceKind)]?.name || '任意设备'
|
||
|
||
/**
|
||
* 汇总主流程和子流程的资源契约,生成运行前需要一次性分配的角色集合。
|
||
* 同一 roleKey 下的必选项取更严格配置,设备用途按 slotKey 合并。
|
||
*/
|
||
export const mergeResourceRoleDefinitions = (roleGroups = []) => {
|
||
const merged = []
|
||
roleGroups.flat().forEach((sourceRole) => {
|
||
let role = merged.find((item) => item.roleKey === sourceRole.roleKey)
|
||
if (!role) {
|
||
merged.push(clone(sourceRole))
|
||
return
|
||
}
|
||
role.required = role.required !== false || sourceRole.required !== false
|
||
;(sourceRole.deviceSlots || []).forEach((sourceSlot) => {
|
||
const slot = role.deviceSlots.find((item) => item.slotKey === sourceSlot.slotKey)
|
||
if (!slot) {
|
||
role.deviceSlots.push(clone(sourceSlot))
|
||
return
|
||
}
|
||
slot.required = slot.required !== false || sourceSlot.required !== false
|
||
slot.allowReuse = slot.allowReuse === true && sourceSlot.allowReuse === true
|
||
})
|
||
})
|
||
return merged
|
||
}
|