diff --git a/.env.development b/.env.development index 51d4ce3..10db161 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,5 @@ # 页面标题 -VITE_APP_TITLE = 招商车研物联网平台管理系统 +VITE_APP_TITLE = 招商车研机器人智能测试管理系统 # 开发环境配置 VITE_APP_ENV = 'development' diff --git a/.env.production b/.env.production index e225bbc..6b82b87 100644 --- a/.env.production +++ b/.env.production @@ -1,5 +1,5 @@ # 页面标题 -VITE_APP_TITLE = 招商车研物联网平台管理系统 +VITE_APP_TITLE = 招商车研机器人智能测试管理系统 # 生产环境配置 VITE_APP_ENV = 'production' diff --git a/.env.staging b/.env.staging index 95b7291..bef0d90 100644 --- a/.env.staging +++ b/.env.staging @@ -1,5 +1,5 @@ # 页面标题 -VITE_APP_TITLE = 招商车研物联网平台管理系统 +VITE_APP_TITLE = 招商车研机器人智能测试管理系统 # 生产环境配置 VITE_APP_ENV = 'staging' diff --git a/src/api/device/arm.js b/src/api/device/arm.js index 00e859c..a7a167e 100644 --- a/src/api/device/arm.js +++ b/src/api/device/arm.js @@ -15,4 +15,12 @@ export function status(data) { method: 'post', data: data }) -} \ No newline at end of file +} + +export function clearFault(params) { + return request({ + url: '/api/arm/clearFault', + method: 'get', + params + }) +} diff --git a/src/api/device/camera.js b/src/api/device/camera.js new file mode 100644 index 0000000..c10a7be --- /dev/null +++ b/src/api/device/camera.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +export function getRgbStreamUrl(robotId, deviceId) { + return request({ + url: '/api/edge/camera/rgb-stream-url', + method: 'get', + params: { robotId, deviceId } + }) +} diff --git a/src/api/device/flow.js b/src/api/device/flow.js deleted file mode 100644 index 5985597..0000000 --- a/src/api/device/flow.js +++ /dev/null @@ -1,79 +0,0 @@ -import request from '@/utils/request' - -// 发布流程 -export function flowDeploy(data) { - return request({ - url: '/flow/publish', - method: 'post', - data: data - }) -} - -export function flowExecute(data) { - return request({ - url: '/flow/execute', - method: 'post', - data: data - }) -} - -export function flowAction(data) { - return request({ - url: '/flow/action', - method: 'post', - data: data - }) -} - -export function flowExecuteTrial(data) { - return request({ - url: '/flow/executeTrial', - method: 'post', - data: data - }) -} - -export function flowView(data) { - return request({ - url: '/flow/view', - method: 'post', - data: data - }) -} - -export function flowPause(instId) { - return request({ - url: `/flow/pause/${instId}`, - method: 'post' - }) -} - -export function flowResume(instId) { - return request({ - url: `/flow/resume/${instId}`, - method: 'post' - }) -} - -export function flowStop(instId) { - return request({ - url: `/flow/stop/${instId}`, - method: 'post' - }) -} - -export function getArmStatus(params) { - return request({ - url: '/api/arm/getPose', - method: 'get', - params: params - }) -} - -export function getAgvStatus(params) { - return request({ - url: '/api/agv/getRuntimeState', - method: 'get', - params: params - }) -} \ No newline at end of file diff --git a/src/api/device/system.js b/src/api/device/system.js new file mode 100644 index 0000000..ae5b3a5 --- /dev/null +++ b/src/api/device/system.js @@ -0,0 +1,31 @@ +import request from '@/utils/request' + +export function getSafetyState(robotId) { + return request({ + url: '/api/system/safetyState', + method: 'get', + params: { robotId }, + }) +} + +/** + * 恢复机器人运行状态;payload 与旧 recoverSafetyState 保持一致,便于调用方渐进迁移。 + */ +export function restoreOperationalState(data) { + return request({ + url: '/api/system/restoreOperationalState', + method: 'post', + data, + }) +} + +/** + * @deprecated 请改用 restoreOperationalState,旧接口仅保留给未迁移调用方。 + */ +export function recoverSafetyState(data) { + return request({ + url: '/api/system/recoverSafetyState', + method: 'post', + data, + }) +} diff --git a/src/api/flow/flow.js b/src/api/flow/flow.js new file mode 100644 index 0000000..b6eaf5c --- /dev/null +++ b/src/api/flow/flow.js @@ -0,0 +1,221 @@ +import request from '@/utils/request' + +/** + * 文件说明:流程设计器相关接口封装。 + * 约定:本文件只负责描述请求地址、请求方式和入参承载位置,不在这里处理业务返回结构。 + */ + +/** + * 发布流程。 + * @param {Object} data 发布请求体,通常包含流程 ID、流程 JSON、发布说明等后端要求字段。 + * @returns {Promise} 后端发布结果。 + */ +export function flowDeploy(data) { + return request({ + url: '/flow/publish', + method: 'post', + data: data + }) +} + +/** + * 保存流程草稿。 + * 试运行前会先保存草稿,确保后端执行的是当前画布最新内容。 + * @param {Object} data 草稿数据,包含流程 ID、flowData、draftRevision 等。 + * @returns {Promise} 草稿保存结果,成功时后端会返回新的草稿版本号。 + */ +export function flowSaveDraft(data) { + return request({ url: '/flow/draft', method: 'put', data }) +} + +/** + * 查询某个流程的发布版本列表。 + * @param {string|number} itemId 流程定义 ID。 + * @returns {Promise} 发布记录列表。 + */ +export function flowVersionList(itemId) { + return request({ url: `/flow/versions/${itemId}`, method: 'get' }) +} + +/** + * 查询指定发布版本的完整详情。 + * @param {string|number} versionId 发布版本 ID。 + * @returns {Promise} 版本详情,通常包含版本号、发布说明和对应 flowData。 + */ +export function flowVersionDetail(versionId) { + return request({ url: `/flow/version/${versionId}`, method: 'get' }) +} + +/** + * 查询可作为子流程引用的已发布工作流。 + * currentItemId 用于后端过滤当前流程自身,以及会形成循环引用链路的工作流。 + * @param {string|number} currentItemId 当前正在编辑的流程 ID。 + * @returns {Promise} 可选子流程发布版本列表。 + */ +export function flowSubFlowOptions(currentItemId) { + return request({ + url: '/flow/subflows', + method: 'get', + params: { currentItemId } + }) +} + +/** + * 将某个发布版本恢复为当前草稿。 + * @param {string|number} versionId 作为恢复来源的发布版本 ID。 + * @param {Object} data 恢复说明或后端要求的附加参数。 + * @returns {Promise} 恢复结果。 + */ +export function flowRestoreDraft(versionId, data) { + return request({ url: `/flow/version/${versionId}/restore-draft`, method: 'post', data }) +} + +/** + * 回退到某个发布版本。 + * @param {string|number} versionId 目标发布版本 ID。 + * @param {Object} data 回退说明或后端要求的附加参数。 + * @returns {Promise} 回退结果。 + */ +export function flowRollbackVersion(versionId, data) { + return request({ url: `/flow/version/${versionId}/rollback`, method: 'post', data }) +} + +/** + * 正式执行流程。 + * @param {Object} data 执行请求体,通常包含流程 ID、任务参数、资源分配等。 + * @returns {Promise} 执行启动结果。 + */ +export function flowExecute(data) { + return request({ + url: '/flow/execute', + method: 'post', + data: data + }) +} + +/** + * 执行流程运行过程中的控制或单节点动作。 + * @param {Object} data 动作请求体,具体字段由后端按 action 类型解析。 + * @returns {Promise} 动作执行结果。 + */ +export function flowAction(data) { + return request({ + url: '/flow/action', + method: 'post', + data: data + }) +} + +/** + * 试运行流程。 + * @param {Object} data 试运行请求体,通常包含流程 ID、草稿版本、运行参数和资源选择。 + * @returns {Promise} 试运行实例创建结果。 + */ +export function flowExecuteTrial(data) { + return request({ + url: '/flow/executeTrial', + method: 'post', + data: data + }) +} + +/** + * 查询流程实例执行日志或节点状态快照。 + * @param {Object} data 查询条件,常用字段包括 itemId、instId、taskId。 + * @returns {Promise} 节点运行日志列表。 + */ +export function flowView(data) { + return request({ + url: '/flow/view', + method: 'post', + data: data + }) +} + +/** + * 暂停正在运行的流程实例。 + * @param {string|number} instId 流程实例 ID。 + * @returns {Promise} 暂停结果。 + */ +export function flowPause(instId) { + return request({ + url: `/flow/pause/${instId}`, + method: 'post' + }) +} + +/** + * 恢复已暂停的流程实例。 + * @param {string|number} instId 流程实例 ID。 + * @returns {Promise} 恢复结果。 + */ +export function flowResume(instId) { + return request({ + url: `/flow/resume/${instId}`, + method: 'post' + }) +} + +/** + * 终止正在运行或暂停中的流程实例。 + * @param {string|number} instId 流程实例 ID。 + * @returns {Promise} 终止结果。 + */ +export function flowStop(instId) { + return request({ + url: `/flow/stop/${instId}`, + method: 'post' + }) +} + +/** + * 查询机械臂当前位姿。 + * @param {Object} params 查询参数,通常包含机器人或设备标识。 + * @returns {Promise} 机械臂位姿信息。 + */ +export function getArmStatus(params) { + return request({ + url: '/api/arm/getPose', + method: 'get', + params: params + }) +} + +/** + * 查询 AGV 底盘运行状态。 + * @param {Object} params 查询参数,通常包含机器人或设备标识。 + * @returns {Promise} AGV 运行状态。 + */ +export function getAgvStatus(params) { + return request({ + url: '/api/agv/getRuntimeState', + method: 'get', + params: params + }) +} + +/** + * 查询 AGV 可用站点列表。 + * @param {Object} params 查询参数,通常包含机器人或地图/设备信息。 + * @returns {Promise} 站点列表。 + */ +export function getAgvStations(params) { + return request({ + url: '/api/agv/listStations', + method: 'get', + params: params + }) +} + +/** + * 查询机械臂关节状态。 + * @param {Object} params 查询参数,通常包含机器人或设备标识。 + * @returns {Promise} 机械臂关节角度/状态信息。 + */ +export function getArmJointState(params) { + return request({ + url: '/api/arm/getJointState', + method: 'get', + params: params + }) +} diff --git a/src/api/inspection/point.js b/src/api/inspection/point.js index 5f4b17e..7c30c81 100644 --- a/src/api/inspection/point.js +++ b/src/api/inspection/point.js @@ -29,4 +29,11 @@ export function deletePoint(id) { url: `/inspection/waypoint/${id}`, method: 'delete' }) -} \ No newline at end of file +} + +export function copyPoint(id) { + return request({ + url: `/inspection/waypoint/copy/${id}`, + method: 'post' + }) +} diff --git a/src/api/inspection/robot.js b/src/api/inspection/robot.js index cde224d..fa91960 100644 --- a/src/api/inspection/robot.js +++ b/src/api/inspection/robot.js @@ -31,6 +31,25 @@ export function deleteRobot(id) { }) } +export function getRobotDevices(id) { + return request({ + url: `/inspection/robot/${id}/devices`, + method: 'get' + }) +} + +export function getRobotDevicesByRobotId(robotId, params = {}) { + return request({ + url: '/inspection/robot/devices', + method: 'get', + params: { + robotId, + onlineOnly: true, + ...params + } + }) +} + /** * 获取机器人地图 * @param {*} robotId @@ -73,10 +92,10 @@ export function getRobotLocation(robotId) { * @param {*} robotId * @returns */ -export function getRobotStatus(robotId) { +export function getRobotStatus(id) { return request({ - url: `/inspection/robot/{robotId}/sync-status`, - method: 'get' + url: `/inspection/robot/${id}/sync-status`, + method: 'post' }) } diff --git a/src/api/inspection/runTask.js b/src/api/inspection/runTask.js index fb2c8b4..84b2ec6 100644 --- a/src/api/inspection/runTask.js +++ b/src/api/inspection/runTask.js @@ -38,10 +38,18 @@ 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 + }) +} + +export function rerunRunTask(id) { + return request({ + url: `/inspection/taskInstance/rerun/${id}`, + method: 'post' }) } @@ -72,4 +80,4 @@ export function getTaskLogListApi(params) { method: 'get', params: params }) -} \ No newline at end of file +} diff --git a/src/api/inspection/task.js b/src/api/inspection/task.js index 9a04f33..f43c5b3 100644 --- a/src/api/inspection/task.js +++ b/src/api/inspection/task.js @@ -44,4 +44,11 @@ export function bindWaypointsToTask(taskId, waypointIds) { method: 'post', data: waypointIds }) -} \ No newline at end of file +} + +export function copyTask(id) { + return request({ + url: `/inspection/task/copy/${id}`, + method: 'post' + }) +} diff --git a/src/api/intelligenceTest/runningTask.js b/src/api/intelligenceTest/runningTask.js index adfd01e..dddc851 100644 --- a/src/api/intelligenceTest/runningTask.js +++ b/src/api/intelligenceTest/runningTask.js @@ -1,5 +1,4 @@ import request from '@/utils/request' -import { method } from 'lodash' export function addTaskinstance(data) { return request({ @@ -27,15 +26,23 @@ export function updateTaskinstance(data) { export function deleteTaskinstance(ids) { return request({ - url: `/aima/taskinstance/{ids}`, + url: `/aima/taskinstance/${ids}`, method: 'delete' }) } -export function startTaskinstance(id) { +export function startTaskinstance(id, data) { return request({ url: `/aima/taskinstance/start/${id}`, - method: 'put' + method: 'put', + data + }) +} + +export function rerunTaskinstance(id) { + return request({ + url: `/aima/taskinstance/rerun/${id}`, + method: 'post' }) } @@ -66,4 +73,4 @@ export function getTaskLogListApi(params) { method: 'get', params: params }) -} \ No newline at end of file +} diff --git a/src/api/intelligenceTest/taskManage.js b/src/api/intelligenceTest/taskManage.js index 457d33f..e20aa62 100644 --- a/src/api/intelligenceTest/taskManage.js +++ b/src/api/intelligenceTest/taskManage.js @@ -44,4 +44,11 @@ export function bindTastCase(taskId, ids) { method: 'post', data: ids }) -} \ No newline at end of file +} + +export function copyTask(id) { + return request({ + url: `/aima/task/copy/${id}`, + method: 'post' + }) +} diff --git a/src/api/intelligenceTest/testCase.js b/src/api/intelligenceTest/testCase.js index 374ebe1..2dd3381 100644 --- a/src/api/intelligenceTest/testCase.js +++ b/src/api/intelligenceTest/testCase.js @@ -29,4 +29,11 @@ export function deleteTestCase(ids) { url: `/aima/testcase/${ids}`, method: 'delete' }) -} \ No newline at end of file +} + +export function copyTestCase(id) { + return request({ + url: `/aima/testcase/copy/${id}`, + method: 'post' + }) +} diff --git a/src/api/test/config.js b/src/api/test/config.js index 5ec8531..fb6fb74 100644 --- a/src/api/test/config.js +++ b/src/api/test/config.js @@ -26,6 +26,13 @@ export function addConfig(data) { }) } +export function copyConfig(id) { + return request({ + url: '/test/config/copy/' + id, + method: 'post' + }) +} + // 修改任务流程编排 export function updateConfig(data) { return request({ diff --git a/src/api/test/detect.js b/src/api/test/detect.js index 8cf79c2..5c252a4 100644 --- a/src/api/test/detect.js +++ b/src/api/test/detect.js @@ -26,6 +26,13 @@ export function addDetect(data) { }) } +export function copyDetect(id) { + return request({ + url: '/test/detect/copy/' + id, + method: 'post' + }) +} + // 修改检测项配置 export function updateDetect(data) { return request({ diff --git a/src/api/test/log.js b/src/api/test/log.js index f1847ba..d84e12b 100644 --- a/src/api/test/log.js +++ b/src/api/test/log.js @@ -52,6 +52,14 @@ export function listInst(query) { }) } +// 使用历史实例保存的执行参数创建新的任务实例 +export function rerunInst(id) { + return request({ + url: `/test/inst/rerun/${id}`, + method: 'post' + }) +} + // 查询任务实例日志 export function getInst(query) { return request({ diff --git a/src/plugins/websocket.js b/src/plugins/websocket.js index f3ca635..2f2ee77 100644 --- a/src/plugins/websocket.js +++ b/src/plugins/websocket.js @@ -17,6 +17,7 @@ class WebSocketManager { this.processingMessages = false; this.maxQueueSize = 100; this.status = false; + this.channelSubscriptions = new Set(); } connect() { @@ -33,6 +34,7 @@ class WebSocketManager { this.status = true; this.startHeartbeat(); this.processMessageQueue(); + this.restoreChannelSubscriptions(); this.dispatch('open'); }; @@ -159,13 +161,31 @@ class WebSocketManager { } send(payload) { + if (payload?.type === 'channel_subscription' && payload.channel) { + if (payload.action === 'subscribe') { + this.channelSubscriptions.add(payload.channel); + } else if (payload.action === 'unsubscribe') { + this.channelSubscriptions.delete(payload.channel); + } + } if (this.ws && this.ws.readyState === 1) { this.ws.send(JSON.stringify(payload)); - } else { + } else if (payload?.type !== 'channel_subscription') { console.warn('WebSocket 未连接,消息发送失败'); } } + restoreChannelSubscriptions() { + if (!this.ws || this.ws.readyState !== 1) return; + this.channelSubscriptions.forEach((channel) => { + this.ws.send(JSON.stringify({ + type: 'channel_subscription', + action: 'subscribe', + channel, + })); + }); + } + close() { this.isManualClose = true; this.status = false; diff --git a/src/store/modules/flow.js b/src/store/modules/flow.js index 287ae45..a78c228 100644 --- a/src/store/modules/flow.js +++ b/src/store/modules/flow.js @@ -2,20 +2,11 @@ import { defineStore } from 'pinia' export const useFlowStore = defineStore('flow', { state: () => ({ - disableForm: false, - deviceList: [] + disableForm: false }), actions: { updateDisableForm(value) { this.disableForm = value - }, - - async getDeviceList() { - const data = await new Promise((resolve, reject) => { - resolve( ['cam1', 'cam2', 'cam3', 'cam4']) - }) - this.deviceList = data - return data } } } diff --git a/src/utils/actionQueue.js b/src/utils/actionQueue.js new file mode 100644 index 0000000..33af39e --- /dev/null +++ b/src/utils/actionQueue.js @@ -0,0 +1,375 @@ +const deepClone = (value) => JSON.parse(JSON.stringify(value)) + +const ACTION_QUEUE_ACTION = 'SYSTEM_EXECUTE_ACTION_QUEUE' +const REFERENCE_KEYS = new Set(['quote', 'nameQuote']) +const DEFAULT_TOTAL_TIMEOUT_BUFFER_MS = 5000 + +const createKey = (prefix) => { + const random = globalThis.crypto?.randomUUID?.().replace(/-/g, '') + || `${Date.now()}${Math.random().toString(16).slice(2)}` + return `${prefix}_${random}` +} + +const paramsToObject = (node) => Object.fromEntries( + (node?.properties?.nodeParams || []).map((param) => [param.name, param.input]), +) + +const numberValue = (value, fallback = 0) => { + if (value === '' || value === null || value === undefined) return fallback + const result = Number(value) + return Number.isFinite(result) ? result : fallback +} + +const arrayValue = (value) => { + if (Array.isArray(value)) return deepClone(value) + if (typeof value !== 'string') return value + try { + const result = JSON.parse(value) + return Array.isArray(result) ? result : value + } catch { + return value + } +} + +const nodeToQueueStep = (node) => { + const action = node?.properties?.action + const params = paramsToObject(node) + const common = { + stepName: node?.properties?.name || '导入步骤', + sourceNode: deepClone(node), + } + if (node?.type === 'sleep' || String(action).toLowerCase() === 'sleep') { + const durationMs = numberValue(params.delayMs) + return { + ...common, + type: 'DELAY', + timeoutMs: Math.max(durationMs + 1000, 5000), + params: { durationMs }, + } + } + if (action === 'AGV_MOVE_TO_POINT') { + return { + ...common, + type: 'AGV_NAVIGATE_TO_POSE', + timeoutMs: 60000, + params: { + x: numberValue(params.x), + y: numberValue(params.y), + theta: numberValue(params.theta), + maxSpeed: numberValue(params.maxSpeed, 0.5), + blockedTimeoutMs: numberValue(params.blockedTimeoutMs, 60000), + }, + } + } + if (action === 'AGV_MOVE_TO_STATION') { + return { + ...common, + type: 'AGV_NAVIGATE_TO_STATION', + timeoutMs: 60000, + params: { + stationId: params.stationId ?? '', + maxSpeed: numberValue(params.maxSpeed, 0.5), + blockedTimeoutMs: numberValue(params.blockedTimeoutMs, 60000), + }, + } + } + if (action === 'ARM_MOVE_TO_POINT') { + return { + ...common, + type: 'ARM_MOVE_L', + timeoutMs: 3000, + params: { + x: numberValue(params.x), + y: numberValue(params.y), + z: numberValue(params.z), + rx: numberValue(params.rx), + ry: numberValue(params.ry), + rz: numberValue(params.rz), + frame: params.frame || 'BASE', + velocity: numberValue(params.velocity, 2.1), + acceleration: numberValue(params.acceleration, 2.2), + }, + } + } + if (action === 'ARM_MOVE_TO_J') { + return { + ...common, + type: 'ARM_MOVE_J', + timeoutMs: 10000, + params: { + positions: arrayValue(params.target), + velocity: numberValue(params.velocity, 0.1), + acceleration: numberValue(params.acceleration, 0.2), + }, + } + } + return null +} + +const validateConvertibleNodeParams = (node) => { + const action = node?.properties?.action + const params = paramsToObject(node) + const requiredByAction = { + AGV_MOVE_TO_POINT: ['x', 'y', 'theta'], + AGV_MOVE_TO_STATION: ['stationId'], + ARM_MOVE_TO_POINT: ['x', 'y', 'z', 'rx', 'ry', 'rz'], + ARM_MOVE_TO_J: ['target'], + } + const missing = (requiredByAction[action] || []).find((name) => ( + params[name] === '' || params[name] === null || params[name] === undefined + )) + if (missing) return `节点[${node.properties?.name || node.id}]的参数 ${missing} 尚未配置` + if (action === 'ARM_MOVE_TO_J') { + const positions = arrayValue(params.target) + if (!Array.isArray(positions) || positions.length === 0) return '机械臂关节位置必须是非空 JSON 数组' + } + if ((node?.type === 'sleep' || String(action).toLowerCase() === 'sleep') + && numberValue(params.delayMs) < 0) return '睡眠时长不能小于 0' + return '' +} + +const hasReference = (value, referencedNodeId, parentKey = '') => { + if (Array.isArray(value)) { + if (REFERENCE_KEYS.has(parentKey) && value[0] === referencedNodeId) return true + return value.some((item) => hasReference(item, referencedNodeId)) + } + if (!value || typeof value !== 'object') return false + return Object.entries(value).some(([key, item]) => hasReference(item, referencedNodeId, key)) +} + +const hasDynamicInput = (node) => (node?.properties?.nodeParams || []).some((param) => ( + param?.type === 'quote' + || (Array.isArray(param?.quote) && param.quote.length > 0) +)) + +const getNodeParam = (node, name, fallback) => ( + node.properties?.nodeParams?.find((param) => param.name === name)?.input ?? fallback +) + +const setNodeParam = (node, name, input, defaults = {}) => { + node.properties ||= {} + node.properties.nodeParams ||= [] + const param = node.properties.nodeParams.find((item) => item.name === name) + if (param) param.input = input + else node.properties.nodeParams.push({ name, type: 'input', input, ...defaults }) +} + +const queueSteps = (queueNode) => { + const value = getNodeParam(queueNode, 'steps', []) + return Array.isArray(value) ? deepClone(value) : [] +} + +const edgeExists = (edges, sourceNodeId, targetNodeId) => edges.some((edge) => ( + edge.sourceNodeId === sourceNodeId && edge.targetNodeId === targetNodeId +)) + +const createEdge = (sourceNodeId, targetNodeId, sourceAnchorId, targetAnchorId) => ({ + id: createKey('edge'), + type: 'bezier', + sourceNodeId, + targetNodeId, + sourceAnchorId: sourceAnchorId || `${sourceNodeId}_1`, + targetAnchorId: targetAnchorId || `${targetNodeId}_3`, +}) + +const normalizeQueueStepIds = (steps) => steps.map((step, index) => ({ + ...step, + stepId: `step_${index + 1}`, +})) + +const updateQueueDefinition = (queueNode, steps) => { + const normalizedSteps = normalizeQueueStepIds(steps) + setNodeParam(queueNode, 'steps', normalizedSteps, { required: true }) + const timeoutSum = normalizedSteps.reduce((sum, step) => sum + Math.max(0, numberValue(step.timeoutMs)), 0) + const currentTimeout = numberValue(getNodeParam(queueNode, 'totalTimeoutMs', 60000), 60000) + setNodeParam( + queueNode, + 'totalTimeoutMs', + Math.max(currentTimeout, timeoutSum + DEFAULT_TOTAL_TIMEOUT_BUFFER_MS), + { componentType: 'number', required: true }, + ) + const bindings = normalizedSteps.filter((step) => step.deviceBindingKey && step.deviceSlotKey).map((step) => ({ + parameterName: step.deviceBindingKey, + slotKey: step.deviceSlotKey, + })) + queueNode.properties.executionTarget = { + ...(queueNode.properties.executionTarget || {}), + dispatchPolicy: 'ONE', + deviceBindings: bindings, + } +} + +export const isNodeConvertibleToActionQueue = (node) => Boolean(nodeToQueueStep(node)) + +/** + * Converts one workflow node into an edge action-queue step and rewrites its former + * linear position out of the graph. The source definition is retained for extraction. + */ +export const appendNodeToActionQueue = (sourceGraph, nodeId, queueId) => { + const graph = deepClone(sourceGraph || { nodes: [], edges: [] }) + const node = graph.nodes.find((item) => item.id === nodeId) + const queueNode = graph.nodes.find((item) => item.id === queueId) + if (!node || !queueNode) return { ok: false, message: '未找到要移动的节点或动作队列' } + if (node.id === queueNode.id) return { ok: false, message: '动作队列不能放入自身' } + if (queueNode.properties?.action !== ACTION_QUEUE_ACTION) return { ok: false, message: '目标节点不是动作队列' } + const step = nodeToQueueStep(node) + if (!step) return { ok: false, message: `节点[${node.properties?.name || node.id}]暂不支持放入动作队列` } + const paramError = validateConvertibleNodeParams(node) + if (paramError) return { ok: false, message: paramError } + if (node.properties?.parentId !== queueNode.properties?.parentId) { + return { ok: false, message: '节点和动作队列必须位于同一个流程或循环体中' } + } + if (hasDynamicInput(node)) return { ok: false, message: '引用其他节点参数的节点暂不能放入动作队列' } + const referencedBy = graph.nodes.find((item) => item.id !== nodeId && hasReference(item.properties, nodeId)) + if (referencedBy) { + return { ok: false, message: `节点[${referencedBy.properties?.name || referencedBy.id}]正在引用该节点的输出` } + } + + const incoming = graph.edges.filter((edge) => edge.targetNodeId === nodeId) + const outgoing = graph.edges.filter((edge) => edge.sourceNodeId === nodeId) + if (incoming.length > 1 || outgoing.length > 1) { + return { ok: false, message: '存在分支连线的节点不能放入动作队列' } + } + if (incoming.length === 1 + && outgoing.length === 1 + && incoming[0].sourceNodeId === outgoing[0].targetNodeId) { + return { ok: false, message: '节点处于回路中,不能放入动作队列' } + } + + const nodeRoleKey = node.properties?.executionTarget?.roleKey + const queueRoleKey = queueNode.properties?.executionTarget?.roleKey + if (nodeRoleKey && queueRoleKey && nodeRoleKey !== queueRoleKey) { + return { ok: false, message: '节点与动作队列使用的机器人角色不同' } + } + if (nodeRoleKey && !queueRoleKey) queueNode.properties.executionTarget = { roleKey: nodeRoleKey } + + const deviceSlotKey = node.properties?.executionTarget?.deviceSlotKey + if (deviceSlotKey) { + step.deviceBindingKey = createKey('queue_device') + step.deviceSlotKey = deviceSlotKey + } + step.stepId = `step_${queueSteps(queueNode).length + 1}` + const steps = [...queueSteps(queueNode), step] + updateQueueDefinition(queueNode, steps) + + graph.nodes = graph.nodes.filter((item) => item.id !== nodeId) + graph.nodes.forEach((item) => { + if (Array.isArray(item.children)) item.children = item.children.filter((childId) => childId !== nodeId) + }) + graph.edges = graph.edges.filter((edge) => edge.sourceNodeId !== nodeId && edge.targetNodeId !== nodeId) + + const predecessorId = incoming[0]?.sourceNodeId + const successorId = outgoing[0]?.targetNodeId + if (predecessorId && successorId && predecessorId !== successorId + && !edgeExists(graph.edges, predecessorId, successorId)) { + graph.edges.push(createEdge( + predecessorId, + successorId, + incoming[0].sourceAnchorId, + outgoing[0].targetAnchorId, + )) + } + return { ok: true, graph, queueId, step } +} + +const remapNodeAnchors = (node, previousId, nextId) => { + const properties = node.properties || (node.properties = {}) + if (!Array.isArray(properties.anchor)) return + properties.anchor = properties.anchor.map((anchor) => ({ + ...anchor, + id: String(anchor.id || '').startsWith(`${previousId}_`) + ? `${nextId}${String(anchor.id).slice(previousId.length)}` + : anchor.id, + })) +} + +/** Restores an imported queue step as a workflow node. */ +export const extractActionQueueStep = (sourceGraph, queueId, stepIndex, placement = 'detached') => { + const graph = deepClone(sourceGraph || { nodes: [], edges: [] }) + const queueNode = graph.nodes.find((item) => item.id === queueId) + if (!queueNode) return { ok: false, message: '动作队列不存在' } + const steps = queueSteps(queueNode) + const step = steps[stepIndex] + if (!step) return { ok: false, message: '要移出的步骤不存在' } + if (!step.sourceNode) return { ok: false, message: '该步骤不是由工作流节点移入,无法还原为原节点' } + + const node = deepClone(step.sourceNode) + const previousId = node.id + node.id = createKey('node') + remapNodeAnchors(node, previousId, node.id) + delete node.children + node.properties ||= {} + node.properties.parentId = queueNode.properties?.parentId + if (!node.properties.parentId) delete node.properties.parentId + const horizontalOffset = placement === 'before' ? -460 : 460 + node.x = numberValue(queueNode.x) + (placement === 'detached' ? 0 : horizontalOffset) + node.y = numberValue(queueNode.y) + (placement === 'detached' ? 260 : 0) + + const incoming = graph.edges.filter((edge) => edge.targetNodeId === queueId) + const outgoing = graph.edges.filter((edge) => edge.sourceNodeId === queueId) + if ((placement === 'before' && incoming.length > 1) || (placement === 'after' && outgoing.length > 1)) { + return { ok: false, message: '动作队列存在多条相关连线,请选择“仅移出,不连线”' } + } + + graph.nodes.push(node) + if (steps.length === 1) { + graph.nodes = graph.nodes.filter((item) => item.id !== queueId) + graph.edges.forEach((edge) => { + if (edge.sourceNodeId === queueId) { + edge.sourceNodeId = node.id + edge.sourceAnchorId = `${node.id}_1` + } + if (edge.targetNodeId === queueId) { + edge.targetNodeId = node.id + edge.targetAnchorId = `${node.id}_3` + } + }) + return { ok: true, graph, node, removedQueue: true } + } + + steps.splice(stepIndex, 1) + updateQueueDefinition(queueNode, steps) + if (placement === 'before') { + graph.edges = graph.edges.filter((edge) => edge.targetNodeId !== queueId) + const previous = incoming[0] + if (previous) graph.edges.push(createEdge(previous.sourceNodeId, node.id, previous.sourceAnchorId, `${node.id}_3`)) + graph.edges.push(createEdge(node.id, queueId, `${node.id}_1`, `${queueId}_3`)) + } else if (placement === 'after') { + graph.edges = graph.edges.filter((edge) => edge.sourceNodeId !== queueId) + const next = outgoing[0] + graph.edges.push(createEdge(queueId, node.id, `${queueId}_1`, `${node.id}_3`)) + if (next) graph.edges.push(createEdge(node.id, next.targetNodeId, `${node.id}_1`, next.targetAnchorId)) + } + return { ok: true, graph, node, removedQueue: false } +} + +export const getActionQueueStepName = (step, index) => { + const name = String(step?.stepName || '').trim() + return name || `步骤 ${index + 1}` +} + +export const moveActionQueueStep = (steps, index, offset) => { + const targetIndex = index + offset + if (!Array.isArray(steps) || index < 0 || targetIndex < 0 || targetIndex >= steps.length) return false + const [step] = steps.splice(index, 1) + steps.splice(targetIndex, 0, step) + return true +} + +export const copyActionQueueStep = (step, overrides = {}) => { + const copied = deepClone(step) + const originalName = String(step?.stepName || '').trim() + return { + ...copied, + ...overrides, + stepName: `${originalName || '未命名步骤'} - 副本`, + } +} + +export const toActionQueueExecutionSteps = (steps) => (Array.isArray(steps) ? steps : []).map((step, index) => ({ + stepId: `step_${index + 1}`, + type: step.type, + ...(step.deviceId ? { deviceId: step.deviceId } : {}), + timeoutMs: Number(step.timeoutMs || 0), + params: deepClone(step.params || {}), +})) diff --git a/src/utils/agvRuntimeStatus.js b/src/utils/agvRuntimeStatus.js new file mode 100644 index 0000000..5d34334 --- /dev/null +++ b/src/utils/agvRuntimeStatus.js @@ -0,0 +1,18 @@ +const AGV_RUNTIME_STATUS = { + 0: { label: '未知', type: 'info', className: 'unknown' }, + 1: { label: '已断开', type: 'info', className: 'offline' }, + 2: { label: '空闲', type: 'success', className: 'online' }, + 3: { label: '手动模式', type: 'primary', className: 'manual' }, + 4: { label: '自动运行', type: 'primary', className: 'running' }, + 5: { label: '充电中', type: 'warning', className: 'charging' }, + 6: { label: '已暂停', type: 'warning', className: 'paused' }, + 7: { label: '已停止', type: 'info', className: 'stopped' }, + 8: { label: '故障', type: 'danger', className: 'fault' }, + 9: { label: '急停', type: 'danger', className: 'emergency' }, +} + +const resolveStatus = value => AGV_RUNTIME_STATUS[Number(value)] || AGV_RUNTIME_STATUS[0] + +export const agvRuntimeStatusLabel = value => resolveStatus(value).label +export const agvRuntimeStatusType = value => resolveStatus(value).type +export const agvRuntimeStatusClass = value => resolveStatus(value).className diff --git a/src/utils/flow.js b/src/utils/flow.js index 1f56eff..b837aba 100644 --- a/src/utils/flow.js +++ b/src/utils/flow.js @@ -1,3 +1,15 @@ +/** + * 文件说明:流程设计器通用工具函数。 + * 该文件大量依赖画布全局实例 lf,用于读取/修改当前流程图、生成参数引用树和转换节点表单数据。 + */ + +/** + * 生成当前画布所有可展示节点的输入/输出字段树。 + * 该函数只负责构造完整候选树,不判断节点是否位于当前节点上游;需要上游限制时使用 getUpstreamInput。 + * @param {string} nodeId 当前节点 ID,候选列表会排除该节点自身。 + * @param {boolean} isStrict 历史保留参数,旧逻辑曾用于要求节点必须存在入边;当前未启用。 + * @returns {Array} Element Plus Cascader 可使用的节点参数树。 + */ export const getInput = (nodeId, isStrict = true) => { const data = lf.getGraphData(); // let flag = false @@ -13,6 +25,7 @@ export const getInput = (nodeId, isStrict = true) => { // if (flag) { const arr = [] + // 结构节点、控制节点和终止节点不产生可供业务节点引用的有效输入/输出。 const filterNodes = ['sleep', 'stopLoop', 'subEnd', 'subStart', 'branch', 'end'] data.nodes.forEach(item => { if (item.id === nodeId) { @@ -23,6 +36,7 @@ export const getInput = (nodeId, isStrict = true) => { return } + // 每个节点下固定分为“输入”和“输出”两组,叶子节点由 transformTree 递归补齐。 const obj = { value: item.id, label: item.properties.name, @@ -55,6 +69,13 @@ export const getInput = (nodeId, isStrict = true) => { // return flag } +/** + * 将参数定义数组递归转换为级联选择器节点。 + * @param {Array} arr 参数定义数组,支持 children 嵌套。 + * @param {Array} parent 当前层级要写入的级联节点数组。 + * @param {"input"|"output"} type 当前参数所属分组,用于后续保存 quoteType。 + * @returns {Array} 转换后的 parent 数组。 + */ const transformTree = (arr, parent = [], type) => { arr.forEach(item => { const currentNode = { @@ -74,6 +95,10 @@ const transformTree = (arr, parent = [], type) => { return parent; }; +/** + * 读取开始节点的输入参数,并补充运行表单需要的默认值和 propPath。 + * @returns {Array} 已带 value/propPath 的开始参数数组。 + */ export const getStartNodeFormData = () => { const flowData = lf.getGraphData(); const startNode = flowData.nodes.find(item => item.type === 'start') @@ -81,6 +106,12 @@ export const getStartNodeFormData = () => { return data } +/** + * 给参数定义递归补充默认值 value 和表单校验路径 propPath。 + * @param {Object|Array} data 参数定义或参数定义数组。 + * @param {string} schemeId 特殊参数 schemeId 的默认值。 + * @returns {Object|Array} 原结构附加 value/propPath 后的结果。 + */ export const addValueProperty = (data, schemeId = '') => { // 处理单个对象或对象数组 const processItem = (item, index, path) => { @@ -105,6 +136,15 @@ export const addValueProperty = (data, schemeId = '') => { return Array.isArray(data) ? data.map((item, index) => processItem(item, index, '')) : processItem(data, 0, '') } +/** + * 递归查找某个节点所属的全部外层容器。 + * 常用于判断节点是否在循环、自定义分组等父级结构内。 + * @param {Array} data 流程节点数组。 + * @param {string} id 子节点 ID。 + * @param {string[]} type 需要匹配的父级节点类型。 + * @param {Array} result 递归累积结果。 + * @returns {Array} 从内到外匹配到的父级节点集合。 + */ export const recursiveFilter = (data, id, type = ['loop', 'customGroup'], result = []) => { data.forEach((item) => { if (type.includes(item.type) && item.children && item.children.includes(id)) { @@ -115,6 +155,13 @@ export const recursiveFilter = (data, id, type = ['loop', 'customGroup'], result return result } +/** + * 将树形表单字段转换为普通对象。 + * 叶子字段取 value;存在 children 的字段会递归生成嵌套对象。 + * @param {Array} arr 表单字段数组。 + * @param {Object} data 写入目标对象,递归时复用。 + * @returns {Object} 转换后的对象。 + */ export const formatTableData = (arr, data = {}) => { arr.forEach(item => { data[item.name] = item.value @@ -126,9 +173,14 @@ export const formatTableData = (arr, data = {}) => { return data } +/** + * 重新创建与指定节点相连的边。 + * 节点宽度或锚点位置变化后,删除再添加边可以触发画布重新计算连线位置。 + * @param {string} nodeId 需要刷新连线的节点 ID。 + */ export const addNewEdge = (nodeId) => { const { edges } = lf.getGraphData(); - const ttt = edges.slice(); // 浅拷贝(比 JSON 深拷贝高效 10x+) + const ttt = edges.slice(); // 浅拷贝即可保护遍历集合,避免删除边时影响原数组迭代。 const edgesToDelete = ttt.filter(_edge => _edge.sourceNodeId === nodeId || _edge.targetNodeId === nodeId @@ -140,6 +192,7 @@ export const addNewEdge = (nodeId) => { clearTimeout(window.addNewEdgeTimer); // 清理上一次未执行的定时器 } + // 延迟到 DOM 和节点尺寸更新后再添加边,避免仍使用旧锚点位置。 window.addNewEdgeTimer = setTimeout(() => { edgesToDelete.forEach(item => { lf.addEdge({ @@ -154,6 +207,11 @@ export const addNewEdge = (nodeId) => { }, 50) } +/** + * 立即刷新指定节点关联的边。 + * 与 addNewEdge 不同,这里不做防抖和延迟,适合无需等待 DOM 尺寸变化的场景。 + * @param {string} nodeId 需要刷新连线的节点 ID。 + */ export const newEdge = (nodeId) => { const { edges } = lf.getGraphData(); const arr = JSON.parse(JSON.stringify(edges)) @@ -172,6 +230,13 @@ export const newEdge = (nodeId) => { }) } +/** + * 初始化或切换节点的展开宽度,并在尺寸变化后刷新连线。 + * @param {string} modelId 节点 DOM class / 节点 ID。 + * @param {boolean} nodeZoom 是否展开为大宽度。 + * @param {string} className 用于查找 VueFlow 外层节点容器的选择器。 + * @param {boolean} init 是否为初始化阶段;初始化时不刷新边。 + */ export const initNodeZoom = (modelId, nodeZoom, className, init=false) => { const nodes = document.getElementsByClassName(modelId) if (nodes.length > 0) { @@ -188,6 +253,11 @@ export const initNodeZoom = (modelId, nodeZoom, className, init=false) => { } } +/** + * 收集当前节点存在入边时,画布中所有 number 类型输入参数。 + * @param {string} nodeId 当前节点 ID。 + * @returns {false|Array} 无入边时返回 false;有入边时返回 number 参数数组。 + */ export const getInputNumber = (nodeId) => { const data = lf.getGraphData(); let flag = false @@ -212,6 +282,12 @@ export const getInputNumber = (nodeId) => { return flag } +/** + * 删除分支节点某个出口锚点上的所有连线。 + * 删除条件分支时调用,避免已删除条件对应的边继续残留在画布上。 + * @param {string} nodeId 分支节点 ID。 + * @param {string} sourceAnchorId 分支出口锚点 ID。 + */ export const removeSwitchEdge = (nodeId, sourceAnchorId) => { const { edges } = lf.getGraphData(); const matchedEdges = edges.filter(item => { @@ -221,6 +297,111 @@ export const removeSwitchEdge = (nodeId, sourceAnchorId) => { matchedEdges.forEach(edge => lf.deleteEdge(edge.id)) } +/** + * 构建“子节点 ID -> 父容器 ID”的映射。 + * 统一兼容 VueFlow 当前 children 结构和旧流程保存的 properties.parentId 字段。 + * @param {Object} data 流程图数据,包含 nodes/edges。 + * @returns {Map} 子节点到父节点的映射。 + */ +const buildParentByChild = (data) => { + const result = new Map(); + // 兼容 VueFlow children 集合和旧数据中的 properties.parentId,两种来源都可标识容器归属。 + ;(data.nodes || []).forEach((node) => { + ;(node.children || []).forEach((childId) => result.set(String(childId), String(node.id))); + }); + ;(data.nodes || []).forEach((node) => { + if (node.properties?.parentId) result.set(String(node.id), String(node.properties.parentId)); + }); + return result; +}; + +/** + * 沿着有向边反向收集 targetId 的所有前置节点。 + * result 由调用方传入,便于在不同容器层级中重复累加可见上游节点。 + * @param {Object} data 流程图数据,包含 nodes/edges。 + * @param {string} targetId 目标节点 ID。 + * @param {Set} result 前置节点 ID 集合。 + */ +const collectPredecessors = (data, targetId, result) => { + const pending = [String(targetId)]; + while (pending.length) { + const currentId = pending.pop(); + ;(data.edges || []).forEach((edge) => { + if (String(edge.targetNodeId) !== currentId) return; + const sourceId = String(edge.sourceNodeId); + if (result.has(sourceId)) return; + result.add(sourceId); + pending.push(sourceId); + }); + } +}; + +/** + * 返回目标节点执行前已经可用的节点集合。 + * 循环体内节点只能引用同一循环体内的前置兄弟节点,以及循环容器外部的前置节点; + * 不能引用后置节点、其他循环体内部节点或循环之后才会执行的节点。 + * @param {Object} data 流程图数据。 + * @param {string} nodeId 目标节点 ID。 + * @returns {Set} 目标节点可引用的上游节点 ID 集合。 + */ +export const getUpstreamNodeIds = (data, nodeId) => { + const graph = data || { nodes: [], edges: [] }; + const targetId = String(nodeId); + const nodeById = new Map((graph.nodes || []).map((node) => [String(node.id), node])); + const parentByChild = buildParentByChild(graph); + const result = new Set( + (graph.nodes || []).filter((node) => node.type === 'start').map((node) => String(node.id)) + ); + + // 从当前节点逐级向外层容器回溯;每一层只接受同作用域内的前置节点,兼容历史嵌套循环数据。 + let currentTargetId = targetId; + let currentScopeId = parentByChild.get(targetId) || ''; + const visitedScopes = new Set(); + while (!visitedScopes.has(currentTargetId)) { + visitedScopes.add(currentTargetId); + const upstream = new Set(); + collectPredecessors(graph, currentTargetId, upstream); + upstream.forEach((sourceId) => { + const sourceScopeId = parentByChild.get(sourceId) || ''; + if (sourceScopeId === currentScopeId && nodeById.has(sourceId)) result.add(sourceId); + }); + if (!currentScopeId) break; + currentTargetId = currentScopeId; + currentScopeId = parentByChild.get(currentScopeId) || ''; + } + result.delete(targetId); + return result; +}; + +/** + * 生成级联选择器使用的上游入参/出参树。 + * 开始节点只暴露输入参数,普通节点只暴露输出参数,防止用户选到运行时不可用的字段。 + * @param {string} nodeId 当前节点 ID。 + * @returns {Array} 已按可执行顺序过滤后的级联选项树。 + */ +export const getUpstreamInput = (nodeId) => { + const data = lf.getGraphData(); + const startIds = new Set( + data.nodes.filter((node) => node.type === 'start').map((node) => String(node.id)) + ); + const upstreamIds = getUpstreamNodeIds(data, nodeId); + return getInput(nodeId) + .filter((option) => upstreamIds.has(String(option.value))) + .map((option) => ({ + ...option, + children: option.children.filter((group) => + startIds.has(String(option.value)) ? group.value === 'input' : group.value === 'output' + ) + })) + .filter((option) => option.children.some((group) => group.children?.length)); +} + +/** + * 将扁平节点数组按 children 字段转换成树结构。 + * 节点关系弹窗使用该结果展示父子层级,例如循环容器内的子节点。 + * @param {Array} data 流程节点数组。 + * @returns {Array} 根节点数组,每个根节点递归带 children。 + */ export const convertToTree = (data) => { // 创建 id 到节点的映射 const nodeMap = new Map(); @@ -264,6 +445,12 @@ export const convertToTree = (data) => { } +/** + * 递归过滤 name 为空的字段定义。 + * 常用于保存输出参数前移除未填写名称的空行,避免生成不可引用的空字段。 + * @param {*} obj 字段定义、字段数组或基础类型。 + * @returns {*} 过滤后的结构;被剔除的对象返回 null。 + */ export const filterEmptyName = (obj) => { // 1. 如果是数组,遍历每一项递归处理 if (Array.isArray(obj)) { @@ -295,9 +482,10 @@ export const filterEmptyName = (obj) => { } /** - * 将原始表单配置数组 转换为 目标对象格式 - * @param {Array} source - 原始数据数组 - * @returns {Object} 转换后的目标格式对象 + * 将 HTTP 节点面板中的表单数组转换为后端执行协议。 + * config/headers/params 保留 children 数组;body 会展开为 { type, json, formData }。 + * @param {Array} source HTTP 节点原始表单配置数组。 + * @returns {Object} 后端执行 HTTP 请求所需的结构化配置。 */ export const transformHttpNodeData = (source) => { // 初始化结果对象 @@ -343,6 +531,12 @@ export const transformHttpNodeData = (source) => { return result; } +/** + * 将智能体节点表单数组转换为后端执行协议。 + * 普通配置字段保留 children;invokeTts 是开关值,需要从 item.input 提取为顶层字段。 + * @param {Array} source 智能体节点原始表单配置数组。 + * @returns {Object} 智能体调用配置。 + */ export const transformSdAgentNodeData = (source) => { // 初始化结果对象 const result = {}; @@ -367,6 +561,12 @@ export const transformSdAgentNodeData = (source) => { return result; } +/** + * 将识别节点表单转换为后端协议。 + * 普通字段写入 nodeParams;alarmRules 单独提取为告警规则数组,并兼容旧版 children 结构。 + * @param {Array} source 识别节点原始表单配置数组。 + * @returns {{nodeParams: Array, alarmRules: Array}} 识别节点配置。 + */ export const transformRecognizeNodeData = (source) => { // 初始化结果对象 const result = { diff --git a/src/utils/flowExecution.js b/src/utils/flowExecution.js new file mode 100644 index 0000000..9767f96 --- /dev/null +++ b/src/utils/flowExecution.js @@ -0,0 +1,120 @@ +import { getFlowParamsApi } from '@/api/device/terminal' +import { getDetect } from '@/api/test/detect' +import { + getResourceRoles, + mergeResourceRoleDefinitions, + normalizeFlowResourceSchema, + validateFlowResources, +} from '@/utils/flowResources' + +const clone = (value) => JSON.parse(JSON.stringify(value || [])) + +const parseFlowData = (itemId, flowData) => { + if (!flowData) throw new Error(`流程项[${itemId}]还没有配置工作流`) + try { + const graph = typeof flowData === 'string' ? JSON.parse(flowData) : flowData + const normalizedGraph = normalizeFlowResourceSchema(graph) + const validation = validateFlowResources(normalizedGraph) + if (validation.errors.length) { + throw new Error(validation.errors[0].message) + } + return validation.graph + } catch (error) { + throw new Error(`流程项[${itemId}]不可执行:${error.message || '工作流数据无法解析'}`) + } +} + +const assertCompatibleDefinitions = (roleGroups) => { + const rolesByKey = new Map() + roleGroups.flat().forEach((role) => { + const existing = rolesByKey.get(role.roleKey) + if (!existing) { + rolesByKey.set(role.roleKey, clone([role])[0]) + return + } + const slotsByKey = new Map((existing.deviceSlots || []).map((slot) => [slot.slotKey, slot])) + ;(role.deviceSlots || []).forEach((slot) => { + const existingSlot = slotsByKey.get(slot.slotKey) + if (existingSlot && String(existingSlot.deviceKind ?? '') !== String(slot.deviceKind ?? '')) { + throw new Error(`机器人角色“${existing.displayName}”的设备用途“${slot.displayName}”在不同流程中类型不一致`) + } + if (!existingSlot) existing.deviceSlots.push(clone([slot])[0]) + }) + }) +} + +export const loadFlowItemRuntimeRoles = async (items = []) => { + if (items.some((item) => !item?.itemId)) throw new Error('任务编排中存在缺少流程项ID的数据') + const uniqueItems = Array.from(new Map( + items.filter((item) => item?.itemId).map((item) => [item.itemId, item]), + ).values()) + if (!uniqueItems.length) throw new Error('当前任务没有编排任何流程项') + + const roleGroups = await Promise.all(uniqueItems.map(async (item) => { + const response = await getDetect(item.itemId) + const graph = parseFlowData(item.itemName || item.itemId, response.data?.flowData) + return getResourceRoles(graph) + })) + assertCompatibleDefinitions(roleGroups) + return mergeResourceRoleDefinitions(roleGroups) +} + +/** + * Loads runtime roles without merging equal role keys across flow items. + * Task orchestration needs one independent assignment per detection item because + * two items may use different physical robots even when their workflows define + * the same logical role (for example, `default_robot`). + */ +export const loadFlowItemScopedRuntimeRoles = async (items = []) => { + if (items.some((item) => !item?.itemId)) throw new Error('任务编排中存在缺少流程项ID的数据') + if (!items.length) throw new Error('当前任务没有编排任何流程项') + + const occurrences = new Map() + const roleGroups = await Promise.all(items.map(async (item) => { + const occurrence = (occurrences.get(String(item.itemId)) || 0) + 1 + occurrences.set(String(item.itemId), occurrence) + const response = await getDetect(item.itemId) + const graph = parseFlowData(item.itemName || item.itemId, response.data?.flowData) + return getResourceRoles(graph).map((role) => ({ + ...clone([role])[0], + roleKey: `${item.itemId}:${occurrence}:${role.roleKey}`, + sourceRoleKey: role.roleKey, + itemId: item.itemId, + itemOccurrence: occurrence, + itemName: item.itemName || String(item.itemId), + displayName: `${item.itemName || item.itemId} / ${role.displayName}`, + })) + })) + return roleGroups.flat() +} + +export const loadTaskRuntimeRoles = async (taskId) => { + if (!taskId) throw new Error('任务没有关联流程编排') + const response = await getFlowParamsApi(taskId) + return loadFlowItemRuntimeRoles(response.rows || []) +} + +export const loadTaskScopedRuntimeRoles = async (taskId) => { + if (!taskId) throw new Error('任务没有关联流程编排') + const response = await getFlowParamsApi(taskId) + return loadFlowItemScopedRuntimeRoles(response.rows || []) +} + +export const createRuntimeAssignments = (roles = [], primaryRobotId = '') => roles.map((role, index) => ({ + roleKey: role.roleKey, + ...(role.sourceRoleKey ? { sourceRoleKey: role.sourceRoleKey } : {}), + ...(role.itemId != null ? { itemId: role.itemId } : {}), + ...(role.itemOccurrence != null ? { itemOccurrence: role.itemOccurrence } : {}), + members: [{ + memberKey: `${role.roleKey}_1`, + robotId: index === 0 ? primaryRobotId || role.legacyRobotId || '' : role.legacyRobotId || '', + devices: Object.fromEntries((role.deviceSlots || []).map((slot) => [ + slot.slotKey, + slot.legacyDeviceId || '', + ])), + }], +})) + +export const firstAssignedRobotId = (assignments = []) => ( + clone(assignments).flatMap((item) => item.members || []).find((member) => member.robotId)?.robotId || '' +) diff --git a/src/utils/flowResources.js b/src/utils/flowResources.js new file mode 100644 index 0000000..841191b --- /dev/null +++ b/src/utils/flowResources.js @@ -0,0 +1,517 @@ +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', desc: '原图坐标 [x1,y1,x2,y2]', children: [], disabled: true }, + { name: 'center', type: 'array', 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', 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 +} diff --git a/src/utils/pagedRequest.js b/src/utils/pagedRequest.js new file mode 100644 index 0000000..e8a7711 --- /dev/null +++ b/src/utils/pagedRequest.js @@ -0,0 +1,45 @@ +const DEFAULT_PAGE_SIZE = 200 +const DEFAULT_MAX_PAGES = 1000 + +/** + * Loads every page from a standard TableDataInfo endpoint. Endpoints that ignore + * page parameters are also supported because their first response already reaches total. + */ +export async function fetchAllPagedRows(fetchPage, params = {}, options = {}) { + const pageSize = Math.max(1, Number(options.pageSize || DEFAULT_PAGE_SIZE)) + const maxPages = Math.max(1, Number(options.maxPages || DEFAULT_MAX_PAGES)) + const rows = [] + const seenIds = new Set() + let lastResponse = { code: 200, rows: [], total: 0 } + + for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) { + const response = await fetchPage({ ...params, pageNum, pageSize }) + lastResponse = response || lastResponse + const batch = Array.isArray(response?.rows) ? response.rows : [] + let addedCount = 0 + batch.forEach((row) => { + const id = row?.id + if (id !== undefined && id !== null && id !== '') { + const key = String(id) + if (seenIds.has(key)) return + seenIds.add(key) + } + rows.push(row) + addedCount += 1 + }) + + const total = Number(response?.total) + const hasTotal = Number.isFinite(total) && total >= 0 + if (addedCount === 0 && hasTotal && rows.length < total) { + throw new Error('日志分页没有返回新记录,无法保证数据完整') + } + if (batch.length === 0 + || addedCount === 0 + || (hasTotal && rows.length >= total) + || (!hasTotal && batch.length < pageSize)) { + return { ...lastResponse, rows, total: hasTotal ? total : rows.length } + } + } + + throw new Error(`日志记录超过 ${pageSize * maxPages} 条,未能完整加载`) +} diff --git a/src/utils/robotDevice.js b/src/utils/robotDevice.js new file mode 100644 index 0000000..7117333 --- /dev/null +++ b/src/utils/robotDevice.js @@ -0,0 +1,58 @@ +/** + * 平台设备类型枚举,供流程资源匹配、机器人设备筛选和界面展示共同使用。 + * Object.freeze 防止运行期间被改写,数值与后端 deviceKind 协议保持一致。 + */ +export const DEVICE_KIND = Object.freeze({ + AGV: 1, + ARM: 2, + BIO_HEAD: 4, + CAMERA: 5, + MICROPHONE: 9, + ROBOT: 12, + SPEAKER: 13, +}) + +/** + * 流程动作到所需设备类型的全局映射。 + * 作用范围为所有根据 action 判断执行设备的流程模块;未列出的动作视为没有固定设备类型。 + */ +export const DEVICE_KIND_BY_ACTION = Object.freeze({ + AGV_MOVE_TO_POINT: DEVICE_KIND.AGV, + AGV_MOVE_TO_STATION: DEVICE_KIND.AGV, + ARM_MOVE_TO_POINT: DEVICE_KIND.ARM, + ARM_MOVE_TO_J: DEVICE_KIND.ARM, + // 机械臂使能/去使能虽然没有运动参数,仍必须绑定 ARM 设备, + // 这样运行前的资源校验和设备下拉筛选才能为节点分配正确的机械臂终端。 + ARM_ENABLE: DEVICE_KIND.ARM, + ARM_DISABLE: DEVICE_KIND.ARM, + TOUCH: DEVICE_KIND.ARM, + BIO_HEAD_SPEAK_START: DEVICE_KIND.BIO_HEAD, + BIO_HEAD_SPEAK_STOP: DEVICE_KIND.BIO_HEAD, + BIO_HEAD_SPECIAL_EXPRESSION: DEVICE_KIND.BIO_HEAD, + CAMERA_START: DEVICE_KIND.CAMERA, + CAMERA_STOP: DEVICE_KIND.CAMERA, + CAMERA_GETRGBIMAGE: DEVICE_KIND.CAMERA, + CAMERA_RECORDING_START: DEVICE_KIND.CAMERA, + CAMERA_RECORDING_STOP: DEVICE_KIND.CAMERA, + MICROPHONE_START: DEVICE_KIND.MICROPHONE, + MICROPHONE_STOP: DEVICE_KIND.MICROPHONE, + SPEAKER_PLAYAUDIO: DEVICE_KIND.SPEAKER, + VI_PLAY_CORPUS: DEVICE_KIND.SPEAKER, +}) + +/** + * 查询某个流程动作所需的设备类型。 + * 直接读取统一映射;未知或无需固定设备的动作返回 undefined,由调用方决定降级策略。 + */ +export const getDeviceKindForAction = (action) => DEVICE_KIND_BY_ACTION[action] + +/** + * 将机器人设备接口数据转换为 Element Plus 下拉框选项,供全局设备选择器复用。 + * 每项以 deviceId 作为值;设备名有效且不同于 ID 时展示“名称 (ID)”,否则仅展示 ID。 + */ +export const toDeviceOptions = (devices = []) => devices.map((device) => ({ + value: device.deviceId, + label: device.deviceName && device.deviceName !== device.deviceId + ? `${device.deviceName} (${device.deviceId})` + : device.deviceId, +})) diff --git a/src/views/device/register/components/Camera/index.vue b/src/views/device/register/components/Camera/index.vue index a7becbe..8e820bf 100644 --- a/src/views/device/register/components/Camera/index.vue +++ b/src/views/device/register/components/Camera/index.vue @@ -47,7 +47,7 @@ const props = defineProps({ type: String, default: null, }, - initialTerminalId: { + initialRobotId: { type: String, default: null, }, @@ -60,7 +60,7 @@ const data = reactive({ stereoModule: false, rgbCamera: false, }, - terminalId: null, + robotId: null, cameraId: null, type: 'camera', callbacks: {}, @@ -95,7 +95,7 @@ function getVideoRef(method) { } async function startDirectRgbStream() { - if (!data.terminalId || !data.cameraId || !colorVideo.value) return; + if (!data.robotId || !data.cameraId || !colorVideo.value) return; rgbStreamAbortController?.abort(); rgbStreamAbortController = null; const generation = ++rgbStreamGeneration; @@ -103,7 +103,7 @@ async function startDirectRgbStream() { rgbStreamRetryTimer = null; streamStatus.getRGBImageStream = 'connecting'; try { - const response = await getRgbStreamUrl(data.terminalId, data.cameraId); + const response = await getRgbStreamUrl(data.robotId, data.cameraId); if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return; const relativeUrl = response.data; const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, ''); @@ -342,11 +342,11 @@ function destroyMediaPlayer(method) { } function channelFor(method) { - return `edgeCameraServiceImpl/${method}/${data.terminalId}/${data.cameraId}`; + return `edgeCameraServiceImpl/${method}/${data.robotId}/${data.cameraId}`; } async function setSubscription(enabled, method, force = false) { - if (!socket || !data.terminalId || !data.cameraId) return; + if (!socket || !data.robotId || !data.cameraId) return; await nextTick(); const channel = channelFor(method); @@ -401,7 +401,7 @@ function scheduleRestart(method) { } function handleSocketOpen() { - if (data.form.stereoModule && data.terminalId && data.cameraId) { + if (data.form.stereoModule && data.robotId && data.cameraId) { destroyMediaPlayer('getDepthImageStream'); setSubscription(true, 'getDepthImageStream', true); } @@ -418,12 +418,12 @@ socket?.on('open', handleSocketOpen); socket?.on('close', handleSocketClose); watch(() => data.form.stereoModule, async (newVal) => { - if (!data.terminalId || !data.cameraId) return; + if (!data.robotId || !data.cameraId) return; await setSubscription(newVal, 'getDepthImageStream'); }); watch(() => data.form.rgbCamera, async (newVal) => { - if (!data.terminalId || !data.cameraId) return; + if (!data.robotId || !data.cameraId) return; if (newVal) { await nextTick(); await startDirectRgbStream(); @@ -436,10 +436,19 @@ watch( () => props.initialDeviceId || route.path.split('/')[3], async (deviceId) => { if (!deviceId) return; + const queryRobotId = props.initialRobotId || route.query.robotId; + const queryDeviceId = props.initialDeviceId || route.query.deviceId; + if (queryRobotId && queryDeviceId) { + data.cameraId = String(queryDeviceId); + data.robotId = String(queryRobotId); + if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream'); + if (data.form.rgbCamera) await startDirectRgbStream(); + return; + } try { const response = await getRegister(deviceId); data.cameraId = response.data.deviceCode; - data.terminalId = props.initialTerminalId || response.data.idDeDeviceTerminalConfig; + data.robotId = props.initialRobotId || response.data.idDeDeviceTerminalConfig; if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream'); if (data.form.rgbCamera) await startDirectRgbStream(); } catch (error) { @@ -455,7 +464,7 @@ onUnmounted(() => { stopDirectRgbStream(); ['getDepthImageStream'].forEach(method => { clearTimeout(retryTimers.get(method)); - if (data.terminalId && data.cameraId) { + if (data.robotId && data.cameraId) { const channel = channelFor(method); socket?.send({ type: 'channel_subscription', action: 'unsubscribe', channel }); if (data.callbacks[channel]) socket.off(channel, data.callbacks[channel]); diff --git a/src/views/device/register/components/DexHand/LeftBottomHand.vue b/src/views/device/register/components/DexHand/LeftBottomHand.vue index 27d3d37..302711f 100644 --- a/src/views/device/register/components/DexHand/LeftBottomHand.vue +++ b/src/views/device/register/components/DexHand/LeftBottomHand.vue @@ -17,7 +17,7 @@ import { inject } from 'vue'; const emit = defineEmits(['update:bottomSeriesData']); const socket = inject('ws'); const handCanvas = ref(null); -const terminalId = ref(''); // 待您修改 +const robotId = ref(''); // 待您修改 const deviceId = ref(''); // 待您修改 const frameData = ref({ count: 0, lastTime: 0 }); const isHandSeries = ref(false); @@ -29,7 +29,7 @@ const props = defineProps({ type: String, default: null, }, - initialTerminalId: { // 示例 prop,用于从弹窗接收 terminalId + initialRobotId: { // 示例 prop,用于从弹窗接收 robotId type: String, default: null, }, @@ -37,7 +37,7 @@ const props = defineProps({ // 2. 使用 watchEffect 来响应 props 和路由的变化 watchEffect(() => { - terminalId.value = props.initialTerminalId; + robotId.value = props.initialRobotId; deviceId.value = props.initialDeviceId; }); @@ -225,11 +225,11 @@ const sensorMap = [ // WebSocket 订阅 const subscribeSensorData = async (sub) => { - if (!socket || !terminalId.value || !deviceId.value) { - console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId.value}, deviceId=${deviceId.value}`); + if (!socket || !robotId.value || !deviceId.value) { + console.warn(`订阅失败: socket=${!!socket}, robotId=${robotId.value}, deviceId=${deviceId.value}`); return; } - const channel = `edgeDexHandServiceImpl/getSensorDataStream/${terminalId.value}/${deviceId.value}`; + const channel = `edgeDexHandServiceImpl/getSensorDataStream/${robotId.value}/${deviceId.value}`; console.log(sub ? '订阅' : '取消订阅', channel); socket.send({ type: 'channel_subscription', diff --git a/src/views/device/register/components/DexHand/LeftTopHand.vue b/src/views/device/register/components/DexHand/LeftTopHand.vue index 5261e51..faeae7c 100644 --- a/src/views/device/register/components/DexHand/LeftTopHand.vue +++ b/src/views/device/register/components/DexHand/LeftTopHand.vue @@ -35,7 +35,7 @@ const handImage = ref(null); const containerRef = ref(null); // 新增:引用父容器 const defaultHandImageInfo = { width: 891, height: 981 }; const imageAspectRatio = defaultHandImageInfo.width / defaultHandImageInfo.height; -const terminalId = ref(''); +const robotId = ref(''); const deviceId = ref(''); // 1. 定义 props const props = defineProps({ @@ -43,7 +43,7 @@ const props = defineProps({ type: String, default: null, }, - initialTerminalId: { // 示例 prop,用于从弹窗接收 terminalId + initialRobotId: { // 示例 prop,用于从弹窗接收 robotId type: String, default: null, }, @@ -51,9 +51,9 @@ const props = defineProps({ // 2. 使用 watchEffect 来响应 props 和路由的变化 watchEffect(() => { - terminalId.value = props.initialTerminalId; + robotId.value = props.initialRobotId; deviceId.value = props.initialDeviceId; - console.log(deviceId.value, terminalId.value,props) + console.log(deviceId.value, robotId.value,props) }); const sliders = ref([ @@ -66,9 +66,9 @@ const sliders = ref([ ]); function updateSeriesData(value, i) { - console.log(deviceId.value, terminalId.value) + console.log(deviceId.value, robotId.value) loading.value = true; - setDexHandAngle({ deviceId: deviceId.value, terminalId: terminalId.value, value: value / 100, id: i }).then(() => { + setDexHandAngle({ deviceId: deviceId.value, robotId: robotId.value, value: value / 100, id: i }).then(() => { loading.value = false; updateToSeriesData(); }).catch(() => { @@ -131,7 +131,7 @@ const updateSliderPositions = () => { }; const updateToSeriesData = () => { - status({ deviceId: deviceId.value, terminalId: terminalId.value }).then((res) => { + status({ deviceId: deviceId.value, robotId: robotId.value }).then((res) => { const newTopSeriesData = res.data.handsList.map(item => item.force); emit('update:topSeriesData', newTopSeriesData); }); diff --git a/src/views/device/register/components/DexHand/index.vue b/src/views/device/register/components/DexHand/index.vue index fd33381..892ad7a 100644 --- a/src/views/device/register/components/DexHand/index.vue +++ b/src/views/device/register/components/DexHand/index.vue @@ -4,11 +4,11 @@
- +
- - + +
@@ -34,7 +34,7 @@ const props = defineProps({ type: String, default: null, }, - initialTerminalId: { + initialRobotId: { type: String, default: null, }, @@ -62,7 +62,7 @@ const avgData = ref({ // 声明组件内部使用的响应式变量 const deviceId = ref(""); -const terminalId = ref(""); +const robotId = ref(""); const handleUpdateTopSeriesData = (newData) => { topSeriesData.value = newData; @@ -77,19 +77,24 @@ const handleUpdateBottomSeriesData = ({ maxData: newMaxData, avgData: newAvgData const route = useRoute(); // 统一处理获取 register 数据的方法 const fetchRegisterData = (currentDeviceId) => { + if (route.query.robotId && route.query.deviceId) { + robotId.value = String(route.query.robotId); + deviceId.value = String(route.query.deviceId); + return; + } if (currentDeviceId) { getRegister(currentDeviceId).then(res => { deviceId.value = res.data.deviceCode; // 再次更新,确保与 API 返回一致 - terminalId.value = res.data.idDeDeviceTerminalConfig; + robotId.value = res.data.idDeDeviceTerminalConfig; }).catch(error => { console.error("Error fetching register:", error); - // 可以根据需要重置 deviceId 和 terminalId + // 可以根据需要重置 deviceId 和 robotId deviceId.value = ""; - terminalId.value = ""; + robotId.value = ""; }); } else { - // 如果 deviceId 为空,可以考虑重置 terminalId - terminalId.value = ""; + // 如果 deviceId 为空,可以考虑重置 robotId + robotId.value = ""; } }; @@ -107,6 +112,11 @@ watch(() => props.initialDeviceId, (newVal) => { // 注意:这里假设 route.path.split("/")[3] 是cameraId // 如果你的路由结构不同,请调整这里的逻辑 watch(() => route.path, (newPath) => { + if (route.query.robotId && route.query.deviceId) { + robotId.value = String(route.query.robotId); + deviceId.value = String(route.query.deviceId); + return; + } const routeDeviceId = newPath.split("/")[3]; if (!deviceId.value && routeDeviceId) { // 只有当 deviceId 尚未从 props 或其他地方设置时才从路由获取 deviceId.value = routeDeviceId; @@ -115,10 +125,10 @@ watch(() => route.path, (newPath) => { }, { immediate: true }); // immediate: true 确保在组件挂载时也检查路由 -// 如果需要 initialTerminalId prop 也影响 terminalId ref,可以添加一个 watch -watch(() => props.initialTerminalId, (newVal) => { +// 如果需要 initialRobotId prop 也影响 robotId ref,可以添加一个 watch +watch(() => props.initialRobotId, (newVal) => { if (newVal) { - terminalId.value = newVal; + robotId.value = newVal; } }, { immediate: true }); @@ -155,4 +165,4 @@ watch(() => props.initialTerminalId, (newVal) => { width: 100%; overflow: hidden; } - \ No newline at end of file + diff --git a/src/views/device/register/components/Head/index.vue b/src/views/device/register/components/Head/index.vue index 3142033..ae48490 100644 --- a/src/views/device/register/components/Head/index.vue +++ b/src/views/device/register/components/Head/index.vue @@ -80,10 +80,12 @@ + + diff --git a/src/views/device/robotManger/index.vue b/src/views/device/robotManger/index.vue index 058ce86..7eb5638 100644 --- a/src/views/device/robotManger/index.vue +++ b/src/views/device/robotManger/index.vue @@ -61,7 +61,7 @@ ×
- +
@@ -398,7 +398,7 @@ const openPopup = (area) => { originalTop: `${area.y}px`, originalLeft: `${area.x + leftOffset}px`, isMaximized: false, - props: { terminalId: 'your-terminal-id', cameraId: 'your-camera-id' } // 示例props + props: { robotId: area.robotId || '', cameraId: area.deviceId || '' } }; popups.value.push(popup); isResizing.value.push(false); @@ -871,4 +871,4 @@ onMounted(() => { flex: 1; overflow: auto; } - \ No newline at end of file + diff --git a/src/views/flow/components/ActionQueueSingleRun.vue b/src/views/flow/components/ActionQueueSingleRun.vue new file mode 100644 index 0000000..993e7f6 --- /dev/null +++ b/src/views/flow/components/ActionQueueSingleRun.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/src/views/flow/components/Aside.vue b/src/views/flow/components/Aside.vue index 0172159..a818eef 100644 --- a/src/views/flow/components/Aside.vue +++ b/src/views/flow/components/Aside.vue @@ -1,3 +1,7 @@ +