262 lines
11 KiB
JavaScript
262 lines
11 KiB
JavaScript
|
|
import { getDeviceKindForAction } from '@/utils/robotDevice'
|
||
|
|
|
||
|
|
export const FLOW_SCHEMA_VERSION = 2
|
||
|
|
export const DEFAULT_ROLE_KEY = 'default_executor'
|
||
|
|
|
||
|
|
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()
|
||
|
|
|
||
|
|
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 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)
|
||
|
|
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: [] })
|
||
|
|
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) => {
|
||
|
|
if (!isRobotNode(node)) return
|
||
|
|
const properties = node.properties || (node.properties = {})
|
||
|
|
const target = properties.executionTarget || {
|
||
|
|
roleKey: defaultRole.roleKey,
|
||
|
|
dispatchPolicy: 'ONE',
|
||
|
|
}
|
||
|
|
target.roleKey ||= defaultRole.roleKey
|
||
|
|
target.dispatchPolicy ||= 'ONE'
|
||
|
|
target.deviceSlotKey ||= target.deviceBindings?.[0]?.slotKey || ''
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
delete target.deviceBindings
|
||
|
|
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) }
|
||
|
|
}
|
||
|
|
|
||
|
|
export const validateFlowResources = (sourceGraph) => {
|
||
|
|
const graph = normalizeFlowResourceSchema(sourceGraph)
|
||
|
|
const roles = getResourceRoles(graph)
|
||
|
|
const errors = []
|
||
|
|
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}引用的设备用途不存在` })
|
||
|
|
}
|
||
|
|
if (nodeRequiresDevice(node)
|
||
|
|
&& !target.deviceSlotKey) {
|
||
|
|
errors.push({ nodeId: node.id, message: `${node.properties?.name || node.id}未选择设备用途` })
|
||
|
|
}
|
||
|
|
})
|
||
|
|
return { graph, errors }
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
delete targetRole.legacyRobotId
|
||
|
|
;(targetRole.deviceSlots || []).forEach((slot) => delete slot.legacyDeviceId)
|
||
|
|
targetRoles.push(targetRole)
|
||
|
|
addedRoles += 1
|
||
|
|
addedSlots += targetRole.deviceSlots?.length || 0
|
||
|
|
}
|
||
|
|
targetRole ||= targetRoles[0]
|
||
|
|
target.roleKey = targetRole.roleKey
|
||
|
|
|
||
|
|
if (target.deviceSlotKey && !targetRole.deviceSlots?.some((slot) => slot.slotKey === target.deviceSlotKey)) {
|
||
|
|
const sourceSlot = sourceRole?.deviceSlots?.find((slot) => slot.slotKey === target.deviceSlotKey)
|
||
|
|
const sameSlot = targetRole.deviceSlots?.find((slot) => (
|
||
|
|
sourceSlot && slot.displayName === sourceSlot.displayName && Number(slot.deviceKind) === Number(sourceSlot.deviceKind)
|
||
|
|
))
|
||
|
|
if (sameSlot) {
|
||
|
|
target.deviceSlotKey = sameSlot.slotKey
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const newSlot = sourceSlot ? clone(sourceSlot) : createDeviceSlot(getDeviceKindForAction(node.properties?.action), targetRole.deviceSlots)
|
||
|
|
delete newSlot.legacyDeviceId
|
||
|
|
targetRole.deviceSlots.push(newSlot)
|
||
|
|
target.deviceSlotKey = newSlot.slotKey
|
||
|
|
addedSlots += 1
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
return { graph, start, addedRoles, addedSlots }
|
||
|
|
}
|
||
|
|
|
||
|
|
export const getDeviceKindName = (deviceKind) => DEVICE_KIND_META[Number(deviceKind)]?.name || '任意设备'
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
;(sourceRole.deviceSlots || []).forEach((sourceSlot) => {
|
||
|
|
if (!role.deviceSlots.some((slot) => slot.slotKey === sourceSlot.slotKey)) {
|
||
|
|
role.deviceSlots.push(clone(sourceSlot))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
return merged
|
||
|
|
}
|