diff --git a/src/utils/flowResources.js b/src/utils/flowResources.js new file mode 100644 index 0000000..76f3b00 --- /dev/null +++ b/src/utils/flowResources.js @@ -0,0 +1,261 @@ +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 +} diff --git a/src/views/flow/components/RuntimeAssignmentsEditor.vue b/src/views/flow/components/RuntimeAssignmentsEditor.vue new file mode 100644 index 0000000..42c418e --- /dev/null +++ b/src/views/flow/components/RuntimeAssignmentsEditor.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/src/views/flow/components/params/RobotExecutionTarget.vue b/src/views/flow/components/params/RobotExecutionTarget.vue new file mode 100644 index 0000000..5cf6cb2 --- /dev/null +++ b/src/views/flow/components/params/RobotExecutionTarget.vue @@ -0,0 +1,117 @@ + + + + +