Merge pull request 'lxl-dev' (#3) from lxl-dev into dev

Reviewed-on: http://192.168.28.10:18088/SmartBench/CMVR-IOT-UI/pulls/3
This commit is contained in:
zhanghao 2026-08-24 13:55:18 +08:00
commit bc0f9a9ece
120 changed files with 14511 additions and 4087 deletions

View File

@ -1,5 +1,5 @@
# 页面标题 # 页面标题
VITE_APP_TITLE = 招商车研物联网平台管理系统 VITE_APP_TITLE = 招商车研机器人智能测试管理系统
# 开发环境配置 # 开发环境配置
VITE_APP_ENV = 'development' VITE_APP_ENV = 'development'

View File

@ -1,5 +1,5 @@
# 页面标题 # 页面标题
VITE_APP_TITLE = 招商车研物联网平台管理系统 VITE_APP_TITLE = 招商车研机器人智能测试管理系统
# 生产环境配置 # 生产环境配置
VITE_APP_ENV = 'production' VITE_APP_ENV = 'production'

View File

@ -1,5 +1,5 @@
# 页面标题 # 页面标题
VITE_APP_TITLE = 招商车研物联网平台管理系统 VITE_APP_TITLE = 招商车研机器人智能测试管理系统
# 生产环境配置 # 生产环境配置
VITE_APP_ENV = 'staging' VITE_APP_ENV = 'staging'

View File

@ -15,4 +15,12 @@ export function status(data) {
method: 'post', method: 'post',
data: data data: data
}) })
} }
export function clearFault(params) {
return request({
url: '/api/arm/clearFault',
method: 'get',
params
})
}

9
src/api/device/camera.js Normal file
View File

@ -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 }
})
}

View File

@ -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
})
}

31
src/api/device/system.js Normal file
View File

@ -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,
})
}

221
src/api/flow/flow.js Normal file
View File

@ -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
})
}

View File

@ -29,4 +29,11 @@ export function deletePoint(id) {
url: `/inspection/waypoint/${id}`, url: `/inspection/waypoint/${id}`,
method: 'delete' method: 'delete'
}) })
} }
export function copyPoint(id) {
return request({
url: `/inspection/waypoint/copy/${id}`,
method: 'post'
})
}

View File

@ -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 * @param {*} robotId
@ -73,10 +92,10 @@ export function getRobotLocation(robotId) {
* @param {*} robotId * @param {*} robotId
* @returns * @returns
*/ */
export function getRobotStatus(robotId) { export function getRobotStatus(id) {
return request({ return request({
url: `/inspection/robot/{robotId}/sync-status`, url: `/inspection/robot/${id}/sync-status`,
method: 'get' method: 'post'
}) })
} }

View File

@ -38,10 +38,18 @@ export function deleteRunTask(id) {
}) })
} }
export function startRunTask(id) { export function startRunTask(id, data) {
return request({ return request({
url: `/inspection/taskInstance/start/${id}`, 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', method: 'get',
params: params params: params
}) })
} }

View File

@ -44,4 +44,11 @@ export function bindWaypointsToTask(taskId, waypointIds) {
method: 'post', method: 'post',
data: waypointIds data: waypointIds
}) })
} }
export function copyTask(id) {
return request({
url: `/inspection/task/copy/${id}`,
method: 'post'
})
}

View File

@ -1,5 +1,4 @@
import request from '@/utils/request' import request from '@/utils/request'
import { method } from 'lodash'
export function addTaskinstance(data) { export function addTaskinstance(data) {
return request({ return request({
@ -27,15 +26,23 @@ export function updateTaskinstance(data) {
export function deleteTaskinstance(ids) { export function deleteTaskinstance(ids) {
return request({ return request({
url: `/aima/taskinstance/{ids}`, url: `/aima/taskinstance/${ids}`,
method: 'delete' method: 'delete'
}) })
} }
export function startTaskinstance(id) { export function startTaskinstance(id, data) {
return request({ return request({
url: `/aima/taskinstance/start/${id}`, 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', method: 'get',
params: params params: params
}) })
} }

View File

@ -44,4 +44,11 @@ export function bindTastCase(taskId, ids) {
method: 'post', method: 'post',
data: ids data: ids
}) })
} }
export function copyTask(id) {
return request({
url: `/aima/task/copy/${id}`,
method: 'post'
})
}

View File

@ -29,4 +29,11 @@ export function deleteTestCase(ids) {
url: `/aima/testcase/${ids}`, url: `/aima/testcase/${ids}`,
method: 'delete' method: 'delete'
}) })
} }
export function copyTestCase(id) {
return request({
url: `/aima/testcase/copy/${id}`,
method: 'post'
})
}

View File

@ -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) { export function updateConfig(data) {
return request({ return request({

View File

@ -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) { export function updateDetect(data) {
return request({ return request({

View File

@ -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) { export function getInst(query) {
return request({ return request({

View File

@ -17,6 +17,7 @@ class WebSocketManager {
this.processingMessages = false; this.processingMessages = false;
this.maxQueueSize = 100; this.maxQueueSize = 100;
this.status = false; this.status = false;
this.channelSubscriptions = new Set();
} }
connect() { connect() {
@ -33,6 +34,7 @@ class WebSocketManager {
this.status = true; this.status = true;
this.startHeartbeat(); this.startHeartbeat();
this.processMessageQueue(); this.processMessageQueue();
this.restoreChannelSubscriptions();
this.dispatch('open'); this.dispatch('open');
}; };
@ -159,13 +161,31 @@ class WebSocketManager {
} }
send(payload) { 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) { if (this.ws && this.ws.readyState === 1) {
this.ws.send(JSON.stringify(payload)); this.ws.send(JSON.stringify(payload));
} else { } else if (payload?.type !== 'channel_subscription') {
console.warn('WebSocket 未连接,消息发送失败'); 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() { close() {
this.isManualClose = true; this.isManualClose = true;
this.status = false; this.status = false;

View File

@ -2,20 +2,11 @@ import { defineStore } from 'pinia'
export const useFlowStore = defineStore('flow', { export const useFlowStore = defineStore('flow', {
state: () => ({ state: () => ({
disableForm: false, disableForm: false
deviceList: []
}), }),
actions: { actions: {
updateDisableForm(value) { updateDisableForm(value) {
this.disableForm = value this.disableForm = value
},
async getDeviceList() {
const data = await new Promise((resolve, reject) => {
resolve( ['cam1', 'cam2', 'cam3', 'cam4'])
})
this.deviceList = data
return data
} }
} }
} }

375
src/utils/actionQueue.js Normal file
View File

@ -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 || {}),
}))

View File

@ -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

View File

@ -1,3 +1,15 @@
/**
* 文件说明:流程设计器通用工具函数。
* 该文件大量依赖画布全局实例 lf,用于读取/修改当前流程图、生成参数引用树和转换节点表单数据。
*/
/**
* 生成当前画布所有可展示节点的输入/输出字段树。
* 该函数只负责构造完整候选树,不判断节点是否位于当前节点上游;需要上游限制时使用 getUpstreamInput。
* @param {string} nodeId 当前节点 ID,候选列表会排除该节点自身。
* @param {boolean} isStrict 历史保留参数,旧逻辑曾用于要求节点必须存在入边;当前未启用。
* @returns {Array} Element Plus Cascader 可使用的节点参数树。
*/
export const getInput = (nodeId, isStrict = true) => { export const getInput = (nodeId, isStrict = true) => {
const data = lf.getGraphData(); const data = lf.getGraphData();
// let flag = false // let flag = false
@ -13,6 +25,7 @@ export const getInput = (nodeId, isStrict = true) => {
// if (flag) { // if (flag) {
const arr = [] const arr = []
// 结构节点、控制节点和终止节点不产生可供业务节点引用的有效输入/输出。
const filterNodes = ['sleep', 'stopLoop', 'subEnd', 'subStart', 'branch', 'end'] const filterNodes = ['sleep', 'stopLoop', 'subEnd', 'subStart', 'branch', 'end']
data.nodes.forEach(item => { data.nodes.forEach(item => {
if (item.id === nodeId) { if (item.id === nodeId) {
@ -23,6 +36,7 @@ export const getInput = (nodeId, isStrict = true) => {
return return
} }
// 每个节点下固定分为“输入”和“输出”两组,叶子节点由 transformTree 递归补齐。
const obj = { const obj = {
value: item.id, value: item.id,
label: item.properties.name, label: item.properties.name,
@ -55,6 +69,13 @@ export const getInput = (nodeId, isStrict = true) => {
// return flag // return flag
} }
/**
* 将参数定义数组递归转换为级联选择器节点。
* @param {Array} arr 参数定义数组,支持 children 嵌套。
* @param {Array} parent 当前层级要写入的级联节点数组。
* @param {"input"|"output"} type 当前参数所属分组,用于后续保存 quoteType。
* @returns {Array} 转换后的 parent 数组。
*/
const transformTree = (arr, parent = [], type) => { const transformTree = (arr, parent = [], type) => {
arr.forEach(item => { arr.forEach(item => {
const currentNode = { const currentNode = {
@ -74,6 +95,10 @@ const transformTree = (arr, parent = [], type) => {
return parent; return parent;
}; };
/**
* 读取开始节点的输入参数,并补充运行表单需要的默认值和 propPath。
* @returns {Array} 已带 value/propPath 的开始参数数组。
*/
export const getStartNodeFormData = () => { export const getStartNodeFormData = () => {
const flowData = lf.getGraphData(); const flowData = lf.getGraphData();
const startNode = flowData.nodes.find(item => item.type === 'start') const startNode = flowData.nodes.find(item => item.type === 'start')
@ -81,6 +106,12 @@ export const getStartNodeFormData = () => {
return data return data
} }
/**
* 给参数定义递归补充默认值 value 和表单校验路径 propPath。
* @param {Object|Array} data 参数定义或参数定义数组。
* @param {string} schemeId 特殊参数 schemeId 的默认值。
* @returns {Object|Array} 原结构附加 value/propPath 后的结果。
*/
export const addValueProperty = (data, schemeId = '') => { export const addValueProperty = (data, schemeId = '') => {
// 处理单个对象或对象数组 // 处理单个对象或对象数组
const processItem = (item, index, path) => { 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, '') 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 = []) => { export const recursiveFilter = (data, id, type = ['loop', 'customGroup'], result = []) => {
data.forEach((item) => { data.forEach((item) => {
if (type.includes(item.type) && item.children && item.children.includes(id)) { 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 return result
} }
/**
* 将树形表单字段转换为普通对象。
* 叶子字段取 value;存在 children 的字段会递归生成嵌套对象。
* @param {Array} arr 表单字段数组。
* @param {Object} data 写入目标对象,递归时复用。
* @returns {Object} 转换后的对象。
*/
export const formatTableData = (arr, data = {}) => { export const formatTableData = (arr, data = {}) => {
arr.forEach(item => { arr.forEach(item => {
data[item.name] = item.value data[item.name] = item.value
@ -126,9 +173,14 @@ export const formatTableData = (arr, data = {}) => {
return data return data
} }
/**
* 重新创建与指定节点相连的边。
* 节点宽度或锚点位置变化后,删除再添加边可以触发画布重新计算连线位置。
* @param {string} nodeId 需要刷新连线的节点 ID。
*/
export const addNewEdge = (nodeId) => { export const addNewEdge = (nodeId) => {
const { edges } = lf.getGraphData(); const { edges } = lf.getGraphData();
const ttt = edges.slice(); // 浅拷贝(比 JSON 深拷贝高效 10x+) const ttt = edges.slice(); // 浅拷贝即可保护遍历集合,避免删除边时影响原数组迭代。
const edgesToDelete = ttt.filter(_edge => const edgesToDelete = ttt.filter(_edge =>
_edge.sourceNodeId === nodeId || _edge.targetNodeId === nodeId _edge.sourceNodeId === nodeId || _edge.targetNodeId === nodeId
@ -140,6 +192,7 @@ export const addNewEdge = (nodeId) => {
clearTimeout(window.addNewEdgeTimer); // 清理上一次未执行的定时器 clearTimeout(window.addNewEdgeTimer); // 清理上一次未执行的定时器
} }
// 延迟到 DOM 和节点尺寸更新后再添加边,避免仍使用旧锚点位置。
window.addNewEdgeTimer = setTimeout(() => { window.addNewEdgeTimer = setTimeout(() => {
edgesToDelete.forEach(item => { edgesToDelete.forEach(item => {
lf.addEdge({ lf.addEdge({
@ -154,6 +207,11 @@ export const addNewEdge = (nodeId) => {
}, 50) }, 50)
} }
/**
* 立即刷新指定节点关联的边。
* 与 addNewEdge 不同,这里不做防抖和延迟,适合无需等待 DOM 尺寸变化的场景。
* @param {string} nodeId 需要刷新连线的节点 ID。
*/
export const newEdge = (nodeId) => { export const newEdge = (nodeId) => {
const { edges } = lf.getGraphData(); const { edges } = lf.getGraphData();
const arr = JSON.parse(JSON.stringify(edges)) 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) => { export const initNodeZoom = (modelId, nodeZoom, className, init=false) => {
const nodes = document.getElementsByClassName(modelId) const nodes = document.getElementsByClassName(modelId)
if (nodes.length > 0) { 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) => { export const getInputNumber = (nodeId) => {
const data = lf.getGraphData(); const data = lf.getGraphData();
let flag = false let flag = false
@ -212,6 +282,12 @@ export const getInputNumber = (nodeId) => {
return flag return flag
} }
/**
* 删除分支节点某个出口锚点上的所有连线。
* 删除条件分支时调用,避免已删除条件对应的边继续残留在画布上。
* @param {string} nodeId 分支节点 ID。
* @param {string} sourceAnchorId 分支出口锚点 ID。
*/
export const removeSwitchEdge = (nodeId, sourceAnchorId) => { export const removeSwitchEdge = (nodeId, sourceAnchorId) => {
const { edges } = lf.getGraphData(); const { edges } = lf.getGraphData();
const matchedEdges = edges.filter(item => { const matchedEdges = edges.filter(item => {
@ -221,6 +297,111 @@ export const removeSwitchEdge = (nodeId, sourceAnchorId) => {
matchedEdges.forEach(edge => lf.deleteEdge(edge.id)) matchedEdges.forEach(edge => lf.deleteEdge(edge.id))
} }
/**
* 构建“子节点 ID -> 父容器 ID”的映射。
* 统一兼容 VueFlow 当前 children 结构和旧流程保存的 properties.parentId 字段。
* @param {Object} data 流程图数据,包含 nodes/edges。
* @returns {Map<string, string>} 子节点到父节点的映射。
*/
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<string>} 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<string>} 目标节点可引用的上游节点 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) => { export const convertToTree = (data) => {
// 创建 id 到节点的映射 // 创建 id 到节点的映射
const nodeMap = new Map(); const nodeMap = new Map();
@ -264,6 +445,12 @@ export const convertToTree = (data) => {
} }
/**
* 递归过滤 name 为空的字段定义。
* 常用于保存输出参数前移除未填写名称的空行,避免生成不可引用的空字段。
* @param {*} obj 字段定义、字段数组或基础类型。
* @returns {*} 过滤后的结构;被剔除的对象返回 null。
*/
export const filterEmptyName = (obj) => { export const filterEmptyName = (obj) => {
// 1. 如果是数组,遍历每一项递归处理 // 1. 如果是数组,遍历每一项递归处理
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
@ -295,9 +482,10 @@ export const filterEmptyName = (obj) => {
} }
/** /**
* 将原始表单配置数组 转换为 目标对象格式 * 将 HTTP 节点面板中的表单数组转换为后端执行协议。
* @param {Array} source - 原始数据数组 * config/headers/params 保留 children 数组;body 会展开为 { type, json, formData }。
* @returns {Object} 转换后的目标格式对象 * @param {Array} source HTTP 节点原始表单配置数组。
* @returns {Object} 后端执行 HTTP 请求所需的结构化配置。
*/ */
export const transformHttpNodeData = (source) => { export const transformHttpNodeData = (source) => {
// 初始化结果对象 // 初始化结果对象
@ -343,6 +531,12 @@ export const transformHttpNodeData = (source) => {
return result; return result;
} }
/**
* 将智能体节点表单数组转换为后端执行协议。
* 普通配置字段保留 children;invokeTts 是开关值,需要从 item.input 提取为顶层字段。
* @param {Array} source 智能体节点原始表单配置数组。
* @returns {Object} 智能体调用配置。
*/
export const transformSdAgentNodeData = (source) => { export const transformSdAgentNodeData = (source) => {
// 初始化结果对象 // 初始化结果对象
const result = {}; const result = {};
@ -367,6 +561,12 @@ export const transformSdAgentNodeData = (source) => {
return result; return result;
} }
/**
* 将识别节点表单转换为后端协议。
* 普通字段写入 nodeParams;alarmRules 单独提取为告警规则数组,并兼容旧版 children 结构。
* @param {Array} source 识别节点原始表单配置数组。
* @returns {{nodeParams: Array, alarmRules: Array}} 识别节点配置。
*/
export const transformRecognizeNodeData = (source) => { export const transformRecognizeNodeData = (source) => {
// 初始化结果对象 // 初始化结果对象
const result = { const result = {

120
src/utils/flowExecution.js Normal file
View File

@ -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 || ''
)

517
src/utils/flowResources.js Normal file
View File

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

45
src/utils/pagedRequest.js Normal file
View File

@ -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} 条,未能完整加载`)
}

58
src/utils/robotDevice.js Normal file
View File

@ -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,
}))

View File

@ -47,7 +47,7 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
initialTerminalId: { initialRobotId: {
type: String, type: String,
default: null, default: null,
}, },
@ -60,7 +60,7 @@ const data = reactive({
stereoModule: false, stereoModule: false,
rgbCamera: false, rgbCamera: false,
}, },
terminalId: null, robotId: null,
cameraId: null, cameraId: null,
type: 'camera', type: 'camera',
callbacks: {}, callbacks: {},
@ -95,7 +95,7 @@ function getVideoRef(method) {
} }
async function startDirectRgbStream() { async function startDirectRgbStream() {
if (!data.terminalId || !data.cameraId || !colorVideo.value) return; if (!data.robotId || !data.cameraId || !colorVideo.value) return;
rgbStreamAbortController?.abort(); rgbStreamAbortController?.abort();
rgbStreamAbortController = null; rgbStreamAbortController = null;
const generation = ++rgbStreamGeneration; const generation = ++rgbStreamGeneration;
@ -103,7 +103,7 @@ async function startDirectRgbStream() {
rgbStreamRetryTimer = null; rgbStreamRetryTimer = null;
streamStatus.getRGBImageStream = 'connecting'; streamStatus.getRGBImageStream = 'connecting';
try { try {
const response = await getRgbStreamUrl(data.terminalId, data.cameraId); const response = await getRgbStreamUrl(data.robotId, data.cameraId);
if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return; if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return;
const relativeUrl = response.data; const relativeUrl = response.data;
const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, ''); const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, '');
@ -342,11 +342,11 @@ function destroyMediaPlayer(method) {
} }
function channelFor(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) { async function setSubscription(enabled, method, force = false) {
if (!socket || !data.terminalId || !data.cameraId) return; if (!socket || !data.robotId || !data.cameraId) return;
await nextTick(); await nextTick();
const channel = channelFor(method); const channel = channelFor(method);
@ -401,7 +401,7 @@ function scheduleRestart(method) {
} }
function handleSocketOpen() { function handleSocketOpen() {
if (data.form.stereoModule && data.terminalId && data.cameraId) { if (data.form.stereoModule && data.robotId && data.cameraId) {
destroyMediaPlayer('getDepthImageStream'); destroyMediaPlayer('getDepthImageStream');
setSubscription(true, 'getDepthImageStream', true); setSubscription(true, 'getDepthImageStream', true);
} }
@ -418,12 +418,12 @@ socket?.on('open', handleSocketOpen);
socket?.on('close', handleSocketClose); socket?.on('close', handleSocketClose);
watch(() => data.form.stereoModule, async (newVal) => { watch(() => data.form.stereoModule, async (newVal) => {
if (!data.terminalId || !data.cameraId) return; if (!data.robotId || !data.cameraId) return;
await setSubscription(newVal, 'getDepthImageStream'); await setSubscription(newVal, 'getDepthImageStream');
}); });
watch(() => data.form.rgbCamera, async (newVal) => { watch(() => data.form.rgbCamera, async (newVal) => {
if (!data.terminalId || !data.cameraId) return; if (!data.robotId || !data.cameraId) return;
if (newVal) { if (newVal) {
await nextTick(); await nextTick();
await startDirectRgbStream(); await startDirectRgbStream();
@ -436,10 +436,19 @@ watch(
() => props.initialDeviceId || route.path.split('/')[3], () => props.initialDeviceId || route.path.split('/')[3],
async (deviceId) => { async (deviceId) => {
if (!deviceId) return; 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 { try {
const response = await getRegister(deviceId); const response = await getRegister(deviceId);
data.cameraId = response.data.deviceCode; 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.stereoModule) await setSubscription(true, 'getDepthImageStream');
if (data.form.rgbCamera) await startDirectRgbStream(); if (data.form.rgbCamera) await startDirectRgbStream();
} catch (error) { } catch (error) {
@ -455,7 +464,7 @@ onUnmounted(() => {
stopDirectRgbStream(); stopDirectRgbStream();
['getDepthImageStream'].forEach(method => { ['getDepthImageStream'].forEach(method => {
clearTimeout(retryTimers.get(method)); clearTimeout(retryTimers.get(method));
if (data.terminalId && data.cameraId) { if (data.robotId && data.cameraId) {
const channel = channelFor(method); const channel = channelFor(method);
socket?.send({ type: 'channel_subscription', action: 'unsubscribe', channel }); socket?.send({ type: 'channel_subscription', action: 'unsubscribe', channel });
if (data.callbacks[channel]) socket.off(channel, data.callbacks[channel]); if (data.callbacks[channel]) socket.off(channel, data.callbacks[channel]);

View File

@ -17,7 +17,7 @@ import { inject } from 'vue';
const emit = defineEmits(['update:bottomSeriesData']); const emit = defineEmits(['update:bottomSeriesData']);
const socket = inject('ws'); const socket = inject('ws');
const handCanvas = ref(null); const handCanvas = ref(null);
const terminalId = ref(''); // 待您修改 const robotId = ref(''); // 待您修改
const deviceId = ref(''); // 待您修改 const deviceId = ref(''); // 待您修改
const frameData = ref({ count: 0, lastTime: 0 }); const frameData = ref({ count: 0, lastTime: 0 });
const isHandSeries = ref(false); const isHandSeries = ref(false);
@ -29,7 +29,7 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
initialTerminalId: { // 示例 prop,用于从弹窗接收 terminalId initialRobotId: { // 示例 prop,用于从弹窗接收 robotId
type: String, type: String,
default: null, default: null,
}, },
@ -37,7 +37,7 @@ const props = defineProps({
// 2. 使用 watchEffect 来响应 props 和路由的变化 // 2. 使用 watchEffect 来响应 props 和路由的变化
watchEffect(() => { watchEffect(() => {
terminalId.value = props.initialTerminalId; robotId.value = props.initialRobotId;
deviceId.value = props.initialDeviceId; deviceId.value = props.initialDeviceId;
}); });
@ -225,11 +225,11 @@ const sensorMap = [
// WebSocket 订阅 // WebSocket 订阅
const subscribeSensorData = async (sub) => { const subscribeSensorData = async (sub) => {
if (!socket || !terminalId.value || !deviceId.value) { if (!socket || !robotId.value || !deviceId.value) {
console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId.value}, deviceId=${deviceId.value}`); console.warn(`订阅失败: socket=${!!socket}, robotId=${robotId.value}, deviceId=${deviceId.value}`);
return; return;
} }
const channel = `edgeDexHandServiceImpl/getSensorDataStream/${terminalId.value}/${deviceId.value}`; const channel = `edgeDexHandServiceImpl/getSensorDataStream/${robotId.value}/${deviceId.value}`;
console.log(sub ? '订阅' : '取消订阅', channel); console.log(sub ? '订阅' : '取消订阅', channel);
socket.send({ socket.send({
type: 'channel_subscription', type: 'channel_subscription',

View File

@ -35,7 +35,7 @@ const handImage = ref(null);
const containerRef = ref(null); // 新增:引用父容器 const containerRef = ref(null); // 新增:引用父容器
const defaultHandImageInfo = { width: 891, height: 981 }; const defaultHandImageInfo = { width: 891, height: 981 };
const imageAspectRatio = defaultHandImageInfo.width / defaultHandImageInfo.height; const imageAspectRatio = defaultHandImageInfo.width / defaultHandImageInfo.height;
const terminalId = ref(''); const robotId = ref('');
const deviceId = ref(''); const deviceId = ref('');
// 1. 定义 props // 1. 定义 props
const props = defineProps({ const props = defineProps({
@ -43,7 +43,7 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
initialTerminalId: { // 示例 prop,用于从弹窗接收 terminalId initialRobotId: { // 示例 prop,用于从弹窗接收 robotId
type: String, type: String,
default: null, default: null,
}, },
@ -51,9 +51,9 @@ const props = defineProps({
// 2. 使用 watchEffect 来响应 props 和路由的变化 // 2. 使用 watchEffect 来响应 props 和路由的变化
watchEffect(() => { watchEffect(() => {
terminalId.value = props.initialTerminalId; robotId.value = props.initialRobotId;
deviceId.value = props.initialDeviceId; deviceId.value = props.initialDeviceId;
console.log(deviceId.value, terminalId.value,props) console.log(deviceId.value, robotId.value,props)
}); });
const sliders = ref([ const sliders = ref([
@ -66,9 +66,9 @@ const sliders = ref([
]); ]);
function updateSeriesData(value, i) { function updateSeriesData(value, i) {
console.log(deviceId.value, terminalId.value) console.log(deviceId.value, robotId.value)
loading.value = true; 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; loading.value = false;
updateToSeriesData(); updateToSeriesData();
}).catch(() => { }).catch(() => {
@ -131,7 +131,7 @@ const updateSliderPositions = () => {
}; };
const updateToSeriesData = () => { 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); const newTopSeriesData = res.data.handsList.map(item => item.force);
emit('update:topSeriesData', newTopSeriesData); emit('update:topSeriesData', newTopSeriesData);
}); });

View File

@ -4,11 +4,11 @@
<div class="left-panel"> <div class="left-panel">
<div class="hand-container"> <div class="hand-container">
<!-- 确保这里使用的是 index.vue 中响应式的 deviceId ref --> <!-- 确保这里使用的是 index.vue 中响应式的 deviceId ref -->
<LeftTopHand :initialDeviceId="deviceId" :initialTerminalId="terminalId" @update:topSeriesData="handleUpdateTopSeriesData" /> <LeftTopHand :initialDeviceId="deviceId" :initialRobotId="robotId" @update:topSeriesData="handleUpdateTopSeriesData" />
</div> </div>
<div class="hand-container"> <div class="hand-container">
<!-- 确保这里使用的是 index.vue 中响应式的 terminalId ref --> <!-- 确保这里使用的是 index.vue 中响应式的 robotId ref -->
<LeftBottomHand :initialDeviceId="deviceId" :initialTerminalId="terminalId" @update:bottomSeriesData="handleUpdateBottomSeriesData" /> <LeftBottomHand :initialDeviceId="deviceId" :initialRobotId="robotId" @update:bottomSeriesData="handleUpdateBottomSeriesData" />
</div> </div>
</div> </div>
<div class="right-panel"> <div class="right-panel">
@ -34,7 +34,7 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
initialTerminalId: { initialRobotId: {
type: String, type: String,
default: null, default: null,
}, },
@ -62,7 +62,7 @@ const avgData = ref({
// 声明组件内部使用的响应式变量 // 声明组件内部使用的响应式变量
const deviceId = ref(""); const deviceId = ref("");
const terminalId = ref(""); const robotId = ref("");
const handleUpdateTopSeriesData = (newData) => { const handleUpdateTopSeriesData = (newData) => {
topSeriesData.value = newData; topSeriesData.value = newData;
@ -77,19 +77,24 @@ const handleUpdateBottomSeriesData = ({ maxData: newMaxData, avgData: newAvgData
const route = useRoute(); const route = useRoute();
// 统一处理获取 register 数据的方法 // 统一处理获取 register 数据的方法
const fetchRegisterData = (currentDeviceId) => { 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) { if (currentDeviceId) {
getRegister(currentDeviceId).then(res => { getRegister(currentDeviceId).then(res => {
deviceId.value = res.data.deviceCode; // 再次更新,确保与 API 返回一致 deviceId.value = res.data.deviceCode; // 再次更新,确保与 API 返回一致
terminalId.value = res.data.idDeDeviceTerminalConfig; robotId.value = res.data.idDeDeviceTerminalConfig;
}).catch(error => { }).catch(error => {
console.error("Error fetching register:", error); console.error("Error fetching register:", error);
// 可以根据需要重置 deviceId 和 terminalId // 可以根据需要重置 deviceId 和 robotId
deviceId.value = ""; deviceId.value = "";
terminalId.value = ""; robotId.value = "";
}); });
} else { } else {
// 如果 deviceId 为空,可以考虑重置 terminalId // 如果 deviceId 为空,可以考虑重置 robotId
terminalId.value = ""; robotId.value = "";
} }
}; };
@ -107,6 +112,11 @@ watch(() => props.initialDeviceId, (newVal) => {
// 注意:这里假设 route.path.split("/")[3] 是cameraId // 注意:这里假设 route.path.split("/")[3] 是cameraId
// 如果你的路由结构不同,请调整这里的逻辑 // 如果你的路由结构不同,请调整这里的逻辑
watch(() => route.path, (newPath) => { 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]; const routeDeviceId = newPath.split("/")[3];
if (!deviceId.value && routeDeviceId) { // 只有当 deviceId 尚未从 props 或其他地方设置时才从路由获取 if (!deviceId.value && routeDeviceId) { // 只有当 deviceId 尚未从 props 或其他地方设置时才从路由获取
deviceId.value = routeDeviceId; deviceId.value = routeDeviceId;
@ -115,10 +125,10 @@ watch(() => route.path, (newPath) => {
}, { immediate: true }); // immediate: true 确保在组件挂载时也检查路由 }, { immediate: true }); // immediate: true 确保在组件挂载时也检查路由
// 如果需要 initialTerminalId prop 也影响 terminalId ref,可以添加一个 watch // 如果需要 initialRobotId prop 也影响 robotId ref,可以添加一个 watch
watch(() => props.initialTerminalId, (newVal) => { watch(() => props.initialRobotId, (newVal) => {
if (newVal) { if (newVal) {
terminalId.value = newVal; robotId.value = newVal;
} }
}, { immediate: true }); }, { immediate: true });
</script> </script>
@ -155,4 +165,4 @@ watch(() => props.initialTerminalId, (newVal) => {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
} }
</style> </style>

View File

@ -80,10 +80,12 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { useRoute } from 'vue-router';
// import { setServo } from '@/api/device/head'; // 假设接口 // import { setServo } from '@/api/device/head'; // 假设接口
const terminalId = ref('25449ff3fcc7b27da5f69462c5efcec2'); const route = useRoute();
const deviceId = ref('head1'); const robotId = ref(String(route.query.robotId || ''));
const deviceId = ref(String(route.query.deviceId || ''));
const servo1 = ref(0.5); // 当前 Yaw const servo1 = ref(0.5); // 当前 Yaw
const servo2 = ref(0.5); // 当前 Pitch const servo2 = ref(0.5); // 当前 Pitch
@ -151,7 +153,7 @@ const applyPitchFromPresets = () => {
const sendServoValues = async () => { const sendServoValues = async () => {
try { try {
// await setServo({ // await setServo({
// terminalId: terminalId.value, // robotId: robotId.value,
// deviceId: deviceId.value, // deviceId: deviceId.value,
// servo1: servo1.value, // servo1: servo1.value,
// servo2: servo2.value, // servo2: servo2.value,
@ -311,4 +313,4 @@ const points = ref([
color: #409eff; color: #409eff;
min-width: 60px; min-width: 60px;
} }
</style> </style>

View File

@ -1,8 +1,33 @@
<script setup> <script setup>
import { reactive, toRefs, onUnmounted, ref, onMounted } from "vue"; import { computed, reactive, toRefs, onUnmounted, ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { CircleClose } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { clearFault as clearArmFault } from "@/api/device/arm";
import UrdfViewer from "./components/UrdfViewer.vue"; import UrdfViewer from "./components/UrdfViewer.vue";
import JointControl from "./components/JointControl.vue"; import JointControl from "./components/JointControl.vue";
const route = useRoute();
const robotId = computed(() => String(route.query.robotId || ""));
const deviceId = computed(() => String(route.query.deviceId || ""));
const clearingFault = ref(false);
const handleClearFault = async () => {
if (!robotId.value || !deviceId.value) {
ElMessage.warning("缺少机器人或机械臂设备信息");
return;
}
clearingFault.value = true;
try {
await clearArmFault({ robotId: robotId.value, deviceId: deviceId.value });
ElMessage.success("机械臂故障状态已清除");
} catch (error) {
console.error("清除机械臂故障失败:", error);
} finally {
clearingFault.value = false;
}
};
const data = reactive({ const data = reactive({
form: { form: {
x: 11, x: 11,
@ -160,6 +185,15 @@ onUnmounted(() => {
>零位姿态</el-button >零位姿态</el-button
> >
<el-button @click="handleResets('init')">初始位置</el-button> <el-button @click="handleResets('init')">初始位置</el-button>
<el-button
type="danger"
plain
:loading="clearingFault"
:disabled="!robotId || !deviceId"
@click="handleClearFault"
>
<el-icon><CircleClose /></el-icon>清除故障
</el-button>
</div> </div>
<div class="pose-info flex align-center"> <div class="pose-info flex align-center">
<div class="pose-item">X: {{ form.x.toFixed(6) }} m</div> <div class="pose-item">X: {{ form.x.toFixed(6) }} m</div>

View File

@ -66,7 +66,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { onMounted, ref } from "vue";
import { Microphone, VideoPause, Mute, Mic, VideoPlay } from "@element-plus/icons-vue"; import { Microphone, VideoPause, Mute, Mic, VideoPlay } from "@element-plus/icons-vue";
import { import {
startMicrophoneApi, startMicrophoneApi,
@ -77,19 +77,27 @@ import {
} from '@/api/device/microphone' } from '@/api/device/microphone'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
const route = useRoute() const route = useRoute()
const terminalId = route.query.terminalId const robotId = route.query.robotId
const deviceId = route.query.deviceId const deviceId = route.query.deviceId
const recordStatus = ref('end'); const recordStatus = ref('end');
const isRecording = ref(false) const isRecording = ref(false)
const list = [] const list = []
const audiosList = ref([]) const audiosList = ref([])
const speaker = ref('spk1') const speaker = ref('')
const speakerOptions = ref(['spk1']) const speakerOptions = ref([])
onMounted(async () => {
if (!robotId) return
const response = await getRobotDevicesByRobotId(String(robotId), { deviceKind: 13 })
speakerOptions.value = (response.data || []).map(item => item.deviceId)
speaker.value = speakerOptions.value[0] || ''
})
/** /**
* 开始录音 * 开始录音
@ -98,13 +106,13 @@ const startRecording = async () => {
const fileName = new Date().getTime() + '.wav' const fileName = new Date().getTime() + '.wav'
isRecording.value = true isRecording.value = true
const res = await startMicrophoneApi({ const res = await startMicrophoneApi({
terminalId, robotId,
deviceId, deviceId,
filePath: `/home/share/record/audios/${terminalId}_${deviceId}_${fileName}` filePath: `/home/share/record/audios/${robotId}_${deviceId}_${fileName}`
}) })
if (res.code === 200) { if (res.code === 200) {
recordStatus.value = 'recording' recordStatus.value = 'recording'
list.push(`${terminalId}_${deviceId}_${fileName}`) list.push(`${robotId}_${deviceId}_${fileName}`)
} }
}; };
@ -113,7 +121,7 @@ const startRecording = async () => {
*/ */
const pauseRecording = async () => { const pauseRecording = async () => {
const res = await pauseMicrophoneApi({ const res = await pauseMicrophoneApi({
terminalId, robotId,
deviceId deviceId
}) })
if (res.code === 200) { if (res.code === 200) {
@ -126,7 +134,7 @@ const pauseRecording = async () => {
*/ */
const recoverRecording = async () => { const recoverRecording = async () => {
const res = await resumeMicrophoneApi({ const res = await resumeMicrophoneApi({
terminalId, robotId,
deviceId deviceId
}) })
if (res.code === 200) { if (res.code === 200) {
@ -139,7 +147,7 @@ const recoverRecording = async () => {
*/ */
const stopRecording = async () => { const stopRecording = async () => {
const res = await stopMicrophoneApi({ const res = await stopMicrophoneApi({
terminalId, robotId,
deviceId deviceId
}) })
if (res.code === 200) { if (res.code === 200) {
@ -154,7 +162,7 @@ const stopRecording = async () => {
*/ */
const handlePlay = async (audioPath) => { const handlePlay = async (audioPath) => {
const res = await playSpeaker({ const res = await playSpeaker({
terminalId, robotId,
deviceId: speaker.value, deviceId: speaker.value,
audioPath: `/home/share/record/audios/${audioPath}` audioPath: `/home/share/record/audios/${audioPath}`
}) })

View File

@ -52,6 +52,7 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { import {
pause, pause,
play, play,
@ -73,11 +74,12 @@ const currentIndex = ref(0);
const isPlaying = ref(false); const isPlaying = ref(false);
const volume = ref(0); const volume = ref(0);
const terminalId = ref('25449ff3fcc7b27da5f69462c5efcec2'); const route = useRoute();
const deviceId = ref('spk1'); const robotId = ref(String(route.query.robotId || ''));
const deviceId = ref(String(route.query.deviceId || ''));
const getCommonParams = () => ({ const getCommonParams = () => ({
terminalId: terminalId.value, robotId: robotId.value,
deviceId: deviceId.value deviceId: deviceId.value
}); });
@ -273,4 +275,4 @@ input[type="range"] {
fill: #ddd; fill: #ddd;
} }
</style> </style>

View File

@ -1,414 +1,184 @@
<template> <template>
<div class="app-container"> <div class="app-container robot-device-page">
<div ref="topContainerRef"> <section class="page-heading">
<TableSearch <div>
:queryParams="queryParams" <h2>机器人设备</h2>
:showSearch="showSearch" <p>设备由机器人在线上报,切换机器人后自动刷新。</p>
label-width="80px" </div>
queryRef="queryRef" <div class="robot-selector">
@refresh="resetQuery" <span>当前机器人</span>
@search="handleQuery" <el-select v-model="selectedRobotId" filterable placeholder="请选择机器人"
> :loading="robotLoading" @change="loadDevices">
<template #one> <el-option v-for="robot in robotOptions" :key="robot.value"
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6"> :label="robot.label" :value="robot.value" />
<el-form-item label="设备编号" prop="deviceCode"> </el-select>
<el-input <el-button :icon="Refresh" circle title="刷新设备" :loading="loading" @click="loadDevices" />
v-model="queryParams.deviceCode" </div>
clearable </section>
placeholder="请输入设备编号"
style="width:100%"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="queryParams.deviceName"
clearable
placeholder="请输入设备名称"
style="width:100%"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="设备类型" prop="deviceModel">
<el-select
v-model="queryParams.deviceModel"
placeholder="请输入设备类型"
>
<el-option
v-for="item in controlModuleList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
</template>
</TableSearch>
<el-row :gutter="10" class="mb8"> <section class="status-strip">
<el-col :span="1.5"> <div><strong>{{ devices.length }}</strong><span>设备总数</span></div>
<el-button <div><strong class="online">{{ onlineCount }}</strong><span>在线设备</span></div>
v-hasPermi="['device:register:add']" <div><strong class="fault">{{ faultCount }}</strong><span>故障设备</span></div>
icon="Plus" <div class="filters">
plain <el-input v-model="keyword" clearable :prefix-icon="Search" placeholder="搜索名称或设备 ID" />
type="primary" <el-select v-model="deviceKind" clearable placeholder="全部类型">
@click="handleAdd" <el-option v-for="item in deviceKinds" :key="item.value"
>新增 :label="item.label" :value="item.value" />
</el-button> </el-select>
</el-col> </div>
<el-col :span="1.5"> </section>
<el-button
v-hasPermi="['device:register:edit']"
:disabled="single"
icon="Edit"
plain
type="success"
@click="handleUpdate"
>修改
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['device:register:remove']"
:disabled="multiple"
icon="Delete"
plain
type="danger"
@click="handleDelete"
>删除
</el-button>
</el-col>
<!-- <el-col :span="1.5">-->
<!-- <el-button-->
<!-- type="warning"-->
<!-- plain-->
<!-- icon="Download"-->
<!-- @click="handleExport"-->
<!-- v-hasPermi="['device:register:export']"-->
<!-- >导出-->
<!-- </el-button>-->
<!-- </el-col>-->
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight"> <el-table v-loading="loading" :data="filteredDevices" height="calc(100vh - 285px)"
<el-table height="100%" v-loading="loading" :data="registerList" @selection-change="handleSelectionChange"> empty-text="当前机器人尚未上报设备">
<el-table-column align="center" type="selection" width="55" /> <el-table-column label="设备" min-width="220">
<el-table-column align="center" label="id" prop="id" show-overflow-tooltip/> <template #default="{ row }">
<el-table-column align="center" label="设备终端配置id" prop="idDeDeviceTerminalConfig" show-overflow-tooltip <div class="device-name">
width="120"/> <span class="device-icon"><el-icon><Cpu /></el-icon></span>
<el-table-column align="center" label="设备编号" prop="deviceCode" show-overflow-tooltip/> <div><b>{{ row.deviceName || row.deviceId }}</b><small>{{ row.deviceId }}</small></div>
<el-table-column align="center" label="设备名称" prop="deviceName" show-overflow-tooltip/> </div>
<!-- <el-table-column label="设备规格" align="center" prop="deviceSpec"/>-->
<el-table-column align="center" label="设备类型" prop="deviceModel">
<template #default="scope">
{{ controlModuleList.find(item => item.value === scope.row.deviceModel)?.label }}
</template> </template>
</el-table-column> </el-table-column>
<!-- <el-table-column label="设备厂家" align="center" prop="factory"/>--> <el-table-column label="类型" width="130">
<!-- <el-table-column label="设备图片" align="center" prop="images"/>--> <template #default="{ row }">{{ kindLabel(row.deviceKind, row.typeName) }}</template>
<!-- <el-table-column show-overflow-tooltip label="备注" align="center" prop="remake"/>--> </el-table-column>
<el-table-column align="center" label="设备状态" prop="status"> <el-table-column label="运行状态" width="120" align="center">
<template #default="scope"> <template #default="{ row }">
<el-switch <el-tag :type="row.onlineStatus === '1' ? 'success' : 'info'" effect="light">
v-model="scope.row.status" {{ row.onlineStatus === '1' ? '在线' : '离线' }}
active-text="启用" </el-tag>
active-value="0"
inactive-text="停用"
inactive-value="1"
inline-prompt
@change="handleChange(scope.row)"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="健康状态" width="120" align="center">
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" width="200"> <template #default="{ row }">
<template #default="scope"> <el-tag :type="isFault(row) ? 'danger' : 'success'" effect="plain">
<el-button v-hasPermi="['device:register:edit']" icon="Edit" link type="primary" {{ isFault(row) ? '故障' : '正常' }}
@click="handleUpdate(scope.row)">修改 </el-tag>
</el-button> </template>
<el-button v-hasPermi="['device:register:deploy']" icon="Cpu" link type="primary" </el-table-column>
@click="handleControl(scope.row)">示教 <el-table-column prop="errorMessage" label="状态信息" min-width="180" show-overflow-tooltip>
</el-button> <template #default="{ row }">{{ row.errorMessage || '运行正常' }}</template>
<el-button v-hasPermi="['device:register:remove']" icon="Delete" link type="primary" </el-table-column>
@click="handleDelete(scope.row)">删除 <el-table-column prop="lastSeenTime" label="最后上报" width="180" />
</el-button> <el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="controlRoute(row.deviceKind)" link type="primary"
:disabled="row.onlineStatus !== '1'" @click="openControl(row)">示教</el-button>
<span v-else class="muted">-</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<pagination
v-show="total>0"
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNum"
:total="total"
@pagination="getList"
/>
</div>
<!-- 添加或修改设备注册对话框 -->
<el-dialog v-model="open" :title="title" append-to-body width="500px">
<el-form ref="registerRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="设备终端配置id" prop="idDeDeviceTerminalConfig">
<el-input v-model="form.idDeDeviceTerminalConfig"
placeholder="请输入设备终端配置id"/>
<!-- <el-input v-model="form.idDeDeviceTerminalConfig" :disabled="form.idDeDeviceTerminalConfig!== ''"
placeholder="请输入设备终端配置id"/> -->
</el-form-item>
<el-form-item label="设备编号" prop="deviceCode">
<el-input v-model="form.deviceCode" placeholder="请输入设备编号"/>
<!-- <el-input v-model="form.deviceCode" :disabled="form.deviceCode!== ''" placeholder="请输入设备编号"/> -->
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="form.deviceName" placeholder="请输入设备名称"/>
</el-form-item>
<!-- <el-form-item label="设备规格" prop="deviceSpec">-->
<!-- <el-input v-model="form.deviceSpec" placeholder="请输入设备规格"/>-->
<!-- </el-form-item>-->
<el-form-item label="设备类型" prop="deviceModel">
<el-select
v-model="form.deviceModel"
placeholder="请输入设备类型"
>
<el-option
v-for="item in controlModuleList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="所属分组">
<el-select v-model="form.groupId" placeholder="请选择所属分组">
<el-option v-for="group in groupList" :key="group.value" :label="group.label"
:value="group.value"></el-option>
</el-select>
</el-form-item>
<!-- <el-form-item label="设备厂家" prop="factory">-->
<!-- <el-input v-model="form.factory" placeholder="请输入设备厂家"/>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="设备图片" prop="images">-->
<!-- <el-input v-model="form.images" placeholder="请输入设备图片"/>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="备注" prop="remake">-->
<!-- <el-input :rows="2"-->
<!-- type="textarea" v-model="form.remake" placeholder="请输入备注"/>-->
<!-- </el-form-item>-->
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script name="Register" setup> <script setup>
import {addRegister, delRegister, getRegister, listRegister, updateRegister} from "@/api/device/register" import { computed, onMounted, ref } from 'vue'
import TableSearch from "@/components/TableSearch/index.vue" import { useRouter } from 'vue-router'
import {useRouter} from "vue-router" import { Cpu, Refresh, Search } from '@element-plus/icons-vue'
import {intoControlPage} from "@/views/device/register/components/index.js"; import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import {controlModuleList} from "@/enum/index.js";
import {listGroup} from "@/api/system/group.js";
import useTagsViewStore from '@/store/modules/tagsView'
import { useContainerHeight } from "@/hooks/tableHeight";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const router = useRouter() const router = useRouter()
const robotLoading = ref(false)
const loading = ref(false)
const selectedRobotId = ref('')
const robotOptions = ref([])
const devices = ref([])
const keyword = ref('')
const deviceKind = ref()
const deviceKinds = [
{ value: 1, label: '移动底盘' }, { value: 2, label: '机械臂' },
{ value: 3, label: '电池' }, { value: 4, label: '仿生头' },
{ value: 5, label: '摄像头' }, { value: 6, label: 'CAN 总线' },
{ value: 7, label: '灵巧手' }, { value: 8, label: '夹爪' },
{ value: 9, label: '麦克风' }, { value: 10, label: '电机' },
{ value: 11, label: '电机系统' }, { value: 12, label: '机器人控制器' },
{ value: 13, label: '扬声器' }
]
const {proxy} = getCurrentInstance() const kindLabel = (kind, fallback) => deviceKinds.find(item => item.value === kind)?.label || fallback || '未知设备'
const isFault = (device) => device.hasError === '1' || Number(device.healthStatus) === 3
const registerList = ref([]) const onlineCount = computed(() => devices.value.filter(item => item.onlineStatus === '1').length)
const open = ref(false) const faultCount = computed(() => devices.value.filter(isFault).length)
const loading = ref(true) const filteredDevices = computed(() => {
const showSearch = ref(true) const search = keyword.value.trim().toLowerCase()
const ids = ref([]) return devices.value.filter(item => {
const single = ref(true) const matchesKind = deviceKind.value == null || item.deviceKind === deviceKind.value
const multiple = ref(true) const matchesSearch = !search || `${item.deviceName || ''} ${item.deviceId || ''}`.toLowerCase().includes(search)
const total = ref(0) return matchesKind && matchesSearch
const title = ref("") })
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
deviceCode: null,
deviceName: null,
deviceSpec: null,
deviceModel: null,
factory: null,
status: null
},
rules: {}
}) })
const {queryParams, form, rules} = toRefs(data) const loadRobots = async () => {
robotLoading.value = true
/** 查询设备注册列表 */ try {
function getList() { const response = await getRobotList({ pageNum: 1, pageSize: 1000 })
loading.value = true robotOptions.value = (response.rows || []).map(robot => ({
listRegister(queryParams.value).then(response => { value: robot.robotId,
registerList.value = response.rows label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
total.value = response.total online: robot.connectStatus === '1'
loading.value = false })).filter(item => item.value)
}) const preferred = robotOptions.value.find(item => item.online) || robotOptions.value[0]
} selectedRobotId.value = preferred?.value || ''
} finally {
// 取消按钮 robotLoading.value = false
function cancel() {
open.value = false
reset()
}
// 表单重置
function reset() {
form.value = {
// id: null,
// createBy: null,
// createTime: null,
// updateBy: null,
// updateTime: null,
// remake: null,
// idDeDeviceTerminalConfig: null,
// deviceCode: null,
// deviceName: null,
// deviceSpec: null,
// deviceModel: null,
// factory: null,
// images: null,
// status: null
} }
proxy.resetForm("registerRef")
} }
/** 搜索按钮操作 */ const loadDevices = async () => {
function handleQuery() { devices.value = []
queryParams.value.pageNum = 1 if (!selectedRobotId.value) return
getList() loading.value = true
try {
const response = await getRobotDevicesByRobotId(selectedRobotId.value, { onlineOnly: false })
devices.value = Array.isArray(response.data) ? response.data : []
} finally {
loading.value = false
}
} }
/** 重置按钮操作 */ const controlRoute = (kind) => ({
function resetQuery() { 2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker'
proxy.resetForm("queryRef") })[kind]
handleQuery()
}
// 多选框选中数据 const openControl = (device) => {
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.id)
single.value = selection.length != 1
multiple.value = !selection.length
}
/** 新增按钮操作 */
function handleAdd() {
reset()
open.value = true
title.value = "添加设备注册"
}
/** 修改按钮操作 */
function handleUpdate(row) {
reset()
const _id = row.id || ids.value
getRegister(_id).then(response => {
form.value = response.data
open.value = true
title.value = "修改设备注册"
})
}
/** 提交按钮 */
function submitForm() {
proxy.$refs["registerRef"].validate(valid => {
if (valid) {
if (form.value.id != null) {
updateRegister(form.value).then(response => {
proxy.$modal.msgSuccess("修改成功")
open.value = false
getList()
})
} else {
addRegister(form.value).then(response => {
proxy.$modal.msgSuccess("新增成功")
open.value = false
getList()
})
}
}
})
}
/** 删除按钮操作 */
function handleDelete(row) {
const _ids = row.id || ids.value
proxy.$modal.confirm('是否确认删除设备注册编号为"' + _ids + '"的数据项?').then(function () {
return delRegister(_ids)
}).then(() => {
getList()
proxy.$modal.msgSuccess("删除成功")
}).catch(() => {
})
}
/** 导出按钮操作 */
function handleExport() {
proxy.download('device/register/export', {
...queryParams.value
}, `register_${new Date().getTime()}.xlsx`)
}
/** 控制按钮操作 */
function handleControl(row) {
const fullPath = `${row.deviceModel}/${row.id}`; // 构造路由路径
router.push({ router.push({
path: fullPath, path: `/device/${controlRoute(device.deviceKind)}/${device.id}`,
query: { query: { robotId: selectedRobotId.value, deviceId: device.deviceId }
terminalId: row.idDeDeviceTerminalConfig,
deviceId: row.deviceCode
}
}); // 使用路由跳转
// intoControlPage(`/${row.deviceModel}`, `${row.id}`)
}
function handleChange(row) {
updateRegister({status: row.status, id: row.id}).then(response => {
proxy.$modal.msgSuccess("修改成功")
getList()
}) })
} }
getList() onMounted(async () => {
await loadRobots()
await loadDevices()
const groupList = ref([]) })
/** 获取系统分组列表(默认传1查询检测项列表) */
function handleGetSystemGroup(row) {
listGroup({type: 3}).then(response => {
groupList.value = response.rows?.map((item) => {
return {
label: item.groupName,
value: String(item.groupId)
}
})
})
}
handleGetSystemGroup()
</script> </script>
<style lang="scss" scoped>
.robot-device-page { min-height: 100%; background: #f5f7fa; }
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 4px 0 20px; }
.page-heading h2 { margin: 0 0 5px; color: #1f2937; font-size: 22px; letter-spacing: 0; }
.page-heading p { margin: 0; color: #8492a6; font-size: 13px; }
.robot-selector { display: flex; align-items: center; gap: 10px; color: #606266; font-size: 13px; }
.robot-selector .el-select { width: 330px; }
.status-strip { display: flex; align-items: center; gap: 34px; margin-bottom: 14px; padding: 14px 18px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fff; }
.status-strip > div:not(.filters) { display: grid; min-width: 74px; }
.status-strip strong { color: #303133; font-size: 24px; line-height: 28px; }
.status-strip strong.online { color: #16a34a; }
.status-strip strong.fault { color: #dc2626; }
.status-strip span { color: #909399; font-size: 12px; }
.filters { display: flex; flex: 1; justify-content: flex-end; gap: 10px; }
.filters .el-input { width: 240px; }
.filters .el-select { width: 150px; }
.device-name { display: flex; align-items: center; gap: 12px; }
.device-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 6px; background: #ecf5ff; color: #409eff; }
.device-name div { display: grid; min-width: 0; }
.device-name b { color: #303133; font-weight: 600; }
.device-name small { overflow: hidden; color: #909399; text-overflow: ellipsis; }
.muted { color: #c0c4cc; }
@media (max-width: 900px) {
.page-heading, .status-strip { align-items: stretch; flex-direction: column; }
.robot-selector, .filters { justify-content: flex-start; flex-wrap: wrap; }
.robot-selector .el-select, .filters .el-input { width: 100%; }
}
</style>

View File

@ -61,7 +61,7 @@
<span class="control-icon close-icon" @click="closePopup(index)">×</span> <span class="control-icon close-icon" @click="closePopup(index)">×</span>
</div> </div>
</div> </div>
<component :is="popup.component" v-bind="popup.props" :initialDeviceId = "popup.deviceId" :initialTerminalId = "popup.configId" /> <component :is="popup.component" v-bind="popup.props" :initialDeviceId = "popup.deviceId" :initialRobotId = "popup.configId" />
<div class="resize-edge top" @mousedown="startResizing($event, index, 'top')"></div> <div class="resize-edge top" @mousedown="startResizing($event, index, 'top')"></div>
<div class="resize-edge bottom" @mousedown="startResizing($event, index, 'bottom')"></div> <div class="resize-edge bottom" @mousedown="startResizing($event, index, 'bottom')"></div>
<div class="resize-edge left" @mousedown="startResizing($event, index, 'left')"></div> <div class="resize-edge left" @mousedown="startResizing($event, index, 'left')"></div>
@ -398,7 +398,7 @@ const openPopup = (area) => {
originalTop: `${area.y}px`, originalTop: `${area.y}px`,
originalLeft: `${area.x + leftOffset}px`, originalLeft: `${area.x + leftOffset}px`,
isMaximized: false, isMaximized: false,
props: { terminalId: 'your-terminal-id', cameraId: 'your-camera-id' } // 示例props props: { robotId: area.robotId || '', cameraId: area.deviceId || '' }
}; };
popups.value.push(popup); popups.value.push(popup);
isResizing.value.push(false); isResizing.value.push(false);
@ -871,4 +871,4 @@ onMounted(() => {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
} }
</style> </style>

View File

@ -0,0 +1,281 @@
<template>
<el-form label-position="top">
<el-form-item label="机器人" required>
<el-select v-model="robotId" filterable style="width: 100%" @change="loadDevices">
<el-option v-for="item in robotOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="队列总超时(毫秒)" required>
<el-input-number v-model="totalTimeoutMs" :min="1" :max="2147483647" style="width: 100%" />
</el-form-item>
<el-form-item label="动作标识(可选)">
<el-input v-model="actionId" placeholder="留空时自动生成" />
</el-form-item>
<div v-for="(step, index) in steps" :key="step.runtimeKey" class="single-queue-step">
<div class="single-queue-step__header">
<div class="single-queue-step__title">{{ getActionQueueStepName(step, index) }} · {{ actionName(step.type) }}</div>
<div class="single-queue-step__actions">
<el-tooltip content="单独执行此步骤" placement="top">
<el-button
:icon="VideoPlay"
circle
size="small"
type="primary"
plain
aria-label="单独执行此步骤"
:loading="executingStepIndex === index"
:disabled="executingStepIndex >= 0 && executingStepIndex !== index"
@click="emit('execute-step', index)"
/>
</el-tooltip>
<el-tooltip content="上移一步" placement="top">
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
</el-tooltip>
<el-tooltip content="下移一步" placement="top">
<el-button :icon="ArrowDown" circle size="small" :disabled="index === steps.length - 1" aria-label="下移一步" @click="moveStep(index, 1)" />
</el-tooltip>
<el-tooltip content="复制步骤" placement="top">
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
</el-tooltip>
</div>
</div>
<el-form-item label="步骤名称">
<el-input v-model="step.stepName" maxlength="50" show-word-limit placeholder="例如:移动到取料位" />
</el-form-item>
<el-form-item v-if="deviceKind(step.type)" label="执行设备" required>
<el-select v-model="step.deviceId" filterable :loading="deviceLoading" style="width: 100%">
<el-option v-for="device in devicesForStep(step)" :key="device.deviceId"
:label="device.deviceName && device.deviceName !== device.deviceId ? `${device.deviceName} (${device.deviceId})` : device.deviceId"
:value="device.deviceId" />
</el-select>
</el-form-item>
<div class="single-queue-step__params">{{ stepSummary(step) }}</div>
</div>
</el-form>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowDown, ArrowUp, CopyDocument, VideoPlay } from '@element-plus/icons-vue'
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { DEVICE_KIND } from '@/utils/robotDevice'
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue'
/**
* 组件输入配置,仅在当前单次动作队列运行表单内使用。
* nodeParams 提供流程节点参数,robotOptions 提供可选机器人,initialRobotId 用于初始化默认机器人。
*/
const props = defineProps({
nodeParams: { type: Array, default: () => [] },
robotOptions: { type: Array, default: () => [] },
initialRobotId: { type: String, default: '' },
executingStepIndex: { type: Number, default: -1 },
})
const emit = defineEmits(['execute-step'])
/** 动作类型到中文名称的映射,作用范围为当前组件的步骤标题展示。 */
const labels = {
DELAY: '等待', ARM_MOVE_J: '机械臂关节运动', ARM_MOVE_L: '机械臂直线运动',
AGV_NAVIGATE_TO_POSE: '底盘导航到坐标', AGV_NAVIGATE_TO_STATION: '底盘导航到站点', AGV_FOLLOW_PATH: '底盘按路径导航',
}
/** 当前选中的机器人 ID,作用于设备查询、校验和最终运行请求。 */
const robotId = ref('')
/** 本次动作队列标识;为空时由服务端自动生成,仅随当前运行请求提交。 */
const actionId = ref('')
/** 整个动作队列的超时时间(毫秒),作用于当前运行请求。 */
const totalTimeoutMs = ref(60000)
/** 当前节点解析出的动作步骤副本,组件内可补充 deviceId,不回写原始 props。 */
const steps = ref([])
/** 当前机器人挂载的设备列表,用于为各动作步骤筛选可执行设备。 */
const devices = ref([])
/** 设备接口请求状态,仅控制当前组件设备选择框的加载效果。 */
const deviceLoading = ref(false)
let runtimeKeySequence = 0
const createRuntimeKey = () => `queue_runtime_${Date.now()}_${runtimeKeySequence += 1}`
/**
* 获取动作类型的展示名称。
* 先查询本地中文映射,未配置的类型直接显示原值,避免未知动作显示为空。
*/
const actionName = (type) => labels[type] || type
/**
* 根据动作名前缀判断所需设备类型。
* 当前组件只处理机械臂和底盘队列动作;等待等无设备动作返回 null。
*/
const deviceKind = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
/**
* 筛选单个步骤可使用的设备,范围限定为当前所选机器人的在线设备结果。
* 将接口 deviceKind 与动作要求统一转为数值后比较,兼容字符串和数字类型。
*/
const devicesForStep = (step) => devices.value.filter((device) => Number(device.deviceKind) === deviceKind(step.type))
/**
* 生成步骤参数摘要,仅用于当前组件的只读预览。
* 等待和站点导航使用易读文案,其余动作保留完整 JSON 信息。
*/
const stepSummary = (step) => {
if (step.type === 'DELAY') return `等待 ${step.params?.durationMs || 0} 毫秒`
if (step.type === 'AGV_NAVIGATE_TO_STATION') return `站点 ${step.params?.stationId || '-'}`
return JSON.stringify(step.params || {})
}
/**
* 使用节点参数重新初始化表单,作用于首次渲染及输入属性变化。
* 步骤:1. 将参数数组转成按名称索引的对象;2. 设置基本字段并深拷贝步骤;
* 3. 应用默认机器人;4. 若存在机器人,则加载设备并为步骤建立默认设备选择。
*/
const initData = async () => {
const values = Object.fromEntries(props.nodeParams.map((item) => [item.name, item.input]))
actionId.value = values.actionId || ''
totalTimeoutMs.value = Number(values.totalTimeoutMs || 60000)
steps.value = JSON.parse(JSON.stringify(Array.isArray(values.steps) ? values.steps : [])).map((step, index) => ({
...step,
stepName: getActionQueueStepName(step, index),
runtimeKey: createRuntimeKey(),
}))
robotId.value = props.initialRobotId || ''
if (robotId.value) await loadDevices(robotId.value)
}
const moveStep = (index, offset) => {
moveActionQueueStep(steps.value, index, offset)
}
const copyStep = (index) => {
const source = steps.value[index]
if (!source) return
const copied = copyActionQueueStep(source, { runtimeKey: createRuntimeKey() })
steps.value.splice(index + 1, 0, copied)
}
/**
* 加载指定机器人的设备并重建各步骤设备选择,影响当前组件的 devices 和 steps。
* 切换机器人后旧 deviceId 已不再可靠,因此先清空设备及所有步骤选择;无机器人时直接结束。
* 接口返回后逐步按动作类型筛选设备,并默认选择第一个匹配项;finally 保证加载状态复位。
*/
const loadDevices = async (value) => {
// 机器人发生变化时,旧设备可能属于另一台机器人,必须整体清除以避免误提交。
devices.value = []
steps.value.forEach((step) => { step.deviceId = '' })
if (!value) return
deviceLoading.value = true
try {
const response = await getRobotDevicesByRobotId(value)
devices.value = Array.isArray(response.data) ? response.data : []
// 每个步骤独立按设备类型匹配;有候选项时预选首项,减少单一设备场景下的重复操作。
steps.value.forEach((step) => {
const options = devicesForStep(step)
if (options.length) step.deviceId = options[0].deviceId
})
} finally {
deviceLoading.value = false
}
}
/**
* 校验当前动作队列是否具备运行条件,供父组件在提交前调用。
* 依次检查机器人、队列非空及每个需要设备的步骤;首次失败即提示并终止,全部通过返回 true。
*/
const validate = () => {
if (!robotId.value) return ElMessage.warning('请选择机器人'), false
if (!steps.value.length) return ElMessage.warning('动作队列不能为空'), false
// 等待类步骤无需设备;机械臂或底盘步骤必须存在与当前机器人匹配的 deviceId。
for (let index = 0; index < steps.value.length; index += 1) {
const step = steps.value[index]
if (deviceKind(step.type) && !step.deviceId) {
ElMessage.warning(`步骤 ${index + 1} 没有可用的执行设备`)
return false
}
}
return true
}
const validateStep = (index) => {
if (!robotId.value) return ElMessage.warning('请选择机器人'), false
const step = steps.value[index]
if (!step) return ElMessage.warning('未找到要执行的步骤'), false
if (deviceKind(step.type) && !step.deviceId) {
ElMessage.warning(`步骤 ${index + 1} 没有可用的执行设备`)
return false
}
return true
}
/** 汇总当前表单数据为运行接口载荷;返回范围包含机器人、队列配置和全部步骤。 */
const getPayload = () => ({
robotId: robotId.value,
actionId: actionId.value,
totalTimeoutMs: totalTimeoutMs.value,
steps: toActionQueueExecutionSteps(steps.value),
})
const getStepPayload = (index) => {
const step = steps.value[index]
const stepTimeoutMs = Math.max(0, Number(step?.timeoutMs || 0))
return {
robotId: robotId.value,
actionId: '',
totalTimeoutMs: Math.max(1000, stepTimeoutMs + 5000),
steps: toActionQueueExecutionSteps(step ? [step] : []),
}
}
/** 返回当前选中的机器人 ID,供父组件处理兼容字段或运行上下文。 */
const getRobotId = () => robotId.value
/** 监听节点配置和默认机器人变化,深度变化时重建当前组件的表单及设备选项。 */
watch(() => [props.nodeParams, props.initialRobotId], initData, { immediate: true, deep: true })
/** 向父组件开放提交前校验、载荷读取和机器人 ID 读取能力。 */
defineExpose({ validate, validateStep, getPayload, getStepPayload, getRobotId })
</script>
<style scoped>
.single-queue-step {
margin-bottom: 10px;
padding: 12px;
border: 1px solid #dfe4ec;
border-radius: 8px;
}
.single-queue-step__header {
display: flex;
min-width: 0;
margin-bottom: 10px;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.single-queue-step__title {
overflow: hidden;
color: #1f2937;
font-size: 13px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.single-queue-step__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.single-queue-step__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.single-queue-step__params {
overflow: hidden;
color: #7a8494;
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:节点组件库侧栏,负责分类、搜索和拖拽创建。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="component-library"> <div class="component-library">
<div class="library-header"> <div class="library-header">
@ -75,6 +79,11 @@ watch(keyword, (value) => {
if (value.trim()) activeNames.value = filteredGroups.value.map((group) => group.collapseTitle); if (value.trim()) activeNames.value = filteredGroups.value.map((group) => group.collapseTitle);
}); });
/**
* 处理 handleDragCustom 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} event 触发操作的浏览器事件
* @param {*} node 当前流程节点
*/
const handleDragCustom = (event, node) => { const handleDragCustom = (event, node) => {
event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("flowNode", JSON.stringify(node)); event.dataTransfer.setData("flowNode", JSON.stringify(node));

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:递归参数表单,负责嵌套字段的增删和类型切换。
* 作用范围:仅服务于流程设计器模块。
-->
<!-- components/FormItemRecursive.vue --> <!-- components/FormItemRecursive.vue -->
<template> <template>
<div class="form-item-recursive"> <div class="form-item-recursive">
@ -13,6 +17,7 @@
<el-input <el-input
class="param-name" class="param-name"
v-model="item.name" v-model="item.name"
:disabled="item.disabled || false"
placeholder="请输入" placeholder="请输入"
clearable clearable
/> />
@ -146,6 +151,10 @@ const { formType, currentList, propPath, depth, isFirstLevel, endDepth, parentPa
const emit = defineEmits(['add-item', 'delete-item']) const emit = defineEmits(['add-item', 'delete-item'])
// 处理类型变化(清空或添加子项) // 处理类型变化(清空或添加子项)
/**
* 处理 handleTypeChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} item 调用方传入的 item 参数
*/
const handleTypeChange = (item) => { const handleTypeChange = (item) => {
if (!["object", "array<object>"].includes(item.type)) { if (!["object", "array<object>"].includes(item.type)) {
item.children = []; // 非对象类型清空子项 item.children = []; // 非对象类型清空子项
@ -155,6 +164,10 @@ const handleTypeChange = (item) => {
}; };
// 添加子项 // 添加子项
/**
* 处理 handleAddChild 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} parentItem 调用方传入的 parentItem 参数
*/
const handleAddChild = (parentItem) => { const handleAddChild = (parentItem) => {
parentItem.children.push({ parentItem.children.push({
name: "", name: "",
@ -167,6 +180,10 @@ const handleAddChild = (parentItem) => {
}; };
// 删除当前项 // 删除当前项
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => { const handleDelete = (index) => {
// 完整路径 = 父级路径 + 当前索引(如果是顶层,parentPath为undefined) // 完整路径 = 父级路径 + 当前索引(如果是顶层,parentPath为undefined)
const fullPath = parentPath ? [...parentPath, index] : [index]; const fullPath = parentPath ? [...parentPath, index] : [index];
@ -174,6 +191,10 @@ const handleDelete = (index) => {
} }
// 接收子组件的删除事件,继续向上传递 // 接收子组件的删除事件,继续向上传递
/**
* 处理 handleChildDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} childFullPath 调用方传入的 childFullPath 参数
*/
const handleChildDelete = (childFullPath) => { const handleChildDelete = (childFullPath) => {
emit("delete-item", childFullPath); emit("delete-item", childFullPath);
} }

View File

@ -0,0 +1,224 @@
<template>
<div class="loop-results">
<div class="loop-results__summary">
<span>{{ history.length }} 次循环</span>
<el-tag v-if="analysisSummary" :type="analysisSummary.type" size="small">
{{ analysisSummary.label }}
</el-tag>
</div>
<el-empty v-if="!history.length" description="暂无循环结果" :image-size="56" />
<el-collapse v-else v-model="expandedIterations" class="loop-results__iterations">
<el-collapse-item v-for="iteration in history" :key="iteration.iteration" :name="iteration.iteration">
<template #title>
<div class="loop-results__iteration-title">
<strong>第 {{ iteration.iteration }} 次</strong>
<el-tag :type="statusMeta(iteration.status).type" size="small">
{{ statusMeta(iteration.status).label }}
</el-tag>
<el-tag v-if="analysisMeta(iteration.analysisStatus)" :type="analysisMeta(iteration.analysisStatus).type" size="small">
{{ analysisMeta(iteration.analysisStatus).label }}
</el-tag>
<span>{{ formatDuration(iteration.durationMs) }}</span>
</div>
</template>
<div class="loop-results__nodes">
<details v-for="node in iteration.nodes || []" :key="`${iteration.iteration}:${node.nodeId}`" class="loop-results__node">
<summary>
<span class="loop-results__node-name">{{ node.nodeName }}</span>
<el-tag :type="statusMeta(node.status).type" size="small">
{{ statusMeta(node.status).label }}
</el-tag>
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
{{ node.passed ? '通过' : '未通过' }}
</el-tag>
<span class="loop-results__duration">{{ formatDuration(node.durationMs) }}</span>
</summary>
<div v-if="node.error" class="loop-results__error">{{ node.error }}</div>
<div v-if="hasValue(node.input)" class="loop-results__data">
<div class="loop-results__label">输入</div>
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
</div>
<div v-if="hasValue(node.output)" class="loop-results__data">
<div class="loop-results__label">输出</div>
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
</div>
</details>
</div>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
history: { type: Array, default: () => [] },
})
const expandedIterations = ref([])
let initialized = false
// 首次拿到运行结果时只自动展开一次:优先展开正在执行的轮次,否则展开最新一轮。
// 之后用户手动折叠/展开时不再被轮询数据刷新打断。
watch(() => props.history, (history) => {
if (initialized || !history?.length) return
const active = history.find(item => String(item.status || '').toUpperCase() === 'RUNNING')
expandedIterations.value = [active?.iteration || history[history.length - 1].iteration]
initialized = true
}, { immediate: true, deep: true })
// 后端状态枚举统一映射为 Element Plus tag 的文案和类型。
const statusMeta = (status) => {
const value = String(status || '').toUpperCase()
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
return { label: '执行中', type: 'warning' }
}
// 循环轮次的分析结论独立于执行状态,主要服务音视频/图片分析类节点的通过性展示。
const analysisMeta = (status) => {
if (status === 'PASSED') return { label: '分析通过', type: 'success' }
if (status === 'FAILED') return { label: '分析未通过', type: 'danger' }
if (status === 'RUNNING') return { label: '分析中', type: 'warning' }
return null
}
// 汇总所有轮次的分析状态,用于顶部快速提示是否存在未通过的循环结果。
const analysisSummary = computed(() => {
const statuses = props.history.map(item => item.analysisStatus).filter(Boolean)
if (!statuses.length) return null
if (statuses.includes('FAILED')) return { label: '存在未通过', type: 'danger' }
if (statuses.includes('RUNNING')) return { label: '分析中', type: 'warning' }
return { label: '全部通过', type: 'success' }
})
// 后端以毫秒返回耗时,短耗时保留 ms,较长耗时转秒,避免标题区域过长。
const formatDuration = (value) => {
const duration = Number(value)
if (!Number.isFinite(duration) || duration < 0) return '-'
if (duration < 1000) return `${duration} ms`
return `${(duration / 1000).toFixed(2)} s`
}
// JSON Viewer 只在确实有内容时展示,避免空对象/空数组占据节点详情空间。
const hasValue = (value) => {
if (value === null || value === undefined || value === '') return false
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value).length > 0
return true
}
</script>
<style scoped lang="scss">
.loop-results__summary {
display: flex;
min-height: 32px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 2px 8px;
color: #344054;
font-size: 13px;
font-weight: 600;
}
.loop-results__iterations {
border-top: 0;
}
.loop-results__iteration-title {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
padding-right: 8px;
}
.loop-results__iteration-title > span:last-child {
margin-left: auto;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.loop-results__nodes {
display: grid;
gap: 6px;
padding: 4px 0 10px;
}
.loop-results__node {
border-bottom: 1px solid #ebeef5;
}
.loop-results__node summary {
display: flex;
min-height: 34px;
align-items: center;
gap: 7px;
padding: 3px 4px;
cursor: pointer;
list-style: none;
}
.loop-results__node summary::-webkit-details-marker {
display: none;
}
.loop-results__node summary::before {
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid #909399;
content: '';
transition: transform 0.16s ease;
}
.loop-results__node[open] summary::before {
transform: rotate(90deg);
}
.loop-results__node-name {
min-width: 0;
overflow: hidden;
color: #303133;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.loop-results__duration {
margin-left: auto;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.loop-results__error {
margin: 4px 4px 8px 18px;
padding: 7px 9px;
border-left: 3px solid var(--el-color-danger);
background: var(--el-color-danger-light-9);
color: var(--el-color-danger-dark-2);
line-height: 18px;
overflow-wrap: anywhere;
}
.loop-results__data {
margin: 6px 4px 10px 18px;
}
.loop-results__label {
margin-bottom: 5px;
color: #667085;
font-size: 12px;
font-weight: 600;
}
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:节点运行状态面板,展示耗时、输入、输出和错误。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div v-if="showState !== 'NORMAL'" class="node-state" :class="`node-state--${showState.toLowerCase()}`"> <div v-if="showState !== 'NORMAL'" class="node-state" :class="`node-state--${showState.toLowerCase()}`">
<div class="node-state__summary"> <div class="node-state__summary">
@ -15,9 +19,9 @@
ref="popoverRef" ref="popoverRef"
trigger="click" trigger="click"
placement="right-start" placement="right-start"
:width="480" :width="props.popoverWidth"
:teleported="true" :teleported="true"
popper-class="flow-result-popover" :popper-class="props.popperClass"
@show="popoverVisible = true" @show="popoverVisible = true"
@hide="popoverVisible = false" @hide="popoverVisible = false"
> >
@ -47,6 +51,9 @@ import { CircleCloseFilled, Loading, SuccessFilled, VideoPause, View } from "@el
const props = defineProps({ const props = defineProps({
state: { type: String, default: "NORMAL" }, state: { type: String, default: "NORMAL" },
runtimes: { type: Number, default: 0 }, runtimes: { type: Number, default: 0 },
// 循环和子流程的运行结果更宽,允许调用方覆盖弹窗尺寸和样式类。
popoverWidth: { type: Number, default: 480 },
popperClass: { type: String, default: "flow-result-popover" },
}); });
const slots = useSlots(); const slots = useSlots();
@ -66,6 +73,9 @@ watch(() => props.state, (value) => {
showState.value = value; showState.value = value;
}, { immediate: true }); }, { immediate: true });
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
showState.value = "NORMAL"; showState.value = "NORMAL";
popoverVisible.value = false; popoverVisible.value = false;
@ -182,4 +192,12 @@ defineExpose({ closePopover });
font-size: 12px; font-size: 12px;
} }
} }
.flow-loop-result-popover {
max-width: min(720px, calc(100vw - 32px));
.result-section__content {
max-height: 520px;
}
}
</style> </style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:节点标题栏,负责重命名、删除和单节点执行。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node-title"> <div class="node-title">
<div class="node-title__main"> <div class="node-title__main">
@ -87,18 +91,41 @@ const isEditing = ref(false);
const localName = ref(""); const localName = ref("");
const nameInputRef = ref(); const nameInputRef = ref();
const isSelectArea = computed(() => props.nodeType === "selectArea"); const isSelectArea = computed(() => props.nodeType === "selectArea");
const canExecute = computed(() => Boolean(props.nodeType) && !["NONE", "selectArea"].includes(props.nodeType)); // 这些动作只是流程结构或上下文占位,不能脱离完整流程单独执行。
const flowContextActions = new Set([
"NONE",
"START",
"END",
"SUB_START",
"SUB_END",
"START_LOOP",
"SUB_FLOW",
"STOP_LOOP",
"BRANCH",
"GET_CURRENT_OBJECT",
]);
const canExecute = computed(() => {
if (isSelectArea.value) return false;
const action = String(props.nodeProperties?.action || "").toUpperCase();
return Boolean(action) && !flowContextActions.has(action);
});
const typeLabel = computed(() => { const typeLabel = computed(() => {
if (!props.nodeType || ["NONE", "selectArea"].includes(props.nodeType)) return ""; if (!props.nodeType || ["NONE", "selectArea"].includes(props.nodeType)) return "";
return { EDGE: "设备", LLM: "模型", AE: "评估" }[props.nodeType] || props.nodeType; return { EDGE: "设备", LLM: "模型", AE: "评估" }[props.nodeType] || props.nodeType;
}); });
/**
* 处理 sourceName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const sourceName = () => props.nodeProperties?.name || props.nodeName || "未命名节点"; const sourceName = () => props.nodeProperties?.name || props.nodeName || "未命名节点";
watch(sourceName, (value) => { watch(sourceName, (value) => {
if (!isEditing.value) localName.value = value; if (!isEditing.value) localName.value = value;
}); });
/**
* 处理 startEditing 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const startEditing = async () => { const startEditing = async () => {
isEditing.value = true; isEditing.value = true;
await nextTick(); await nextTick();
@ -106,6 +133,9 @@ const startEditing = async () => {
nameInputRef.value?.select?.(); nameInputRef.value?.select?.();
}; };
/**
* 确认 confirmNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const confirmNodeName = () => { const confirmNodeName = () => {
const name = localName.value.trim(); const name = localName.value.trim();
if (!name) return; if (!name) return;
@ -114,11 +144,17 @@ const confirmNodeName = () => {
isEditing.value = false; isEditing.value = false;
}; };
/**
* 处理 cancelNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const cancelNodeName = () => { const cancelNodeName = () => {
localName.value = sourceName(); localName.value = sourceName();
isEditing.value = false; isEditing.value = false;
}; };
/**
* 删除 deleteNode 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const deleteNode = async () => { const deleteNode = async () => {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@ -140,11 +176,15 @@ const deleteNode = async () => {
} }
}; };
/**
* 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const execute = () => { const execute = () => {
document.dispatchEvent(new CustomEvent("singleNodeExecution", { document.dispatchEvent(new CustomEvent("singleNodeExecution", {
bubbles: false, bubbles: false,
cancelable: false, cancelable: false,
detail: { ...props.nodeProperties }, // 携带节点 ID,供单节点执行弹窗按节点恢复上次选择的机器人。
detail: { ...props.nodeProperties, nodeId: props.nodeId },
})); }));
}; };

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:动态节点包装器,挂载具体节点并同步内容变化。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__box"> <div class="node__box">
<component :is="componentId" :model="props.model" :properties="props.properties" ref="nodeRef" @contentChange="contentChange"></component> <component :is="componentId" :model="props.model" :properties="props.properties" ref="nodeRef" @contentChange="contentChange"></component>
@ -18,6 +22,9 @@ const emits = defineEmits(['contentChange', 'bindRef'])
const componentId = ref(props.component) const componentId = ref(props.component)
/**
* 处理 contentChange 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const contentChange = () => { const contentChange = () => {
emits('contentChange') emits('contentChange')
} }
@ -40,4 +47,4 @@ onMounted(() => {
box-shadow: 0 5px 15px 0#00000008; box-shadow: 0 5px 15px 0#00000008;
position: relative; position: relative;
} }
</style> </style>

View File

@ -1,10 +1,14 @@
<!--
* 文件说明:节点参数抽屉,统一触发参数校验和保存。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<el-drawer <el-drawer
:model-value="props.drawer" :model-value="props.drawer"
custom-class="flow-params-drawer" custom-class="flow-params-drawer"
:title="props.data.properties?.name || '参数配置'" :title="props.data.properties?.name || '参数配置'"
class="flow-params-drawer" class="flow-params-drawer"
size="min(560px, 92vw)" :size="drawerSize"
append-to-body append-to-body
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
@ -14,8 +18,10 @@
:is="currentComponent" :is="currentComponent"
ref="paramsComponentRef" ref="paramsComponentRef"
:data="props.data" :data="props.data"
v-bind="componentExtraProps"
@save-success="handleSaveSuccess" @save-success="handleSaveSuccess"
@save-error="handleSaveError" @save-error="handleSaveError"
@extract-step="handleExtractStep"
/> />
</el-drawer> </el-drawer>
</template> </template>
@ -31,28 +37,59 @@ import CodeNodeParams from './params/CodeNodeParams.vue'
import HttpNodeParams from './params/HttpNodeParams.vue' import HttpNodeParams from './params/HttpNodeParams.vue'
import SdAgentNodeParams from './params/SdAgentNodeParams.vue' import SdAgentNodeParams from './params/SdAgentNodeParams.vue'
import RecognizeNodeParams from './params/RecognizeNodeParams.vue' import RecognizeNodeParams from './params/RecognizeNodeParams.vue'
import DeviceUniversal from './params/DeviceUniversal.vue'
import SwitchNodeParams from './params/SwitchNodeParams.vue'
import ActionQueueParams from './params/ActionQueueParams.vue'
import MediaAnalysisParams from './params/MediaAnalysisParams.vue'
import ImageAnalysisParams from './params/ImageAnalysisParams.vue'
import EndParams from './params/EndParams.vue'
import SubFlowParams from './params/SubFlowParams.vue'
const props = defineProps({ const props = defineProps({
drawer: Boolean, drawer: Boolean,
data: Object data: Object,
flowId: String
}) })
const emits = defineEmits(['close']) const emits = defineEmits(['close', 'extract-step'])
const paramsComponentRef = ref(null) const paramsComponentRef = ref(null)
/**
* 分支条件包含左值、运算符和右值三列,仅为分支节点提供更宽的编辑空间。
*/
const drawerSize = computed(() => (
props.data?.type === 'branch' ? 'min(920px, 96vw)' : 'min(560px, 92vw)'
))
// 根据节点类型决定渲染哪个子组件 // 根据节点类型决定渲染哪个子组件
const currentComponent = computed(() => { const currentComponent = computed(() => {
const type = props.data?.type const type = props.data?.type
if (type === 'start') return StartParams if (type === 'start') return StartParams
if (type === 'end') return EndParams
if (type === 'subFlow') return SubFlowParams
if (['currentLoop', 'serviceNode', 'sleep', 'loop'].includes(type)) return BasicNodeParams if (['currentLoop', 'serviceNode', 'sleep', 'loop'].includes(type)) return BasicNodeParams
if (type === 'code') return CodeNodeParams if (type === 'code') return CodeNodeParams
if (type === 'http') return HttpNodeParams if (type === 'http') return HttpNodeParams
if (type === 'sdAgent') return SdAgentNodeParams if (type === 'sdAgent') return SdAgentNodeParams
if (type === 'recognize') return RecognizeNodeParams if (type === 'recognize') return RecognizeNodeParams
if (['deviceUniversal', 'device_universal'].includes(type)) return DeviceUniversal
if (type === 'actionQueue') return ActionQueueParams
if (type === 'mediaAnalysis') return MediaAnalysisParams
// 图片分析支持多图、参考图和自由提示词输出,使用独立面板保持媒体分析面板聚焦音视频。
if (type === 'imageAnalysis') return ImageAnalysisParams
if (type === 'branch') return SwitchNodeParams
return null return null
}) })
// 只有子流程参数面板需要当前流程 ID,用于后端过滤不可引用的发布版本。
const componentExtraProps = computed(() => (
props.data?.type === 'subFlow' ? { flowId: props.flowId } : {}
))
// 这里提供一个公开方法供父组件调用 // 这里提供一个公开方法供父组件调用
/**
* 确认 confirmSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const confirmSave = async () => { const confirmSave = async () => {
if (paramsComponentRef.value && typeof paramsComponentRef.value.validateAndSave === 'function') { if (paramsComponentRef.value && typeof paramsComponentRef.value.validateAndSave === 'function') {
await paramsComponentRef.value.validateAndSave() await paramsComponentRef.value.validateAndSave()
@ -65,6 +102,9 @@ const confirmSave = async () => {
defineExpose({ confirmSave }) defineExpose({ confirmSave })
// 保存成功 / 失败处理 // 保存成功 / 失败处理
/**
* 处理 handleSaveSuccess 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const handleSaveSuccess = () => { const handleSaveSuccess = () => {
emitter.emit('setProperties', { id: props.data.id }) emitter.emit('setProperties', { id: props.data.id })
emitter.emit("throwAnError", { id: props.data.id, hasError: false, type: props.data.type }); emitter.emit("throwAnError", { id: props.data.id, hasError: false, type: props.data.type });
@ -72,12 +112,17 @@ const handleSaveSuccess = () => {
emits('close') emits('close')
} }
/**
* 处理 handleSaveError 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const handleSaveError = () => { const handleSaveError = () => {
emitter.emit('setProperties', { id: props.data.id }) emitter.emit('setProperties', { id: props.data.id })
emitter.emit("throwAnError", { id: props.data.id, hasError: true, type: props.data.type }); emitter.emit("throwAnError", { id: props.data.id, hasError: true, type: props.data.type });
emits('close') emits('close')
} }
const handleExtractStep = (payload) => emits('extract-step', payload)
// 组件卸载时清理事件监听 // 组件卸载时清理事件监听
// 注意:emitter.off 应在各子组件中自行清理,父组件不再重复 // 注意:emitter.off 应在各子组件中自行清理,父组件不再重复
</script> </script>

View File

@ -0,0 +1,411 @@
<!--
* 文件说明:运行前资源分配编辑器,根据流程定义的机器人角色和设备用途,为本次运行生成 runtimeAssignments。
* 输入/输出:roles 描述资源需求,modelValue 承载已有分配;组件通过 v-model 回传当前选择。
* 重要约束:只加载在线机器人;AGV 角色可按 requiredMapId 过滤;同一检测项内部遵守设备复用规则。
-->
<template>
<div class="runtime-assignments">
<!-- 每个 role 对应一个运行时机器人角色,目前每个角色只分配一个 member。 -->
<div v-for="role in roles" :key="role.roleKey" class="runtime-role">
<div class="runtime-role__title">
<span>{{ role.displayName }}</span>
<el-tag v-if="role.required" size="small" type="info">必需</el-tag>
</div>
<el-form label-position="top">
<!-- 机器人选项会先限制为在线机器人;包含 AGV 用途时再按任务地图过滤。 -->
<el-form-item label="执行机器人" :required="role.required">
<el-select
:model-value="memberOf(role).robotId"
clearable
filterable
:loading="robotLoading || autoAssigning"
placeholder="请选择机器人"
style="width: 100%"
@change="(robotId) => changeRobot(role, robotId)"
>
<el-option v-for="item in robotOptionsOf(role)" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<!-- deviceSlots 是该角色所需的设备用途,例如底盘、相机、机械臂;选项依赖当前机器人。 -->
<el-form-item v-for="slot in role.deviceSlots" :key="slot.slotKey" :label="slot.displayName" :required="slot.required">
<el-select
v-model="memberOf(role).devices[slot.slotKey]"
filterable
:loading="autoAssigning || loadingRoleKey === role.roleKey"
:disabled="!memberOf(role).robotId"
:placeholder="memberOf(role).robotId ? '请选择设备' : '请先选择机器人'"
style="width: 100%"
@change="emitValue"
>
<el-option v-for="item in optionsOf(role, slot)" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<!-- 已选择机器人但没有可选设备时给出局部提示,最终阻断仍由 validate 统一完成。 -->
<div v-if="memberOf(role).robotId && !optionsOf(role, slot).length" class="runtime-role__error">
该机器人没有匹配的在线设备
</div>
</el-form-item>
</el-form>
</div>
<!-- 列表加载失败和设备加载失败统一聚合为一个错误提示,避免多个异步错误同时占用表单空间。 -->
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
<el-empty v-if="!roles.length" :image-size="52" description="当前任务不需要机器人" />
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { toDeviceOptions } from '@/utils/robotDevice'
/**
* roles 来自流程起点的 resourceRoles,描述本次任务需要哪些机器人角色和设备用途。
* modelValue 是父组件缓存或回显的 runtimeAssignments,本组件只维护一份可编辑副本并通过 update:modelValue 同步出去。
* robotQuery 用于叠加业务筛选条件;requiredMapId 用于有 AGV 用途时筛出位于同一地图的机器人。
*/
const props = defineProps({
roles: { type: Array, default: () => [] },
modelValue: { type: Array, default: () => [] },
robotQuery: { type: Object, default: () => ({}) },
requiredMapId: { type: [String, Number], default: '' },
})
const emit = defineEmits(['update:modelValue'])
// assignments 是表单直接修改的运行时分配副本,避免直接改父组件传入的 modelValue。
const assignments = ref([])
// robotOptions 保存在线机器人列表;devicesByRole 保存“角色 -> 当前机器人在线设备”的下拉数据。
const robotOptions = ref([])
const devicesByRole = reactive({})
// 三类 loading 分开维护,模板可精确显示机器人列表、自动分配或某个角色设备加载状态。
const robotLoading = ref(false)
const autoAssigning = ref(false)
const loadingRoleKey = ref('')
const robotLoadError = ref('')
const deviceLoadErrors = reactive({})
// 缓存按机器人 ID 发起的设备列表请求;多个角色尝试同一机器人时复用同一个 Promise。
const onlineDevicesByRobot = new Map()
// 对外只展示一个当前最重要的加载错误,validate 也复用该聚合结果。
const loadError = computed(() => robotLoadError.value || Object.values(deviceLoadErrors).find(Boolean) || '')
const clone = (value) => JSON.parse(JSON.stringify(value || []))
// initialize 会保证每个 role 都有一个 assignment/member,因此模板和校验可以按 roleKey 直接取值。
const assignmentOf = (role) => assignments.value.find((item) => item.roleKey === role.roleKey)
const memberOf = (role) => assignmentOf(role).members[0]
const emitValue = () => emit('update:modelValue', clone(assignments.value))
/**
* 加载在线机器人列表。
*
* 这里不直接处理地图过滤,因为是否需要地图约束取决于具体 role 是否包含 AGV 设备用途;
* role 级过滤由 robotOptionsOf 统一计算。
*/
const loadRobots = async () => {
robotLoading.value = true
robotLoadError.value = ''
try {
const response = await getRobotList({
...props.robotQuery,
pageNum: 1,
pageSize: 1000,
connectStatus: '1',
})
robotOptions.value = (response.rows || []).map((robot) => ({
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
value: robot.robotId,
currentMapId: robot.currentMapId,
}))
} catch (error) {
robotOptions.value = []
robotLoadError.value = '机器人列表加载失败,请稍后重试'
} finally {
robotLoading.value = false
}
}
/**
* 返回某个角色可选的机器人。
*
* 只有包含移动底盘设备用途的角色才要求机器人位于任务地图,其他纯传感器/执行器角色不受 requiredMapId 限制。
*/
const robotOptionsOf = (role) => {
const requiresTaskMap = (role.deviceSlots || []).some((slot) => Number(slot.deviceKind) === 1)
if (!props.requiredMapId || !requiresTaskMap) return robotOptions.value
return robotOptions.value.filter((robot) => String(robot.currentMapId || '') === String(props.requiredMapId))
}
/**
* 获取某台机器人当前在线设备。
*
* 缓存的是请求 Promise,而不只是结果数组:并发自动分配多个角色时,相同机器人只会触发一次接口请求;
* 失败时清掉缓存,避免后续重试继续拿到已 rejected 的 Promise。
*/
const getOnlineDevices = async (robotId) => {
if (!onlineDevicesByRobot.has(robotId)) {
const request = getRobotDevicesByRobotId(robotId).then((response) => (
Array.isArray(response.data) ? response.data : []
))
onlineDevicesByRobot.set(robotId, request)
}
try {
return await onlineDevicesByRobot.get(robotId)
} catch (error) {
onlineDevicesByRobot.delete(robotId)
throw error
}
}
// 设备复用规则按“检测项 + 第几次出现”隔离;没有检测项上下文时退回到任务级作用域。
const itemScopeOf = (role) => role.itemId == null
? 'task'
: `${role.itemId}:${role.itemOccurrence || 1}`
/**
* 在不修改当前表单的前提下,为一个角色预演设备选择。
*
* committedUses 是前面角色已经确定占用的物理设备;函数复制一份 plannedUses 后再尝试写入,
* 只有整个角色的必需设备都能满足时才返回计划,避免失败候选污染后续匹配。
*/
const planRoleDevices = (role, robotId, devices, committedUses, preferredDevices = {}) => {
const plannedUses = new Map(committedUses)
const selectedDevices = {}
for (const slot of role.deviceSlots || []) {
// 先按设备类型筛选,deviceKind 为空表示该用途接受任意设备。
const matchingDevices = devices.filter((device) => (
device?.deviceId
&& (slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind))
))
const preferredId = preferredDevices[slot.slotKey]
// 自动分配时优先保留用户已有选择;若该设备不可用,再按列表顺序寻找替代设备。
const orderedDevices = preferredId
? [
...matchingDevices.filter((device) => device.deviceId === preferredId),
...matchingDevices.filter((device) => device.deviceId !== preferredId),
]
: matchingDevices
const selected = orderedDevices.find((device) => {
// 同一个检测项内部用 role/item/robot/device 组成物理占用键,确保复用判断落到真实设备。
const physicalKey = `${itemScopeOf(role)}:${robotId}:${device.deviceId}`
const existingUse = plannedUses.get(physicalKey)
return !existingUse || (slot.allowReuse && existingUse.allowReuse)
})
if (!selected && slot.required) return null
selectedDevices[slot.slotKey] = selected?.deviceId || ''
if (selected) {
plannedUses.set(`${itemScopeOf(role)}:${robotId}:${selected.deviceId}`, {
allowReuse: slot.allowReuse === true,
})
}
}
return { selectedDevices, plannedUses }
}
/**
* 根据当前在线机器人和设备,按角色顺序自动补齐可行分配。
*
* 该算法采用顺序贪心策略:每个角色选择第一台能满足全部必需设备的机器人;
* 找不到可行候选时清空该角色,交给用户手动选择或 validate 给出明确提示。
*/
const autoAssignRoles = async () => {
let committedUses = new Map()
autoAssigning.value = true
try {
for (const role of props.roles) {
const member = memberOf(role)
const candidates = robotOptionsOf(role)
let match = null
for (const robot of candidates) {
try {
const devices = (role.deviceSlots || []).length
? await getOnlineDevices(robot.value)
: []
const preferredDevices = robot.value === member.robotId ? member.devices : {}
const plan = planRoleDevices(role, robot.value, devices, committedUses, preferredDevices)
if (plan) {
match = { robot, devices, ...plan }
break
}
} catch (error) {
// A failed device query makes this robot ineligible for automatic assignment.
}
}
if (match) {
member.robotId = match.robot.value
member.devices = match.selectedDevices
devicesByRole[role.roleKey] = match.devices
committedUses = match.plannedUses
} else {
member.robotId = ''
member.devices = Object.fromEntries((role.deviceSlots || []).map((slot) => [slot.slotKey, '']))
devicesByRole[role.roleKey] = []
}
}
emitValue()
} finally {
autoAssigning.value = false
}
}
/**
* 生成某个设备用途的下拉选项。
*
* 同一角色内已经被其他用途占用、且不允许复用的设备会被过滤掉;
* 当前 slot 已选中的值会保留在选项中,避免选项刷新时把正在编辑的值瞬间隐藏。
*/
const optionsOf = (role, slot) => {
const member = memberOf(role)
const unavailableDeviceIds = new Set((role.deviceSlots || [])
.filter((otherSlot) => (
otherSlot.slotKey !== slot.slotKey
&& (!slot.allowReuse || !otherSlot.allowReuse)
))
.map((otherSlot) => member.devices[otherSlot.slotKey])
.filter(Boolean))
return toDeviceOptions((devicesByRole[role.roleKey] || []).filter((device) => (
(slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind))
&& (!unavailableDeviceIds.has(device.deviceId) || member.devices[slot.slotKey] === device.deviceId)
)))
}
/**
* 按角色当前选择的机器人加载设备列表,并根据 preserve 决定是否尽量保留旧设备选择。
*
* 用户切换机器人时 preserve=false,设备用途全部重新选;初始化/自动刷新时 preserve=true,
* 已选设备仍然可用则保留,否则回退到当前第一个可用设备。
*/
const loadDevices = async (role, preserve = true) => {
const member = memberOf(role)
if (!member.robotId) {
devicesByRole[role.roleKey] = []
return
}
loadingRoleKey.value = role.roleKey
delete deviceLoadErrors[role.roleKey]
try {
devicesByRole[role.roleKey] = await getOnlineDevices(member.robotId)
;(role.deviceSlots || []).forEach((slot) => {
const options = optionsOf(role, slot)
if (!preserve || !options.some((option) => option.value === member.devices[slot.slotKey])) {
member.devices[slot.slotKey] = options[0]?.value || ''
}
})
emitValue()
} catch (error) {
devicesByRole[role.roleKey] = []
deviceLoadErrors[role.roleKey] = `“${role.displayName}”的设备列表加载失败,请重新选择机器人`
} finally {
loadingRoleKey.value = ''
}
}
// 机器人发生变化后,先清空该角色下所有设备选择,再基于新机器人加载并填充可用设备。
const changeRobot = async (role, robotId) => {
const member = memberOf(role)
member.robotId = robotId
member.devices = Object.fromEntries((role.deviceSlots || []).map((slot) => [slot.slotKey, '']))
await loadDevices(role, false)
}
/**
* 根据最新 props 重建内部状态。
*
* 这里会清空设备缓存和加载错误,随后把 modelValue、旧版 legacyRobotId/legacyDeviceId 回填到 assignments;
* emitValue 先把规范化结构同步给父组件,再加载在线资源并执行自动匹配。
*/
const initialize = async () => {
Object.keys(deviceLoadErrors).forEach((roleKey) => delete deviceLoadErrors[roleKey])
Object.keys(devicesByRole).forEach((roleKey) => delete devicesByRole[roleKey])
onlineDevicesByRobot.clear()
const existing = clone(props.modelValue)
assignments.value = props.roles.map((role) => {
const existingAssignment = existing.find((item) => item.roleKey === role.roleKey)
const existingMember = existingAssignment?.members?.[0]
return {
roleKey: role.roleKey,
...(role.sourceRoleKey ? { sourceRoleKey: role.sourceRoleKey } : {}),
...(role.itemId != null ? { itemId: role.itemId } : {}),
...(role.itemOccurrence != null ? { itemOccurrence: role.itemOccurrence } : {}),
members: [{
...existingMember,
memberKey: `${role.roleKey}_1`,
robotId: existingMember?.robotId || role.legacyRobotId || '',
devices: Object.fromEntries((role.deviceSlots || []).map((slot) => [
slot.slotKey,
existingMember?.devices?.[slot.slotKey] || slot.legacyDeviceId || '',
])),
}],
}
})
emitValue()
await loadRobots()
await autoAssignRoles()
}
/**
* 提交运行前的最终校验。
*
* 返回空字符串表示通过;返回中文错误文案表示父组件应阻断执行并提示用户。
* 校验顺序从异步加载状态、必填机器人/设备,到同一检测项内部的物理设备冲突。
*/
const validate = () => {
if (robotLoading.value || autoAssigning.value) return '正在自动匹配在线机器人和设备,请稍候'
const physicalDeviceUses = new Map()
for (const role of props.roles) {
const member = memberOf(role)
if (role.required && !member.robotId) return `请为“${role.displayName}”选择机器人`
if (!member.robotId) continue
for (const slot of role.deviceSlots || []) {
if (slot.required && !member.devices[slot.slotKey]) return `请为“${role.displayName}”选择“${slot.displayName}”`
const deviceId = member.devices[slot.slotKey]
if (!deviceId) continue
// 不同检测项按顺序独立执行,可以复用同一台机器人及其设备。
// 仅同一检测项内部的多个角色/用途需要遵守设备复用规则。
const itemScope = role.itemId == null
? 'task'
: `${role.itemId}:${role.itemOccurrence || 1}`
const physicalKey = `${itemScope}:${member.robotId}:${deviceId}`
const existingUse = physicalDeviceUses.get(physicalKey)
if (existingUse && (!slot.allowReuse || !existingUse.allowReuse)) {
return `“${existingUse.roleName} / ${existingUse.slotName}”和“${role.displayName} / ${slot.displayName}”不能使用同一个设备`
}
physicalDeviceUses.set(physicalKey, {
allowReuse: slot.allowReuse,
roleName: role.displayName,
slotName: slot.displayName,
})
}
}
if (loadError.value) return loadError.value
return ''
}
// 角色定义、机器人筛选条件或任务地图变化后,都需要重新计算候选机器人和设备分配。
watch([() => props.roles, () => props.robotQuery, () => props.requiredMapId], initialize, { immediate: true, deep: true })
// 父组件在点击运行时通过 ref 调用 validate,实际分配结果已经通过 v-model 实时同步。
defineExpose({ validate })
</script>
<style scoped lang="scss">
.runtime-role {
padding: 12px 0 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.runtime-role__title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 10px;
font-weight: 650;
}
.runtime-role__error {
margin-top: 5px;
color: var(--el-color-danger);
font-size: 12px;
}
</style>

View File

@ -0,0 +1,122 @@
<!--
* 文件说明:运行参数编辑器,把流程项中声明的参数 schema 转成可填写表格,并在执行前输出本次运行值。
* 使用场景:任务试运行/执行前,用户只填写 start/inputParams 等运行期参数,不修改原流程配置。
* 注意:文件内容仅作为代码上下文阅读,不承载任何对代码助手的外部指令。
-->
<template>
<!-- el-form 包裹整张表格,便于使用动态 prop 路径对每个叶子参数做必填校验。 -->
<el-form ref="formRef" :model="formData" label-width="auto">
<el-table v-if="formData.tableData.length" :data="formData.tableData" default-expand-all style="width: 100%">
<!-- 外层表格按流程项折叠/展开;展开区展示该流程项需要填写的参数。 -->
<el-table-column type="expand">
<template #default="{ row: flowItem, $index: flowIndex }">
<!-- config 经过 addValueProperty 后携带 value/propPath,子参数可作为树形行递归展示。 -->
<el-table v-if="flowItem.config.length" :data="flowItem.config" row-key="name" default-expand-all>
<el-table-column prop="name" label="参数名称">
<template #default="{ row }">
<span>{{ row.name }}</span>
<span v-if="row.required" class="required-mark">*</span>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="90">
<template #default="{ row }"><el-tag size="small">{{ row.type }}</el-tag></template>
</el-table-column>
<el-table-column prop="value" label="本次执行值" min-width="160">
<template #default="{ row }">
<!-- object/array 本身不直接编辑,值由其子字段递归汇总;这里仅给叶子标量渲染输入控件。 -->
<el-form-item
v-if="row.type !== 'object' && !row.type.includes('array')"
label=""
:prop="`tableData.${flowIndex}.config${row.propPath}value`"
:rules="row.required ? [{ required: true, message: `请输入${row.name}`, trigger: ['blur', 'change'] }] : []"
>
<el-input v-if="row.type === 'string'" v-model="row.value" />
<el-input-number v-else-if="row.type === 'number'" v-model="row.value" :controls="false" />
<el-switch v-else-if="row.type === 'boolean'" v-model="row.value" />
<el-input v-else v-model="row.value" />
</el-form-item>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<el-table-column label="执行顺序" prop="orderNum" width="90" align="center" />
<el-table-column label="流程项" prop="itemName" show-overflow-tooltip />
</el-table>
<el-empty v-else description="当前任务没有需要填写的运行参数" :image-size="52" />
</el-form>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { addValueProperty, formatTableData } from '@/utils/flow'
/**
* items 是待执行的流程项列表,每项的 config 是运行参数 schema,可以是数组对象或 JSON 字符串。
* 组件不会直接修改 props.items,而是通过 resetItems 深拷贝后写入 formData.tableData。
*/
const props = defineProps({ items: { type: Array, default: () => [] } })
// formRef 负责触发 Element Plus 表单校验;tableData 是带 value 字段的可编辑流程项副本。
const formRef = ref()
const formData = reactive({ tableData: [] })
/**
* 把后端/流程配置中的参数 schema 转成表单可编辑结构。
*
* addValueProperty 会为每个参数补默认 value 和 propPath;propPath 用于拼出 el-form-item 的校验路径,
* 因此这里必须深拷贝 schema,避免把临时 value 写回原始流程项配置。
*/
const parseConfig = (config) => {
if (!config) return []
const schema = typeof config === 'string' ? JSON.parse(config) : config
return addValueProperty(JSON.parse(JSON.stringify(schema || [])))
}
/**
* 重置表格数据。
*
* orderNum 缺失时按当前数组顺序补齐,保证 UI 上始终有清晰的执行顺序;
* config 在这里统一解析,模板层只负责渲染,不再关心原始格式是字符串还是数组。
*/
const resetItems = (items) => {
formData.tableData = (items || []).map((item, index) => ({
...item,
orderNum: item.orderNum ?? index + 1,
config: parseConfig(item.config),
}))
}
// 对外暴露的校验方法:没有表单实例时视为通过;校验失败时统一返回 false。
const validate = async () => !formRef.value || formRef.value.validate().catch(() => false)
/**
* 组装本次运行参数。
*
* 同一个 itemId 可能在任务中出现多次,因此用 itemId:occurrence 作为 key;
* formatTableData 会把树形参数数组恢复为普通对象,供执行接口直接消费。
*/
const getRunParams = () => {
const occurrences = new Map()
const result = {}
formData.tableData.forEach((item) => {
const itemId = String(item.itemId)
const occurrence = (occurrences.get(itemId) || 0) + 1
occurrences.set(itemId, occurrence)
result[`${itemId}:${occurrence}`] = formatTableData(item.config, {})
})
return result
}
// 父组件切换任务项或刷新配置时,立即重建表格,deep 用于捕获 config 内部的变更。
watch(() => props.items, resetItems, { immediate: true, deep: true })
// 父组件通过 ref 触发表单校验、读取运行参数,也可以在必要时主动重置表格。
defineExpose({ validate, getRunParams, resetItems })
</script>
<style scoped>
.required-mark { margin-left: 6px; color: var(--el-color-danger); }
:deep(.el-form-item) { margin-bottom: 0; }
:deep(.el-input-number) { width: 100%; }
</style>

View File

@ -0,0 +1,180 @@
<template>
<div class="subflow-results">
<div class="subflow-results__summary">
<div>
<strong>{{ data.name || '子流程' }}</strong>
<span v-if="data.versionNo">V{{ data.versionNo }}</span>
</div>
<div class="subflow-results__summary-status">
<el-tag v-if="analysisMeta" :type="analysisMeta.type" size="small">{{ analysisMeta.label }}</el-tag>
<el-tag :type="statusMeta(data.status).type" size="small">{{ statusMeta(data.status).label }}</el-tag>
<span>{{ formatDuration(data.durationMs) }}</span>
</div>
</div>
<el-empty v-if="!executions.length" description="暂无内部节点日志" :image-size="56" />
<el-collapse v-else v-model="expandedExecutions" class="subflow-results__groups">
<el-collapse-item v-for="execution in executions" :key="execution.key" :name="execution.key">
<template #title>
<div class="subflow-results__group-title">
<strong>{{ execution.label }}</strong>
<el-tag :type="statusMeta(execution.status).type" size="small">
{{ statusMeta(execution.status).label }}
</el-tag>
<span>{{ execution.nodes?.length || 0 }} 个节点</span>
</div>
</template>
<div class="subflow-results__nodes">
<details v-for="(node, index) in execution.nodes || []" :key="`${execution.key}:${node.nodeId}:${index}`">
<summary>
<span class="subflow-results__order">{{ index + 1 }}</span>
<span class="subflow-results__node-name">{{ node.nodeName }}</span>
<span v-if="node.actionLabel && node.actionLabel !== node.nodeName" class="subflow-results__action">
{{ node.actionLabel }}
</span>
<el-tag :type="statusMeta(node.status).type" size="small">{{ statusMeta(node.status).label }}</el-tag>
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
{{ node.passed ? '通过' : '未通过' }}
</el-tag>
<span class="subflow-results__duration">{{ formatDuration(node.durationMs) }}</span>
</summary>
<div v-if="node.error" class="subflow-results__error">{{ node.error }}</div>
<div v-if="hasValue(node.input)" class="subflow-results__data">
<div class="subflow-results__label">输入</div>
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
</div>
<div v-if="hasValue(node.output)" class="subflow-results__data">
<div class="subflow-results__label">输出</div>
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
</div>
</details>
</div>
</el-collapse-item>
</el-collapse>
<details v-if="hasValue(data.output)" class="subflow-results__final-output">
<summary>子流程最终输出</summary>
<JsonViewer :value="data.output" copyable boxed sort theme="light" />
</details>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({ data: { type: Object, default: () => ({}) } })
const executions = computed(() => Array.isArray(props.data.executions) ? props.data.executions : [])
const expandedExecutions = ref([])
let initialized = false
// 子流程可能被轮询持续刷新,默认展开只做一次,避免用户查看历史分组时被新数据打断。
// 若有正在运行的分组优先展开它,否则展开最新一次执行分组。
watch(executions, (groups) => {
if (initialized || !groups.length) return
const active = groups.find((group) => String(group.status || '').toUpperCase() === 'RUNNING')
expandedExecutions.value = [active?.key || groups[groups.length - 1].key]
initialized = true
}, { immediate: true })
// 后端状态枚举统一映射为 Element Plus tag 的文案和类型。
const statusMeta = (status) => {
const value = String(status || '').toUpperCase()
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
return { label: '执行中', type: 'warning' }
}
// 子流程整体的分析结论由内部判定型节点汇总得出,和执行成功/失败状态分开展示。
const analysisMeta = computed(() => {
if (props.data.analysisStatus === 'PASSED') return { label: '分析通过', type: 'success' }
if (props.data.analysisStatus === 'FAILED') return { label: '分析未通过', type: 'danger' }
if (props.data.analysisStatus === 'RUNNING') return { label: '分析中', type: 'warning' }
return null
})
// 后端以毫秒返回耗时,短耗时保留 ms,较长耗时转秒,避免标题区域过长。
const formatDuration = (value) => {
const duration = Number(value)
if (!Number.isFinite(duration) || duration < 0) return '-'
if (duration < 1000) return `${duration} ms`
return `${(duration / 1000).toFixed(2)} s`
}
// JSON Viewer 只在确实有内容时展示,避免空对象/空数组占据节点详情空间。
const hasValue = (value) => {
if (value === null || value === undefined || value === '') return false
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value).length > 0
return true
}
</script>
<style scoped lang="scss">
.subflow-results__summary,
.subflow-results__summary-status,
.subflow-results__group-title,
.subflow-results__nodes summary {
display: flex;
align-items: center;
}
.subflow-results__summary {
justify-content: space-between;
gap: 12px;
padding: 2px 2px 12px;
color: #344054;
}
.subflow-results__summary > div,
.subflow-results__summary-status { display: flex; align-items: center; gap: 8px; }
.subflow-results__summary span { color: #667085; font-size: 12px; }
.subflow-results__groups { border-top: 0; }
.subflow-results__group-title {
min-width: 0;
flex: 1;
gap: 8px;
padding-right: 8px;
}
.subflow-results__group-title > span:last-child {
margin-left: auto;
color: #909399;
font-size: 12px;
}
.subflow-results__nodes { display: grid; gap: 5px; padding-bottom: 10px; }
.subflow-results__nodes details { border-bottom: 1px solid #ebeef5; }
.subflow-results__nodes summary {
min-height: 36px;
gap: 7px;
padding: 3px 4px;
cursor: pointer;
list-style: none;
}
.subflow-results__nodes summary::-webkit-details-marker { display: none; }
.subflow-results__order { width: 20px; color: #98a2b3; font-size: 11px; text-align: center; }
.subflow-results__node-name { min-width: 0; overflow: hidden; color: #303133; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.subflow-results__action { color: #98a2b3; font-size: 11px; white-space: nowrap; }
.subflow-results__duration { margin-left: auto; color: #909399; font-size: 12px; white-space: nowrap; }
.subflow-results__error {
margin: 4px 4px 8px 31px;
padding: 7px 9px;
border-left: 3px solid var(--el-color-danger);
background: var(--el-color-danger-light-9);
color: var(--el-color-danger-dark-2);
overflow-wrap: anywhere;
}
.subflow-results__data { margin: 6px 4px 10px 31px; }
.subflow-results__label { margin-bottom: 5px; color: #667085; font-size: 12px; font-weight: 600; }
.subflow-results__final-output { margin-top: 10px; padding-top: 10px; border-top: 1px solid #ebeef5; }
.subflow-results__final-output summary { margin-bottom: 8px; color: #344054; cursor: pointer; font-size: 12px; font-weight: 600; }
</style>

View File

@ -0,0 +1,909 @@
<template>
<div class="task-log" :style="{ height: `${height}px` }">
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
<template v-for="(log, index) in displayLogs" :key="log.__displayKey || log.id || index">
<button
v-if="log.__iterationHeader"
type="button"
class="task-log__iteration-header"
@click="toggleIteration(log.__iterationKey)"
>
<el-icon :class="{ 'is-expanded': isIterationExpanded(log.__iterationKey) }"><ArrowRight /></el-icon>
<span class="task-log__iteration-title">{{ log.__iterationLabel }}</span>
<el-tag :type="log.__statusType" size="small">{{ log.__statusLabel }}</el-tag>
<el-tag v-if="log.__analysisLabel" :type="log.__analysisType" size="small">
{{ log.__analysisLabel }}
</el-tag>
<span class="task-log__iteration-count">{{ log.__count }} 条日志</span>
</button>
<div v-else class="task-log__item" :class="{ 'is-iteration-child': iterationPathOf(log).length }">
<div class="task-log__time">{{ formatDate(logTimeOf(log)) }}</div>
<div class="task-log__body">
<div class="task-log__content">{{ logContentOf(log) }}</div>
<div v-if="displayErrorOf(log)" class="task-log__error">
<strong>失败原因</strong>
<span>{{ displayErrorOf(log) }}</span>
</div>
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
<div v-if="analysisOf(log)" class="task-log__analysis">
<!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 -->
<div class="task-log__analysis-heading">
<el-tag :type="analysisOf(log).tagType" size="small">
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
</el-tag>
<span v-if="isDebugView && analysisOf(log).modelName" class="task-log__analysis-meta">
模型:{{ analysisOf(log).modelName }}
</span>
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
耗时:{{ analysisOf(log).timingMs }}ms
</span>
</div>
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 -->
<div class="task-log__analysis-grid">
<div v-if="analysisOf(log).targetLabel" class="task-log__analysis-field">
<span>目标声音</span>
<strong>{{ analysisOf(log).targetLabel }}</strong>
</div>
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
<span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</span>
<strong>{{ analysisOf(log).label }}</strong>
</div>
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field">
<span>匹配状态</span>
<strong>{{ analysisOf(log).matchLabel }}</strong>
</div>
<div v-if="analysisOf(log).confidence !== null" class="task-log__analysis-field">
<span>置信度</span>
<strong>{{ analysisOf(log).confidence }}%</strong>
</div>
<div v-if="analysisOf(log).passedLabel" class="task-log__analysis-field">
<span>判断结果</span>
<strong>{{ analysisOf(log).passedLabel }}</strong>
</div>
</div>
<div v-if="analysisOf(log).summary" class="task-log__analysis-summary">
{{ analysisOf(log).summary }}
</div>
<!-- 关键事件可能来自模型输出数组,analysisOf 已限制最多展示前 20 条。 -->
<div v-if="analysisOf(log).events.length" class="task-log__analysis-events">
<div class="task-log__analysis-label">关键事件</div>
<div v-for="(event, eventIndex) in analysisOf(log).events" :key="eventIndex" class="task-log__analysis-event">
{{ event }}
</div>
</div>
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 -->
<pre v-if="isDebugView && analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
</div>
<pre v-if="isDebugView && genericOutputOf(log)" class="task-log__output">{{ genericOutputOf(log) }}</pre>
<div v-else-if="!isDebugView && businessSummaryOf(log)" class="task-log__business-summary">
{{ businessSummaryOf(log) }}
</div>
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
<div v-if="mediaOf(log).length" class="task-log__media">
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
<el-image
v-if="media.type === 'image'"
class="task-log__image"
:src="media.url"
:preview-src-list="imageUrlsOf(log)"
preview-teleported
fit="cover"
/>
<!-- 视频和音频只预加载元数据,避免日志列表初次渲染时下载完整媒体文件。 -->
<video v-else-if="media.type === 'video'" class="task-log__video" :src="media.url" controls preload="metadata" />
<audio v-else class="task-log__audio" :src="media.url" controls preload="metadata" />
</template>
</div>
</div>
</div>
</template>
</div>
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
<el-tooltip v-if="!isAtBottom" content="跳到最新日志" placement="left">
<el-button
class="task-log__jump-latest"
type="primary"
:icon="Bottom"
circle
aria-label="跳到最新日志"
@click.stop="scrollToBottom"
/>
</el-tooltip>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ArrowRight, Bottom } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/index'
/**
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
* height: 日志容器固定高度;内容超出后在容器内滚动,默认 150px。
*/
const props = defineProps({
list: { type: Array, default: () => [] },
height: { type: Number, default: 150 },
mode: { type: String, default: 'debug' },
})
const isDebugView = computed(() => props.mode !== 'user')
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
const messageContainer = ref(null)
// 使用少量像素容差吸收浏览器对子像素和缩放比例的取整误差,避免视觉上已到底部却仍显示按钮。
const BOTTOM_THRESHOLD = 8
// 初始状态视为位于底部;组件挂载后会根据真实滚动尺寸完成第一次定位。
const isAtBottom = ref(true)
// 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。
const mediaCache = new WeakMap()
/**
* 将扩展结果安全转换为普通对象。
* 后端历史数据可能返回 JSON 字符串,新版接口可能直接返回对象;空值、基础类型或非法 JSON 均降级为空对象,
* 避免一条格式异常的日志阻断整个列表渲染。
*/
const parseObject = (value) => {
if (!value) return {}
if (typeof value === 'object') return value
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
// 统一合并新版 paramsOut 和旧版 actualResult/extraInfo,后写入的 extraInfo 拥有最高展示优先级。
const outputOf = (log) => ({
...parseObject(log?.paramsOut),
...parseObject(log?.actualResult),
...parseObject(log?.extraInfo),
})
// _executionContext 保存后端补充的运行上下文,例如循环层级、事件类型和任务内出现次数。
const executionContextOf = (log) => outputOf(log)._executionContext || {}
/**
* 从新旧字段中读取循环路径,并兼容后端可能返回的数组、数字或分隔字符串。
* 返回空数组表示该日志不属于循环轮次。
*/
const rawIterationPathOf = (log) => {
const values = [log?.iterations, executionContextOf(log).iterations, log?.iteration]
for (const value of values) {
const path = Array.isArray(value)
? value.filter(item => item !== null && item !== '')
: value === null || value === undefined || value === ''
? []
: String(value).split(/[-.,/]/).map(item => item.trim()).filter(Boolean)
if (path.length) return path
}
return []
}
// 历史 NODE_STARTED 记录入库时还没有写入循环路径,通过同一节点后续终态事件反推,
// 这样旧任务日志也能被折叠进对应轮次,而不需要修改数据库历史数据。
const inferredIterationPaths = computed(() => {
const result = new WeakMap()
const pendingStarts = new Map()
;(Array.isArray(props.list) ? props.list : []).forEach((log) => {
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || '')
if (!nodeKey) return
if (eventType === 'NODE_STARTED') {
if (!rawIterationPathOf(log).length) {
if (!pendingStarts.has(nodeKey)) pendingStarts.set(nodeKey, [])
pendingStarts.get(nodeKey).push(log)
}
return
}
if (!['NODE_COMPLETED', 'NODE_FAILED'].includes(eventType)) return
const pending = pendingStarts.get(nodeKey)
if (!pending?.length) return
const startedLog = pending.shift()
const path = rawIterationPathOf(log)
if (path.length) result.set(startedLog, path)
})
return result
})
// 优先使用日志自身的循环路径;没有时再使用历史日志的推断结果。
const iterationPathOf = (log) => {
const path = rawIterationPathOf(log)
return path.length ? path : inferredIterationPaths.value.get(log) || []
}
// 节点标识 + 循环路径 + 任务内出现次数共同决定一条开始/结束事件是否属于同一次执行。
const executionKeyOf = (log) => {
const context = executionContextOf(log)
const nodeKey = log?.nodeId || log?.executionStep || log?.nodeName || log?.logContent || ''
return `${nodeKey}:${iterationPathOf(log).join('.')}:${context.itemOccurrence || ''}`
}
// 面向用户的日志在节点运行中保留开始事件,终态事件到达后替换开始事件;
// 开始、结束、循环和子流程这类结构节点没有业务信息,非失败场景下隐藏。
const visibleLogs = computed(() => {
const source = Array.isArray(props.list) ? props.list : []
if (isDebugView.value) return source
const terminalKeys = new Set(source
.filter((log) => ['NODE_COMPLETED', 'NODE_FAILED'].includes(
String(executionContextOf(log).eventType || '').toUpperCase(),
))
.map(executionKeyOf))
const structuralTypes = new Set(['START', 'END', 'SUB_START', 'SUB_END', 'SUB_FLOW', 'LOOP'])
return source.filter((log) => {
const context = executionContextOf(log)
const eventType = String(context.eventType || '').toUpperCase()
const nodeType = String(context.nodeType || log?.nodeType || '').toUpperCase()
if (eventType === 'NODE_STARTED' && terminalKeys.has(executionKeyOf(log))) return false
if (structuralTypes.has(nodeType) && !errorOf(log)) return false
return true
})
})
const logTimeOf = (log) => log?.logTime || log?.createTime || log?.startTime || log?.endTime
const logContentOf = (log) => log?.logContent || log?.message
|| [log?.action || log?.nodeType, log?.status].filter(Boolean).join(' - ')
|| '节点执行记录'
/**
* 提取循环折叠标题上的分析判定。非判定型日志返回 null,避免普通节点影响“分析通过”摘要。
*/
const analysisPassedOf = (log) => {
const output = outputOf(log)
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
const isDecisionAnalysis = analysisType.includes('AUDIO') || analysisType.includes('VIDEO')
|| profileCode.includes('audio') || profileCode.includes('video')
|| [3, 4].includes(Number(log?.logType))
if (!isDecisionAnalysis) return null
if (['FAILED', 'ERROR'].includes(String(output.analysisStatus || '').toUpperCase())) return false
if (output.passed === undefined || output.passed === null) return null
return output.passed === true || String(output.passed).toLowerCase() === 'true'
}
const expandedIterations = ref(new Set())
const isIterationExpanded = key => expandedIterations.value.has(key)
const toggleIteration = (key) => {
const next = new Set(expandedIterations.value)
next.has(key) ? next.delete(key) : next.add(key)
expandedIterations.value = next
}
/**
* 根据同一循环轮次内的日志状态推导折叠标题状态。
* 优先级为失败 > 正在执行 > 已完成,兼容数字状态和事件型上下文。
*/
const statusOfIteration = (logs) => {
const statuses = logs.map(log => String(log?.status ?? log?.logStatus ?? '').toUpperCase())
const contexts = logs.map(log => String(executionContextOf(log).eventType || '').toUpperCase())
if (statuses.some(status => ['2', 'FAILED', 'FAILURE', 'ERROR'].includes(status))
|| contexts.includes('NODE_FAILED')) {
return { label: '执行失败', type: 'danger' }
}
const latestEventByNode = new Map()
logs.forEach((log, index) => {
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
if (!eventType) return
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || index)
latestEventByNode.set(nodeKey, eventType)
})
if ([...latestEventByNode.values()].some(eventType => eventType === 'NODE_STARTED')) {
return { label: '执行中', type: 'warning' }
}
if (latestEventByNode.size) return { label: '执行完成', type: 'success' }
if (statuses.some(status => ['RUNNING', '1'].includes(status))) {
return { label: '执行中', type: 'warning' }
}
return { label: '执行完成', type: 'success' }
}
/**
* 把扁平日志列表转换成“循环标题 + 可展开子日志”的展示序列。
* 这一步只改变展示结构,不修改 props.list 中的原始日志对象。
*/
const displayLogs = computed(() => {
const source = visibleLogs.value
const groups = new Map()
source.forEach((log) => {
const path = iterationPathOf(log)
if (!path.length) return
const key = path.join('.')
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(log)
})
const emitted = new Set()
const result = []
source.forEach((log, sourceIndex) => {
const path = iterationPathOf(log)
if (!path.length) {
result.push(log)
return
}
const key = path.join('.')
if (emitted.has(key)) return
emitted.add(key)
const logs = groups.get(key) || []
const status = statusOfIteration(logs)
const decisions = logs.map(analysisPassedOf).filter(value => value !== null)
const analysisPassed = decisions.length ? decisions.every(Boolean) : null
result.push({
__iterationHeader: true,
__displayKey: `iteration:${key}:${sourceIndex}`,
__iterationKey: key,
__iterationLabel: `第 ${path.join('.')} 次循环`,
__count: logs.length,
__statusLabel: status.label,
__statusType: status.type,
__analysisLabel: analysisPassed === null ? '' : analysisPassed ? '分析通过' : '分析未通过',
__analysisType: analysisPassed ? 'success' : 'danger',
})
if (isIterationExpanded(key)) result.push(...logs)
})
return result
})
// 调试模式展示未被分析面板消费的原始输出,方便排查节点执行结果。
const genericOutputOf = (log) => {
if (analysisOf(log)) return ''
const output = { ...outputOf(log) }
delete output._executionContext
delete output.errorMessage
if (!Object.keys(output).length) return ''
return JSON.stringify(output, null, 2)
}
// 用户模式只保留最有业务含义的一行摘要,避免把完整 JSON 暴露给非调试场景。
const businessSummaryOf = (log) => {
if (analysisOf(log) || mediaOf(log).length) return ''
const output = { ...outputOf(log) }
delete output._executionContext
if (typeof output.result === 'string' && output.result.trim()) return output.result.trim()
if (['number', 'boolean'].includes(typeof output.result)) return `结果:${output.result}`
if (Array.isArray(output.coordinates) && output.coordinates.length) {
return `坐标:${output.coordinates.join(', ')}`
}
if (Number.isFinite(Number(output.completedSteps))) return `已完成 ${output.completedSteps} 个步骤`
if (output.success === true) return '操作成功'
return ''
}
/**
* 把一个或多个媒体 URL 追加到结果数组。
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
* 非字符串、空白字符串和已经添加过的条目会被忽略。
*/
const addMedia = (result, seen, type, value) => {
const values = Array.isArray(value) ? value : [value]
values.forEach((item) => {
const url = typeof item === 'string' ? item.trim() : ''
const key = `${type}:${url}`
if (url && !seen.has(key)) {
seen.add(key)
result.push({ type, url })
}
})
}
// 分析结果的计算相对复杂,使用 WeakMap 保证模板多次调用 analysisOf(log) 时只解析一次。
const analysisCache = new WeakMap()
const errorCache = new WeakMap()
const errorOf = (log) => {
if (!log || typeof log !== 'object') return ''
if (errorCache.has(log)) return errorCache.get(log)
const actualResult = parseObject(log.actualResult)
const extraInfo = parseObject(log.extraInfo)
const paramsOut = parseObject(log.paramsOut)
const status = String(log.status ?? log.logStatus ?? '').trim().toLowerCase()
const failed = ['2', 'failed', 'failure', 'error'].includes(status)
const candidates = [
log.errorMessage,
log.errorStack,
actualResult.errorMessage,
actualResult.error,
extraInfo.errorMessage,
extraInfo.error,
paramsOut.errorMessage,
paramsOut.error,
failed ? log.message : '',
]
const result = candidates.find(value => typeof value === 'string' && value.trim())?.trim() || ''
errorCache.set(log, result)
return result
}
const displayErrorOf = (log) => {
const error = errorOf(log)
if (!error || isDebugView.value) return error
return error
.split(/\r?\n/)[0]
.replace(/^(?:[\w$.]+(?:Exception|Error):\s*)+/, '')
.replace(/^执行异常[::]\s*/, '')
.trim()
}
/**
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
* 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
*/
const analysisOf = (log) => {
// 防御非对象输入,同时优先读取缓存,避免模板中多个字段重复做 JSON.parse 和格式化工作。
if (!log || typeof log !== 'object') return null
if (analysisCache.has(log)) return analysisCache.get(log)
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
const output = {
...parseObject(log.paramsOut),
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
const isAudio = analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4
const isVideo = analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3
// 图片分析没有历史 logType 枚举,只依赖新协议中的 analysisType/profileCode 识别。
const isImage = analysisType.includes('IMAGE') || profileCode.includes('image')
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
const hasResult = Boolean(
output.analysisStatus
|| output.analysisType
|| output.profileCode
|| output.label
|| output.labelName
|| output.summary
|| output.conclusion
|| output.resultText
|| output.result !== undefined
|| output.passed !== undefined
)
// 非分析日志也缓存 null,避免之后每次渲染都重复执行同一套识别逻辑。
if (!hasResult || (!isAudio && !isVideo && !isImage)) {
analysisCache.set(log, null)
return null
}
const status = String(output.analysisStatus || '').toUpperCase()
const matched = output.matched
const passed = output.passed
const confidenceValue = Number(output.confidence)
// 置信度兼容 0~1 和 0~100 两种协议,统一限制到 0~100 并保留一位小数。
// 非数字值返回 null,模板据此隐藏置信度字段,而不是展示 NaN。
const confidence = Number.isFinite(confidenceValue)
? Math.max(0, Math.min(100, confidenceValue <= 1 ? confidenceValue * 100 : confidenceValue)).toFixed(1)
: null
// 常见成功/失败枚举转换为中文;未知状态保留原值,完全缺失时使用中性的“已返回结果”。
const statusLabel = status === 'SUCCEEDED' || status === 'SUCCESS'
|| status === 'COMPLETED'
? '分析完成'
: status === 'FAILED' || status === 'ERROR'
? '分析失败'
: status || '已返回结果'
// 复制一份原始输出用于生成扩展详情,后续删除操作不会修改日志本身或合并后的 output。
const detailSource = { ...output }
// _executionContext 只用于前端把日志归属到任务中的检测项和执行次数,属于内部路由元数据。
// 它与已经结构化展示的分析字段一起从“详情”中移除,避免用户看到重复且难以理解的底层上下文。
;[
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
'mediaUrl', 'mediaUrls', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
'expectedLabel', 'expectedLabelName', 'decisionReason', 'resultText', 'result',
].forEach((key) => delete detailSource[key])
// 所有已知字段删除后还有剩余内容时,才生成详情 JSON,避免显示空对象。
const hasDetails = Object.keys(detailSource).length > 0
// 统一生成展示模型,模板不需要了解各版本日志字段和状态枚举的差异。
const result = {
title: isAudio ? '声音类型检测' : isImage ? '通用图片分析' : '视频分析',
statusLabel,
// 分析失败或业务判断未通过使用红色;仅匹配失败使用警告色,其余使用成功色。
tagType: status === 'FAILED' || status === 'ERROR' || passed === false
? 'danger'
: matched === false ? 'warning' : 'success',
modelName: typeof output.analysisModel === 'string'
? output.analysisModel
: output.analysisModel?.name || '',
timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null,
label: output.labelName || output.label || '',
targetLabel: output.expectedLabelName || output.expectedLabel || '',
matchLabel: isAudio ? '' : matched === true ? '匹配' : matched === false ? '未匹配' : '',
confidence,
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
// 图片分析由提示词决定输出形态,优先展示模型文本或字符串结果,避免把枚举型原因当摘要。
summary: output.summary || output.conclusion || output.resultText
|| (typeof output.result === 'string' ? output.result : '')
|| (/^[A-Z_]+$/.test(String(output.decisionReason || '')) ? '' : output.decisionReason)
|| '',
isAudio,
events: Array.isArray(output.events)
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
: [],
details: hasDetails ? JSON.stringify(detailSource, null, 2) : '',
}
// 正常分析结果也写入缓存,后续模板读取状态、模型、摘要等字段时直接复用同一对象。
analysisCache.set(log, result)
return result
}
/**
* 从日志的新旧字段中提取所有可播放/预览的媒体资源。
* 返回值格式统一为 { type, url },模板无需关心 URL 原来位于 actualResult、extraInfo 还是日志顶层。
*/
const mediaOf = (log) => {
if (!log || typeof log !== 'object') return []
if (mediaCache.has(log)) return mediaCache.get(log)
const result = []
const seen = new Set()
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
const output = {
...parseObject(log.paramsOut),
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
// 优先读取类型明确的新字段,它们不需要额外推断媒体类型。
addMedia(result, seen, 'image', output.imageUrl)
addMedia(result, seen, 'video', output.videoUrl)
addMedia(result, seen, 'audio', output.audioUrl)
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
// mediaUrl 本身没有类型,必须结合分析类型或 profileCode 后才能作为视频/音频加入。
if (analysisType.includes('VIDEO') || profileCode.includes('video')) addMedia(result, seen, 'video', output.mediaUrl)
if (analysisType.includes('AUDIO') || profileCode.includes('audio')) addMedia(result, seen, 'audio', output.mediaUrl)
if (analysisType.includes('IMAGE') || profileCode.includes('image')) {
addMedia(result, seen, 'image', output.mediaUrls)
addMedia(result, seen, 'image', output.mediaUrl)
}
addMedia(result, seen, 'image', log.screenshotUrl)
addMedia(result, seen, 'video', log.videoUrl)
// 旧版日志把媒体统一放在顶层 mediaUrl,并通过 logType=2/3/4 区分图片、视频和音频。
const legacyType = Number(log.logType) === 2 ? 'image'
: Number(log.logType) === 3 ? 'video'
: Number(log.logType) === 4 ? 'audio' : ''
if (legacyType) addMedia(result, seen, legacyType, log.mediaUrl)
// 即使没有找到媒体也缓存空数组,避免无媒体日志在每次渲染时重复扫描全部字段。
mediaCache.set(log, result)
return result
}
// Element Plus 图片预览需要同一条日志下的纯图片 URL 数组,视频和音频不会进入预览列表。
const imageUrlsOf = (log) => mediaOf(log)
.filter((item) => item.type === 'image')
.map((item) => item.url)
/**
* 根据滚动层的剩余距离更新“是否位于底部”。
* 用户拖动滚动条、滚轮滚动以及程序修改 scrollTop 都会触发 scroll 事件,因此只需要维护一个状态源。
*/
const updateBottomState = () => {
const container = messageContainer.value
if (!container) {
isAtBottom.value = true
return
}
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
isAtBottom.value = distanceToBottom <= BOTTOM_THRESHOLD
}
// 滚动事件只做轻量尺寸计算,不写入 scrollTop,避免干扰用户正在进行的历史日志浏览。
const handleScroll = () => updateBottomState()
/**
* 将滚动位置移动到最新日志,并恢复自动跟随状态。
* nextTick 确保新日志已经渲染,scrollHeight 是更新后的完整内容高度。
*/
const scrollToBottom = async () => {
await nextTick()
const container = messageContainer.value
if (!container) return
container.scrollTop = container.scrollHeight
isAtBottom.value = true
}
/**
* 日志变化时先读取更新前的滚动状态:
* - 原本在底部,说明用户正在追踪最新日志,DOM 更新后继续滚到底部;
* - 原本不在底部,说明用户正在查看历史内容,只更新按钮状态,不改变其 scrollTop。
* deep 监听同时覆盖追加日志和现有日志内容更新。
*/
watch(() => props.list, () => {
if (isAtBottom.value) {
scrollToBottom()
} else {
nextTick(updateBottomState)
}
}, { deep: true })
// 首次进入日志视图仍默认展示最新一条,此时尚不存在用户主动选择的历史位置。
onMounted(scrollToBottom)
</script>
<style scoped lang="scss">
/* 外层容器固定高度并裁剪内容,同时作为“跳到最新日志”悬浮按钮的定位上下文。 */
.task-log {
position: relative;
box-sizing: border-box;
margin-top: 12px;
overflow: hidden;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
}
/* 只有内部区域负责滚动,悬浮按钮不会随日志内容一起移出可视区域。 */
.task-log__scroll {
box-sizing: border-box;
height: 100%;
padding: 12px;
overflow-y: auto;
}
/* 固定在滚动视口右下角,不参与日志排版;稳定尺寸避免按钮出现时挤压或移动内容。 */
.task-log__jump-latest {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 2;
width: 36px;
height: 36px;
box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
}
/* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
.task-log__item {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
gap: 12px;
align-items: start;
margin-bottom: 10px;
}
.task-log__iteration-header {
display: flex;
width: 100%;
min-height: 38px;
gap: 8px;
align-items: center;
margin: 8px 0;
padding: 7px 10px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #f7f8fa;
color: #303133;
cursor: pointer;
text-align: left;
}
.task-log__iteration-header .el-icon {
flex: 0 0 auto;
transition: transform 0.18s ease;
}
.task-log__iteration-header .el-icon.is-expanded {
transform: rotate(90deg);
}
.task-log__iteration-title {
min-width: 0;
font-size: 14px;
font-weight: 600;
}
.task-log__iteration-count {
margin-left: auto;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.task-log__item.is-iteration-child {
margin-left: 18px;
padding-left: 10px;
border-left: 2px solid #e4e7ed;
}
.task-log__time {
color: #999;
font-size: 13px;
white-space: nowrap;
}
.task-log__body {
min-width: 0;
}
.task-log__content {
color: #333;
font-size: 14px;
line-height: 20px;
}
.task-log__error {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px;
margin-top: 7px;
padding: 8px 10px;
border-left: 3px solid var(--el-color-danger);
background: var(--el-color-danger-light-9);
color: var(--el-color-danger-dark-2);
font-size: 13px;
line-height: 19px;
}
.task-log__error span {
min-width: 0;
overflow-wrap: anywhere;
}
/* 分析结果使用左侧强调线与普通日志正文区分,但保持在同一条日志的内容列中。 */
.task-log__analysis {
margin-top: 9px;
padding: 10px 12px;
border-left: 3px solid #5b8ff9;
background: #f6f8fc;
color: #344054;
}
.task-log__analysis-heading {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.task-log__analysis-meta,
.task-log__analysis-field span,
.task-log__analysis-label {
color: #667085;
font-size: 12px;
}
/* 四列摘要分别承载识别结果、匹配状态、置信度和判断结果。 */
.task-log__analysis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 9px;
}
.task-log__analysis-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.task-log__analysis-field strong {
overflow: hidden;
color: #1f2937;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-log__analysis-summary {
margin-top: 9px;
color: #344054;
line-height: 20px;
}
.task-log__analysis-events {
margin-top: 9px;
}
.task-log__analysis-event {
margin-top: 3px;
color: #475467;
font-size: 12px;
line-height: 18px;
}
/* 原始扩展详情限制最大高度并允许内部滚动,长 JSON 不会无限撑高单条日志。 */
.task-log__analysis-details {
max-height: 180px;
margin: 9px 0 0;
overflow: auto;
padding: 8px;
background: #fff;
color: #475467;
font-size: 11px;
line-height: 16px;
white-space: pre-wrap;
word-break: break-word;
}
.task-log__output {
max-height: 180px;
margin: 8px 0 0;
overflow: auto;
padding: 8px 10px;
border-left: 3px solid #67c23a;
background: #f5f7fa;
color: #475467;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
word-break: break-word;
}
.task-log__business-summary {
margin-top: 8px;
padding: 8px 10px;
border-left: 3px solid #67c23a;
background: #f3faf0;
color: #3f5f39;
line-height: 20px;
overflow-wrap: anywhere;
}
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
.task-log__media {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.task-log__image {
width: 96px;
height: 72px;
border-radius: 4px;
}
.task-log__video {
width: min(320px, 100%);
max-height: 220px;
background: #111;
border-radius: 4px;
}
.task-log__audio {
width: min(340px, 100%);
height: 40px;
}
/* 窄屏改为上下布局,并把分析摘要缩减为两列,保证字段名称和结果仍有可读宽度。 */
@media (max-width: 640px) {
.task-log__item {
grid-template-columns: 1fr;
gap: 3px;
}
.task-log__analysis-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@ -1,141 +1,319 @@
<!-- 流程试运行:分配机器人角色并收集业务运行参数。 -->
<template> <template>
<el-drawer <el-drawer :model-value="props.drawer" title="试运行" size="min(620px, 96vw)" :before-close="handleClose">
:model-value="props.drawer" <div class="section-heading">本次执行安排</div>
title="试运行" <div class="assignment-tip">流程只保存角色分工,以下机器人和设备仅用于本次运行。</div>
:before-close="handleClose"
> <div v-for="role in roles" :key="role.roleKey" class="assignment-row">
<el-form <div class="assignment-row__title">
ref="ruleFormRef" <span>{{ role.displayName }}</span>
style="max-width: 600px" <el-tag v-if="role.required" size="small" type="info">必需</el-tag>
:model="{ tableData }" </div>
:rules="rules" <el-form label-position="top">
label-width="auto" <el-form-item label="执行机器人" required>
> <el-select
<el-table :model-value="getMember(role).robotId"
:data="tableData" filterable
style="width: 100%; margin-bottom: 20px" :loading="robotLoading"
row-key="name" placeholder="请选择机器人"
default-expand-all style="width: 100%"
> @change="(robotId) => handleRobotChange(role, robotId)"
<el-table-column prop="name" label="入参名称"> >
<template #default="{row}"> <el-option v-for="item in robotOptions" :key="item.value" :label="item.label" :value="item.value" />
<span>{{ row.name }}</span> </el-select>
<span v-if="row.required" style="color: #f56c6c; margin-left: 8px;">*</span> </el-form-item>
</template> <el-form-item v-for="slot in role.deviceSlots" :key="slot.slotKey" :label="slot.displayName" :required="slot.required">
</el-table-column> <el-select
<el-table-column prop="type" label="入参类型"> v-model="getMember(role).devices[slot.slotKey]"
<template #default="scope"> filterable
<el-tag>{{ scope.row.type }}</el-tag> :loading="loadingRoleKey === role.roleKey"
</template> :disabled="!getMember(role).robotId"
</el-table-column> :placeholder="getMember(role).robotId ? '请选择设备' : '请先选择机器人'"
<el-table-column prop="value" label="入参值"> style="width: 100%"
<template #default="{ row, column, $index }"> >
<el-form-item v-if="row.name === 'terminalId'" label="" :prop="`tableData${row.propPath}value`" :rules="row.required ? [{ required: true, message: '请输入变量名', trigger: 'blur' }] : []"> <el-option
<el-select v-model="row.value" style="width: 200px"> v-for="item in getSlotOptions(role, slot)"
<el-option :key="item.value"
v-for="item in terminalIdOptions" :label="item.label"
:key="item.value" :value="item.value"
:label="item.label" />
:value="item.value" </el-select>
/> <div v-if="getMember(role).robotId && !getSlotOptions(role, slot).length" class="assignment-error">
</el-select> 该机器人没有匹配的在线设备
</el-form-item> </div>
</el-form-item>
<el-form-item v-else-if="row.type !== 'object' && !row.type.includes('array')" label="" :prop="`tableData${row.propPath}value`" :rules="row.required ? [{ required: true, message: '请输入变量名', trigger: 'blur' }] : []"> </el-form>
<el-input v-if="row.type === 'string'" v-model="row.value" /> </div>
<el-input-number v-if="row.type === 'number'" :controls="false" v-model="row.value" />
<el-switch v-if="row.type === 'boolean'" v-model="row.value" /> <div class="section-heading section-heading--params">运行参数</div>
</el-form-item> <el-form ref="ruleFormRef" :model="{ tableData }" label-width="auto">
<el-form-item v-else-if="row.type !== 'object' && row.type.includes('array')" label="" :prop="`tableData${row.propPath}value`" :rules="row.required ? [{ required: true, message: '请输入变量名', trigger: 'blur' }] : []"> <el-table :data="tableData" row-key="name" default-expand-all style="width: 100%; margin-bottom: 20px">
<el-input v-model="row.value" placeholder="请输入数组值,格式如:[1,2,3]" /> <el-table-column prop="name" label="参数">
</el-form-item> <template #default="{ row }">
</template> <span>{{ row.name }}</span><span v-if="row.required" class="required-mark">*</span>
</el-table-column> </template>
</el-table> </el-table-column>
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }"><el-tag>{{ row.type }}</el-tag></template>
</el-table-column>
<el-table-column prop="value" label="值">
<template #default="{ row }">
<el-form-item
v-if="row.type !== 'object' && !row.type.includes('array')"
:prop="`tableData${row.propPath}value`"
:rules="row.required ? [{ required: true, message: '请填写参数', trigger: 'blur' }] : []"
>
<el-input v-if="row.type === 'string'" v-model="row.value" />
<el-input-number v-else-if="row.type === 'number'" v-model="row.value" :controls="false" />
<el-switch v-else-if="row.type === 'boolean'" v-model="row.value" />
</el-form-item>
<el-form-item
v-else-if="row.type !== 'object'"
:prop="`tableData${row.propPath}value`"
:rules="row.required ? [{ required: true, message: '请填写参数', trigger: 'blur' }] : []"
>
<el-input v-model="row.value" placeholder="数组格式,例如:[1,2,3]" />
</el-form-item>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!tableData.length" :image-size="52" description="没有需要填写的运行参数" />
</el-form> </el-form>
<template #footer>
<el-button @click="handleClose">关闭</el-button> <template #footer>
<el-button type="primary" @click="confirm">确定</el-button> <el-button @click="handleClose">关闭</el-button>
</template> <el-button type="primary" :loading="submitting" @click="confirm">开始运行</el-button>
</el-drawer> </template>
</el-drawer>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import { getStartNodeFormData, formatTableData } from '@/utils/flow' import { ElMessage } from 'element-plus'
// import { Plus, Minus } from '@element-plus/icons-vue' import { flowExecuteTrial } from '@/api/flow/flow'
import { flowExecuteTrial } from '@/api/device/flow' import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { emitter } from '@/utils/eventBus'; import { emitter } from '@/utils/eventBus'
import { ElMessage } from 'element-plus'; import { formatTableData, getStartNodeFormData } from '@/utils/flow'
import {listTerminal} from "@/api/device/terminal.js"; import { toDeviceOptions } from '@/utils/robotDevice'
import {
const props = defineProps({ getResourceRoles,
drawer: Boolean, normalizeFlowResourceSchema,
flowId: String sanitizeFlowResourceDefinition,
}) validateFlowResources,
} from '@/utils/flowResources'
const ruleFormRef = ref()
const rules = ref({});
const tableData = ref([])
const props = defineProps({ drawer: Boolean, flowId: String })
const emits = defineEmits(['close', 'changeState']) const emits = defineEmits(['close', 'changeState'])
const handleClose = () => { const ruleFormRef = ref()
ruleFormRef.value?.resetFields() const tableData = ref([])
emits('close') const roles = ref([])
const runtimeAssignments = ref([])
const robotOptions = ref([])
const devicesByRole = reactive({})
const robotLoading = ref(false)
const loadingRoleKey = ref('')
const submitting = ref(false)
const trialRunCache = new Map()
const clone = (value) => JSON.parse(JSON.stringify(value || []))
const cacheKey = () => props.flowId || '__new_flow__'
const mergeCachedValues = (source, cached) => {
const cachedByName = new Map((cached || []).map((item) => [item.name, item]))
return (source || []).map((item, index) => {
const cachedItem = cached?.[index]?.name === item.name ? cached[index] : cachedByName.get(item.name)
if (!cachedItem) return item
const merged = { ...item, value: cachedItem.value }
if (Array.isArray(item.children)) merged.children = mergeCachedValues(item.children, cachedItem.children)
return merged
})
} }
watch(() => props.drawer, const getAssignment = (role) => runtimeAssignments.value.find((item) => item.roleKey === role.roleKey)
(newVal) => { const getMember = (role) => getAssignment(role).members[0]
if (newVal) {
const data = getStartNodeFormData()
tableData.value = data
handleGetTerminalGroup()
}
}
)
const confirm = () => { const createAssignments = (cached = []) => roles.value.map((role) => {
lf.fitView() const old = cached.find((item) => item.roleKey === role.roleKey)
ruleFormRef.value.validate().then(async () => { return old || {
const formData = formatTableData(tableData.value) roleKey: role.roleKey,
const { nodes } = lf.getGraphData() members: [{
nodes.forEach(item => { memberKey: `${role.roleKey}_1`,
emitter.emit('contentChange', { id: item.id}) robotId: role.legacyRobotId || '',
}) devices: Object.fromEntries((role.deviceSlots || []).map((slot) => [slot.slotKey, slot.legacyDeviceId || ''])),
}],
}
})
const flowData = JSON.stringify(lf.getGraphData()) const remember = () => {
const { terminalId, ...runParams } = formData trialRunCache.set(cacheKey(), {
const res = await flowExecuteTrial({ tableData: clone(tableData.value),
flowData, runtimeAssignments: clone(runtimeAssignments.value),
itemId: props.flowId, })
terminalId, }
runParams
}) const loadRobotOptions = async () => {
if (res.code === 200) { robotLoading.value = true
emits('changeState', { try {
type: 'testRunning', const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: 1 })
instId: res.msg robotOptions.value = (response.rows || []).map((robot) => ({
}) label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
} else { value: robot.robotId,
ElMessage.error(res.msg) }))
} } finally {
robotLoading.value = false
}
}
const getSlotOptions = (role, slot) => toDeviceOptions((devicesByRole[role.roleKey] || []).filter((device) => (
slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind)
)))
const loadRoleDevices = async (role, preserveValues = true) => {
const member = getMember(role)
if (!member.robotId) {
devicesByRole[role.roleKey] = []
return
}
loadingRoleKey.value = role.roleKey
try {
const response = await getRobotDevicesByRobotId(member.robotId)
devicesByRole[role.roleKey] = Array.isArray(response.data) ? response.data : []
role.deviceSlots.forEach((slot) => {
const options = getSlotOptions(role, slot)
const currentValid = options.some((option) => option.value === member.devices[slot.slotKey])
if (!preserveValues || !currentValid) member.devices[slot.slotKey] = options.length === 1 ? options[0].value : ''
}) })
.catch(() => {}) } catch (error) {
devicesByRole[role.roleKey] = []
ElMessage.error(`读取“${role.displayName}”的设备失败`)
} finally {
loadingRoleKey.value = ''
}
} }
const terminalIdOptions = ref([]) const handleRobotChange = async (role, robotId) => {
const member = getMember(role)
member.robotId = robotId
member.devices = Object.fromEntries(role.deviceSlots.map((slot) => [slot.slotKey, '']))
await loadRoleDevices(role, false)
}
/** 获取设备终端列表 */ const initialize = async () => {
const handleGetTerminalGroup = async (row) => { const graph = normalizeFlowResourceSchema(globalThis.lf.getGraphData())
const res = await listTerminal({ pageNum: 1, pageSize: 1000 }) roles.value = getResourceRoles(graph)
if (res.code === 200) { const cached = trialRunCache.get(cacheKey())
terminalIdOptions.value = res.rows?.map((item) => { tableData.value = mergeCachedValues(getStartNodeFormData(), cached?.tableData)
return { runtimeAssignments.value = createAssignments(cached?.runtimeAssignments)
label: `${item.name}(${item.host}:${item.port})`, await loadRobotOptions()
value: item.id await Promise.all(roles.value.filter((role) => getMember(role).robotId).map((role) => loadRoleDevices(role)))
} }
})
watch(() => props.drawer, (open) => { if (open) initialize() }, { immediate: true })
const handleClose = () => {
remember()
ruleFormRef.value?.clearValidate()
emits('close')
}
const validateAssignments = () => {
for (const role of roles.value) {
const member = getMember(role)
if (role.required && !member.robotId) return `请为“${role.displayName}”选择机器人`
for (const slot of role.deviceSlots || []) {
if (slot.required && !member.devices[slot.slotKey]) return `请为“${role.displayName}”选择“${slot.displayName}”`
} }
}
return ''
} }
const confirm = async () => {
const currentGraph = globalThis.lf.getGraphData()
const resourceValidation = validateFlowResources(currentGraph)
if (resourceValidation.errors.length) {
ElMessage.warning(resourceValidation.errors[0].message)
return
}
const assignmentError = validateAssignments()
if (assignmentError) {
ElMessage.warning(assignmentError)
return
}
const formValid = await ruleFormRef.value?.validate().catch(() => false)
if (formValid === false) return
remember()
submitting.value = true
try {
const formData = formatTableData(tableData.value)
resourceValidation.graph.nodes.forEach((node) => emitter.emit('contentChange', { id: node.id }))
const executionDefinition = sanitizeFlowResourceDefinition(globalThis.lf.getGraphData())
const firstRobotId = runtimeAssignments.value.flatMap((item) => item.members).find((member) => member.robotId)?.robotId || ''
const response = await flowExecuteTrial({
flowData: JSON.stringify(executionDefinition),
itemId: props.flowId,
robotId: firstRobotId,
runtimeAssignments: runtimeAssignments.value,
runParams: formData,
})
if (response.code !== 200) {
ElMessage.error(response.msg)
return
}
emits('changeState', { type: 'testRunning', instId: response.msg })
} finally {
submitting.value = false
}
}
</script> </script>
<style scoped lang="scss">
.section-heading {
margin: 6px 0 10px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
}
.section-heading--params {
margin-top: 24px;
}
.assignment-tip {
margin-bottom: 14px;
padding: 10px 12px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
.assignment-row {
padding: 14px 0 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.assignment-row__title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
font-weight: 650;
}
.assignment-error,
.required-mark {
color: var(--el-color-danger);
}
.assignment-error {
margin-top: 5px;
font-size: 12px;
}
.required-mark {
margin-left: 6px;
}
</style>

View File

@ -0,0 +1,585 @@
<template>
<div class="queue-editor">
<div class="queue-editor__heading">执行设置</div>
<el-form label-position="top">
<el-form-item label="由谁执行" required>
<el-select v-model="formData.executionTarget.roleKey" style="width: 100%" @change="handleRoleChange">
<el-option v-for="role in roles" :key="role.roleKey" :label="role.displayName" :value="role.roleKey" />
</el-select>
</el-form-item>
<el-form-item label="队列总超时(毫秒)" required>
<el-input-number v-model="formData.totalTimeoutMs" :min="1" :max="2147483647" style="width: 100%" />
</el-form-item>
<el-form-item label="动作标识(可选)">
<el-input v-model="formData.actionId" placeholder="留空时执行端自动生成" />
</el-form-item>
</el-form>
<div class="queue-editor__toolbar">
<div>
<div class="queue-editor__heading">动作步骤</div>
<div class="queue-editor__hint">步骤会在边缘端严格按顺序执行。</div>
</div>
<el-button type="primary" plain @click="addStep"><el-icon><Plus /></el-icon>添加步骤</el-button>
</div>
<el-collapse v-model="activeStepKeys" class="queue-steps">
<el-collapse-item v-for="(step, index) in formData.steps" :key="step.localKey" :name="step.localKey">
<template #title>
<div class="queue-step__header">
<div class="queue-step__title">
<span class="queue-step__index">{{ index + 1 }}</span>
<span class="queue-step__name">{{ getActionQueueStepName(step, index) }}</span>
<span class="queue-step__action">{{ actionLabel(step.type) }}</span>
</div>
<div class="queue-step__actions" @click.stop>
<el-tooltip content="单独执行此步骤" placement="top">
<el-button :icon="VideoPlay" circle size="small" type="primary" plain aria-label="单独执行此步骤" @click="runStep(index)" />
</el-tooltip>
<el-tooltip content="上移一步" placement="top">
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
</el-tooltip>
<el-tooltip content="下移一步" placement="top">
<el-button :icon="ArrowDown" circle size="small" :disabled="index === formData.steps.length - 1" aria-label="下移一步" @click="moveStep(index, 1)" />
</el-tooltip>
<el-tooltip content="复制步骤" placement="top">
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
</el-tooltip>
<el-dropdown v-if="step.sourceNode" trigger="click" @command="(placement) => requestExtractStep(index, placement)">
<el-button :icon="MoreFilled" circle size="small" aria-label="移出队列" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="before">移到队列前并连接</el-dropdown-item>
<el-dropdown-item command="after">移到队列后并连接</el-dropdown-item>
<el-dropdown-item command="detached" divided>仅移出,不连线</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-tooltip content="删除步骤" placement="top">
<el-button :icon="Delete" circle size="small" type="danger" plain aria-label="删除步骤" @click="removeStep(index)" />
</el-tooltip>
</div>
</div>
</template>
<div class="queue-step">
<el-form label-position="top">
<el-form-item label="步骤名称">
<el-input v-model="step.stepName" maxlength="50" show-word-limit placeholder="例如:移动到取料位" />
</el-form-item>
<el-form-item label="动作" required>
<el-select v-model="step.type" style="width: 100%" @change="handleStepTypeChange(step)">
<el-option v-for="item in actionTypes" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-if="deviceKindForType(step.type)" label="使用设备" required>
<el-select v-model="step.deviceSlotKey" style="width: 100%">
<el-option
v-for="slot in matchingSlots(step.type)"
:key="slot.slotKey"
:label="slot.displayName"
:value="slot.slotKey"
/>
</el-select>
<div v-if="!matchingSlots(step.type).length" class="queue-editor__warning">当前角色没有对应设备用途。</div>
</el-form-item>
<div v-if="stepHelperLabel(step.type)" class="queue-step__helper">
<el-button type="primary" plain :icon="Location" @click="openStepHelper(step)">
{{ stepHelperLabel(step.type) }}
</el-button>
</div>
<el-form-item label="步骤超时(毫秒)">
<el-input-number v-model="step.timeoutMs" :min="0" :max="2147483647" style="width: 100%" />
</el-form-item>
<template v-for="field in fieldsForType(step.type)" :key="field.key">
<el-form-item :label="field.label" :required="field.required !== false">
<el-select
v-if="field.key === 'stationId' && step.stationOptions?.length"
v-model="step.params[field.key]"
filterable
style="width: 100%"
>
<el-option v-for="option in step.stationOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select v-else-if="field.options" v-model="step.params[field.key]" style="width: 100%">
<el-option v-for="option in field.options" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-input-number
v-else-if="field.number"
v-model="step.params[field.key]"
:min="field.min"
:max="field.max ?? Infinity"
:step="field.step || 1"
style="width: 100%"
/>
<el-input v-else v-model="step.params[field.key]" :type="field.multiline ? 'textarea' : 'text'" :rows="3" />
</el-form-item>
</template>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
<el-empty v-if="!formData.steps.length" description="请添加至少一个动作步骤" :image-size="72" />
<el-dialog v-model="helperDialogVisible" :title="stepHelperLabel(helperStep?.type)" width="460" append-to-body>
<div class="queue-helper__tip">在线设备仅用于读取参数,不会写入流程定义。</div>
<el-form label-position="top">
<el-form-item label="机器人" required>
<el-select v-model="helperForm.robotId" filterable style="width: 100%" @change="loadHelperDevices">
<el-option v-for="item in helperRobotOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="执行设备" required>
<el-select v-model="helperForm.deviceId" filterable :loading="helperDeviceLoading" style="width: 100%">
<el-option v-for="item in helperDeviceOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="helperDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="helperSubmitting" @click="applyStepHelper">确认读取</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, MoreFilled, Plus, VideoPlay } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
import {
createDeviceSlot,
createResourceKey,
FLOW_NODE_RESOURCE_OWNER,
getResourceRoles,
getStartNode,
pruneUnusedNodeResources,
} from '@/utils/flowResources'
import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep } from '@/utils/actionQueue'
const props = defineProps({ data: { type: Object, required: true } })
const emit = defineEmits(['save-success', 'save-error', 'extract-step'])
const actionTypes = [
{ value: 'DELAY', label: '等待' },
{ value: 'ARM_MOVE_J', label: '机械臂关节运动' },
{ value: 'ARM_MOVE_L', label: '机械臂直线运动' },
{ value: 'AGV_NAVIGATE_TO_POSE', label: '底盘导航到坐标' },
{ value: 'AGV_NAVIGATE_TO_STATION', label: '底盘导航到站点' },
{ value: 'AGV_FOLLOW_PATH', label: '底盘按路径导航' },
]
const commonMotionFields = [
{ key: 'velocity', label: '速度', number: true, min: 0, step: 0.01, required: false },
{ key: 'acceleration', label: '加速度', number: true, min: 0, step: 0.01, required: false },
]
const fieldsByType = {
DELAY: [{ key: 'durationMs', label: '等待时长(毫秒)', number: true, min: 0 }],
ARM_MOVE_J: [{ key: 'positions', label: '关节位置(JSON 数组)', multiline: true }, ...commonMotionFields],
ARM_MOVE_L: [
...['x', 'y', 'z', 'rx', 'ry', 'rz'].map((key) => ({ key, label: key.toUpperCase(), number: true })),
{ key: 'frame', label: '坐标系', options: ['BASE', 'TOOL', 'WORLD', 'USER'].map((value) => ({ value, label: value })) },
...commonMotionFields,
],
AGV_NAVIGATE_TO_POSE: [
...['x', 'y', 'theta'].map((key) => ({ key, label: key === 'theta' ? '角度' : key.toUpperCase(), number: true })),
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
{ key: 'blockedTimeoutMs', label: '遇障超时(毫秒)', number: true, min: 1, step: 1000 },
],
AGV_NAVIGATE_TO_STATION: [
{ key: 'stationId', label: '站点 ID' },
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
{ key: 'blockedTimeoutMs', label: '遇障超时(毫秒)', number: true, min: 1, step: 1000 },
],
AGV_FOLLOW_PATH: [
{ key: 'path', label: '路径段(JSON 数组)', multiline: true },
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
{ key: 'blockedTimeoutMs', label: '遇障超时(毫秒)', number: true, min: 1, step: 1000 },
],
}
const defaultParams = {
DELAY: { durationMs: 1000 },
ARM_MOVE_J: { positions: '[0, 0, 0, 0, 0, 0]', velocity: 0.1, acceleration: 0.2 },
ARM_MOVE_L: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0, frame: 'BASE', velocity: 2.1, acceleration: 2.2 },
AGV_NAVIGATE_TO_POSE: { x: 0, y: 0, theta: 0, maxSpeed: 0.5, blockedTimeoutMs: 60000 },
AGV_NAVIGATE_TO_STATION: { stationId: '', maxSpeed: 0.5, blockedTimeoutMs: 60000 },
AGV_FOLLOW_PATH: { path: '[]', maxSpeed: 0.5, blockedTimeoutMs: 60000 },
}
const defaultTimeoutByType = {
DELAY: 5000,
ARM_MOVE_J: 10000,
ARM_MOVE_L: 3000,
AGV_NAVIGATE_TO_POSE: 60000,
AGV_NAVIGATE_TO_STATION: 60000,
AGV_FOLLOW_PATH: 120000,
}
const roles = ref([])
const formData = reactive({ actionId: '', totalTimeoutMs: 60000, steps: [], executionTarget: {} })
const activeStepKeys = ref([])
const helperDialogVisible = ref(false)
const helperStep = ref(null)
const helperRobotOptions = ref([])
const helperDeviceOptions = ref([])
const helperDeviceLoading = ref(false)
const helperSubmitting = ref(false)
const helperForm = reactive({ robotId: '', deviceId: '' })
const fieldsForType = (type) => fieldsByType[type] || []
const actionLabel = (type) => actionTypes.find((item) => item.value === type)?.label || '未选择动作'
const deviceKindForType = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
const stepHelperLabel = (type) => ({
ARM_MOVE_J: '读取当前关节位置',
ARM_MOVE_L: '读取当前末端位姿',
AGV_NAVIGATE_TO_POSE: '读取底盘当前位置',
AGV_NAVIGATE_TO_STATION: '加载底盘站点',
}[type] || '')
const selectedRole = () => roles.value.find((role) => role.roleKey === formData.executionTarget.roleKey)
const matchingSlots = (type) => (selectedRole()?.deviceSlots || []).filter((slot) => Number(slot.deviceKind) === deviceKindForType(type))
const normalizeStep = (source = {}, index = 0) => {
const params = { ...(defaultParams[source.type || 'DELAY'] || {}), ...(source.params || {}) }
for (const key of ['positions', 'path']) {
if (Array.isArray(params[key])) params[key] = JSON.stringify(params[key])
}
return {
localKey: source.localKey || createResourceKey('queue_step'),
stepId: source.stepId || `step_${index + 1}`,
stepName: String(source.stepName || '').trim() || `步骤 ${index + 1}`,
type: source.type || 'DELAY',
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
? defaultTimeoutByType[source.type || 'DELAY']
: Number(source.timeoutMs),
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
deviceSlotKey: source.deviceSlotKey || '',
stationOptions: [],
...(source.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(source.sourceNode)) } : {}),
params,
}
}
const initData = () => {
roles.value = getResourceRoles()
const properties = props.data.properties || {}
const params = Object.fromEntries((properties.nodeParams || []).map((item) => [item.name, item.input]))
formData.actionId = params.actionId || ''
formData.totalTimeoutMs = Number(params.totalTimeoutMs || 60000)
formData.steps = (Array.isArray(params.steps) ? params.steps : []).map(normalizeStep)
formData.executionTarget = JSON.parse(JSON.stringify(properties.executionTarget || {}))
formData.executionTarget.roleKey ||= roles.value[0]?.roleKey || ''
formData.steps.forEach((step) => ensureStepSlot(step, false))
activeStepKeys.value = []
}
const persistRoles = () => {
const graph = lf.getGraphData()
const start = getStartNode(graph)
if (!start) return
lf.setProperties(start.id, { ...start.properties, resourceRoles: JSON.parse(JSON.stringify(roles.value)) })
}
const ensureStepSlot = (step, createWhenMissing = true) => {
const kind = deviceKindForType(step.type)
if (!kind) {
step.deviceSlotKey = ''
return
}
const role = selectedRole()
if (!role) return
const options = matchingSlots(step.type)
if (options.some((slot) => slot.slotKey === step.deviceSlotKey)) return
let slot = options[0]
if (!slot && createWhenMissing) {
slot = createDeviceSlot(kind, role.deviceSlots || [])
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
role.deviceSlots ||= []
role.deviceSlots.push(slot)
persistRoles()
}
step.deviceSlotKey = slot?.slotKey || ''
}
const addStep = () => {
const step = normalizeStep({}, formData.steps.length)
formData.steps.push(step)
activeStepKeys.value = [...activeStepKeys.value, step.localKey]
}
const removeStep = (index) => {
const [removed] = formData.steps.splice(index, 1)
activeStepKeys.value = activeStepKeys.value.filter((key) => key !== removed?.localKey)
}
const moveStep = (index, offset) => {
moveActionQueueStep(formData.steps, index, offset)
}
const copyStep = (index) => {
const source = formData.steps[index]
if (!source) return
const copied = copyActionQueueStep(source, {
localKey: createResourceKey('queue_step'),
stepId: `step_${formData.steps.length + 1}`,
deviceBindingKey: createResourceKey('queue_device'),
stationOptions: [],
})
formData.steps.splice(index + 1, 0, copied)
activeStepKeys.value = [...new Set([...activeStepKeys.value, copied.localKey])]
}
const handleStepTypeChange = (step) => {
step.params = { ...(defaultParams[step.type] || {}) }
step.timeoutMs = defaultTimeoutByType[step.type] || 0
step.stationOptions = []
ensureStepSlot(step)
}
const handleRoleChange = () => formData.steps.forEach((step) => ensureStepSlot(step))
const loadHelperRobots = async () => {
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' })
helperRobotOptions.value = (response.rows || []).map((robot) => ({
value: robot.robotId,
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
}))
}
const loadHelperDevices = async (robotId) => {
helperForm.deviceId = ''
helperDeviceOptions.value = []
if (!robotId || !helperStep.value) return
helperDeviceLoading.value = true
try {
const response = await getRobotDevicesByRobotId(robotId, {
deviceKind: deviceKindForType(helperStep.value.type),
})
const devices = Array.isArray(response.data) ? response.data : []
helperDeviceOptions.value = toDeviceOptions(devices)
if (devices.length) helperForm.deviceId = devices[0].deviceId
else ElMessage.warning('当前机器人没有匹配的在线设备')
} finally {
helperDeviceLoading.value = false
}
}
const openStepHelper = async (step) => {
helperStep.value = step
helperForm.deviceId = ''
helperDialogVisible.value = true
try {
await loadHelperRobots()
if (helperForm.robotId && helperRobotOptions.value.some((item) => item.value === helperForm.robotId)) {
await loadHelperDevices(helperForm.robotId)
}
} catch (error) {
console.error('加载机器人设备失败:', error)
ElMessage.error('加载机器人设备失败')
}
}
const stationOptions = (stations) => (Array.isArray(stations) ? stations : [])
.filter((station) => station?.id !== undefined && station?.id !== null && String(station.id).trim())
.map((station) => {
const value = String(station.id).trim()
const description = String(station.description || '').trim()
return { value, label: description ? `${value} - ${description}` : value }
})
const applyStepHelper = async () => {
const step = helperStep.value
if (!helperForm.robotId || !helperForm.deviceId) {
ElMessage.warning('请选择机器人和执行设备')
return
}
helperSubmitting.value = true
try {
const target = { robotId: helperForm.robotId, deviceId: helperForm.deviceId }
if (step.type === 'ARM_MOVE_J') {
const response = await getArmJointState(target)
const positions = response?.data?.position || response?.data?.positions
if (!Array.isArray(positions) || !positions.length) throw new Error('机械臂未返回有效的关节位置')
step.params.positions = JSON.stringify(positions)
} else if (step.type === 'ARM_MOVE_L') {
const response = await getArmStatus(target)
const pose = response?.data
if (!pose) throw new Error('机械臂未返回当前末端位姿')
;['x', 'y', 'z', 'rx', 'ry', 'rz'].forEach((key) => {
if (pose[key] !== undefined && pose[key] !== null) step.params[key] = Number(pose[key])
})
} else if (step.type === 'AGV_NAVIGATE_TO_POSE') {
const response = await getAgvStatus(target)
const pose = response?.data?.pose
if (!pose) throw new Error('底盘未返回当前位置')
;['x', 'y', 'theta'].forEach((key) => {
if (pose[key] !== undefined && pose[key] !== null) step.params[key] = Number(pose[key])
})
} else if (step.type === 'AGV_NAVIGATE_TO_STATION') {
const response = await getAgvStations(target)
const options = stationOptions(response?.data)
if (!options.length) throw new Error('底盘未返回可用站点')
step.stationOptions = options
if (!options.some((option) => option.value === step.params.stationId)) step.params.stationId = ''
ElMessage.success(`已加载${options.length}个站点`)
}
helperDialogVisible.value = false
} catch (error) {
console.error('读取动作参数失败:', error)
ElMessage.error(error?.message || '读取动作参数失败')
} finally {
helperSubmitting.value = false
}
}
const parseJsonFields = (step) => {
const params = { ...step.params }
for (const key of ['positions', 'path']) {
if (typeof params[key] !== 'string') continue
try {
const parsed = JSON.parse(params[key])
if (!Array.isArray(parsed)) throw new Error()
params[key] = parsed
} catch {
throw new Error(`${fieldsForType(step.type).find((field) => field.key === key)?.label || key}格式不正确`)
}
}
return params
}
const buildStandaloneStep = (step, index) => {
if (!step?.type) throw new Error(`步骤 ${index + 1} 未选择动作`)
const params = parseJsonFields(step)
fieldsForType(step.type).filter((field) => field.required !== false).forEach((field) => {
if (params[field.key] === '' || params[field.key] == null) {
throw new Error(`步骤 ${index + 1} 的${field.label}不能为空`)
}
})
return {
stepId: 'step_1',
stepName: getActionQueueStepName(step, index),
type: step.type,
timeoutMs: Number(step.timeoutMs || 0),
params,
}
}
const runStep = (index) => {
try {
const step = formData.steps[index]
const executionStep = buildStandaloneStep(step, index)
const stepTimeoutMs = Math.max(0, Number(executionStep.timeoutMs || 0))
document.dispatchEvent(new CustomEvent('singleNodeExecution', {
bubbles: false,
cancelable: false,
detail: {
name: getActionQueueStepName(step, index),
nodeType: 'EDGE',
action: 'SYSTEM_EXECUTE_ACTION_QUEUE',
nodeId: `${props.data.id}:queue-step:${step.localKey || index}`,
nodeParams: [
{ name: 'actionId', type: 'input', input: '', required: false },
{ name: 'totalTimeoutMs', type: 'input', componentType: 'number', input: Math.max(1000, stepTimeoutMs + 5000), required: true },
{ name: 'steps', type: 'input', input: [executionStep], required: true },
],
},
}))
} catch (error) {
ElMessage.warning(error.message)
}
}
const buildQueueDefinition = () => {
if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色')
if (!formData.steps.length) throw new Error('请添加至少一个动作步骤')
const steps = formData.steps.map((step, index) => {
if (!step.type) throw new Error(`步骤 ${index + 1} 未选择动作`)
if (deviceKindForType(step.type) && !step.deviceSlotKey) throw new Error(`步骤 ${index + 1} 未选择设备用途`)
const params = parseJsonFields(step)
fieldsForType(step.type).filter((field) => field.required !== false).forEach((field) => {
if (params[field.key] === '' || params[field.key] == null) throw new Error(`步骤 ${index + 1} 的${field.label}不能为空`)
})
return {
stepId: `step_${index + 1}`,
stepName: String(step.stepName || '').trim() || `步骤 ${index + 1}`,
type: step.type,
timeoutMs: Number(step.timeoutMs || 0),
...(deviceKindForType(step.type) ? {
deviceBindingKey: step.deviceBindingKey,
deviceSlotKey: step.deviceSlotKey,
} : {}),
...(step.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(step.sourceNode)) } : {}),
params,
}
})
return {
steps,
executionTarget: {
roleKey: formData.executionTarget.roleKey,
dispatchPolicy: 'ONE',
deviceBindings: steps.filter((step) => step.deviceBindingKey).map((step) => ({
parameterName: step.deviceBindingKey,
slotKey: step.deviceSlotKey,
})),
},
}
}
const persistQueueDefinition = ({ steps, executionTarget }) => {
lf.setProperties(props.data.id, {
...props.data.properties,
executionTarget,
nodeParams: [
{ name: 'actionId', type: 'input', input: formData.actionId, required: false },
{ name: 'totalTimeoutMs', type: 'input', componentType: 'number', input: formData.totalTimeoutMs, required: true },
{ name: 'steps', type: 'input', input: steps, required: true },
],
})
}
const requestExtractStep = (index, placement) => {
try {
persistQueueDefinition(buildQueueDefinition())
emit('extract-step', { queueId: props.data.id, index, placement })
} catch (error) {
ElMessage.warning(error.message)
}
}
const validateAndSave = async () => {
try {
persistQueueDefinition(buildQueueDefinition())
const cleanup = pruneUnusedNodeResources(lf.getGraphData())
if (cleanup.start) {
lf.setProperties(cleanup.start.id, {
...cleanup.start.properties,
resourceRoles: cleanup.start.properties.resourceRoles,
})
}
emit('save-success')
} catch (error) {
ElMessage.warning(error.message)
emit('save-error')
}
}
watch(() => props.data, initData, { immediate: true, deep: true })
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.queue-editor { padding-top: 16px; }
.queue-editor__heading { color: #344054; font-size: 14px; font-weight: 650; }
.queue-editor__hint { margin-top: 3px; color: #7a8494; font-size: 12px; }
.queue-editor__toolbar { display: flex; margin: 14px 0 12px; align-items: center; justify-content: space-between; }
.queue-steps { border-top: 0; }
.queue-steps :deep(.el-collapse-item) { margin-bottom: 10px; border: 1px solid #dfe4ec; border-radius: 8px; overflow: hidden; }
.queue-steps :deep(.el-collapse-item__header) { min-height: 54px; height: auto; padding: 8px 12px; border-bottom: 0; }
.queue-steps :deep(.el-collapse-item__wrap) { border-top: 1px solid #edf0f4; border-bottom: 0; }
.queue-steps :deep(.el-collapse-item__content) { padding-bottom: 0; }
.queue-step { padding: 14px; background: #fff; }
.queue-step__header { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; color: #1f2937; font-weight: 650; }
.queue-step__title { display: flex; min-width: 0; align-items: center; gap: 10px; }
.queue-step__index { display: inline-flex; width: 24px; height: 24px; flex: 0 0 24px; align-items: center; justify-content: center; border-radius: 50%; background: #eef4ff; color: var(--el-color-primary); font-size: 12px; }
.queue-step__name { overflow: hidden; max-width: 150px; text-overflow: ellipsis; white-space: nowrap; }
.queue-step__action { overflow: hidden; color: #667085; font-size: 12px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.queue-step__actions { display: flex; flex: 0 0 auto; margin-right: 8px; align-items: center; }
.queue-step__actions .el-button + .el-button { margin-left: 6px; }
.queue-step__helper { margin: -2px 0 16px; }
.queue-editor__warning { color: var(--el-color-warning); font-size: 12px; }
.queue-helper__tip { margin: -4px 0 16px; padding: 9px 12px; border-left: 3px solid var(--el-color-primary); background: var(--el-fill-color-light); color: #667085; font-size: 12px; line-height: 18px; }
</style>

View File

@ -1,4 +1,13 @@
<!--
* 文件说明:通用节点参数面板,配置输入输出及机器人动作调试。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<RobotExecutionTarget
v-if="isRobotNode"
:data="props.data"
v-model="formData.executionTarget"
/>
<el-collapse v-model="activeNames"> <el-collapse v-model="activeNames">
<!-- 输入参数 --> <!-- 输入参数 -->
<el-collapse-item name="1" icon-position="left"> <el-collapse-item name="1" icon-position="left">
@ -11,7 +20,7 @@
</el-button> </el-button>
</template> </template>
<div class="form__container"> <div class="form__container">
<el-button type="primary" v-if="['AGV_MOVE_TO_POINT', 'ARM_MOVE_TO_POINT'].includes(props.data.properties.action)" size="small" @click="openRobotForm(props.data.properties.action)">获取位置</el-button> <el-button type="primary" v-if="hasDeviceDebugAction" size="small" @click="openRobotForm(props.data.properties.action)">{{ robotActionButtonText }}</el-button>
<el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top" <el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top"
label-width="auto"> label-width="auto">
<div v-for="(property, index) in formData.nodeParams" :key="index"> <div v-for="(property, index) in formData.nodeParams" :key="index">
@ -22,7 +31,7 @@
v-model="property.name" placeholder="请输入" clearable /> v-model="property.name" placeholder="请输入" clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`"> <el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index, formData, property.type)"> <el-select v-model="property.type" class="param-type" :disabled="property?.forceSelect || false" @change="handleTypeChange(property)">
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -30,7 +39,7 @@
:rules="testRule(property)" :rules="testRule(property)"
:prop="`nodeParams.${index}.input`"> :prop="`nodeParams.${index}.input`">
<el-input-number class="param-value" v-if="property.componentType === 'number'" <el-input-number class="param-value" v-if="property.componentType === 'number'"
v-model="property.input" :min="0" :max="property.max || Infinity" v-model="property.input" :min="property.min ?? 0" :max="property.max || Infinity"
:controls="property?.controls || true" :step-strictly="true" :step="property.step || 1" placeholder="请输入" clearable /> :controls="property?.controls || true" :step-strictly="true" :step="property.step || 1" placeholder="请输入" clearable />
<el-select v-model="property.input" class="param-value" <el-select v-model="property.input" class="param-value"
v-else-if="property.componentType === 'select'"> v-else-if="property.componentType === 'select'">
@ -44,10 +53,10 @@
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]" :rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
:prop="`nodeParams.${index}.quote`"> :prop="`nodeParams.${index}.quote`">
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }" <el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
v-model="property.quote" :checkStrictly="true" :options="quoteOptions" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions"
placeholder="请选择" placeholder="请选择"
@visible-change="(visible) => visibleChange(visible, index, property.quote)" @visible-change="(visible) => visibleChange(visible, index, property.quote)"
@change="(value) => cascaderChange(value, index, formData, 'nodeParams')" /> @change="(value) => cascaderChange(value, property)" />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
<el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false" <el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false"
@ -82,32 +91,45 @@
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
title="获取位置信息" :title="robotDialogTitle"
width="360" width="460"
> >
<el-form ref="robotFormRef" :model="robotForm" :rules="robotFormRules" label-width="auto"> <div class="robot-device-tip">设备来自机器人最近一次在线上报,单个匹配设备会自动锁定。</div>
<el-form-item label="终端ID" prop="terminalId"> <el-form ref="robotFormRef" :model="robotForm" :rules="robotFormRules" label-width="90px">
<el-input v-model="robotForm.terminalId" /> <el-form-item label="机器人" prop="robotId">
<el-select v-model="robotForm.robotId" filterable placeholder="请选择在线机器人"
style="width: 100%" @change="loadRobotDevices">
<el-option v-for="item in robotOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="设备ID" prop="deviceId"> <el-form-item label="执行设备" prop="deviceId">
<el-input v-model="robotForm.deviceId" /> <el-select v-model="robotForm.deviceId" filterable :loading="deviceLoading"
:disabled="deviceOptions.length === 1" placeholder="请先选择机器人" style="width: 100%">
<el-option v-for="item in deviceOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<div v-if="deviceOptions.length === 1" class="device-auto-hint">已自动匹配并锁定</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitRobotForm">确认</el-button> <el-button type="primary" :loading="stationListLoading" @click="submitRobotForm">确认</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref, reactive, watch, nextTick } from 'vue' import { computed, ref, reactive, watch, nextTick } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue' import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue' import FormItemRecursive from '../FormItemRecursive.vue'
import { getInput } from '@/utils/flow' import RobotExecutionTarget from './RobotExecutionTarget.vue'
import { useQuote } from './useQuote.js' import { useQuote } from './useQuote.js'
import { getArmStatus, getAgvStatus } from '@/api/device/flow' import { getArmStatus, getArmJointState, getAgvStatus, getAgvStations } from '@/api/flow/flow'
import { de } from 'element-plus/es/locale/index.mjs' import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { ElMessage } from 'element-plus'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import { actionRequiresDevice } from '@/utils/flowResources'
const props = defineProps({ const props = defineProps({
data: Object data: Object
@ -117,7 +139,8 @@ const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({ const formData = reactive({
nodeParams: [], nodeParams: [],
outputParams: [] outputParams: [],
executionTarget: {}
}) })
const rules = reactive({}) const rules = reactive({})
@ -130,6 +153,11 @@ const activeNames = ref(['1', '2'])
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id) const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项 // 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => { const addFormItem = (e, type) => {
e.stopPropagation() e.stopPropagation()
const obj = { const obj = {
@ -144,11 +172,20 @@ const addFormItem = (e, type) => {
} }
// 删除输入参数(非递归) // 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => { const handleDelete = (index) => {
formData.nodeParams.splice(index, 1) formData.nodeParams.splice(index, 1)
} }
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理) // 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => { const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath] let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) { for (let i = 0; i < fullPath.length - 1; i++) {
@ -159,11 +196,15 @@ const deleteTopLevelItem = (fullPath, propPath) => {
} }
// 初始化数据 // 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => { const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || [])) const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || [])) const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
formData.nodeParams = nodeParams; formData.nodeParams = nodeParams;
formData.outputParams = outputParams; formData.outputParams = outputParams;
formData.executionTarget = JSON.parse(JSON.stringify(props.data.properties.executionTarget || {}));
} }
watch( watch(
@ -175,6 +216,9 @@ watch(
) )
// 保存验证 // 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => { const validateAndSave = async () => {
let inputValid = true let inputValid = true
let outputValid = true let outputValid = true
@ -184,12 +228,21 @@ const validateAndSave = async () => {
if (outputFormRef.value) { if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false }) await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
} }
if (isRobotNode.value && !formData.executionTarget.roleKey) inputValid = false
if (isRobotNode.value
&& actionRequiresDevice(props.data.properties.action)
&& !formData.executionTarget.deviceSlotKey) {
inputValid = false
ElMessage.warning('请选择该节点使用的设备用途')
}
// 保存到 lf // 非机器人节点不应携带机器人执行目标,避免后端将空对象误判为无效角色引用。
lf.setProperties(props.data.id, { const properties = {
...props.data.properties, ...props.data.properties,
...formData ...formData
}) }
if (!isRobotNode.value) delete properties.executionTarget
lf.setProperties(props.data.id, properties)
if (inputValid && outputValid) { if (inputValid && outputValid) {
emit('save-success') emit('save-success')
@ -201,15 +254,66 @@ const validateAndSave = async () => {
const dialogVisible = ref(false) const dialogVisible = ref(false)
const robotFormRef = ref(null) const robotFormRef = ref(null)
const robotFormRules = reactive({ const robotFormRules = reactive({
terminalId: [{ required: true, message: '终端ID不能为空', trigger: 'blur' }], robotId: [{ required: true, message: '请选择机器人', trigger: 'change' }],
deviceId: [{ required: true, message: '设备ID不能为空', trigger: 'blur' }] deviceId: [{ required: true, message: '设备ID不能为空', trigger: 'blur' }]
}) })
const robotForm = reactive({ const robotForm = reactive({
deviceId: '', deviceId: '',
terminalId: '' robotId: ''
}) })
const robotOptions = ref([])
const deviceOptions = ref([])
const deviceLoading = ref(false)
/**
* 加载 loadRobotOptions 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const loadRobotOptions = async () => {
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' })
robotOptions.value = (response.rows || []).map(robot => ({
value: robot.robotId,
label: `${robot.robotName || robot.robotId} (${robot.robotId})`
}))
}
/**
* 加载 loadRobotDevices 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} robotId 机器人 ID
*/
const loadRobotDevices = async (robotId) => {
robotForm.deviceId = ''
deviceOptions.value = []
if (!robotId) return
deviceLoading.value = true
try {
const response = await getRobotDevicesByRobotId(robotId, {
deviceKind: getDeviceKindForAction(robotFormAction.value)
})
const devices = Array.isArray(response.data) ? response.data : []
deviceOptions.value = toDeviceOptions(devices)
if (devices.length > 0) robotForm.deviceId = devices[0].deviceId
if (devices.length === 0) ElMessage.warning('当前机器人没有匹配的在线设备')
} finally {
deviceLoading.value = false
}
}
/**
* 测试 testRule 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} property 参数属性
*/
const testRule = (property) => { const testRule = (property) => {
if (property?.name === 'stationId' && props.data.properties.action === 'AGV_MOVE_TO_STATION') {
return [
{ required: true, message: '请从站点列表中选择站点', trigger: 'change' },
{ validator: () => {
const selectedFromList = property.selectOptions?.some(option => option.value === property.input)
if (selectedFromList) return Promise.resolve()
return Promise.reject(new Error('请重新加载并选择站点'))
}, trigger: 'change' }
]
}
if (property?.name === 'acceleration') { if (property?.name === 'acceleration') {
return [ return [
{ required: true, message: '请输入参数值', trigger: 'blur' }, { required: true, message: '请输入参数值', trigger: 'blur' },
@ -241,21 +345,40 @@ const testRule = (property) => {
} }
const robotFormAction = ref() const robotFormAction = ref()
const stationListLoading = ref(false)
const POSITION_ACTIONS = ['AGV_MOVE_TO_POINT', 'ARM_MOVE_TO_POINT', 'ARM_MOVE_TO_J']
const isRobotNode = computed(() => props.data.properties?.nodeType === 'EDGE')
const hasDeviceDebugAction = computed(() => (
POSITION_ACTIONS.includes(props.data.properties.action)
|| props.data.properties.action === 'AGV_MOVE_TO_STATION'
))
const robotActionButtonText = computed(() => {
if (props.data.properties.action === 'AGV_MOVE_TO_STATION') return '选择机器人并加载站点'
if (POSITION_ACTIONS.includes(props.data.properties.action)) return '选择机器人并获取位置'
return '选择机器人设备'
})
const robotDialogTitle = computed(() => {
if (robotFormAction.value === 'AGV_MOVE_TO_STATION') return '选择AGV并加载站点'
if (POSITION_ACTIONS.includes(robotFormAction.value)) return '选择机器人并读取当前位置'
return '选择机器人设备'
})
/**
* 处理 submitRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const submitRobotForm = () => { const submitRobotForm = () => {
if (robotFormAction.value === 'AGV_MOVE_TO_POINT') { if (robotFormAction.value === 'AGV_MOVE_TO_POINT') {
robotFormRef.value.validate(async (valid) => { robotFormRef.value.validate(async (valid) => {
if (valid) { if (valid) {
try { try {
const response = await getAgvStatus({ deviceId: robotForm.deviceId, terminalId: robotForm.terminalId }) const response = await getAgvStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
if (response && response.data) { if (response && response.data) {
dialogVisible.value = false dialogVisible.value = false
const { x, y, theta } = response.data.pose const { x, y, theta } = response.data.pose
const testMap = { const testMap = {
x: x, x: x,
y: y, y: y,
theta: theta, theta: theta
deviceId: robotForm.deviceId
} }
// 将获取到的位置信息设置到 formData 中 // 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => { formData.nodeParams.forEach(param => {
@ -269,11 +392,41 @@ const submitRobotForm = () => {
} }
} }
}) })
} else if (robotFormAction.value === 'AGV_MOVE_TO_STATION') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
stationListLoading.value = true
try {
const response = await getAgvStations({
deviceId: robotForm.deviceId,
robotId: robotForm.robotId
})
const stationOptions = toStationOptions(response?.data)
if (stationOptions.length === 0) {
throw new Error('AGV未返回可用站点')
}
const stationParam = formData.nodeParams.find(param => param.name === 'stationId')
if (stationParam) {
stationParam.selectOptions = stationOptions
if (!stationOptions.some(item => item.value === stationParam.input)) {
stationParam.input = ''
}
}
dialogVisible.value = false
ElMessage.success(`已加载${stationOptions.length}个AGV站点`)
} catch (error) {
console.error('加载AGV站点列表失败:', error)
ElMessage.error(error?.message || '加载AGV站点列表失败')
} finally {
stationListLoading.value = false
}
}
})
} else if (robotFormAction.value === 'ARM_MOVE_TO_POINT') { } else if (robotFormAction.value === 'ARM_MOVE_TO_POINT') {
robotFormRef.value.validate(async (valid) => { robotFormRef.value.validate(async (valid) => {
if (valid) { if (valid) {
try { try {
const response = await getArmStatus({ deviceId: robotForm.deviceId, terminalId: robotForm.terminalId }) const response = await getArmStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
if (response && response.data) { if (response && response.data) {
dialogVisible.value = false dialogVisible.value = false
const { x, y, z, rx, ry, rz } = response.data const { x, y, z, rx, ry, rz } = response.data
@ -284,7 +437,6 @@ const submitRobotForm = () => {
rx: rx, rx: rx,
ry: ry, ry: ry,
rz: rz, rz: rz,
deviceId: robotForm.deviceId
} }
// 将获取到的位置信息设置到 formData 中 // 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => { formData.nodeParams.forEach(param => {
@ -298,12 +450,56 @@ const submitRobotForm = () => {
} }
} }
}) })
} else if (robotFormAction.value === 'ARM_MOVE_TO_J') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
try {
const response = await getArmJointState({
deviceId: robotForm.deviceId,
robotId: robotForm.robotId
})
if (response && response.data) {
const target = response.data.position || response.data.positions
if (!Array.isArray(target) || target.length === 0) {
throw new Error('机械臂未返回有效的关节位置')
}
dialogVisible.value = false
const targetParam = formData.nodeParams.find(param => param.name === 'target')
if (targetParam) targetParam.input = JSON.stringify(target)
}
} catch (error) {
console.error('获取机械臂关节位置失败:', error)
}
}
})
} }
} }
const openRobotForm = (action) => { /**
* 处理 toStationOptions 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} stations 站点集合
*/
const toStationOptions = (stations) => (Array.isArray(stations) ? stations : [])
.filter(station => station?.id !== undefined && station?.id !== null && String(station.id).trim())
.map(station => {
const id = String(station.id).trim()
const description = station.description ? String(station.description).trim() : ''
return {
value: id,
label: description ? `${id} - ${description}` : id
}
})
/**
* 打开 openRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} action 节点动作类型
*/
const openRobotForm = async (action) => {
dialogVisible.value = true dialogVisible.value = true
robotFormAction.value = action robotFormAction.value = action
robotForm.deviceId = ''
await loadRobotOptions()
if (robotForm.robotId) await loadRobotDevices(robotForm.robotId)
} }
defineExpose({ validateAndSave }) defineExpose({ validateAndSave })
@ -383,4 +579,19 @@ defineExpose({ validateAndSave })
cursor: pointer; cursor: pointer;
} }
} }
</style>
.robot-device-tip {
margin: -4px 0 18px;
padding: 10px 12px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 20px;
}
.device-auto-hint {
color: var(--el-color-success);
font-size: 12px;
}
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:代码节点参数面板,编辑并校验脚本和输入输出字段。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<el-collapse v-model="activeNames"> <el-collapse v-model="activeNames">
<el-collapse v-model="activeNames"> <el-collapse v-model="activeNames">
@ -48,7 +52,7 @@
<el-select <el-select
v-model="property.type" v-model="property.type"
class="param-type" class="param-type"
@change="handleTypeChange(index)" @change="handleTypeChange(property)"
> >
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
@ -113,13 +117,13 @@
} }
" "
v-model="property.quote" v-model="property.quote"
:checkStrictly="true" :props="{ checkStrictly: true }"
:options="quoteOptions" :options="quoteOptions"
placeholder="请选择" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (visible) => visibleChange(visible, index, property.quote)
" "
@change="(value) => cascaderChange(value, index, formData, 'nodeParams')" @change="(value) => cascaderChange(value, property)"
/> />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
@ -194,7 +198,6 @@ import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue' import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue' import FormItemRecursive from '../FormItemRecursive.vue'
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import { getInput } from '@/utils/flow'
import { useQuote } from './useQuote.js' import { useQuote } from './useQuote.js'
const props = defineProps({ const props = defineProps({
@ -211,6 +214,9 @@ const formData = reactive({
const codeRef = ref() const codeRef = ref()
let editorInstance let editorInstance
/**
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
*/
function hasErrors() { function hasErrors() {
const model = editorInstance.getModel(); const model = editorInstance.getModel();
if (!model) return false; if (!model) return false;
@ -220,6 +226,10 @@ function hasErrors() {
// 检查是否存在错误级别标记 // 检查是否存在错误级别标记
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error); return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
} }
/**
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
*/
const initEditor = (type) => { const initEditor = (type) => {
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020, target: monaco.languages.typescript.ScriptTarget.ES2020,
@ -260,6 +270,10 @@ const initEditor = (type) => {
} }
// 设置代码值 // 设置代码值
/**
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} value 待处理的新值
*/
const setValue = (value) => { const setValue = (value) => {
if (editorInstance) { if (editorInstance) {
editorInstance.setValue(value) editorInstance.setValue(value)
@ -267,6 +281,9 @@ const setValue = (value) => {
} }
// 销毁编辑器 // 销毁编辑器
/**
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const disposeEditor = () => { const disposeEditor = () => {
if (editorInstance) { if (editorInstance) {
editorInstance.dispose() editorInstance.dispose()
@ -284,6 +301,11 @@ const activeNames = ref(['1', '2'])
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id) const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项 // 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => { const addFormItem = (e, type) => {
e.stopPropagation() e.stopPropagation()
const obj = { const obj = {
@ -298,11 +320,20 @@ const addFormItem = (e, type) => {
} }
// 删除输入参数(非递归) // 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => { const handleDelete = (index) => {
formData.nodeParams.splice(index, 1) formData.nodeParams.splice(index, 1)
} }
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理) // 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => { const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath] let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) { for (let i = 0; i < fullPath.length - 1; i++) {
@ -313,6 +344,9 @@ const deleteTopLevelItem = (fullPath, propPath) => {
} }
// 初始化数据 // 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => { const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || [])) const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || [])) const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
@ -329,6 +363,9 @@ const initData = () => {
} }
// 保存验证 // 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => { const validateAndSave = async () => {
const targetObj = formData.nodeParams.find(item => item.name === 'code'); const targetObj = formData.nodeParams.find(item => item.name === 'code');
if (targetObj) { if (targetObj) {
@ -460,4 +497,4 @@ onBeforeUnmount(() => {
height: 300px; height: 300px;
margin: 12px 0; margin: 12px 0;
} }
</style> </style>

View File

@ -0,0 +1,520 @@
<!--
* 文件说明:通用设备参数面板,选择设备并配置脚本和参数。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<RobotExecutionTarget :data="props.data" v-model="formData.executionTarget" />
<el-collapse v-model="activeNames">
<el-collapse v-model="activeNames">
<el-collapse-item name="1" icon-position="left">
<template #title>
输入参数
<el-tooltip
class="box-item"
effect="dark"
content="这里输入的变量可以被下方的代码引用。"
placement="top"
>
<el-icon class="header-icon" style="margin-left: 6px">
<info-filled />
</el-icon>
</el-tooltip>
</template>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="dynamicFormRef"
label-position="top"
label-width="auto"
>
<div v-for="(property, index) in formData.nodeParams" :key="index">
<el-row v-if="property.name !== 'requestJson'">
<el-form-item
:label="index === 0 ? '参数名' : ''"
:prop="`nodeParams.${index}.name`"
:rules="[
{ required: true, message: '请输入参数名', trigger: 'blur' },
]"
>
<el-input
:disabled="property?.disabled || false"
class="param-name"
v-model="property.name"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
:label="index === 0 ? '参数值' : ''"
:prop="`nodeParams.${index}.type`"
>
<el-select
v-model="property.type"
class="param-type"
@change="handleTypeChange(property)"
>
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item
v-if="property.type === 'input'"
:rules="[
{
required: property?.required ?? true,
message: '请输入参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.input`"
>
<el-input-number
class="param-value"
v-if="property.componentType === 'number'"
v-model="property.input"
:min="0"
:max="property.max || Infinity"
:controls="false"
:step-strictly="true"
placeholder="请输入"
clearable
/>
<el-select
v-model="property.input"
class="param-value"
v-else-if="property.componentType === 'select'"
:disabled="property.deviceLocked"
no-data-text="请先选择机器人设备"
>
<el-option
v-for="item in property.selectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
class="param-value"
v-else
v-model="property.input"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
v-if="property.type === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.quote`"
>
<el-cascader
:ref="
(el) => {
if (el) cascaderRefs[index] = el;
}
"
v-model="property.quote"
:props="{ checkStrictly: true }"
:options="quoteOptions"
placeholder="请选择"
@visible-change="
(visible) => visibleChange(visible, index, property.quote)
"
@change="(value) => cascaderChange(value, property)"
/>
</el-form-item>
</el-form-item>
<el-button
:icon="Minus"
circle
size="small"
@click="handleDelete(index)"
:disabled="property?.disabled || false"
class="deleteBtn"
/>
</el-row>
</div>
</el-form>
</div>
</el-collapse-item>
<el-collapse-item title="JSON" name="2" icon-position="left">
<div class="code__container">
<div ref="codeRef" style="width: 100%; height: 100%"></div>
</div>
</el-collapse-item>
<el-collapse-item name="3" icon-position="left">
<template #title>
输出参数
<el-tooltip
class="box-item"
effect="dark"
content="这里定义的输出变量可以被后续节点引用。"
placement="top"
>
<el-icon class="header-icon" style="margin-left: 6px">
<info-filled />
</el-icon>
</el-tooltip>
<el-button
:circle="true"
size="small"
@click="(e) => addFormItem(e, 'outputParams')"
class="addFormItem"
>
<el-icon :size="14"><Plus /></el-icon>
</el-button>
</template>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
label-position="top"
label-width="auto"
:rules="outputRules"
ref="outputFormRef"
>
<FormItemRecursive
formType="output"
:current-list="formData.outputParams"
prop-path="outputParams"
:depth="0"
:is-first-level="true"
:endDepth="2"
:parent-path="[]"
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')"
/>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
</el-collapse>
</template>
<script setup>
import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue'
import RobotExecutionTarget from './RobotExecutionTarget.vue'
import * as monaco from 'monaco-editor';
import { useQuote } from './useQuote.js'
import { ElMessage } from 'element-plus'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: [],
executionTarget: {}
})
const codeRef = ref()
let editorInstance
/**
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
*/
function hasErrors() {
const model = editorInstance.getModel();
if (!model) return false;
// 获取当前模型的所有标记
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
// 检查是否存在错误级别标记
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
}
/**
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
*/
const initEditor = (type) => {
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
checkJs: true, // 对 .js 文件进行类型检查
strict: true, // 启用所有严格检查
noImplicitAny: false, // 禁止隐式 any
noUnusedLocals: true, // 未使用的局部变量报错(可选)
noUnusedParameters: true, // 未使用的参数报错(可选)
});
// 确保语义验证开启(默认就是开启的,但可以显式设置)
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false, // 开启语义检查
noSyntaxValidation: false, // 开启语法检查
});
editorInstance = monaco.editor.create(codeRef.value, {
value: ['{}'].join('\n'),
language: type === 'code' ? 'javascript' : 'json',
fontSize: 14,
lineNumbers: 'on',
roundedSelection: true,
scrollBeyondLastLine: false,
formatOnPaste: true,
formatOnType: true,
quickSuggestions: true,
suggestOnTriggerCharacters: true,
// 自动完成
suggest: {
showWords: true,
showFunctions: true,
showVariables: true,
showClasses: true,
showModules: true,
},
});
}
// 设置代码值
/**
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} value 待处理的新值
*/
const setValue = (value) => {
if (editorInstance) {
editorInstance.setValue(value)
}
}
// 销毁编辑器
/**
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const disposeEditor = () => {
if (editorInstance) {
editorInstance.dispose()
editorInstance = null
}
}
const rules = reactive({})
const outputRules = reactive({})
const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
// 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => {
formData.nodeParams.splice(index, 1)
}
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) {
currentLevel = currentLevel[fullPath[i]].children
}
const lastIndex = fullPath[fullPath.length - 1]
currentLevel.splice(lastIndex, 1)
}
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const properties = props.data?.properties || {}
const nodeParams = JSON.parse(JSON.stringify(properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(properties.outputParams || []))
formData.nodeParams = nodeParams;
formData.outputParams = outputParams;
formData.executionTarget = JSON.parse(JSON.stringify(properties.executionTarget || {}));
const codeParams = nodeParams.find(item => item.name === 'requestJson')
if (codeParams) {
nextTick(() => {
if (editorInstance) {
setValue(codeParams.input)
}
})
}
}
watch(
() => props.data,
initData,
{ immediate: true, deep: true }
)
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
const targetObj = formData.nodeParams.find(item => item.name === 'requestJson');
if (targetObj) {
// 存在:修改该对象的 input 属性
targetObj.input = editorInstance.getValue();
} else {
formData.nodeParams.push(
{ name: "requestJson", type: "input", input: editorInstance.getValue() }
)
}
let inputValid = true
let outputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
if (!formData.executionTarget.roleKey || !formData.executionTarget.deviceSlotKey) {
inputValid = false
ElMessage.warning('请选择执行角色和设备用途')
}
// 保存到 lf
lf.setProperties(props.data.id, {
...props.data.properties,
...formData
})
if (hasErrors()) {
emit('save-error')
return
}
if (inputValid && outputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
onMounted(() => {
nextTick(() => {
initEditor('requestJson')
const codeParams = formData.nodeParams.find(item => item.name === 'requestJson')
if (codeParams) setValue(codeParams.input)
})
})
defineExpose({ validateAndSave })
onBeforeUnmount(() => {
disposeEditor()
})
</script>
<style lang="scss" scoped>
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
.code__container {
display: flex;
position: relative;
text-align: initial;
width: 100%;
height: 300px;
margin: 12px 0;
}
</style>

View File

@ -0,0 +1,171 @@
<!-- End node output contract and value references. -->
<template>
<div class="end-params">
<div class="section-heading">
<div>
<div class="section-title">流程输出</div>
<div class="section-note">输出值来自开始参数或前面节点的输出。</div>
</div>
<el-button type="primary" plain :icon="Plus" @click="addOutput">添加输出</el-button>
</div>
<el-empty v-if="!outputs.length" description="当前流程没有输出" :image-size="72" />
<div v-for="(item, index) in outputs" :key="item.key" class="output-row">
<el-form-item label="输出名称" required>
<el-input v-model="item.name" maxlength="40" placeholder="例如:result" />
</el-form-item>
<el-form-item label="引用来源" required>
<el-cascader
v-model="item.quote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择开始参数或节点输出"
style="width: 100%"
@visible-change="refreshQuoteOptions"
@change="(value) => changeQuote(item, value)"
/>
</el-form-item>
<div class="output-row__meta">
<el-tag effect="plain" type="info">{{ item.valueType || '自动识别' }}</el-tag>
<el-input v-model="item.desc" placeholder="输出说明(可选)" />
<el-button circle :icon="Delete" aria-label="删除输出" @click="outputs.splice(index, 1)" />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getUpstreamInput } from '@/utils/flow'
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
const outputs = ref([])
const quoteOptions = ref([])
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
const key = () => `output_${Date.now()}_${Math.random().toString(16).slice(2)}`
// 结束节点的输出只能来自已经执行过的开始参数或上游节点输出,面板展开时实时刷新引用树。
const refreshQuoteOptions = (visible = true) => {
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
}
/**
* 根据级联引用路径找到源字段定义,用于继承类型、子字段和默认说明。
* 路径格式为 [节点ID, input/output, 字段名, 子字段名...]。
*/
const findDefinition = (quote) => {
if (!Array.isArray(quote) || quote.length < 3) return null
const graph = globalThis.lf.getGraphData()
const source = graph.nodes.find((node) => node.id === quote[0])
let definitions = quote[1] === 'input'
? [...(source?.properties?.inputParams || []), ...(source?.properties?.nodeParams || [])]
: source?.properties?.outputParams || []
let definition = null
quote.slice(2).forEach((name) => {
definition = definitions.find((item) => item.name === name)
definitions = definition?.children || []
})
return definition
}
/**
* 用户切换引用来源后同步输出契约的类型和层级结构。
* 未手动填写名称/说明时,优先继承被引用字段的元信息。
*/
const changeQuote = (item, value) => {
item.quoteType = value?.[1] || ''
const definition = findDefinition(value)
if (!definition) return
item.valueType = definition.type || 'string'
item.children = clone(definition.children || [])
if (!item.name) item.name = definition.name
if (!item.desc) item.desc = definition.desc || ''
}
const addOutput = () => outputs.value.push({
key: key(),
name: '',
valueType: 'string',
quote: [],
quoteType: '',
desc: '',
children: [],
})
const init = () => {
const paramsByName = new Map((props.data.properties?.nodeParams || []).map((item) => [item.name, item]))
outputs.value = (props.data.properties?.outputParams || []).map((output) => {
const param = paramsByName.get(output.name) || {}
return {
key: key(),
name: output.name || '',
valueType: output.type || 'string',
quote: Array.isArray(param.quote) ? [...param.quote] : [],
quoteType: param.quoteType || param.quote?.[1] || '',
desc: output.desc || '',
children: clone(output.children || []),
}
})
refreshQuoteOptions()
}
/**
* 校验流程输出契约并写回结束节点。
* nodeParams 保存运行时取值引用,outputParams 保存对外暴露的输出字段说明。
*/
const validateAndSave = () => {
const names = outputs.value.map((item) => item.name.trim())
if (names.some((name) => !name)) {
ElMessage.warning('请填写流程输出名称')
emit('save-error')
return
}
if (new Set(names).size !== names.length) {
ElMessage.warning('流程输出名称不能重复')
emit('save-error')
return
}
if (outputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
ElMessage.warning('每个流程输出都必须选择引用来源')
emit('save-error')
return
}
const nodeParams = outputs.value.map((item) => ({
name: item.name.trim(),
type: 'quote',
input: '',
quote: [...item.quote],
quoteType: item.quoteType || item.quote[1],
required: true,
}))
const outputParams = outputs.value.map((item) => ({
name: item.name.trim(),
type: item.valueType || 'string',
desc: item.desc || '',
children: clone(item.children || []),
}))
globalThis.lf.setProperties(props.data.id, {
...props.data.properties,
nodeParams,
outputParams,
outputType: 'json',
})
emit('save-success')
}
onMounted(init)
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.end-params { padding-top: 14px; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.section-title { color: #1f2937; font-size: 14px; font-weight: 650; }
.section-note { margin-top: 4px; color: #7a8494; font-size: 12px; }
.output-row { margin-bottom: 12px; padding: 14px; border: 1px solid #e4e8ef; border-radius: 6px; background: #fff; }
.output-row__meta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; }
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:HTTP 节点参数面板,配置请求及输出字段。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<el-form :inline="true" :model="httpNodeData" :rules="rules" class="http-form" ref="dynamicFormRef" <el-form :inline="true" :model="httpNodeData" :rules="rules" class="http-form" ref="dynamicFormRef"
label-position="top" label-width="auto"> label-position="top" label-width="auto">
@ -21,7 +25,7 @@
clearable /> clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`"> <el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)"> <el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -53,10 +57,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = el; if (el) cascaderRefs[index] = el;
} }
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择" " v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (visible) => visibleChange(visible, index, property.quote)
" @change="(value) => cascaderChange(value, index, httpNodeData, 'config')" /> " @change="(value) => cascaderChange(value, property)" />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
</el-row> </el-row>
@ -85,7 +89,7 @@
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" <el-form-item :label="index === 0 ? '参数值' : ''"
:prop="httpNodeData.headers.length > index ? `headers.${index}.input` : undefined"> :prop="httpNodeData.headers.length > index ? `headers.${index}.input` : undefined">
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)"> <el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -117,10 +121,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = el; if (el) cascaderRefs[index] = el;
} }
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择" " v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (visible) => visibleChange(visible, index, property.quote)
" @change="(value) => cascaderChange(value, index, httpNodeData, 'headers')" /> " @change="(value) => cascaderChange(value, property)" />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('headers', index)" <el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('headers', index)"
@ -150,7 +154,7 @@
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" <el-form-item :label="index === 0 ? '参数值' : ''"
:prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined"> :prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined">
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)"> <el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -182,10 +186,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = el; if (el) cascaderRefs[index] = el;
} }
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择" " v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (visible) => visibleChange(visible, index, property.quote)
" @change="(value) => cascaderChange(value, index, httpNodeData, 'params')" /> " @change="(value) => cascaderChange(value, property)" />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('params', index)" <el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('params', index)"
@ -239,7 +243,7 @@
<el-select <el-select
v-model="property.type" v-model="property.type"
class="param-type" class="param-type"
@change="handleTypeChange(index, 'httpBody')" @change="handleTypeChange(property)"
> >
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
@ -304,13 +308,13 @@
} }
" "
v-model="property.quote" v-model="property.quote"
:checkStrictly="true" :props="{ checkStrictly: true }"
:options="quoteOptions" :options="quoteOptions"
placeholder="请选择" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote, 'httpBody') (visible) => visibleChange(visible, index, property.quote, 'httpBody')
" "
@change="(value) => cascaderChange(value, index, httpNodeData, 'body')" @change="(value) => cascaderChange(value, property)"
/> />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
@ -335,7 +339,7 @@ import { ref, reactive, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue' import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue' import FormItemRecursive from '../FormItemRecursive.vue'
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import { getInput, transformHttpNodeData } from '@/utils/flow' import { transformHttpNodeData } from '@/utils/flow'
import { useQuote } from './useQuote.js' import { useQuote } from './useQuote.js'
const props = defineProps({ const props = defineProps({
@ -352,6 +356,9 @@ const formData = reactive({
const codeRef = ref() const codeRef = ref()
let editorInstance let editorInstance
/**
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
*/
function hasErrors() { function hasErrors() {
const model = editorInstance.getModel(); const model = editorInstance.getModel();
if (!model) return false; if (!model) return false;
@ -362,6 +369,10 @@ function hasErrors() {
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error); return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
} }
/**
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
*/
const initEditor = (type) => { const initEditor = (type) => {
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020, target: monaco.languages.typescript.ScriptTarget.ES2020,
@ -402,6 +413,10 @@ const initEditor = (type) => {
} }
// 设置代码值 // 设置代码值
/**
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} value 待处理的新值
*/
const setValue = (value) => { const setValue = (value) => {
if (editorInstance) { if (editorInstance) {
editorInstance.setValue(value) editorInstance.setValue(value)
@ -409,6 +424,9 @@ const setValue = (value) => {
} }
// 销毁编辑器 // 销毁编辑器
/**
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const disposeEditor = () => { const disposeEditor = () => {
if (editorInstance) { if (editorInstance) {
editorInstance.dispose() editorInstance.dispose()
@ -424,6 +442,11 @@ const activeNames = ref(['1', '2', '3', '4'])
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id) const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项 // 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => { const addFormItem = (e, type) => {
e.stopPropagation() e.stopPropagation()
const obj = { const obj = {
@ -438,11 +461,20 @@ const addFormItem = (e, type) => {
} }
// 删除输入参数(非递归) // 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => { const handleDelete = (index) => {
formData.nodeParams.splice(index, 1) formData.nodeParams.splice(index, 1)
} }
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理) // 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => { const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath] let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) { for (let i = 0; i < fullPath.length - 1; i++) {
@ -461,6 +493,11 @@ const httpNodeData = ref({
} }
}); });
/**
* 新增 addHttpNodeFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addHttpNodeFormItem = (e, type) => { const addHttpNodeFormItem = (e, type) => {
e.stopPropagation(); e.stopPropagation();
if (type === 'body') { if (type === 'body') {
@ -476,6 +513,11 @@ const addHttpNodeFormItem = (e, type) => {
} }
}; };
/**
* 处理 handleHttpNodeDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
* @param {*} index 目标项索引
*/
const handleHttpNodeDelete = (type, index) => { const handleHttpNodeDelete = (type, index) => {
if (type === 'body') { if (type === 'body') {
httpNodeData.value.body.formData.splice(index, 1); httpNodeData.value.body.formData.splice(index, 1);
@ -485,6 +527,9 @@ const handleHttpNodeDelete = (type, index) => {
}; };
// 初始化数据 // 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => { const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || [])) const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const transformedData = transformHttpNodeData(nodeParams); const transformedData = transformHttpNodeData(nodeParams);
@ -503,7 +548,12 @@ const initData = () => {
} }
// 保存验证 // 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => { const validateAndSave = async () => {
// 分区校验动态输入、请求头、查询参数和请求体;JSON 请求体还需通过编辑器语法检查。
// 全部分区有效后才一次性写回节点属性,防止半成品配置覆盖画布中的可用数据。
let inputValid = true let inputValid = true
if (dynamicFormRef.value) { if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false }) await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
@ -651,4 +701,4 @@ onBeforeUnmount(() => {
height: 300px; height: 300px;
margin: 12px 0; margin: 12px 0;
} }
</style> </style>

View File

@ -0,0 +1,431 @@
<!--
* 文件说明:通用图片分析节点参数编辑器,支持引用上游图片或直接填写 HTTP/HTTPS 图片地址。
* 数据流向:从 nodeParams 回填表单,保存时写回 IMAGE_ANALYZE 执行协议和统一输出字段。
-->
<template>
<div class="image-analysis-params">
<div class="intro">
<div class="intro__title">通用图片分析</div>
<div class="intro__desc">根据提示词分析一张或多张图片,模型结果保持提示词要求的内容和格式。</div>
</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
<el-form-item label="分析方式">
<el-segmented v-model="formData.analysisMethod" :options="methodOptions" block />
</el-form-item>
<el-form-item :label="isReferenceMatch ? '大图来源' : (isTextLocate ? '目标图片来源' : '图片来源')" prop="imageMode">
<el-segmented v-model="formData.imageMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.imageMode === 'quote'" label="引用上游图片" prop="imageQuote">
<el-cascader
ref="imageCascaderRef"
v-model="formData.imageQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择图片节点的 imageUrl 输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleQuoteChange"
/>
<div class="field-hint" v-if="isVisionAnalysis">上游输出为图片数组时,将按原顺序全部分析,最多 12 张。</div>
</el-form-item>
<el-form-item v-else label="图片地址" prop="imageUrlsText">
<el-input
v-model="formData.imageUrlsText"
type="textarea"
:rows="4"
:placeholder="isLocationMethod ? '输入一张目标图片的 HTTP/HTTPS 地址' : '每行一个 HTTP/HTTPS 图片地址,最多 12 张'"
/>
</el-form-item>
<el-form-item v-if="!isReferenceMatch" :label="isTextLocate ? '目标描述' : '分析提示词'" prop="prompt">
<el-input
v-model="formData.prompt"
type="textarea"
:rows="5"
maxlength="4000"
show-word-limit
:placeholder="isTextLocate ? '例如:底部应用栏中的设置按钮' : '例如:读取图片中的仪表盘数值,只输出数字。或:识别所有仪表,按 JSON 输出名称、数值和单位。'"
/>
</el-form-item>
<el-form-item v-if="!isReferenceMatch" label="分析模式">
<el-segmented v-model="formData.analysisMode" :options="modeOptions" block />
</el-form-item>
<el-form-item label="异步执行">
<el-switch v-model="formData.asynchronous" />
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
</el-form-item>
<el-form-item v-if="isTextLocate" label="最低定位置信度">
<el-slider v-model="formData.analysisTuning.locateConfidenceThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
</el-form-item>
<template v-if="isReferenceMatch">
<el-form-item label="小图来源">
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用待查小图" prop="referenceQuote">
<el-cascader
ref="referenceCascaderRef"
v-model="formData.referenceQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择待查小图输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleReferenceQuoteChange"
/>
</el-form-item>
<el-form-item v-else label="小图地址" prop="referenceImageUrl">
<el-input v-model="formData.referenceImageUrl" clearable placeholder="输入待查小图的 HTTP/HTTPS 地址" />
</el-form-item>
<el-form-item label="最低匹配分数">
<el-slider v-model="formData.analysisTuning.matchThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
</el-form-item>
<el-form-item label="选择匹配结果">
<el-select v-model="formData.analysisTuning.matchSelector" style="width: 100%">
<el-option v-for="item in matchSelectorOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<template v-if="formData.analysisTuning.matchSelector === 'INDEX'">
<el-form-item label="排序方式">
<el-segmented v-model="formData.analysisTuning.matchSort" :options="matchSortOptions" block />
</el-form-item>
<el-form-item label="选择第几个">
<el-input-number v-model="formData.analysisTuning.matchIndex" :min="1" :max="20" />
</el-form-item>
</template>
</template>
<el-collapse v-if="!isReferenceMatch" v-model="advancedSections" class="advanced-settings">
<el-collapse-item v-if="isVisionAnalysis" title="参考图片(可选)" name="reference">
<el-form-item label="参考图片来源">
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用参考图片" prop="referenceQuote">
<el-cascader
ref="referenceCascaderRef"
v-model="formData.referenceQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择参考图片输出"
style="width: 100%"
clearable
@visible-change="loadQuoteOptions"
@change="handleReferenceQuoteChange"
/>
</el-form-item>
<el-form-item v-else label="参考图片地址" prop="referenceImageUrl">
<el-input v-model="formData.referenceImageUrl" clearable placeholder="HTTP/HTTPS 图片地址" />
</el-form-item>
<div class="field-hint">参考图片会作为最后一张图片传入;请在提示词中说明它的用途。</div>
</el-collapse-item>
<el-collapse-item title="高级设置" name="advanced">
<el-form-item label="图片最大宽度">
<el-slider v-model="formData.analysisTuning.maxWidth" :min="640" :max="1600" :step="64" show-input />
</el-form-item>
<el-form-item v-if="isVisionAnalysis" label="最多分析图片">
<el-input-number v-model="formData.analysisTuning.maxImages" :min="1" :max="12" />
</el-form-item>
<el-form-item label="最大输出长度">
<el-input-number v-model="formData.analysisTuning.maxOutputTokens" :min="32" :max="4096" :step="32" />
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<el-divider content-position="left">节点输出</el-divider>
<div class="output-list">
<div v-for="item in outputSummary" :key="item.name" class="output-item">
<code>{{ item.name }}</code><span>{{ item.desc }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { getUpstreamInput } from '@/utils/flow'
import { IMAGE_ANALYSIS_OUTPUT_PARAMS } from '@/utils/flowResources'
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
const formRef = ref()
const imageCascaderRef = ref()
const referenceCascaderRef = ref()
const quoteOptions = ref([])
const advancedSections = ref([])
// 这里的 value 会直接写入 nodeParams.type,需与流程参数协议中的 quote/input 保持一致。
const sourceOptions = [
{ label: '引用节点输出', value: 'quote' },
{ label: '输入图片地址', value: 'input' },
]
const modeOptions = [
{ label: '快速', value: 'FAST' },
{ label: '精准', value: 'ACCURATE' },
]
const methodOptions = [
{ label: '智能分析', value: 'VISION_MODEL' },
{ label: '描述找图', value: 'TEXT_LOCATE' },
{ label: '精确找图', value: 'REFERENCE_MATCH' },
]
const matchSelectorOptions = [
{ label: '最佳匹配', value: 'BEST' },
{ label: '按序号选择', value: 'INDEX' },
{ label: '左上', value: 'TOP_LEFT' },
{ label: '右上', value: 'TOP_RIGHT' },
{ label: '左下', value: 'BOTTOM_LEFT' },
{ label: '右下', value: 'BOTTOM_RIGHT' },
]
const matchSortOptions = [
{ label: '先行后列', value: 'ROW_MAJOR' },
{ label: '先列后行', value: 'COLUMN_MAJOR' },
]
/**
* 使用稳定的 reactive 对象承载表单状态;initData 只覆盖字段,避免 Element Plus 校验引用失效。
*/
const formData = reactive({
analysisMethod: 'VISION_MODEL',
imageMode: 'quote',
imageQuote: [],
imageQuoteType: 'output',
imageUrlsText: '',
referenceMode: 'input',
referenceQuote: [],
referenceQuoteType: 'output',
referenceImageUrl: '',
prompt: '',
analysisMode: 'FAST',
asynchronous: false,
analysisTuning: {
maxWidth: 896,
maxImages: 12,
maxOutputTokens: 384,
matchThreshold: 0.48,
matchSelector: 'BEST',
matchIndex: 1,
matchSort: 'ROW_MAJOR',
locateConfidenceThreshold: 0.45,
},
})
const isReferenceMatch = computed(() => formData.analysisMethod === 'REFERENCE_MATCH')
const isTextLocate = computed(() => formData.analysisMethod === 'TEXT_LOCATE')
const isVisionAnalysis = computed(() => formData.analysisMethod === 'VISION_MODEL')
const isLocationMethod = computed(() => isReferenceMatch.value || isTextLocate.value)
const outputSummary = [
{ name: 'result', desc: '按提示词返回的结果;JSON、数组和数字会自动解析' },
{ name: 'resultText', desc: '模型返回的原始文本' },
{ name: 'matched', desc: '找图结果是否达到最低分数' },
{ name: 'matchScore', desc: '找图的匹配分数或定位置信度' },
{ name: 'coordinates', desc: '大图原始尺寸坐标 [x1,y1,x2,y2]' },
{ name: 'center', desc: '目标中心点坐标 [x,y]' },
{ name: 'matchedDescription', desc: '模型实际定位的目标描述' },
{ name: 'refinementMethod', desc: '坐标精修方式' },
{ name: 'matchCount', desc: '找到的匹配项数量' },
{ name: 'matches', desc: '全部匹配项及其序号、坐标和分数' },
{ name: 'selectedMatchIndex', desc: '当前选择的匹配序号' },
]
// 文本输入允许换行或逗号分隔,保存前统一转成执行端需要的 URL 数组。
const parseUrls = value => String(value || '').split(/\r?\n|,/).map(item => item.trim()).filter(Boolean)
const validUrl = value => /^https?:\/\//i.test(value)
const hasReference = () => formData.referenceMode === 'quote'
? formData.referenceQuote.length > 0
: Boolean(formData.referenceImageUrl.trim())
/**
* 条件校验只约束当前可见的图片来源,隐藏模式不阻塞保存;直接输入仅接受 HTTP/HTTPS。
*/
const rules = {
imageQuote: [{
validator: (_, value) => formData.imageMode !== 'quote' || value?.length
? Promise.resolve()
: Promise.reject(new Error('请选择图片输出')),
trigger: 'change',
}],
imageUrlsText: [{
validator: (_, value) => {
if (formData.imageMode !== 'input') return Promise.resolve()
const urls = parseUrls(value)
if (!urls.length) return Promise.reject(new Error('请至少输入一个图片地址'))
const maximum = isLocationMethod.value ? 1 : 12
if (urls.length > maximum) return Promise.reject(new Error(isLocationMethod.value ? '找图只能输入一张目标图片' : '最多输入 12 张图片'))
return urls.every(validUrl)
? Promise.resolve()
: Promise.reject(new Error('图片地址必须是 HTTP/HTTPS 地址'))
},
trigger: 'blur',
}],
referenceImageUrl: [{
validator: (_, value) => {
if (formData.referenceMode !== 'input') return Promise.resolve()
if (isReferenceMatch.value && !String(value || '').trim()) return Promise.reject(new Error('请输入待查小图地址'))
return !value || validUrl(String(value).trim())
? Promise.resolve()
: Promise.reject(new Error('请输入有效的参考图片地址'))
},
trigger: 'blur',
}],
referenceQuote: [{
validator: (_, value) => !isReferenceMatch.value || formData.referenceMode !== 'quote' || value?.length
? Promise.resolve()
: Promise.reject(new Error('请选择待查小图输出')),
trigger: 'change',
}],
prompt: [{
validator: (_, value) => isReferenceMatch.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请输入分析提示词')),
trigger: 'blur',
}],
}
// 引用选项按需加载,上游流程较大时避免每次渲染都遍历画布。
const loadQuoteOptions = visible => { if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || [] }
const handleQuoteChange = () => {
formData.imageQuoteType = imageCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
}
const handleReferenceQuoteChange = () => {
formData.referenceQuoteType = referenceCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
}
const getParam = (params, name) => params.find(item => item.name === name)
/**
* 兼容旧版图片识别节点:把 taskType、targetDescription、instruction 合成新的自由提示词。
*/
const legacyPrompt = (params) => {
const target = String(getParam(params, 'targetDescription')?.input || '').trim()
const instruction = String(getParam(params, 'instruction')?.input || '').trim()
const taskType = String(getParam(params, 'taskType')?.input || '').toUpperCase()
const parts = [target || instruction]
if (taskType === 'ICON_TEMPLATE_LOCATE') {
parts.push('最后一张图片是参考图片,请在其他图片中查找对应目标并返回坐标。')
}
parts.push('只返回分析结果,不输出通过、未通过或置信度。')
return parts.filter(Boolean).join('\n')
}
/**
* 将持久化 nodeParams 回填到表单,并为缺少新字段的旧节点补默认值。
*/
const initData = () => {
loadQuoteOptions(true)
const params = props.data?.properties?.nodeParams || []
const images = getParam(params, 'imageUrl')
const reference = getParam(params, 'referenceImageUrl')
const tuning = getParam(params, 'analysisTuning')?.input || {}
const asynchronous = getParam(params, 'asynchronous')
formData.imageMode = images?.type === 'input' ? 'input' : 'quote'
formData.imageQuote = Array.isArray(images?.quote) ? [...images.quote] : []
formData.imageQuoteType = images?.quoteType || 'output'
const staticImages = Array.isArray(images?.input) ? [...images.input] : parseUrls(images?.input || '')
const savedMethod = String(getParam(params, 'analysisMethod')?.input || '').toUpperCase()
const savedPrompt = getParam(params, 'prompt')?.input || legacyPrompt(params)
const coordinatePrompt = /(坐标|位置|小图|参考图|图标|coordinate|position|bbox|template)/i.test(savedPrompt)
const legacyReferenceMatch = !savedMethod && coordinatePrompt
&& ((reference && staticImages.length === 1) || (!reference && staticImages.length === 2))
const legacyTextLocate = !savedMethod && coordinatePrompt && !reference && staticImages.length === 1
formData.analysisMethod = savedMethod === 'REFERENCE_MATCH' || legacyReferenceMatch
? 'REFERENCE_MATCH'
: (savedMethod === 'TEXT_LOCATE' || legacyTextLocate ? 'TEXT_LOCATE' : 'VISION_MODEL')
formData.imageUrlsText = (legacyReferenceMatch ? staticImages.slice(0, 1) : staticImages).join('\n')
formData.referenceMode = reference?.type === 'quote' ? 'quote' : 'input'
formData.referenceQuote = Array.isArray(reference?.quote) ? [...reference.quote] : []
formData.referenceQuoteType = reference?.quoteType || 'output'
formData.referenceImageUrl = reference?.input || (legacyReferenceMatch ? staticImages[1] : '')
formData.prompt = savedPrompt
const savedMode = String(getParam(params, 'analysisMode')?.input || '').toUpperCase()
formData.analysisMode = savedMode === 'ACCURATE' ? 'ACCURATE' : 'FAST'
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
formData.analysisTuning.maxWidth = Number(tuning.maxWidth) || (formData.analysisMode === 'FAST' ? 896 : 1280)
formData.analysisTuning.maxImages = Number(tuning.maxImages) || 12
formData.analysisTuning.maxOutputTokens = Number(tuning.maxOutputTokens)
|| (formData.analysisMode === 'FAST' ? 384 : 512)
formData.analysisTuning.matchThreshold = Number(tuning.matchThreshold) || 0.48
formData.analysisTuning.matchSelector = matchSelectorOptions.some(item => item.value === tuning.matchSelector)
? tuning.matchSelector
: 'BEST'
formData.analysisTuning.matchIndex = Math.max(1, Math.min(20, Number(tuning.matchIndex) || 1))
formData.analysisTuning.matchSort = tuning.matchSort === 'COLUMN_MAJOR' ? 'COLUMN_MAJOR' : 'ROW_MAJOR'
formData.analysisTuning.locateConfidenceThreshold = Number(tuning.locateConfidenceThreshold) || 0.45
advancedSections.value = isVisionAnalysis.value && hasReference() ? ['reference'] : []
}
watch(() => props.data, initData, { immediate: true, deep: true })
/**
* 媒体参数统一保存为 quote/input 二选一结构;未使用的一侧清空,避免执行端误读。
*/
const buildMediaParam = (name, mode, input, quote, quoteType, required = true) => ({
name,
type: mode,
input: mode === 'input' ? input : '',
quote: mode === 'quote' ? [...quote] : '',
quoteType,
required,
disabled: true,
})
/**
* 校验通过后重建 IMAGE_ANALYZE 节点属性,确保 action、类型和输出契约同步升级。
*/
const validateAndSave = async () => {
try {
await formRef.value.validate()
const nodeParams = [
{ name: 'profileCode', type: 'input', input: 'common.image_analysis.v1', required: true, disabled: true },
buildMediaParam('imageUrl', formData.imageMode, parseUrls(formData.imageUrlsText), formData.imageQuote, formData.imageQuoteType),
{ name: 'analysisMethod', type: 'input', input: formData.analysisMethod, required: false, disabled: true },
{ name: 'prompt', type: 'input', input: isReferenceMatch.value ? '在大图中查找小图,返回原图像素坐标 [x1,y1,x2,y2]' : formData.prompt.trim(), required: true, disabled: true, componentType: 'textarea' },
{ name: 'analysisMode', type: 'input', input: formData.analysisMode, required: false, disabled: true, componentType: 'select', selectOptions: modeOptions },
{ name: 'analysisTuning', type: 'input', input: { ...formData.analysisTuning }, required: false, disabled: true },
{ name: 'asynchronous', type: 'input', input: formData.asynchronous, required: false, disabled: true, componentType: 'switch' },
]
if (!isTextLocate.value && hasReference()) {
// 参考图存在时插入到图片来源和提示词之间,保持执行端按固定顺序读取。
nodeParams.splice(3, 0, buildMediaParam(
'referenceImageUrl', formData.referenceMode, formData.referenceImageUrl.trim(),
formData.referenceQuote, formData.referenceQuoteType, false,
))
}
const properties = {
...props.data.properties,
name: '通用图片分析',
action: 'IMAGE_ANALYZE',
nodeType: 'LLM',
nodeParams,
outputType: 'json',
outputParams: JSON.parse(JSON.stringify(IMAGE_ANALYSIS_OUTPUT_PARAMS)),
}
// 图片分析由 LLM 服务执行,不绑定机器人或设备资源。
delete properties.executionTarget
lf.setProperties(props.data.id, properties)
emit('save-success')
} catch {
emit('save-error')
}
}
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.image-analysis-params { padding-top: 16px; }
.intro { margin-bottom: 22px; padding: 14px 16px; border-left: 3px solid var(--el-color-primary); background: var(--el-fill-color-light); }
.intro__title { color: var(--el-text-color-primary); font-weight: 650; }
.intro__desc, .field-hint { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
.advanced-settings { margin-bottom: 18px; }
.output-list { display: grid; gap: 8px; }
.output-item { display: flex; align-items: baseline; gap: 12px; color: var(--el-text-color-regular); font-size: 13px; }
.output-item code { min-width: 92px; color: var(--el-color-primary); }
</style>

View File

@ -0,0 +1,567 @@
<!--
* 文件说明:媒体分析节点的参数编辑器,同时服务于“声音类型检测”和“视频智能分析”节点。
* 数据流向:从 props.data.properties.nodeParams 回填表单,校验通过后再组装为标准 nodeParams
* 并写回 LogicFlow 节点属性;本组件不直接调用媒体分析接口。
* 作用范围:仅服务于流程设计器参数抽屉,由 ParamsDrawer 调用 validateAndSave 完成保存。
-->
<template>
<div class="media-analysis-params">
<!-- 标题和说明由节点 action 决定,同一组件在音频、视频节点下呈现不同业务语义。 -->
<div class="media-analysis-intro">
<div class="media-analysis-intro__title">{{ title }}</div>
<div class="media-analysis-intro__desc">{{ description }}</div>
</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
<!--
profileCode 是后端选择分析配置和模型策略的场景编码。
allow-create 用于兼容后端临时新增、前端尚未内置的场景,不把可用值限制在下拉选项内。
-->
<el-form-item label="分析场景" prop="profileCode">
<el-select
v-model="formData.profileCode"
filterable
allow-create
default-first-option
placeholder="选择或输入场景编码"
style="width: 100%"
>
<el-option
v-for="item in profileOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="field-hint">场景决定识别标签、判断阈值和分析要求。</div>
</el-form-item>
<!-- 音频检测需要一个期望声音;视频分析改由 instruction 描述通过标准,因此不显示该字段。 -->
<el-form-item v-if="!isVideo" label="目标声音类型" prop="expectedLabel">
<el-select
v-model="formData.expectedLabel"
filterable
allow-create
default-first-option
placeholder="请选择目标声音类型"
style="width: 100%"
@change="handleExpectedLabelChange"
>
<el-option
v-for="item in soundTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="field-hint">只有实际声音与目标类型一致时才通过;新增类型可直接输入编码或名称。</div>
</el-form-item>
<!-- 媒体既可以引用上游录音/录像节点的输出,也可以直接填写一个可访问的网络地址。 -->
<el-form-item :label="mediaLabel" prop="mediaMode">
<el-segmented v-model="formData.mediaMode" :options="mediaModes" />
</el-form-item>
<!-- 引用模式保存级联路径和输出类型;打开下拉时才查询上游节点,避免每次渲染都遍历流程图。 -->
<el-form-item v-if="formData.mediaMode === 'quote'" label="引用节点输出" prop="mediaQuote">
<el-cascader
ref="mediaCascaderRef"
v-model="formData.mediaQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择上游录音或录像输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleQuoteChange"
/>
</el-form-item>
<!-- 直接输入模式只接受 HTTP/HTTPS 地址,具体格式在 rules.mediaUrl 中按当前模式条件校验。 -->
<el-form-item v-else :label="`${mediaLabel}地址`" prop="mediaUrl">
<el-input v-model="formData.mediaUrl" type="textarea" :rows="3" placeholder="请输入 HTTP/HTTPS 媒体地址" />
</el-form-item>
<el-form-item label="异步执行">
<el-switch v-model="formData.asynchronous" />
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
</el-form-item>
<!-- 视频模型依赖自然语言标准判断是否通过;音频节点使用 expectedLabel,不需要 instruction。 -->
<el-form-item v-if="isVideo" label="通过标准" prop="instruction" required>
<el-input
v-model="formData.instruction"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="例如:车辆启动后,仪表必须在3秒内点亮"
/>
<div class="field-hint">未观察到完整证据或置信度不足时,结果按未通过处理。</div>
</el-form-item>
<!-- 切换分析模式时同步恢复该模式预设,避免旧模式的微调参数意外带入新模式。 -->
<el-form-item v-if="isVideo" label="分析模式">
<el-segmented v-model="formData.analysisMode" :options="analysisModes" block @change="handleAnalysisModeChange" />
<div class="field-hint">{{ modeDescription }}</div>
</el-form-item>
<el-form-item v-if="isVideo" label="输出内容">
<!-- detailedOutput 只影响视频节点:默认精简,需要事件证据和时间范围时再让后端返回完整结果。 -->
<el-switch
v-model="formData.detailedOutput"
inline-prompt
active-text="详细"
inactive-text="精简"
/>
<div class="field-hint">精简结果响应更快;需要逐步事件和时间范围时再启用详细输出。</div>
</el-form-item>
<!--
高级参数只对视频抽帧分析生效。默认折叠以降低普通用户的配置负担,isTuned 用于提示
当前值已经偏离模式预设;“恢复默认”只重置当前模式的参数,不切换分析模式。
-->
<el-collapse v-if="isVideo" v-model="advancedSections" class="analysis-tuning">
<el-collapse-item name="tuning">
<template #title>
<div class="analysis-tuning__title">
<span>高级设置</span>
<el-tag v-if="isTuned" size="small" type="warning" effect="plain">已微调</el-tag>
</div>
</template>
<div class="analysis-tuning__body">
<div class="analysis-tuning__toolbar">
<span>当前模式参数</span>
<el-button :icon="Refresh" text type="primary" @click.stop="restorePreset">恢复默认</el-button>
</div>
<el-form-item label="采样帧率(帧/秒)">
<el-slider v-model="formData.analysisTuning.sampleFps" :min="0.25" :max="6" :step="0.25" show-input />
</el-form-item>
<el-form-item label="图片最大宽度">
<el-slider v-model="formData.analysisTuning.maxWidth" :min="640" :max="1280" :step="64" show-input />
</el-form-item>
<el-form-item label="通过置信度">
<el-slider v-model="formData.analysisTuning.confidenceThreshold" :min="0.5" :max="0.95" :step="0.05" show-input />
</el-form-item>
<el-form-item v-if="formData.analysisMode === 'AUTO'" label="低置信度转精确分析">
<el-switch v-model="formData.analysisTuning.fallbackToAccurate" />
</el-form-item>
</div>
</el-collapse-item>
</el-collapse>
</el-form>
<!-- 输出字段只作契约说明,实际 outputParams 由节点配置定义,不能在这里编辑。 -->
<el-divider content-position="left">节点输出</el-divider>
<div class="output-list">
<div v-for="item in outputSummary" :key="item.name" class="output-item">
<code>{{ item.name }}</code>
<span>{{ item.desc }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getUpstreamInput } from '@/utils/flow'
/**
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
* 保存成功/失败事件由 ParamsDrawer 接收,用于更新节点错误状态并关闭参数抽屉。
*/
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
// formRef 触发表单校验;mediaCascaderRef 用于读取级联选择器最终选中节点的元数据。
const formRef = ref()
const mediaCascaderRef = ref()
// 上游节点输出在级联选择器展开时按需加载;高级设置默认保持折叠。
const quoteOptions = ref([])
const advancedSections = ref([])
/**
* 三种视频分析模式的推荐参数:
* maxFrames 控制抽取帧数,maxWidth 控制送入模型前的缩放宽度,confidenceThreshold 是通过阈值;
* fallbackToAccurate 仅 AUTO 模式有意义,表示快速分析置信度不足时是否升级为精确分析。
*/
const analysisPresets = {
FAST: { sampleFps: 1, maxWidth: 896, confidenceThreshold: 0.7, fallbackToAccurate: false },
AUTO: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true },
ACCURATE: { sampleFps: 3, maxWidth: 1120, confidenceThreshold: 0.8, fallbackToAccurate: false },
}
/**
* 表单使用一个稳定的 reactive 对象,initData 只更新字段而不替换对象,保证 Element Plus
* 的表单项、校验状态和 v-model 引用始终指向同一个实例。
*/
const formData = reactive({
profileCode: '',
expectedLabel: '',
expectedLabelName: '',
mediaMode: 'quote',
mediaQuote: [],
mediaQuoteType: 'output',
mediaUrl: '',
instruction: '',
analysisMode: 'AUTO',
detailedOutput: false,
asynchronous: false,
analysisTuning: { ...analysisPresets.AUTO },
})
// action 是区分音频节点和视频节点的唯一业务判别字段,后续文案、参数名和校验都由它派生。
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
// 以下计算属性统一收敛音频/视频差异,模板不需要重复判断具体 action 字符串。
const title = computed(() => isVideo.value ? '视频智能分析' : '声音类型检测')
const description = computed(() => isVideo.value
? '对录像抽帧并按所选场景生成结构化分析结果。'
: '判断录音是否属于指定的目标声音类型,并输出通过或不通过。')
const mediaLabel = computed(() => isVideo.value ? '视频来源' : '音频来源')
// nodeParams 中的媒体参数名属于执行协议:视频使用 videoUrl,音频使用 audioUrl。
const mediaName = computed(() => isVideo.value ? 'videoUrl' : 'audioUrl')
// mediaMode 的值会直接写入媒体参数 type,因此必须与流程参数协议中的 quote/input 保持一致。
const mediaModes = [
{ label: '引用节点输出', value: 'quote' },
{ label: '直接输入地址', value: 'input' },
]
const analysisModes = [
{ label: '快速', value: 'FAST' },
{ label: '均衡', value: 'AUTO' },
{ label: '精确', value: 'ACCURATE' },
]
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。
const soundTypeOptions = [
{ label: '开机', value: 'POWER_ON' },
{ label: '关机', value: 'POWER_OFF' },
{ label: '设防', value: 'ARMED' },
{ label: '解防', value: 'DISARMED' },
{ label: '鸣笛寻车', value: 'FIND_VEHICLE_HORN' },
]
const modeDescriptions = {
FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。',
AUTO: '先快速判断,低置信度时自动切换精确分析。',
ACCURATE: '使用高精度模型分析更多画面细节,耗时较长。',
}
const modeDescription = computed(() => modeDescriptions[formData.analysisMode] || '')
// 任意高级参数偏离当前模式预设即视为“已微调”,用于控制折叠标题中的提示标签。
const isTuned = computed(() => {
const preset = analysisPresets[formData.analysisMode] || analysisPresets.AUTO
return Object.keys(preset).some((key) => formData.analysisTuning[key] !== preset[key])
})
// 不同媒体类型使用不同场景编码,默认值也从这里取得,避免音视频节点互相沿用 profileCode。
const profileOptions = computed(() => isVideo.value
? [{ label: '爱玛测试视频分析', value: 'aima.power_video.v1' }]
: [{ label: '爱玛车辆声音类型检测', value: 'aima.power_state.v1' }])
/**
* 输出摘要与运行时返回对象保持一致,仅负责向流程设计用户解释后续节点可引用的字段。
* 音频结果强调目标声音与实际声音的比较;视频结果强调证据、结论和实际分析模式。
*/
const outputSummary = computed(() => isVideo.value
? [
{ name: 'result', desc: '分析结果' },
{ name: 'passed', desc: '是否通过' },
{ name: 'confidence', desc: '判定置信度' },
{ name: 'evidenceSufficient', desc: '证据是否充分' },
{ name: 'conclusion', desc: '分析结论' },
{ name: 'summary', desc: '分析摘要' },
{ name: 'events', desc: '关键事件列表' },
{ name: 'analysisMode', desc: '实际使用的分析模式' },
{ name: 'fallback', desc: '是否切换到精确分析' },
{ name: 'detailedOutput', desc: '是否返回详细事件证据' },
]
: [
{ name: 'passed', desc: '是否检测到目标声音' },
{ name: 'expectedLabelName', desc: '目标声音类型' },
{ name: 'labelName', desc: '实际声音类型' },
{ name: 'matched', desc: '是否识别到已知声音' },
{ name: 'confidence', desc: '最高匹配度' },
{ name: 'decisionReason', desc: '检测结论' },
{ name: 'scores', desc: '各分类匹配度' },
])
/**
* 校验规则包含多个条件字段:
* - expectedLabel 只在音频节点必填;instruction 只在视频节点必填;
* - mediaQuote/mediaUrl 根据 mediaMode 二选一校验,隐藏字段不会阻塞保存;
* - 直接地址限制为 HTTP/HTTPS,避免执行端收到本地路径或无法识别的协议。
*/
const rules = {
profileCode: [{ required: true, message: '请选择或输入分析场景', trigger: 'change' }],
expectedLabel: [{
validator: (_, value) => isVideo.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请选择目标声音类型')),
trigger: 'change',
}],
mediaQuote: [{
validator: (_, value) => formData.mediaMode !== 'quote' || (Array.isArray(value) && value.length)
? Promise.resolve()
: Promise.reject(new Error('请选择媒体输出')),
trigger: 'change',
}],
mediaUrl: [{
validator: (_, value) => formData.mediaMode !== 'input' || /^https?:\/\//i.test(String(value || '').trim())
? Promise.resolve()
: Promise.reject(new Error('请输入有效的 HTTP/HTTPS 地址')),
trigger: 'blur',
}],
instruction: [{
validator: (_, value) => !isVideo.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请输入明确的通过标准')),
trigger: 'blur',
}],
}
// 使用 Object.assign 保留 analysisTuning 的响应式引用,只覆盖当前模式定义的预设字段。
const restorePreset = () => {
Object.assign(formData.analysisTuning, analysisPresets[formData.analysisMode] || analysisPresets.AUTO)
}
// 模式切换后直接采用新模式预设,用户仍可在高级设置中继续微调。
const handleAnalysisModeChange = () => restorePreset()
// 同时维护编码和展示名称;用户创建自定义类型时,输入值本身作为可读名称兜底。
const handleExpectedLabelChange = (value) => {
const option = soundTypeOptions.find(item => item.value === value)
formData.expectedLabelName = option?.label || String(value || '').trim()
}
/**
* analysisTuning 的历史数据可能是对象或 JSON 字符串。这里统一解析为普通对象;
* 非法 JSON 返回空对象,后续 normalizeStoredTuning 会用模式预设补齐全部字段。
*/
const parseStoredTuning = (value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
if (typeof value !== 'string' || !value.trim()) return {}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
// 将旧配置或外部写入值限制在当前 UI 支持范围内,NaN 等非法值回退到模式默认值。
const clamp = (value, minimum, maximum, fallback) => {
const number = Number(value)
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, number)) : fallback
}
/**
* 标准化持久化的高级参数,保证滑块始终接收到合法数字,并兼容缺少新字段的旧流程数据。
* 帧数和宽度必须为整数;fallbackToAccurate 只接受真正的布尔值,不把字符串误判为 true。
*/
const normalizeStoredTuning = (value, preset) => {
const stored = parseStoredTuning(value)
return {
sampleFps: Math.round(clamp(stored.sampleFps, 0.25, 6, preset.sampleFps) * 100) / 100,
maxWidth: Math.round(clamp(stored.maxWidth, 640, 1280, preset.maxWidth)),
confidenceThreshold: clamp(stored.confidenceThreshold, 0.5, 0.95, preset.confidenceThreshold),
fallbackToAccurate: typeof stored.fallbackToAccurate === 'boolean'
? stored.fallbackToAccurate
: preset.fallbackToAccurate,
}
}
// getUpstreamInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
const loadQuoteOptions = (visible) => {
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
}
/**
* 级联路径只记录选中位置,quoteType 需要从叶子节点元数据单独取得;
* 读取不到元数据时回退为 output,兼容旧版上游输出选项。
*/
const handleQuoteChange = () => {
const checked = mediaCascaderRef.value?.getCheckedNodes?.(true)?.[0]
formData.mediaQuoteType = checked?.data?.type || 'output'
}
/**
* 将节点已保存的 nodeParams 回填到表单。
*
* nodeParams 是按 name 存储的数组而不是对象,因此先逐项定位;缺失字段使用当前节点类型的默认值。
* 媒体参数的 type 决定“引用/输入”模式,quote、quoteType 和 input 分别恢复对应控件。
* 旧版音频节点可能只有 expectedLabel 没有 expectedLabelName,此时通过选项表补齐中文名称。
* 视频高级配置经过 normalizeStoredTuning 清洗,非 AUTO 模式强制关闭仅 AUTO 支持的精确分析回退。
*/
const initData = () => {
loadQuoteOptions(true)
const params = props.data?.properties?.nodeParams || []
const profile = params.find(item => item.name === 'profileCode')
const expectedLabel = params.find(item => item.name === 'expectedLabel')
const expectedLabelName = params.find(item => item.name === 'expectedLabelName')
const media = params.find(item => item.name === mediaName.value)
const instruction = params.find(item => item.name === 'instruction')
const analysisMode = params.find(item => item.name === 'analysisMode')
const detailedOutput = params.find(item => item.name === 'detailedOutput')
const analysisTuning = params.find(item => item.name === 'analysisTuning')
const asynchronous = params.find(item => item.name === 'asynchronous')
formData.profileCode = profile?.input || profileOptions.value[0]?.value || ''
formData.expectedLabel = expectedLabel?.input || ''
formData.expectedLabelName = expectedLabelName?.input || ''
if (formData.expectedLabel && !formData.expectedLabelName) {
handleExpectedLabelChange(formData.expectedLabel)
}
formData.mediaMode = media?.type === 'input' ? 'input' : 'quote'
formData.mediaQuote = Array.isArray(media?.quote) ? [...media.quote] : []
formData.mediaQuoteType = media?.quoteType || 'output'
formData.mediaUrl = media?.input || ''
formData.instruction = instruction?.input || ''
formData.analysisMode = analysisPresets[analysisMode?.input] ? analysisMode.input : 'AUTO'
// 兼容旧流程或外部写入的字符串值,表单内部统一使用布尔值驱动开关。
formData.detailedOutput = detailedOutput?.input === true || detailedOutput?.input === 'true'
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
const preset = analysisPresets[formData.analysisMode] || analysisPresets.AUTO
Object.assign(formData.analysisTuning, normalizeStoredTuning(analysisTuning?.input, preset))
if (formData.analysisMode !== 'AUTO') formData.analysisTuning.fallbackToAccurate = false
}
// 抽屉可能在不销毁组件的情况下切换节点;深度监听保证节点属性更新后表单立即重新回填。
watch(() => props.data, initData, { immediate: true, deep: true })
/**
* 校验并保存当前媒体分析节点。
*
* 保存过程重新构造 nodeParams,而不是修改原数组,避免残留另一种媒体类型的专属字段:
* 1. 所有节点保存 profileCode 和当前媒体参数;
* 2. 音频节点额外保存 expectedLabel/expectedLabelName;
* 3. 视频节点额外保存 instruction、analysisMode 和 analysisTuning;
* 4. 删除旧的 executionTarget 后,通过 LogicFlow 全局实例 lf 一次性写回 properties。
*
* ParamsDrawer 只依赖 save-success/save-error,不需要了解 Element Plus 的校验异常细节。
*/
const validateAndSave = async () => {
try {
await formRef.value.validate()
// 公共参数始终位于数组前部,保持与 config.js 中节点初始定义一致,便于调试和序列化比较。
const nodeParams = [
{
name: 'profileCode',
type: 'input',
input: formData.profileCode.trim(),
required: true,
disabled: true,
componentType: 'select',
selectOptions: profileOptions.value,
},
{
name: mediaName.value,
type: formData.mediaMode,
input: formData.mediaMode === 'input' ? formData.mediaUrl.trim() : '',
quote: formData.mediaMode === 'quote' ? [...formData.mediaQuote] : '',
quoteType: formData.mediaQuoteType,
required: true,
disabled: true,
},
]
nodeParams.push({
name: 'asynchronous',
type: 'input',
input: formData.asynchronous,
required: false,
disabled: true,
componentType: 'switch',
})
// 音频节点把目标类型插入 profileCode 与媒体参数之间,不保存任何视频专属参数。
if (!isVideo.value) {
nodeParams.splice(1, 0,
{
name: 'expectedLabel',
type: 'input',
input: formData.expectedLabel.trim(),
required: true,
disabled: true,
componentType: 'select',
selectOptions: soundTypeOptions,
},
{
name: 'expectedLabelName',
type: 'input',
input: formData.expectedLabelName.trim() || formData.expectedLabel.trim(),
required: false,
disabled: true,
},
)
}
// 视频节点保留自然语言标准和分析策略;高级参数复制为普通对象,避免泄漏 Vue 响应式代理。
if (isVideo.value) {
nodeParams.push({
name: 'instruction',
type: 'input',
input: formData.instruction.trim(),
required: true,
disabled: true,
})
nodeParams.push({
name: 'analysisMode',
type: 'input',
input: formData.analysisMode,
required: false,
disabled: true,
componentType: 'select',
selectOptions: analysisModes,
})
// 该开关控制后端是否返回 events 等较重字段,默认 false 可降低单节点执行响应成本。
nodeParams.push({
name: 'detailedOutput',
type: 'input',
input: formData.detailedOutput,
required: false,
disabled: true,
componentType: 'switch',
})
nodeParams.push({
name: 'analysisTuning',
type: 'input',
input: { ...formData.analysisTuning },
required: false,
disabled: true,
})
}
// 保留节点名称、action 等其他属性,仅替换本组件负责的 nodeParams。
const properties = { ...props.data.properties, nodeParams }
// executionTarget 属于旧执行模型字段,媒体分析节点统一由流程运行时调度,保存时主动移除。
delete properties.executionTarget
// lf 由流程设计器页面提供为全局 LogicFlow 实例;setProperties 会触发画布节点属性更新。
lf.setProperties(props.data.id, properties)
emit('save-success')
} catch (error) {
emit('save-error')
}
}
// ParamsDrawer 在关闭前通过组件 ref 调用该方法,因此必须显式暴露。
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.media-analysis-params { padding-top: 16px; }
.media-analysis-intro {
margin-bottom: 22px;
padding: 14px 16px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
}
.media-analysis-intro__title { color: var(--el-text-color-primary); font-weight: 650; }
.media-analysis-intro__desc, .field-hint { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
.output-list { display: grid; gap: 8px; }
.output-item { display: flex; gap: 12px; align-items: baseline; color: var(--el-text-color-regular); font-size: 13px; }
.output-item code { min-width: 92px; color: var(--el-color-primary); }
.analysis-tuning { margin-bottom: 18px; border-top: 1px solid var(--el-border-color-lighter); }
.analysis-tuning :deep(.el-collapse-item__header) { min-height: 48px; height: auto; }
.analysis-tuning :deep(.el-collapse-item__content) { padding-bottom: 4px; }
.analysis-tuning__title { display: flex; min-width: 0; align-items: center; gap: 8px; font-weight: 650; }
.analysis-tuning__body { padding: 4px 2px 0; }
.analysis-tuning__toolbar { display: flex; min-height: 32px; margin-bottom: 10px; align-items: center; justify-content: space-between; color: var(--el-text-color-secondary); font-size: 12px; }
.analysis-tuning :deep(.el-slider) { --el-slider-button-size: 16px; }
.analysis-tuning :deep(.el-slider__runway.show-input) { margin-right: 18px; }
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:识别节点参数面板,配置识别条件和告警规则。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<el-form :inline="true" :model="RecognizeNodeData" :rules="rules" ref="dynamicFormRef" class="http-form" <el-form :inline="true" :model="RecognizeNodeData" :rules="rules" ref="dynamicFormRef" class="http-form"
label-position="top" label-width="auto"> label-position="top" label-width="auto">
@ -12,7 +16,7 @@
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`"> <el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
<el-select v-model="property.type" class="param-type" <el-select v-model="property.type" class="param-type"
@change="handleTypeChange(index, RecognizeNodeData, property.type)"> @change="handleTypeChange(property)">
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -34,10 +38,10 @@
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]" :rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
:prop="`nodeParams.${index}.quote`"> :prop="`nodeParams.${index}.quote`">
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }" <el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
v-model="property.quote" :checkStrictly="true" :options="quoteOptions" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions"
placeholder="请选择" placeholder="请选择"
@visible-change="(visible) => visibleChange(visible, index, property.quote)" @visible-change="(visible) => visibleChange(visible, index, property.quote)"
@change="(value) => cascaderChange(value, index, RecognizeNodeData, 'nodeParams')" /> @change="(value) => cascaderChange(value, property)" />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
</el-row> </el-row>
@ -134,6 +138,11 @@ const activeNames = ref(['1', '2'])
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id) const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项 // 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => { const addFormItem = (e, type) => {
e.stopPropagation() e.stopPropagation()
const obj = { const obj = {
@ -159,6 +168,10 @@ const alarmOperators = [
{ label: '不等于(NE)', value: 'NE' } { label: '不等于(NE)', value: 'NE' }
] ]
/**
* 新增 addAlarmRule 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const addAlarmRule = (e) => { const addAlarmRule = (e) => {
e.stopPropagation() e.stopPropagation()
RecognizeNodeData.value.alarmRules.push({ RecognizeNodeData.value.alarmRules.push({
@ -169,15 +182,28 @@ const addAlarmRule = (e) => {
}) })
} }
/**
* 删除 deleteAlarmRule 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const deleteAlarmRule = (index) => { const deleteAlarmRule = (index) => {
RecognizeNodeData.value.alarmRules.splice(index, 1) RecognizeNodeData.value.alarmRules.splice(index, 1)
} }
/**
* 新增 addRecognizeItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const addRecognizeItem = (e) => { const addRecognizeItem = (e) => {
e.stopPropagation(); e.stopPropagation();
RecognizeNodeData.value.outputParams.push({ name: "", type: "string", input: "" }); RecognizeNodeData.value.outputParams.push({ name: "", type: "string", input: "" });
} }
/**
* 删除 deleteRecognizeItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteRecognizeItem = (fullPath, propPath) => { const deleteRecognizeItem = (fullPath, propPath) => {
// 从顶层数据开始查找 // 从顶层数据开始查找
let currentLevel = RecognizeNodeData.value[propPath]; let currentLevel = RecognizeNodeData.value[propPath];
@ -195,6 +221,9 @@ const deleteRecognizeItem = (fullPath, propPath) => {
} }
// 初始化数据 // 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => { const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || [])) const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || [])) const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
@ -214,6 +243,9 @@ watch(
) )
// 保存验证 // 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => { const validateAndSave = async () => {
let inputValid = true let inputValid = true
let outputValid = true let outputValid = true

View File

@ -0,0 +1,120 @@
<template>
<div class="execution-target">
<div class="execution-target__heading">执行设置</div>
<el-form label-position="top">
<el-form-item label="由谁执行" required>
<el-select v-model="localTarget.roleKey" style="width: 100%" @change="handleRoleChange">
<el-option
v-for="role in roles"
:key="role.roleKey"
:label="role.displayName"
:value="role.roleKey"
/>
</el-select>
</el-form-item>
<el-form-item v-if="hasDeviceParameter" label="使用设备" required>
<el-select
v-model="deviceSlotKey"
style="width: 100%"
:placeholder="matchingSlots.length ? '请选择设备用途' : '请先在开始节点配置设备用途'"
@change="emitTarget"
>
<el-option
v-for="slot in matchingSlots"
:key="slot.slotKey"
:label="`${slot.displayName} · ${getDeviceKindName(slot.deviceKind)}`"
:value="slot.slotKey"
/>
</el-select>
<div v-if="!matchingSlots.length" class="execution-target__warning">
当前角色没有匹配的设备用途,请先打开开始节点添加。
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { getDeviceKindForAction } from '@/utils/robotDevice'
import { actionRequiresDevice, getDeviceKindName, getResourceRoles } from '@/utils/flowResources'
const props = defineProps({
data: { type: Object, required: true },
modelValue: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue'])
const roles = ref([])
const localTarget = reactive({ roleKey: '', dispatchPolicy: 'ONE', deviceSlotKey: '' })
const deviceSlotKey = ref('')
const hasDeviceParameter = computed(() => (
actionRequiresDevice(props.data.properties?.action)
))
const requiredDeviceKind = computed(() => getDeviceKindForAction(props.data.properties?.action))
const selectedRole = computed(() => roles.value.find((role) => role.roleKey === localTarget.roleKey))
const matchingSlots = computed(() => (selectedRole.value?.deviceSlots || []).filter((slot) => (
requiredDeviceKind.value == null
|| slot.deviceKind == null
|| Number(slot.deviceKind) === Number(requiredDeviceKind.value)
)))
const emitTarget = () => {
emit('update:modelValue', {
roleKey: localTarget.roleKey,
dispatchPolicy: 'ONE',
deviceSlotKey: hasDeviceParameter.value ? deviceSlotKey.value : '',
})
}
const selectDefaultSlot = () => {
const currentValid = matchingSlots.value.some((slot) => slot.slotKey === deviceSlotKey.value)
if (!currentValid) deviceSlotKey.value = matchingSlots.value.length === 1 ? matchingSlots.value[0].slotKey : ''
}
const handleRoleChange = () => {
deviceSlotKey.value = ''
selectDefaultSlot()
emitTarget()
}
const syncFromModel = () => {
roles.value = getResourceRoles()
const value = props.modelValue || {}
localTarget.roleKey = roles.value.some((role) => role.roleKey === value.roleKey)
? value.roleKey
: roles.value[0]?.roleKey || ''
localTarget.dispatchPolicy = value.dispatchPolicy || 'ONE'
deviceSlotKey.value = value.deviceSlotKey || value.deviceBindings?.[0]?.slotKey || ''
selectDefaultSlot()
}
watch(
[() => props.data.id, () => props.modelValue],
syncFromModel,
{ immediate: true, deep: true }
)
</script>
<style scoped lang="scss">
.execution-target {
margin: 12px 0 2px;
padding: 14px 0 2px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.execution-target__heading {
margin-bottom: 12px;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 650;
}
.execution-target__warning {
margin-top: 6px;
color: var(--el-color-warning-dark-2);
font-size: 12px;
line-height: 18px;
}
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:智能体参数面板,配置智能体调用和语音合成。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<el-form :inline="true" :model="sdAgentNodeData" :rules="rules" ref="dynamicFormRef" class="http-form" <el-form :inline="true" :model="sdAgentNodeData" :rules="rules" ref="dynamicFormRef" class="http-form"
label-position="top" label-width="auto"> label-position="top" label-width="auto">
@ -12,7 +16,7 @@
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable /> <el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`"> <el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)"> <el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -43,10 +47,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = el; if (el) cascaderRefs[index] = el;
} }
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择" " v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (visible) => visibleChange(visible, index, property.quote)
" @change="(value) => cascaderChange(value, index, sdAgentNodeData, 'config')" /> " @change="(value) => cascaderChange(value, property)" />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
</el-row> </el-row>
@ -65,7 +69,7 @@
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable /> <el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
</el-form-item> </el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`tts.${index}.input`"> <el-form-item :label="index === 0 ? '参数值' : ''" :prop="`tts.${index}.input`">
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(index)"> <el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
<el-option label="引用" value="quote" /> <el-option label="引用" value="quote" />
<el-option label="输入" value="input" /> <el-option label="输入" value="input" />
</el-select> </el-select>
@ -96,10 +100,10 @@
<el-cascader :ref="(el) => { <el-cascader :ref="(el) => {
if (el) cascaderRefs[index] = el; if (el) cascaderRefs[index] = el;
} }
" v-model="property.quote" :checkStrictly="true" :options="quoteOptions" placeholder="请选择" " v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
@visible-change=" @visible-change="
(visible) => visibleChange(visible, index, property.quote) (visible) => visibleChange(visible, index, property.quote)
" @change="(value) => cascaderChange(value, index, sdAgentNodeData, 'tts')" /> " @change="(value) => cascaderChange(value, property)" />
</el-form-item> </el-form-item>
</el-form-item> </el-form-item>
</el-row> </el-row>
@ -138,7 +142,7 @@
import { ref, reactive, watch, nextTick } from 'vue' import { ref, reactive, watch, nextTick } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue' import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue' import FormItemRecursive from '../FormItemRecursive.vue'
import { getInput, transformSdAgentNodeData } from '@/utils/flow' import { transformSdAgentNodeData } from '@/utils/flow'
import { useQuote } from './useQuote.js' import { useQuote } from './useQuote.js'
const props = defineProps({ const props = defineProps({
@ -162,6 +166,11 @@ const activeNames = ref(['1', '2'])
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id) const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项 // 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => { const addFormItem = (e, type) => {
e.stopPropagation() e.stopPropagation()
const obj = { const obj = {
@ -178,6 +187,9 @@ const addFormItem = (e, type) => {
// 商道智能体 // 商道智能体
const sdAgentNodeData = ref({}) const sdAgentNodeData = ref({})
/**
* 处理 voiceOptions 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const voiceOptions = () => { const voiceOptions = () => {
return [{ return [{
value: 'x4_xiaoyan', value: 'x4_xiaoyan',
@ -185,6 +197,10 @@ const voiceOptions = () => {
}] }]
} }
/**
* 处理 handleTtsChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} val 变更值
*/
const handleTtsChange = (val) => { const handleTtsChange = (val) => {
if (val) { if (val) {
sdAgentNodeData.value.tts = [] sdAgentNodeData.value.tts = []
@ -200,11 +216,20 @@ const handleTtsChange = (val) => {
} }
} }
/**
* 新增 addSdAgentItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const addSdAgentItem = (e) => { const addSdAgentItem = (e) => {
e.stopPropagation(); e.stopPropagation();
sdAgentNodeData.value.outputParams.push({ name: "", type: "string", input: "" }); sdAgentNodeData.value.outputParams.push({ name: "", type: "string", input: "" });
} }
/**
* 删除 deleteSdAgentItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteSdAgentItem = (fullPath, propPath) => { const deleteSdAgentItem = (fullPath, propPath) => {
// 从顶层数据开始查找 // 从顶层数据开始查找
let currentLevel = sdAgentNodeData.value[propPath]; let currentLevel = sdAgentNodeData.value[propPath];
@ -222,6 +247,9 @@ const deleteSdAgentItem = (fullPath, propPath) => {
} }
// 初始化数据 // 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => { const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || [])) const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || [])) const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
@ -241,6 +269,9 @@ watch(
) )
// 保存验证 // 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => { const validateAndSave = async () => {
let inputValid = true let inputValid = true
let outputValid = true let outputValid = true

View File

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

View File

@ -0,0 +1,308 @@
<!-- Published sub-flow selector and synchronized contract editor. -->
<template>
<div class="subflow-params">
<el-form label-position="top">
<el-form-item label="已发布工作流" required>
<el-select
v-model="selectedVersionId"
filterable
:loading="loading"
placeholder="选择同一项目的已发布工作流"
style="width: 100%"
@change="selectVersion"
>
<el-option
v-for="item in options"
:key="item.versionId"
:label="`${item.detectName} · V${item.versionNo}`"
:value="item.versionId"
/>
</el-select>
</el-form-item>
</el-form>
<el-alert
v-if="latestOption && selection.versionId && latestOption.versionId !== selection.versionId"
type="warning"
:closable="false"
show-icon
class="version-alert"
>
<template #title>
已发布 V{{ latestOption.versionNo }},当前节点仍使用 V{{ selection.versionNo }}
</template>
<el-button size="small" type="warning" plain @click="applyOption(latestOption)">同步最新发布版本</el-button>
</el-alert>
<el-alert
v-else-if="optionsLoaded && selection.versionId && !selectedOptionAvailable"
title="当前子流程已不可用或会形成循环引用,请重新选择"
type="error"
:closable="false"
show-icon
class="version-alert"
/>
<template v-if="selection.versionId">
<div class="contract-header">
<div>
<div class="contract-title">{{ selection.detectName }}</div>
<div class="contract-meta">{{ projectLabel }} · 发布版本 V{{ selection.versionNo }}</div>
</div>
</div>
<div class="contract-section">
<div class="contract-section__title">子流程输入</div>
<div class="contract-section__note">所有输入必须引用当前流程的开始参数或前面节点输出。</div>
<el-empty v-if="!inputs.length" description="子流程没有输入参数" :image-size="64" />
<div v-for="item in inputs" :key="item.name" class="contract-row">
<div class="contract-row__name">
<span>{{ item.name }}</span>
<el-tag size="small" effect="plain" type="info">{{ item.dataType }}</el-tag>
</div>
<el-cascader
v-model="item.quote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择引用来源"
style="width: 100%"
@visible-change="refreshQuoteOptions"
@change="(value) => { item.quoteType = value?.[1] || '' }"
/>
</div>
</div>
<div class="contract-section">
<div class="contract-section__title">子流程输出</div>
<el-empty v-if="!outputs.length" description="子流程没有输出参数" :image-size="64" />
<div v-for="item in outputs" :key="item.name" class="output-contract">
<span>{{ item.name }}</span>
<el-tag size="small" effect="plain" type="info">{{ item.type }}</el-tag>
<span class="output-contract__desc">{{ item.desc || '无说明' }}</span>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { flowSubFlowOptions } from '@/api/flow/flow'
import { getUpstreamInput } from '@/utils/flow'
import {
FLOW_NODE_RESOURCE_OWNER,
pruneUnusedNodeResources,
} from '@/utils/flowResources'
const props = defineProps({ data: Object, flowId: String })
const emit = defineEmits(['save-success', 'save-error'])
const loading = ref(false)
const optionsLoaded = ref(false)
const options = ref([])
const selectedVersionId = ref('')
const inputs = ref([])
const outputs = ref([])
const quoteOptions = ref([])
const selection = reactive({ itemId: '', detectName: '', projectCode: '', versionId: '', versionNo: null, resourceRoles: [] })
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
const latestOption = computed(() => options.value.find((item) => item.itemId === selection.itemId))
const selectedOptionAvailable = computed(() => options.value.some((item) => item.versionId === selection.versionId))
const projectLabel = computed(() => selection.projectCode === 'AIMA' ? '爱玛' : '长安')
// 引用来源随画布连线和节点输出变化而变化,级联面板每次展开时重新读取最新上游字段。
const refreshQuoteOptions = (visible = true) => {
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
}
/**
* 从节点已有配置恢复子流程选择和输入/输出契约。
* nodeParams 存储的是输入引用,outputParams 存储的是子流程对外暴露的输出定义。
*/
const restore = () => {
const stored = props.data.properties?.subFlow || {}
Object.assign(selection, {
itemId: stored.itemId || '',
detectName: stored.detectName || '',
projectCode: stored.projectCode || '',
versionId: stored.versionId || '',
versionNo: stored.versionNo ?? null,
resourceRoles: clone(stored.resourceRoles || []),
})
selectedVersionId.value = selection.versionId
const outputByName = new Map((props.data.properties?.outputParams || []).map((item) => [item.name, item]))
inputs.value = (props.data.properties?.nodeParams || []).map((item) => ({
name: item.name,
dataType: item.dataType || 'string',
quote: Array.isArray(item.quote) ? [...item.quote] : [],
quoteType: item.quoteType || item.quote?.[1] || '',
required: true,
}))
outputs.value = [...outputByName.values()].map(clone)
refreshQuoteOptions()
}
/**
* 应用一个已发布版本的契约。
* 同名输入会沿用当前已经配置的引用,避免用户同步版本后重复选择所有上游字段。
*/
const applyOption = (option) => {
if (!option) return
const existing = new Map(inputs.value.map((item) => [item.name, item]))
Object.assign(selection, {
itemId: option.itemId,
detectName: option.detectName,
projectCode: option.projectCode,
versionId: option.versionId,
versionNo: option.versionNo,
resourceRoles: clone(option.resourceRoles || []),
})
selectedVersionId.value = option.versionId
inputs.value = (option.inputParams || []).map((item) => ({
name: item.name,
dataType: item.type || 'string',
quote: Array.isArray(existing.get(item.name)?.quote) ? [...existing.get(item.name).quote] : [],
quoteType: existing.get(item.name)?.quoteType || '',
required: true,
}))
outputs.value = clone(option.outputParams || [])
}
const selectVersion = (versionId) => applyOption(options.value.find((item) => item.versionId === versionId))
const loadOptions = async () => {
if (!props.flowId) return
loading.value = true
try {
const response = await flowSubFlowOptions(props.flowId)
options.value = response.data || []
if (!selection.versionId && options.value.length === 1) applyOption(options.value[0])
} finally {
loading.value = false
optionsLoaded.value = true
}
}
/**
* 把子流程需要的机器人角色和设备用途合并到当前流程开始节点。
* 同 roleKey 的资源取更严格的 required/quantity,设备用途类型冲突时直接阻断保存。
*/
const mergeResourceRoles = () => {
const graph = globalThis.lf.getGraphData()
const start = graph.nodes.find((node) => node.type === 'start')
if (!start) throw new Error('当前流程缺少开始节点')
const roles = clone(start.properties?.resourceRoles || [])
for (const childRole of selection.resourceRoles || []) {
let parentRole = roles.find((role) => role.roleKey === childRole.roleKey)
if (!parentRole) {
const addedRole = clone(childRole)
addedRole.managedBy = FLOW_NODE_RESOURCE_OWNER
;(addedRole.deviceSlots || []).forEach((slot) => { slot.managedBy = FLOW_NODE_RESOURCE_OWNER })
roles.push(addedRole)
continue
}
parentRole.required = parentRole.required !== false || childRole.required !== false
parentRole.quantity ||= { min: 1, max: 1 }
parentRole.quantity.min = Math.max(Number(parentRole.quantity.min || 0), Number(childRole.quantity?.min || 0))
parentRole.quantity.max = Math.max(Number(parentRole.quantity.max || 0), Number(childRole.quantity?.max || 0))
parentRole.deviceSlots ||= []
for (const childSlot of childRole.deviceSlots || []) {
const parentSlot = parentRole.deviceSlots.find((slot) => slot.slotKey === childSlot.slotKey)
if (!parentSlot) {
parentRole.deviceSlots.push({
...clone(childSlot),
managedBy: FLOW_NODE_RESOURCE_OWNER,
})
} else if (Number(parentSlot.deviceKind) !== Number(childSlot.deviceKind)) {
throw new Error(`机器人角色“${childRole.displayName}”的设备用途“${childSlot.displayName}”类型冲突`)
} else {
parentSlot.required = parentSlot.required !== false || childSlot.required !== false
parentSlot.allowReuse = parentSlot.allowReuse === true && childSlot.allowReuse === true
}
}
}
return { graph, start, roles }
}
/**
* 校验并写回子流程节点配置。
* 保存前先在临时图上执行资源清理,确保切换子流程版本后不残留已不用的自动托管设备用途。
*/
const validateAndSave = () => {
if (!selection.versionId) {
ElMessage.warning('请选择已发布工作流')
emit('save-error')
return
}
if (optionsLoaded.value && !selectedOptionAvailable.value) {
ElMessage.warning('当前子流程已不可用或会形成循环引用,请重新选择')
emit('save-error')
return
}
if (inputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
ElMessage.warning('子流程的所有输入参数都必须选择引用来源')
emit('save-error')
return
}
let resources
try {
resources = mergeResourceRoles()
} catch (error) {
ElMessage.error(error.message)
emit('save-error')
return
}
const nodeParams = inputs.value.map((item) => ({
name: item.name,
type: 'quote',
input: '',
quote: [...item.quote],
quoteType: item.quoteType || item.quote[1],
dataType: item.dataType,
required: true,
disabled: true,
}))
const nodeProperties = {
...props.data.properties,
subFlow: clone(selection),
nodeParams,
outputParams: clone(outputs.value),
outputType: 'json',
}
const prospectiveGraph = clone(resources.graph)
const prospectiveStart = prospectiveGraph.nodes.find((node) => node.id === resources.start.id)
const prospectiveNode = prospectiveGraph.nodes.find((node) => node.id === props.data.id)
prospectiveStart.properties = { ...prospectiveStart.properties, resourceRoles: resources.roles }
prospectiveNode.properties = nodeProperties
const cleanup = pruneUnusedNodeResources(prospectiveGraph)
globalThis.lf.setProperties(resources.start.id, {
...resources.start.properties,
resourceRoles: cleanup.start.properties.resourceRoles,
})
globalThis.lf.setProperties(props.data.id, nodeProperties)
emit('save-success')
}
onMounted(async () => {
restore()
await loadOptions()
})
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.subflow-params { padding-top: 14px; }
.version-alert { margin-bottom: 16px; }
.version-alert :deep(.el-alert__content) { width: 100%; }
.version-alert .el-button { margin-top: 8px; }
.contract-header { margin-bottom: 16px; padding: 14px; border: 1px solid #dce5ef; border-radius: 6px; background: #f8fafc; }
.contract-title { color: #1f2937; font-size: 15px; font-weight: 650; }
.contract-meta { margin-top: 4px; color: #697386; font-size: 12px; }
.contract-section { margin-top: 18px; }
.contract-section__title { color: #344054; font-size: 13px; font-weight: 650; }
.contract-section__note { margin: 4px 0 10px; color: #7a8494; font-size: 12px; }
.contract-row { margin-top: 10px; padding: 12px; border: 1px solid #e4e8ef; border-radius: 6px; }
.contract-row__name { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: #344054; font-size: 13px; font-weight: 600; }
.output-contract { display: grid; grid-template-columns: minmax(100px, auto) auto minmax(0, 1fr); align-items: center; gap: 10px; min-height: 38px; border-bottom: 1px solid #edf0f4; color: #344054; font-size: 13px; }
.output-contract__desc { overflow: hidden; color: #7a8494; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@ -0,0 +1,414 @@
<!--
* 文件说明:分支节点专用参数组件,独立负责条件组的新增、删除、引用选择、校验和保存。
* 作用范围:仅用于 branch 类型节点,不与通用节点参数表单共享业务逻辑。
-->
<template>
<div class="switch-params">
<div class="switch-params__header">
<div>
<div class="switch-params__title">分支条件</div>
<div class="switch-params__description">条件按从上到下的顺序匹配,每个条件对应节点上的一个输出锚点。</div>
</div>
<el-button type="primary" :icon="Plus" @click="addCondition">添加条件</el-button>
</div>
<el-form ref="formRef" :model="formData" label-position="top">
<div v-for="(condition, conditionIndex) in formData.nodeParams" :key="condition.id" class="condition-card">
<div class="condition-card__header">
<div class="condition-card__identity">
<span>{{ conditionIndex === 0 ? "If" : "Else If" }}</span>
<el-input
v-model="condition.label"
maxlength="20"
show-word-limit
placeholder="请输入条件名称"
class="condition-name"
/>
</div>
<el-button
circle
plain
type="danger"
:icon="Minus"
:disabled="formData.nodeParams.length === 1"
@click="removeCondition(conditionIndex)"
/>
</div>
<el-form-item label="条件关系">
<el-radio-group v-model="condition.withOr">
<el-radio-button value="and">AND</el-radio-button>
<el-radio-button value="or">OR</el-radio-button>
</el-radio-group>
</el-form-item>
<div v-for="(item, itemIndex) in condition.list" :key="itemIndex" class="condition-row">
<div class="condition-row__fields">
<el-form-item
label="引用变量"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.name${item.nameType === 'quote' ? 'Quote' : ''}`"
:rules="requiredRule(item.nameType === 'quote' ? '请选择引用变量' : '请输入变量值')"
>
<el-input v-if="item.nameType === 'input'" v-model="item.name" placeholder="请输入变量值">
<template #prepend>
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
</el-input>
<div v-else class="reference-field">
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
<el-cascader
v-model="item.nameQuote"
:options="quoteOptions"
:show-all-levels="false"
placeholder="请选择"
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'nameQuote')"
/>
</div>
</el-form-item>
<el-form-item
label="判断方式"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.condition`"
:rules="requiredRule('请选择判断方式')"
>
<el-select v-model="item.condition">
<el-option v-for="option in conditionOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item
label="比较值"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.${item.type === 'quote' ? 'quote' : 'input'}`"
:rules="requiresComparison(item.condition) ? requiredRule(item.type === 'quote' ? '请选择比较变量' : '请输入比较值') : []"
>
<el-input
v-if="item.type === 'input'"
v-model="item.input"
:disabled="!requiresComparison(item.condition)"
placeholder="请输入比较值"
>
<template #prepend>
<el-select v-model="item.type" class="value-type" @change="resetCompareValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
</el-input>
<div v-else class="reference-field">
<el-select
v-model="item.type"
class="value-type"
:disabled="!requiresComparison(item.condition)"
@change="resetCompareValue(item)"
>
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
<el-cascader
v-model="item.quote"
:disabled="!requiresComparison(item.condition)"
:options="quoteOptions"
:show-all-levels="false"
placeholder="请选择"
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'quote')"
/>
</div>
</el-form-item>
</div>
<div class="condition-row__actions">
<el-button circle :icon="Plus" @click="addConditionItem(conditionIndex)" />
<el-button
circle
:icon="Minus"
:disabled="condition.list.length === 1"
@click="removeConditionItem(conditionIndex, itemIndex)"
/>
</div>
</div>
</div>
</el-form>
<div class="else-tip"><strong>Else</strong><span>所有条件均不匹配时,从 Else 锚点继续执行。</span></div>
</div>
</template>
<script setup>
import { reactive, ref, watch } from "vue";
import { Plus, Minus } from "@element-plus/icons-vue";
import { v4 as randomUUID } from "uuid";
import { getUpstreamInput, removeSwitchEdge } from "@/utils/flow";
const props = defineProps({ data: Object });
const emit = defineEmits(["save-success", "save-error"]);
const formRef = ref(null);
const quoteOptions = ref([]);
const originalConditionIds = ref([]);
const formData = reactive({ nodeParams: [] });
const conditionOptions = [
{ label: "等于", value: "equal" },
{ label: "不等于", value: "notEqualTo" },
{ label: "为空", value: "null" },
{ label: "不为空", value: "notNull" },
{ label: "包含", value: "include" },
{ label: "不包含", value: "notInclude" },
{ label: "大于", value: "greaterThan" },
{ label: "大于等于", value: "greaterThanOrEqual" },
{ label: "小于", value: "lessThan" },
{ label: "小于等于", value: "lessThanOrEqual" },
];
/** 创建一条与原有数据结构兼容的判断项。 */
const createConditionItem = () => ({
name: "",
nameQuote: "",
nameType: "input",
condition: "",
type: "input",
input: "",
quote: "",
});
/** 创建具有稳定锚点 ID 的条件组。 */
const createCondition = (label = "") => ({
id: randomUUID().replace(/-/g, ""),
label,
withOr: "and",
list: [createConditionItem()],
});
/**
* 将旧版本可能保存的点号字符串统一转换为级联组件需要的路径数组。
* @param {string|string[]} value 已保存的引用值
* @returns {string[]} 级联选择路径
*/
const normalizeQuotePath = (value) => {
if (Array.isArray(value)) return value;
if (typeof value === "string" && value) return value.split(".").filter(Boolean);
return [];
};
/** 深拷贝已保存条件,确保取消编辑时不会污染节点属性。 */
const initData = () => {
// 必须先提供选项树再赋已有值,否则级联组件无法将节点 ID 路径解析成可见标签。
quoteOptions.value = getUpstreamInput(props.data.id) || [];
const source = props.data?.properties?.nodeParams || props.data?.properties?.conditions || [];
formData.nodeParams = JSON.parse(JSON.stringify(source));
if (!formData.nodeParams.length) formData.nodeParams.push(createCondition());
formData.nodeParams.forEach((condition, index) => {
if (!condition.label) condition.label = `条件 ${index + 1}`;
condition.list?.forEach((item) => {
if (item.nameType === "quote") item.nameQuote = normalizeQuotePath(item.nameQuote);
if (item.type === "quote") item.quote = normalizeQuotePath(item.quote);
});
});
originalConditionIds.value = formData.nodeParams.map((condition) => condition.id);
};
/** 返回 Element Plus 表单必填规则。 */
const requiredRule = (message) => [{ required: true, message, trigger: ["blur", "change"] }];
/** 判断当前运算符是否需要比较值。 */
const requiresComparison = (condition) => !["null", "notNull"].includes(condition);
/** 新增一个分支条件,保存后将生成同 ID 的节点锚点。 */
const addCondition = () => {
formData.nodeParams.push(createCondition(`条件 ${formData.nodeParams.length + 1}`));
};
/** 删除条件;实际连线清理延迟到保存成功时执行。 */
const removeCondition = (index) => {
if (formData.nodeParams.length > 1) formData.nodeParams.splice(index, 1);
};
/** 向指定条件组追加判断项。 */
const addConditionItem = (conditionIndex) => {
formData.nodeParams[conditionIndex].list.push(createConditionItem());
};
/** 删除指定判断项,同时保证每个条件至少保留一项。 */
const removeConditionItem = (conditionIndex, itemIndex) => {
const list = formData.nodeParams[conditionIndex].list;
if (list.length > 1) list.splice(itemIndex, 1);
};
/** 切换左值类型时清空另一种类型遗留的数据。 */
const resetNameValue = (item) => {
if (item.nameType === "input") item.nameQuote = "";
else item.name = "";
};
/** 切换比较值类型时清空另一种类型遗留的数据。 */
const resetCompareValue = (item) => {
if (item.type === "input") item.quote = "";
else item.input = "";
};
/**
* 打开引用选择器时获取当前节点可引用的上游输出,并保留当前选值。
* @param {boolean} visible 选择器是否打开
* @param {object} item 当前判断项
* @param {"nameQuote"|"quote"} field 当前引用字段
*/
const refreshQuoteOptions = (visible, item, field) => {
if (!visible) return;
quoteOptions.value = getUpstreamInput(props.data.id) || [];
item[field] = item[field] || "";
};
/**
* 将当前分支配置写回画布节点。
* 即使表单校验失败也要写回,这样关闭抽屉后用户刚填的错误配置仍会保留在节点上。
*/
const persistConditions = () => {
const conditions = JSON.parse(JSON.stringify(formData.nodeParams));
const activeIds = new Set(conditions.map((condition) => condition.id));
const removedIds = originalConditionIds.value.filter((id) => !activeIds.has(id));
removedIds.forEach((id) => removeSwitchEdge(props.data.id, id));
const properties = window.lf.getProperties(props.data.id);
const anchor = (properties.anchor || []).filter((item) => (
item.id === `${props.data.id}_else` || activeIds.has(item.id)
));
window.lf.setProperties(props.data.id, {
...properties,
nodeParams: conditions,
anchor,
});
};
/**
* 校验并保存条件。校验失败时仍关闭抽屉,但向父组件发 save-error,
* 由 ParamsDrawer 标记画布节点为错误状态,并让试运行阶段继续拦截。
*/
const validateAndSave = async () => {
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
try {
persistConditions();
emit(valid ? "save-success" : "save-error");
} catch {
emit("save-error");
}
};
watch(() => props.data, initData, { immediate: true, deep: true });
defineExpose({ validateAndSave });
</script>
<style lang="scss" scoped>
.switch-params {
padding-top: 16px;
}
.switch-params__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.switch-params__title {
color: #1f2937;
font-size: 16px;
font-weight: 650;
}
.switch-params__description {
margin-top: 4px;
color: #7a8494;
font-size: 12px;
line-height: 1.6;
}
.condition-card {
margin-bottom: 14px;
padding: 14px;
border: 1px solid #e4e8ef;
border-radius: 8px;
background: #f8fafc;
}
.condition-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: #3978c5;
font-size: 13px;
font-weight: 650;
}
.condition-card__identity {
display: flex;
align-items: center;
gap: 10px;
}
.condition-name {
width: 240px;
}
.condition-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding-top: 10px;
border-top: 1px dashed #dfe4ec;
}
.condition-row + .condition-row {
margin-top: 10px;
}
.condition-row__fields {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(120px, 0.8fr) minmax(0, 1.4fr);
flex: 1;
gap: 10px;
}
.condition-row__actions {
display: flex;
padding-bottom: 18px;
}
.value-type {
width: 74px;
}
.reference-field {
display: grid;
grid-template-columns: 74px minmax(0, 1fr);
width: 100%;
gap: 8px;
}
.else-tip {
display: flex;
gap: 12px;
padding: 12px 14px;
border: 1px solid #f0d9a7;
border-radius: 8px;
background: #fffaf0;
color: #7a8494;
font-size: 12px;
strong {
color: #b7791f;
}
}
:deep(.el-form-item) {
margin-bottom: 18px;
}
</style>

View File

@ -1,46 +1,43 @@
import { ref } from 'vue' import { ref } from 'vue'
import { getInput } from '@/utils/flow' import { getUpstreamInput } from '@/utils/flow'
export function useQuote(nodeId) { export function useQuote(nodeId) {
const quoteOptions = ref([]) const quoteOptions = ref([])
const cascaderRefs = ref([]) const cascaderRefs = ref([])
const handleTypeChange = (index, formData, type = 'default') => { // 引用树必须按当前节点重新计算,避免选择到同级后置节点、下游节点或其他循环体内部字段。
// 根据节点类型处理不同数据源 const refreshQuoteOptions = () => {
// 这里简化为通用逻辑,具体实现需根据实际数据结构调整 quoteOptions.value = getUpstreamInput(nodeId) || []
if (formData.nodeParams[index].type === 'input') { }
formData.nodeParams[index].quote = ''
// Cascader 渲染已保存路径前需要先存在完整 option 树,否则回显会丢失末级字段。
refreshQuoteOptions()
/**
* 参数类型在手输和引用之间切换时,清空另一侧的值,避免保存出互相冲突的 input/quote。
*/
const handleTypeChange = (property) => {
if (!property) return
if (property.type === 'input') {
property.quote = []
} else { } else {
formData.nodeParams[index].input = '' property.input = ''
const option = getInput(nodeId) refreshQuoteOptions()
if (option) quoteOptions.value = option
} }
} }
const cascaderChange = (value, index, formData, field = 'nodeParams') => { /**
const selectedOptions = cascaderRefs.value[index]?.getCheckedNodes(true) * 级联选择器只把路径写入表单项,quoteType 由路径第二级 input/output 推导。
if (selectedOptions && selectedOptions.length) { */
formData[field][index].quote = value const cascaderChange = (value, property) => {
formData[field][index].quoteType = selectedOptions[0].data.type if (!property) return
} property.quote = Array.isArray(value) ? [...value] : []
property.quoteType = value?.[1] === 'input' ? 'input' : 'output'
} }
const visibleChange = (visible, index, currentQuote, formData, field = 'nodeParams') => { // 面板打开时重新读取上游节点,承接刚刚新增的连线或输出字段。
if (visible) { const visibleChange = (visible) => {
const option = getInput(nodeId) if (visible) refreshQuoteOptions()
if (option) {
quoteOptions.value = option
const currentValue = [...currentQuote]
if (cascaderRefs.value[index] && currentValue.length > 0) {
setTimeout(() => {
formData[field][index].quote = []
setTimeout(() => {
formData[field][index].quote = currentValue
}, 0)
}, 0)
}
}
}
} }
return { return {
@ -48,6 +45,7 @@ export function useQuote(nodeId) {
cascaderRefs, cascaderRefs,
handleTypeChange, handleTypeChange,
cascaderChange, cascaderChange,
visibleChange visibleChange,
refreshQuoteOptions,
} }
} }

View File

@ -1,9 +1,18 @@
/**
* 文件说明:流程事件订阅组合逻辑,并在卸载时释放监听器。
* 作用范围:仅服务于流程设计器模块。
*/
import { onUnmounted } from "vue"; import { onUnmounted } from "vue";
import { emitter } from "@/utils/eventBus"; import { emitter } from "@/utils/eventBus";
export const useFlowEvents = () => { export const useFlowEvents = () => {
const listeners = []; const listeners = [];
/**
* 订阅 onFlowEvent 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
* @param {*} handler 事件处理函数
*/
const onFlowEvent = (type, handler) => { const onFlowEvent = (type, handler) => {
emitter.on(type, handler); emitter.on(type, handler);
listeners.push([type, handler]); listeners.push([type, handler]);

View File

@ -1,10 +1,12 @@
/**
* 文件说明:定义流程设计器可用的节点、表单字段及下拉选项等静态配置。
* 作用范围:仅服务于流程设计器模块。
*/
import cameraSvg from './icon/camera.svg' import cameraSvg from './icon/camera.svg'
import videoSvg from './icon/video.svg' import videoSvg from './icon/video.svg'
import loopSvg from './icon/loop.svg' import loopSvg from './icon/loop.svg'
import stopLoopSvg from './icon/stopLoop.svg' import stopLoopSvg from './icon/stopLoop.svg'
import switchSvg from './icon/switch.svg' import switchSvg from './icon/switch.svg'
import startSvg from './icon/start.svg'
import endSvg from './icon/end.svg'
import microphoneSvg from './icon/microphone.svg' import microphoneSvg from './icon/microphone.svg'
import sleepSvg from './icon/sleep.svg' import sleepSvg from './icon/sleep.svg'
import planSvg from './icon/plan.svg' import planSvg from './icon/plan.svg'
@ -14,29 +16,9 @@ import audioSvg from './icon/audio.svg'
import touchSvg from './icon/touch.svg' import touchSvg from './icon/touch.svg'
import expressionSvg from './icon/expression.svg' import expressionSvg from './icon/expression.svg'
const deviceOptions = () => { /**
return [{ * 处理与 http method options 相关的状态或数据,该影响范围仅限当前模块。
value: 'cam1', */
label: 'cam1'
}, {
value: 'cam2',
label: 'cam2'
}, {
value: 'cam3',
label: 'cam3'
}, {
value: 'cam4',
label: 'cam4'
}]
}
const audioOptions = () => {
return [{
value: 'spk1',
label: 'spk1'
}]
}
const httpMethodOptions = () => { const httpMethodOptions = () => {
return [{ return [{
value: 'POST', value: 'POST',
@ -53,6 +35,9 @@ const httpMethodOptions = () => {
}] }]
} }
/**
* 处理与 express options 相关的状态或数据,该影响范围仅限当前模块。
*/
const expressOptions = () => { const expressOptions = () => {
return [{ return [{
value: 1, value: 1,
@ -75,6 +60,9 @@ const expressOptions = () => {
}] }]
} }
/**
* 处理与 task type options 相关的状态或数据,该影响范围仅限当前模块。
*/
const taskTypeOptions = () => { const taskTypeOptions = () => {
return [{ return [{
value: 'voice', value: 'voice',
@ -85,6 +73,9 @@ const taskTypeOptions = () => {
}] }]
} }
/**
* 处理与 voice options 相关的状态或数据,该影响范围仅限当前模块。
*/
const voiceOptions = () => { const voiceOptions = () => {
return [{ return [{
value: 'x4_xiaoyan', value: 'x4_xiaoyan',
@ -92,6 +83,9 @@ const voiceOptions = () => {
}] }]
} }
/**
* 处理与 inspection event types 相关的状态或数据,该影响范围仅限当前模块。
*/
const inspectionEventTypes = () => { const inspectionEventTypes = () => {
return [{ return [{
value: 'No-Glove', value: 'No-Glove',
@ -113,9 +107,7 @@ export const collapseList = [
desc: "在获取相机照片时,需要先启动相机", desc: "在获取相机照片时,需要先启动相机",
action: 'CAMERA_START', action: 'CAMERA_START',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: deviceOptions(), disabled: true }
],
outputParams: [], outputParams: [],
outputType: 'json' outputType: 'json'
}, },
@ -126,9 +118,7 @@ export const collapseList = [
desc: "获取相机拍摄的照片", desc: "获取相机拍摄的照片",
action: 'CAMERA_GETRGBIMAGE', action: 'CAMERA_GETRGBIMAGE',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: deviceOptions(), disabled: true }
],
outputType: 'img', outputType: 'img',
outputParams: [{ name: 'imageUrl', type: 'array<string>', desc: '图片地址数组', children: [], disabled: true }] outputParams: [{ name: 'imageUrl', type: 'array<string>', desc: '图片地址数组', children: [], disabled: true }]
}, },
@ -139,9 +129,7 @@ export const collapseList = [
desc: "获取相机照片后,关闭该相机", desc: "获取相机照片后,关闭该相机",
action: 'CAMERA_STOP', action: 'CAMERA_STOP',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: deviceOptions(), disabled: true }
],
outputParams: [], outputParams: [],
outputType: 'json' outputType: 'json'
}, },
@ -157,9 +145,7 @@ export const collapseList = [
desc: "开启摄像头,并开始录像", desc: "开启摄像头,并开始录像",
action: 'CAMERA_RECORDING_START', action: 'CAMERA_RECORDING_START',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: deviceOptions(), disabled: true }
],
outputParams: [], outputParams: [],
outputType: 'json' outputType: 'json'
}, },
@ -170,9 +156,7 @@ export const collapseList = [
desc: "停止录像,并关闭摄像头", desc: "停止录像,并关闭摄像头",
action: 'CAMERA_RECORDING_STOP', action: 'CAMERA_RECORDING_STOP',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: deviceOptions(), disabled: true }
],
outputType: 'video', outputType: 'video',
outputParams: [{ name: 'videoUrl', type: 'array<string>', children: [], desc: '视频播放地址数组', disabled: true}] outputParams: [{ name: 'videoUrl', type: 'array<string>', children: [], desc: '视频播放地址数组', disabled: true}]
} }
@ -191,6 +175,19 @@ export const collapseList = [
outputParams: [], outputParams: [],
outputType: 'json' outputType: 'json'
}, },
{
icon: planSvg,
name: "子流程",
type: "subFlow",
action: 'SUB_FLOW',
nodeType: 'NONE',
desc: "执行同一项目中已发布的工作流",
// 具体输入/输出契约在参数面板选择发布版本后同步,配置清单只提供初始空壳。
subFlow: {},
nodeParams: [],
outputParams: [],
outputType: 'json'
},
{ {
icon: stopLoopSvg, icon: stopLoopSvg,
name: "结束循环", name: "结束循环",
@ -270,6 +267,10 @@ export const collapseList = [
{ name: "input", type: "input", input: "", required: true }, { name: "input", type: "input", input: "", required: true },
{ name: "code", type: "input", input: ` { name: "code", type: "input", input: `
// 方法定义不能修改 // 方法定义不能修改
/**
* 处理与 r 相关的状态或数据,该影响范围仅限当前模块。
* @param {*} params 待处理的参数集合
*/
function handler(params) { function handler(params) {
// 返回值是一个可序列化成 json 的 dict 或 object // 返回值是一个可序列化成 json 的 dict 或 object
const result ={ const result ={
@ -316,10 +317,57 @@ function handler(params) {
action: 'AGV_MOVE_TO_POINT', action: 'AGV_MOVE_TO_POINT',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "x", type: "input", input: "", disabled: true }, { name: "x", type: "input", input: "", disabled: true },
{ name: "y", type: "input", input: "", disabled: true }, { name: "y", type: "input", input: "", disabled: true },
{ name: "theta", type: "input", input: "", disabled: true }, { name: "theta", type: "input", input: "", disabled: true },
{ name: "blockedTimeoutMs", type: "input", componentType: "number", input: 60000, min: 1, required: true, disabled: true },
],
outputType: 'json'
},
{
icon: videoSvg,
name: "AGV站点导航",
type: "serviceNode",
desc: "从AGV站点列表选择目标站点并导航",
action: 'AGV_MOVE_TO_STATION',
nodeType: 'EDGE',
nodeParams: [
{ name: "stationId", type: "input", input: "", componentType: 'select', selectOptions: [], forceSelect: true, disabled: true },
{ name: "blockedTimeoutMs", type: "input", componentType: "number", input: 60000, min: 1, required: true, disabled: true }
],
outputType: 'json'
},
{
icon: cameraSvg,
name: "通用指令",
type: "deviceUniversal",
desc: "执行设备通用指令",
action: 'DEVICE_EXECUTE_JSON_COMMAND',
nodeType: 'EDGE',
nodeParams: [
{ name: "requestJson", type: "input", input: `{
"a":123
}`, required: false, disabled: true },
],
outputParams: [],
outputType: 'json'
},
{
icon: planSvg,
name: "动作队列",
type: "actionQueue",
desc: "在边缘端按顺序连续执行多个动作",
action: 'SYSTEM_EXECUTE_ACTION_QUEUE',
nodeType: 'EDGE',
nodeParams: [
{ name: "actionId", type: "input", input: "", required: false },
{ name: "totalTimeoutMs", type: "input", componentType: 'number', input: 60000, required: true },
{ name: "steps", type: "input", input: [], required: true },
],
outputParams: [
{ name: 'actionId', type: 'string', desc: '动作队列标识' },
{ name: 'completedSteps', type: 'number', desc: '已完成步骤数' },
{ name: 'result', type: 'string', desc: '执行结果' },
], ],
outputType: 'json' outputType: 'json'
}, },
@ -331,18 +379,57 @@ function handler(params) {
action: 'ARM_MOVE_TO_POINT', action: 'ARM_MOVE_TO_POINT',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "x", type: "input", input: "", disabled: true }, { name: "x", type: "input", input: "", disabled: true },
{ name: "y", type: "input", input: "", disabled: true }, { name: "y", type: "input", input: "", disabled: true },
{ name: "z", type: "input", input: "", disabled: true }, { name: "z", type: "input", input: "", disabled: true },
{ name: "rx", type: "input", input: "", disabled: true }, { name: "rx", type: "input", input: "", disabled: true },
{ name: "ry", type: "input", input: "", disabled: true }, { name: "ry", type: "input", input: "", disabled: true },
{ name: "rz", type: "input", input: "", disabled: true }, { name: "rz", type: "input", input: "", disabled: true },
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.1, input: 0, disabled: true }, { name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true },
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.1, input: 0, disabled: true }, { name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true },
], ],
outputType: 'json' outputType: 'json'
}, },
{
icon: videoSvg,
name: "机械臂关节运动",
type: "serviceNode",
desc: "通过关节空间插值运动到目标关节位置",
action: 'ARM_MOVE_TO_J',
nodeType: 'EDGE',
nodeParams: [
{ name: "target", type: "input", input: "[0, 0, 0, 0, 0, 0]", disabled: true },
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true },
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true },
],
outputType: 'json'
},
// 使能状态由两个独立节点显式控制。节点本身不接收参数,也不依赖前一运动节点的隐式使能,
// 因而可在流程开始、异常恢复或任务收尾位置按业务需要自由编排。
{
icon: videoSvg,
name: "开启机械臂使能",
type: "serviceNode",
desc: "开启机械臂使能,后续运动节点不会自动使能",
action: 'ARM_ENABLE',
nodeType: 'EDGE',
nodeParams: [],
outputParams: [],
outputType: 'json'
},
// 关闭节点与开启节点使用不同 action,边缘执行器据此下发去使能指令;
// 空 outputParams 表示该节点只负责设备状态切换,不向后续节点暴露业务输出。
{
icon: videoSvg,
name: "关闭机械臂使能",
type: "serviceNode",
desc: "关闭机械臂使能",
action: 'ARM_DISABLE',
nodeType: 'EDGE',
nodeParams: [],
outputParams: [],
outputType: 'json'
},
{ {
icon: cameraSvg, icon: cameraSvg,
name: "开始监听报警事件", name: "开始监听报警事件",
@ -351,7 +438,7 @@ function handler(params) {
action: 'INSPECTION_ALERT_LISTEN_START', action: 'INSPECTION_ALERT_LISTEN_START',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [
{ name: "terminalId", type: "input", input: "", disabled: true }, { name: "robotId", type: "input", input: "", disabled: true },
{ name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true } { name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true }
], ],
outputParams: [], outputParams: [],
@ -365,7 +452,7 @@ function handler(params) {
action: 'INSPECTION_ALERT_LISTEN_STOP', action: 'INSPECTION_ALERT_LISTEN_STOP',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [
{ name: "terminalId", type: "input", input: "", disabled: true }, { name: "robotId", type: "input", input: "", disabled: true },
{ name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true } { name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true }
], ],
outputParams: [], outputParams: [],
@ -415,9 +502,7 @@ function handler(params) {
desc: "用于启动麦克风的节点", desc: "用于启动麦克风的节点",
action: 'MICROPHONE_START', action: 'MICROPHONE_START',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: audioOptions(), disabled: true }
],
outputType: 'json' outputType: 'json'
}, },
{ {
@ -427,10 +512,9 @@ function handler(params) {
desc: "用于停止麦克风的节点", desc: "用于停止麦克风的节点",
action: 'MICROPHONE_STOP', action: 'MICROPHONE_STOP',
nodeType: 'EDGE', nodeType: 'EDGE',
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: audioOptions(), disabled: true } outputType: 'audio',
], outputParams: [{ name: 'audioUrl', type: 'array<string>', children: [], desc: '录音播放地址数组', disabled: true }]
outputType: 'json'
}, },
{ {
icon: audioSvg, icon: audioSvg,
@ -438,8 +522,9 @@ function handler(params) {
type: "serviceNode", type: "serviceNode",
desc: "用于播放音频的节点", desc: "用于播放音频的节点",
action: 'SPEAKER_PLAYAUDIO', action: 'SPEAKER_PLAYAUDIO',
nodeType: 'EDGE',
nodeParams: [ nodeParams: [
{ name: "deviceId", type: "input", input: "", componentType: 'select', selectOptions: audioOptions(), disabled: true } { name: "audioPath", type: "input", input: "", disabled: true }
], ],
outputType: 'json' outputType: 'json'
}, },
@ -448,6 +533,97 @@ function handler(params) {
{ {
collapseTitle: "大模型", collapseTitle: "大模型",
nodeList: [ nodeList: [
{
icon: microphoneSvg,
name: "声音类型检测",
type: "mediaAnalysis",
desc: "判断录音是否属于指定的声音类型",
action: "AUDIO_EVENT_CLASSIFY",
nodeType: "LLM",
nodeParams: [
{ name: "profileCode", type: "input", input: "aima.power_state.v1", required: true, disabled: true },
{ name: "expectedLabel", type: "input", input: "", required: true, disabled: true },
{ name: "expectedLabelName", type: "input", input: "", required: false, disabled: true },
{ name: "audioUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
],
outputType: "json",
outputParams: [
{ name: "passed", type: "boolean", desc: "是否检测到目标声音", disabled: true },
{ name: "expectedLabel", type: "string", desc: "目标声音类型编码", disabled: true },
{ name: "expectedLabelName", type: "string", desc: "目标声音类型名称", disabled: true },
{ name: "label", type: "string", desc: "实际声音类型编码,未匹配时为UNKNOWN", disabled: true },
{ name: "labelName", type: "string", desc: "实际声音类型名称", disabled: true },
{ name: "matched", type: "boolean", desc: "是否识别到已知声音", disabled: true },
{ name: "confidence", type: "number", desc: "最高匹配度", disabled: true },
{ name: "decisionReason", type: "string", desc: "检测结论", disabled: true },
{ name: "scores", type: "object", desc: "各分类匹配度", children: [], disabled: true },
],
},
{
icon: videoSvg,
name: "视频智能分析",
type: "mediaAnalysis",
desc: "按分析场景理解测试录像并输出结构化结论",
action: "VIDEO_ANALYZE",
nodeType: "LLM",
nodeParams: [
{ name: "profileCode", type: "input", input: "aima.power_video.v1", required: true, disabled: true },
{ name: "videoUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
{ name: "instruction", type: "input", input: "", required: true, disabled: true },
{ name: "analysisMode", type: "input", input: "AUTO", required: false, disabled: true },
// 默认返回精简结论;用户开启详细输出时,执行端才补充事件证据。
{ name: "detailedOutput", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
{ name: "analysisTuning", type: "input", input: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true }, required: false, disabled: true },
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
],
outputType: "json",
outputParams: [
{ name: "result", type: "string", desc: "分析结果", disabled: true },
{ name: "passed", type: "boolean", desc: "是否通过", disabled: true },
{ name: "confidence", type: "number", desc: "判定置信度", disabled: true },
{ name: "evidenceSufficient", type: "boolean", desc: "证据是否充分", disabled: true },
{ name: "decisionReason", type: "string", desc: "判定原因", disabled: true },
{ name: "conclusion", type: "string", desc: "分析结论", disabled: true },
{ name: "summary", type: "string", desc: "分析摘要", disabled: true },
{ name: "events", type: "array<object>", desc: "关键事件", children: [], disabled: true },
{ name: "analysisMode", type: "string", desc: "实际分析模式", disabled: true },
{ name: "fallback", type: "boolean", desc: "是否切换精确分析", disabled: true },
{ name: "detailedOutput", type: "boolean", desc: "是否返回详细事件证据", disabled: true },
],
},
{
// 通用图片分析不内置通过/不通过判定,返回结构完全由用户提示词约定。
icon: cameraSvg,
name: "通用图片分析",
type: "imageAnalysis",
desc: "按照提示词分析一张或多张图片并返回指定内容",
action: 'IMAGE_ANALYZE',
nodeType: "LLM",
nodeParams: [
{ name: "profileCode", type: "input", input: "common.image_analysis.v1", required: true, disabled: true },
{ name: "imageUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
{ name: "analysisMethod", type: "input", input: "VISION_MODEL", required: false, disabled: true },
{ name: "prompt", type: "input", input: "", required: true, disabled: true, componentType: "textarea" },
{ name: "analysisMode", type: "input", input: "FAST", required: false, disabled: true },
{ name: "analysisTuning", type: "input", input: { maxWidth: 896, maxImages: 12, maxOutputTokens: 384, matchThreshold: 0.48, matchSelector: "BEST", matchIndex: 1, matchSort: "ROW_MAJOR", locateConfidenceThreshold: 0.45 }, required: false, disabled: true },
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
],
outputType: "json",
outputParams: [
{ name: "result", type: "object", desc: "按提示词返回的结果", children: [], disabled: true },
{ name: "resultText", type: "string", desc: "模型返回的原始文本", disabled: true },
{ name: "matched", type: "boolean", desc: "找图是否匹配", disabled: true },
{ name: "matchScore", type: "number", desc: "匹配分数或定位置信度", disabled: true },
{ name: "coordinates", type: "array<number>", desc: "原图坐标 [x1,y1,x2,y2]", children: [], disabled: true },
{ name: "center", type: "array<number>", desc: "目标中心点 [x,y]", children: [], disabled: true },
{ name: "matchedDescription", type: "string", desc: "模型实际定位的目标描述", disabled: true },
{ name: "refinementMethod", type: "string", desc: "坐标精修方式", disabled: true },
{ name: "matchCount", type: "number", desc: "匹配项数量", disabled: true },
{ name: "matches", type: "array<object>", desc: "全部匹配项", children: [], disabled: true },
{ name: "selectedMatchIndex", type: "number", desc: "当前选择的匹配序号", disabled: true },
],
},
{ {
icon: microphoneSvg, icon: microphoneSvg,
name: "获取触控坐标", name: "获取触控坐标",
@ -592,13 +768,12 @@ function handler(params) {
icon: audioSvg, icon: audioSvg,
name: "播放语料", name: "播放语料",
type: "serviceNode", type: "serviceNode",
desc: "用于播放语料的节点,需要输入语音路径(audioPath)和播放语音的设备id(deviceId)", desc: "用于播放语料的节点,需要输入语音路径,执行设备由流程角色配置",
action: 'VI_PLAY_CORPUS', action: 'VI_PLAY_CORPUS',
nodeType: "EDGE", nodeType: "EDGE",
outputType: 'json', outputType: 'json',
nodeParams: [ nodeParams: [
{ name: 'audioPath', type: "input", input: "", disabled: true }, { name: 'audioPath', type: "input", input: "", disabled: true },
{ name: 'deviceId', type: "input", input: "", disabled: true },
{ name: 'bioId', type: "input", input: "", disabled: true, required: false } { name: 'bioId', type: "input", input: "", disabled: true, required: false }
], ],
} }
@ -615,7 +790,6 @@ function handler(params) {
action: 'TOUCH', action: 'TOUCH',
nodeType: "EDGE", nodeType: "EDGE",
nodeParams: [ nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "touchParams", type: "input", input: "", disabled: true } { name: "touchParams", type: "input", input: "", disabled: true }
], ],
outputType: 'json', outputType: 'json',
@ -627,9 +801,7 @@ function handler(params) {
desc: "控制机器人说话", desc: "控制机器人说话",
action: 'BIO_HEAD_SPEAK_START', action: 'BIO_HEAD_SPEAK_START',
nodeType: "EDGE", nodeType: "EDGE",
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", disabled: true }
],
outputType: 'json', outputType: 'json',
}, },
{ {
@ -639,9 +811,7 @@ function handler(params) {
desc: "停止机器人说话", desc: "停止机器人说话",
action: 'BIO_HEAD_SPEAK_STOP', action: 'BIO_HEAD_SPEAK_STOP',
nodeType: "EDGE", nodeType: "EDGE",
nodeParams: [ nodeParams: [],
{ name: "deviceId", type: "input", input: "", disabled: true }
],
outputType: 'json', outputType: 'json',
}, },
{ {
@ -652,7 +822,6 @@ function handler(params) {
action: 'BIO_HEAD_SPECIAL_EXPRESSION', action: 'BIO_HEAD_SPECIAL_EXPRESSION',
nodeType: "EDGE", nodeType: "EDGE",
nodeParams: [ nodeParams: [
{ name: "deviceId", type: "input", input: "", disabled: true },
{ name: "expressKey", type: "input",componentType: 'select', selectOptions: expressOptions(), disabled: true }, { name: "expressKey", type: "input",componentType: 'select', selectOptions: expressOptions(), disabled: true },
], ],
outputType: 'json', outputType: 'json',

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:流程正常结束节点。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container"> <div class="node__container">
<div class="terminal-node"> <div class="terminal-node">

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:节点分组容器并展示成员数量。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="group__container"> <div class="group__container">
<NodeTitle <NodeTitle
@ -33,6 +37,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { ...properties, name }); lf.setProperties(props.model.id, { ...properties, name });

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:通用服务节点并响应运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
@ -7,7 +11,7 @@
<template #output> <template #output>
<div v-if="nodeOperatingStatus != 'FAILED'"> <div v-if="nodeOperatingStatus != 'FAILED'">
<JsonViewer <JsonViewer
v-if="!['img', 'video'].includes(props.properties.outputType)" v-if="!['img', 'video', 'audio'].includes(props.properties.outputType)"
:value="outputJsonData" :value="outputJsonData"
copyable copyable
boxed boxed
@ -29,6 +33,14 @@
v-for="item in outputJsonData.videoUrl" v-for="item in outputJsonData.videoUrl"
:videoUrl="item" :videoUrl="item"
/> />
<audio
v-for="item in audioUrls"
:key="item"
class="node-audio-player"
:src="item"
controls
preload="metadata"
/>
</div> </div>
<div v-else>{{ errorInfoData }}</div> <div v-else>{{ errorInfoData }}</div>
</template> </template>
@ -47,7 +59,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { computed, ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css"; import "vue3-json-viewer/dist/index.css";
@ -63,6 +75,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -78,8 +94,18 @@ const inputJsonData = ref({});
const outputJsonData = ref({}); const outputJsonData = ref({});
const errorInfoData = ref(''); const errorInfoData = ref('');
const audioUrls = computed(() => {
const value = outputJsonData.value?.audioUrl;
if (Array.isArray(value)) return value.filter(Boolean);
if (typeof value === "string" && value) return [value];
return [];
});
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();
@ -135,4 +161,9 @@ defineExpose({ closePopover })
width: 100%; width: 100%;
height: auto; height: auto;
} }
.node-audio-player {
width: min(320px, 100%);
height: 36px;
}
</style> </style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:流程入口节点并响应运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<keep-alive> <keep-alive>
@ -42,6 +46,9 @@ const outputJsonData = ref({});
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();

View File

@ -0,0 +1,25 @@
<template>
<div class="action-queue-node">
<ServiceNode ref="serviceNodeRef" v-bind="props" @content-change="$emit('contentChange')" />
<div class="action-queue-node__summary">{{ stepCount }} 个步骤</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import ServiceNode from '../common/serviceNode.vue'
const props = defineProps({ model: Object, properties: Object })
defineEmits(['contentChange'])
const serviceNodeRef = ref()
const stepCount = computed(() => {
const steps = props.properties?.nodeParams?.find((item) => item.name === 'steps')?.input
return Array.isArray(steps) ? steps.length : 0
})
const closePopover = () => serviceNodeRef.value?.closePopover?.()
defineExpose({ closePopover })
</script>
<style scoped>
.action-queue-node__summary { margin-top: 7px; color: #667085; font-size: 11px; line-height: 16px; }
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:代码执行节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
@ -61,6 +65,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -78,6 +86,9 @@ const errorInfoData = ref('');
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:当前循环上下文节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
@ -47,6 +51,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -63,6 +71,9 @@ const outputJsonData = ref({});
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();

View File

@ -0,0 +1,146 @@
<!--
* 文件说明:通用设备执行节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<div v-if="nodeOperatingStatus != 'FAILED'">
<JsonViewer
v-if="!['img', 'video'].includes(props.properties.outputType)"
:value="outputJsonData"
copyable
boxed
sort
theme="light"
/>
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="outputJsonData.imageUrl"
:preview-teleported="true"
show-progress
fit="fill"
/>
<IPlayer
v-if="props.properties.outputType === 'video'"
v-for="item in outputJsonData.videoUrl"
:videoUrl="item"
/>
</div>
<div v-else>{{ errorInfoData }}</div>
</template>
</NodeState>
<NodeTitle
:icon="props.properties.icon"
:nodeId="props.model.id"
:nodeProperties="props.properties"
:nodeType="props.properties.nodeType || 'NONE'"
:nodeName="props.properties.name"
:nodeDesc="props.properties.desc"
@setNodeName="setNodeName"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import { useFlowEvents } from "../../composables/useFlowEvents.js";
import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({
model: Object,
properties: Object,
});
const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, {
...properties,
name
});
emits("contentChange");
}
const nodeOperatingStatus = ref("NORMAL");
const runtimes = ref(0);
const inputJsonData = ref({});
const outputJsonData = ref({});
const errorInfoData = ref('');
const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => {
if (nodeStateRef.value) {
nodeStateRef.value.closePopover();
}
}
onMounted(() => {
onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) {
runtimes.value = data.endTime - data.startTime;
}
let inputData = {};
let output = {};
errorInfoData.value = data?.message || ''
try {
inputData = JSON.parse(data.paramsIn) || {};
output = JSON.parse(data.paramsOut) || {};
} catch (error) {
inputData = data.paramsIn || {};
output = data.paramsOut || {};
}
inputJsonData.value = inputData;
outputJsonData.value = output;
emits("contentChange");
}
});
onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {};
outputJsonData.value = {};
emits("contentChange");
}
});
onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) {
emits("contentChange");
}
});
})
defineExpose({ closePopover })
</script>
<style lang="scss" scoped>
.node__container {
width: 100%;
height: auto;
}
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:HTTP 请求执行节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
@ -62,6 +66,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -79,6 +87,9 @@ const errorInfoData = ref('');
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:循环容器节点,管理循环体节点拖入和布局。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div <div
class="group__container" class="group__container"
@ -16,7 +20,23 @@
/> />
<div class="loop-body-title"> <div class="loop-body-title">
<span>循环体</span> <span>循环体</span>
<span class="loop-body-count">{{ actualChildCount }} 个节点</span> <div class="loop-body-actions">
<span class="loop-body-count">{{ actualChildCount }} 个节点</span>
<NodeState
ref="nodeStateRef"
:state="nodeOperatingStatus"
:runtimes="runtimes"
:popover-width="720"
popper-class="flow-result-popover flow-loop-result-popover"
>
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<LoopIterationResults :history="iterationHistory" />
</template>
</NodeState>
</div>
</div> </div>
<div <div
class="child__container" class="child__container"
@ -29,9 +49,15 @@
</template> </template>
<script setup> <script setup>
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import LoopIterationResults from "../../components/LoopIterationResults.vue";
import loop from "../../icon/loop.svg"; import loop from "../../icon/loop.svg";
import { emitter } from "@/utils/eventBus"; import { emitter } from "@/utils/eventBus";
import { computed, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useFlowEvents } from "../../composables/useFlowEvents";
import "vue3-json-viewer/dist/index.css";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({ const props = defineProps({
model: Object, model: Object,
@ -45,16 +71,64 @@ const props = defineProps({
const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]); const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]);
const isDragOver = ref(false); const isDragOver = ref(false);
const actualChildCount = computed(() => props.childCount ?? props.model?.children?.size ?? 0); const actualChildCount = computed(() => props.childCount ?? props.model?.children?.size ?? 0);
const nodeOperatingStatus = ref("NORMAL");
const runtimes = ref(0);
const inputJsonData = ref({});
const outputJsonData = ref({});
const nodeStateRef = ref(null);
const iterationHistory = computed(() => Array.isArray(outputJsonData.value?.iterationHistory)
? outputJsonData.value.iterationHistory
: []);
const parseRuntimeValue = (value) => {
if (!value) return {};
if (typeof value === "object") return value;
try {
return JSON.parse(value) || {};
} catch {
return {};
}
};
onMounted(() => {
onFlowEvent("changeNodeState", (data) => {
if (String(data?.nodeId || "") !== String(props.model.id)) return;
nodeOperatingStatus.value = data.status || "NORMAL";
if (data.endTime && data.startTime) runtimes.value = data.endTime - data.startTime;
inputJsonData.value = parseRuntimeValue(data.paramsIn);
outputJsonData.value = parseRuntimeValue(data.paramsOut);
emits("contentChange");
});
onFlowEvent("contentChange", (data) => {
if (String(data?.id || "") !== String(props.model.id)) return;
nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {};
outputJsonData.value = {};
});
});
/**
* 处理 handleDragover 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const handleDragover = (e) => { const handleDragover = (e) => {
e.preventDefault(); e.preventDefault();
isDragOver.value = true; isDragOver.value = true;
}; };
/**
* 处理 handleDragleave 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const handleDragleave = (e) => { const handleDragleave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) isDragOver.value = false; if (!e.currentTarget.contains(e.relatedTarget)) isDragOver.value = false;
}; };
/**
* 处理 handleDrop 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const handleDrop = (e) => { const handleDrop = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -84,6 +158,10 @@ const handleDrop = (e) => {
lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition); lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition);
}; };
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -154,6 +232,18 @@ const setNodeName = (name) => {
font-weight: 400; font-weight: 400;
} }
.loop-body-actions {
display: flex;
align-items: center;
gap: 8px;
}
.loop-body-actions :deep(.node-state) {
min-height: 28px;
margin: 0;
padding: 2px 5px 2px 7px;
}
.child__container { .child__container {
min-height: 300px; min-height: 300px;
flex: 1; flex: 1;

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:识别执行节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
@ -48,6 +52,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -64,6 +72,9 @@ const outputJsonData = ref({});
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:智能体执行节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
@ -48,6 +52,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -64,6 +72,9 @@ const outputJsonData = ref({});
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:延时等待节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" :class="props.model.id"> <div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
@ -47,6 +51,10 @@ const props = defineProps({
const emits = defineEmits(["contentChange"]); const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -63,6 +71,9 @@ const outputJsonData = ref({});
const nodeStateRef = ref(null); const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => { const closePopover = () => {
if (nodeStateRef.value) { if (nodeStateRef.value) {
nodeStateRef.value.closePopover(); nodeStateRef.value.closePopover();

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:终止循环节点,用于结束当前循环。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container" > <div class="node__container" >
<!-- 状态栏 --> <!-- 状态栏 -->
@ -16,6 +20,10 @@ const props = defineProps({
text: String, text: String,
}); });
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { lf.setProperties(props.model.id, {
@ -194,4 +202,4 @@ const setNodeName = (name) => {
} }
} }
} }
</style> </style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:循环体单次执行出口节点。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container"> <div class="node__container">
<div class="terminal-node"> <div class="terminal-node">

View File

@ -0,0 +1,100 @@
<template>
<div class="node__container" :class="props.model.id">
<NodeState
ref="nodeStateRef"
:state="nodeOperatingStatus"
:runtimes="runtimes"
:popover-width="760"
popper-class="flow-result-popover flow-subflow-result-popover"
>
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<SubFlowExecutionResults v-if="nodeOperatingStatus !== 'FAILED'" :data="subFlowExecution" />
<div v-else class="subflow-error">{{ errorInfoData }}</div>
</template>
</NodeState>
<NodeTitle
:icon="props.properties.icon"
:node-id="props.model.id"
:node-properties="props.properties"
:node-type="props.properties.nodeType || 'NONE'"
:node-name="props.properties.name"
:node-desc="props.properties.desc"
@set-node-name="setNodeName"
/>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import NodeState from '../../components/NodeState.vue'
import NodeTitle from '../../components/NodeTitle.vue'
import SubFlowExecutionResults from '../../components/SubFlowExecutionResults.vue'
import { useFlowEvents } from '../../composables/useFlowEvents'
const props = defineProps({ model: Object, properties: Object })
const emits = defineEmits(['contentChange'])
const { onFlowEvent } = useFlowEvents()
const nodeStateRef = ref(null)
const nodeOperatingStatus = ref('NORMAL')
const runtimes = ref(0)
const inputJsonData = ref({})
const outputJsonData = ref({})
const errorInfoData = ref('')
// 优先使用主页面轮询聚合出的 subFlowExecution;没有内部日志时退回到普通输出,保证旧数据仍可展示。
const subFlowExecution = computed(() => outputJsonData.value?.subFlowExecution || {
name: props.properties?.subFlow?.detectName || props.properties?.name,
versionNo: props.properties?.subFlow?.versionNo,
output: outputJsonData.value,
executions: [],
})
// 子流程输出来自后端运行日志,兼容 JSON 字符串和对象;解析失败时保留原值用于排查。
const parseValue = (value) => {
if (!value) return {}
if (typeof value === 'object') return value
try { return JSON.parse(value) || {} } catch { return value }
}
const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id)
lf.setProperties(props.model.id, { ...properties, name })
emits('contentChange')
}
onMounted(() => {
onFlowEvent('changeNodeState', (data) => {
if (data.nodeId !== props.model.id) return
// 外层子流程节点状态由主页面统一聚合内部日志后派发,这里只负责同步到节点 UI。
nodeOperatingStatus.value = data.status
runtimes.value = data.endTime && data.startTime ? data.endTime - data.startTime : 0
inputJsonData.value = parseValue(data.paramsIn)
outputJsonData.value = parseValue(data.paramsOut)
errorInfoData.value = data?.message || ''
emits('contentChange')
})
onFlowEvent('contentChange', (data) => {
if (data.id !== props.model.id) return
nodeOperatingStatus.value = 'NORMAL'
inputJsonData.value = {}
outputJsonData.value = {}
emits('contentChange')
})
})
defineExpose({ closePopover: () => nodeStateRef.value?.closePopover?.() })
</script>
<style scoped lang="scss">
.node__container { width: 100%; height: auto; }
.subflow-error { color: var(--el-color-danger); line-height: 20px; overflow-wrap: anywhere; }
</style>
<style lang="scss">
.flow-subflow-result-popover {
max-width: min(760px, calc(100vw - 32px));
.result-section__content { max-height: 560px; }
}
</style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:循环体单次执行入口节点。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div class="node__container"> <div class="node__container">
<div class="terminal-node"> <div class="terminal-node">

View File

@ -1,277 +1,63 @@
<!--
* 文件说明:条件分支节点,展示已保存的条件摘要并同步每个条件对应的画布锚点。
* 作用范围:仅服务于流程设计器模块;条件编辑由 SwitchNodeParams 组件独立负责。
-->
<template> <template>
<div class="node__container" ref="switchRef" @mouseleave="setNodeProperties"> <div class="node__container" :class="props.model.id" ref="switchRef">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef"> <NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #input> <template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" /> <JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template> </template>
<template #output> <template #output>
<div v-if="nodeOperatingStatus != 'FAILED'"> <div v-if="nodeOperatingStatus !== 'FAILED'">
<JsonViewer :value="outputJsonData" copyable boxed sort theme="light" /> <JsonViewer :value="outputJsonData" copyable boxed sort theme="light" />
</div> </div>
<div v-else>{{ errorInfoData }}</div> <div v-else>{{ errorInfoData }}</div>
</template> </template>
</NodeState> </NodeState>
<NodeTitle <NodeTitle
:icon="switchSvg" :icon="switchSvg"
:nodeId="props.model.id" :nodeId="props.model.id"
:nodeProperties="props.properties" :nodeProperties="props.properties"
:nodeName="props.properties?.name || '分支'" :nodeName="props.properties?.name || '分支'"
nodeDesc="连接多个下游分支,根据设定的条件按照顺序查找的方式来匹配运行的分支,如果匹配到某条件则只运行该条件对应的分支,否则继续匹配下一条件直至结束" nodeDesc="双击节点配置分支条件;流程将按顺序匹配条件,并只运行首个匹配条件对应的分支"
@setNodeName="setNodeName" @setNodeName="setNodeName"
/> />
<div class="condition_title_container">
<div class="left"> <div class="condition-summary">
<div class="space"></div> <div class="condition-summary__title">
<div class="title">所有条件</div> <span>分支条件</span>
<span class="condition-summary__count">{{ conditions.length }} 个</span>
</div> </div>
<div class="right">
<el-button <div
:disabled="flowStore.disableForm" v-for="(condition, index) in conditions"
@click="addFormItem" :id="condition.id"
class="addFormItem" :key="condition.id"
> :data-branch-anchor-id="condition.id"
<el-icon :size="20"><Plus /></el-icon> class="condition-summary__item"
</el-button>
</div>
</div>
<div>
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="dynamicForm"
label-position="top"
label-width="auto"
:disabled="flowStore.disableForm"
> >
<div <span class="condition-summary__order">{{ condition.label || `条件 ${index + 1}` }}</span>
class="condition" <span class="condition-summary__text">{{ conditionSummary(condition) }}</span>
v-for="(params, index) in formData.nodeParams" </div>
:key="params.id" <div
:id="params.id" :id="`${props.model.id}_else`"
:data-branch-anchor-id="params.id" :data-branch-anchor-id="`${props.model.id}_else`"
> class="condition-summary__item condition-summary__item--else"
<div class="title__container"> >
<div class="left"> <span class="condition-summary__order">Else</span>
<div class="space"></div> <span class="condition-summary__text">以上条件均不满足时执行</span>
<div class="title">{{ index === 0 ? "If" : "Else If" }}</div> </div>
</div>
<div class="right">
<el-button
:disabled="flowStore.disableForm"
circle
size="small"
@click="deleteFormItem(params.id, index)"
>
<el-icon :size="16"><Minus /></el-icon>
</el-button>
</div>
</div>
<div class="withOr">
<div class="title">条件</div>
<div>
<el-select
v-model="params.withOr"
placeholder="Select"
style="width: 240px"
@focus="handleInputFocus"
@click.stop
@mousedown.stop
>
<el-option key="and" label="AND" value="and" />
<el-option key="or" label="OR" value="or" />
</el-select>
</div>
</div>
<div class="form__container">
<el-row v-for="(property, pIndex) in params.list" :key="`${params.id}-${pIndex}`">
<el-form-item
:label="pIndex === 0 ? '引用变量' : ''"
:prop="`nodeParams.${index}.list.${pIndex}.nameType`"
>
<el-select
v-model="property.nameType"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
@change="handleTypeChange(index, pIndex)"
>
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item
v-if="property.nameType === 'input'"
:rules="[
{
required: true,
message: '请输入参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.name`"
>
<el-input
v-model="property.name"
placeholder="请输入"
clearable
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
<el-form-item
v-if="property.nameType === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.nameQuote`"
>
<el-cascader
v-model="property.nameQuote"
:options="quoteOptions"
placeholder="请选择"
:key="nameQuoteKey"
@visible-change="(val) => visibleChange(val, 'nameQuote', index, pIndex)"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
</el-form-item>
<el-form-item
:label="pIndex === 0 ? '选择条件' : ''"
:prop="`nodeParams.${index}.list.${pIndex}.condition`"
:rules="[
{ required: true, message: '请输入变量名', trigger: 'blur' },
]"
>
<el-select
v-model="property.condition"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
>
<el-option label="等于" value="equal" />
<el-option label="不等于" value="notEqualTo" />
<el-option label="为空" value="null" />
<el-option label="不为空" value="notNull" />
<el-option label="包含" value="include" />
<el-option label="不包含" value="notInclude" />
<el-option label="大于" value="greaterThan" />
<el-option label="大于等于" value="greaterThanOrEqual" />
<el-option label="小于" value="lessThan" />
<el-option label="小于等于" value="lessThanOrEqual" />
</el-select>
</el-form-item>
<el-form-item
:label="pIndex === 0 ? '比较值' : ''"
:prop="`nodeParams.${index}.list.${pIndex}.type`"
>
<el-select
v-model="property.type"
@change="handleTypeChange(index, pIndex)"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
>
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item
v-if="property.type === 'input'"
:rules="[
{
required: true,
message: '请输入参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.input`"
>
<el-input
v-model="property.input"
placeholder="请输入"
clearable
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
<el-form-item
v-if="property.type === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.list.${pIndex}.quote`"
>
<el-cascader
v-model="property.quote"
:options="quoteOptions"
placeholder="请选择"
@visible-change="(val) => visibleChange(val, 'quote', index, pIndex)"
@focus="handleInputFocus"
@keydown="handleInputKeydown"
@click.stop
@mousedown.stop
/>
</el-form-item>
</el-form-item>
<el-form-item :label="pIndex === 0 ? '操作' : ''">
<el-button
:disabled="flowStore.disableForm"
circle
size="small"
@click="addListItem(index)"
>
<el-icon :size="12"><Plus /></el-icon>
</el-button>
<el-button
:disabled="flowStore.disableForm"
circle
size="small"
@click="removeListItem(index, pIndex)"
>
<el-icon :size="12"><Minus /></el-icon>
</el-button>
</el-form-item>
</el-row>
</div>
</div>
</el-form>
</div>
<div
class="else"
:id="''.concat(props.model.id, '_else')"
:data-branch-anchor-id="''.concat(props.model.id, '_else')"
>
<div class="space"></div>
<div class="title">Else</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch } from "vue"; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import NodeTitle from "../../components/NodeTitle.vue"; import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue"; import NodeState from "../../components/NodeState.vue";
import switchSvg from "../../icon/switch.svg"; import switchSvg from "../../icon/switch.svg";
import { v4 as randomUUID } from "uuid";
import { useFlowStore } from "@/store/modules/flow";
import { Plus, Minus } from "@element-plus/icons-vue";
import { getInput, removeSwitchEdge } from "@/utils/flow";
import { useFlowEvents } from "../../composables/useFlowEvents"; import { useFlowEvents } from "../../composables/useFlowEvents";
const props = defineProps({ const props = defineProps({
@ -281,118 +67,101 @@ const props = defineProps({
nowTime: Number, nowTime: Number,
}); });
const emits = defineEmits([ const emits = defineEmits(["contentChange", "syncAnchors"]);
"addAnchor",
"removeAnchor",
"bindRef",
"changeAnchor",
"syncAnchors",
]);
const switchRef = ref();
const dynamicForm = ref();
const flowStore = useFlowStore();
const rules = reactive({});
const { onFlowEvent } = useFlowEvents(); const { onFlowEvent } = useFlowEvents();
const switchRef = ref(null);
const nodeStateRef = ref(null);
const conditions = computed(() => props.properties?.nodeParams || props.properties?.conditions || []);
const formData = reactive({ const nodeOperatingStatus = ref("NORMAL");
nodeParams: props.properties?.conditions || [], const runtimes = ref(0);
}); const inputJsonData = ref({});
const outputJsonData = ref({});
const addFormItem = () => { const errorInfoData = ref("");
addCondition(0);
};
const deleteFormItem = (id, index) => {
if (index === 0) {
return;
}
formData.nodeParams.splice(index, 1);
emits("removeAnchor", id);
removeSwitchEdge(props.model.id, id);
nextTick(() => {
scheduleAnchorSync();
});
};
const quoteOptions = ref([]);
const handleTypeChange = (pIndex, index) => {
if (formData.nodeParams[pIndex].list[index].type === "input") {
formData.nodeParams[pIndex].list[index].quote = "";
} else {
formData.nodeParams[pIndex].list[index].input = "";
const option = getInput(props.model.id, false);
if (option) {
quoteOptions.value = option;
}
}
};
const addCondition = (height) => {
const id = randomUUID().replace(/-/g, "");
formData.nodeParams.push({
id,
withOr: "and",
list: [
{
name: "",
nameQuote: "",
nameType: "input",
type: "input",
input: "",
quote: "",
},
],
});
emits("addAnchor", height, id);
nextTick(() => {
scheduleAnchorSync();
});
};
const addListItem = (pIndex, cIndex) => {
formData.nodeParams[pIndex].list.push({
name: "",
nameQuote: "",
nameType: "input",
type: "input",
input: "",
quote: "",
});
nextTick(() => {
scheduleAnchorSync();
});
};
const removeListItem = (pIndex, cIndex) => {
if (cIndex === 0) {
return;
}
formData.nodeParams[pIndex].list.splice(cIndex, 1);
nextTick(() => {
scheduleAnchorSync();
});
};
let anchorSyncFrame = null; let anchorSyncFrame = null;
let resizeObserver = null; let resizeObserver = null;
const conditionTextMap = {
equal: "等于",
notEqualTo: "不等于",
null: "为空",
notNull: "不为空",
include: "包含",
notInclude: "不包含",
greaterThan: "大于",
greaterThanOrEqual: "大于等于",
lessThan: "小于",
lessThanOrEqual: "小于等于",
};
/**
* 生成画布卡片中的完整条件说明,多个判断项按配置的 AND/OR 关系连接。
* @param {object} condition 当前条件组
* @returns {string} 可直接展示的条件表达式
*/
const conditionSummary = (condition) => {
if (!condition.list?.length) return "未配置判断";
const relation = condition.withOr === "or" ? " OR " : " AND ";
return condition.list.map((item) => {
const left = item.nameType === "quote"
? formatQuote(item.nameQuote)
: String(item.name || "未填写变量");
const operator = conditionTextMap[item.condition] || "未选择判断";
if (["null", "notNull"].includes(item.condition)) return `${left} ${operator}`;
const right = item.type === "quote"
? formatQuote(item.quote)
: String(item.input ?? "未填写比较值");
return `${left} ${operator} ${right}`;
}).join(relation);
};
/**
* 将级联引用路径格式化为易读文本。
* @param {string|string[]} quote 引用路径
* @returns {string} 点号连接的引用名称
*/
const formatQuote = (quote) => {
const path = Array.isArray(quote)
? quote.filter(Boolean)
: String(quote || "").split(".").filter(Boolean);
if (!path.length) return "未选择引用";
const [nodeId, direction, ...fieldPath] = path;
const graphNode = window.lf?.getGraphData().nodes.find((node) => node.id === nodeId);
const nodeName = graphNode?.properties?.name || graphNode?.name || nodeId;
const directionName = direction === "input"
? "输入"
: direction === "output" ? "输出" : direction;
return [nodeName, directionName, ...fieldPath].filter(Boolean).join(".");
};
/**
* 根据摘要项的实际纵向中心同步分支锚点;范围仅限当前分支节点。
*/
const syncAnchorPositions = () => { const syncAnchorPositions = () => {
const root = switchRef.value; const root = switchRef.value;
if (!root) return; if (!root) return;
const rootRect = root.getBoundingClientRect(); // Handle 的 top 相对于 FlowNode 外壳定位,不能使用 switch 根元素作为坐标原点。
const scale = root.offsetWidth > 0 ? rootRect.width / root.offsetWidth : 1; // 外层卡片存在 padding;若以 rootRect.top 计算,所有锚点都会统一向上偏移。
const positioningParent = root.closest(".flow-node-shell");
if (!positioningParent) return;
const parentRect = positioningParent.getBoundingClientRect();
const scale = positioningParent.offsetWidth > 0
? parentRect.width / positioningParent.offsetWidth
: 1;
const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => { const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return { return {
id: element.dataset.branchAnchorId, id: element.dataset.branchAnchorId,
height: Math.round(((rect.top - rootRect.top + rect.height / 2) / scale) * 100) / 100, height: Math.round(((rect.top - parentRect.top + rect.height / 2) / scale) * 100) / 100,
}; };
}); });
emits("syncAnchors", positions); emits("syncAnchors", positions);
}; };
/**
* 将同一帧内的多次尺寸变化合并为一次锚点同步;范围仅限当前分支节点。
*/
const scheduleAnchorSync = () => { const scheduleAnchorSync = () => {
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame); if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
anchorSyncFrame = requestAnimationFrame(() => { anchorSyncFrame = requestAnimationFrame(() => {
@ -401,100 +170,30 @@ const scheduleAnchorSync = () => {
}); });
}; };
const setNodeProperties = async () => { /**
try { * 保存节点名称;范围仅限当前分支节点。
const valid = await dynamicForm.value.validate(); * @param {string} name 新的节点名称
if (valid) { */
const data = toRaw(formData);
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, {
...properties,
...data,
});
emits("contentChange");
}
} catch (error) {
dynamicForm.value.clearValidate();
}
};
const setNodeName = (name) => { const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id); const properties = window.lf.getProperties(props.model.id);
lf.setProperties(props.model.id, { window.lf.setProperties(props.model.id, { ...properties, name });
...properties,
name
});
emits("contentChange"); emits("contentChange");
}
const visibleChange = async (value, type, index, pIndex) => {
if (value) {
const option = getInput(props.model.id);
if (option) {
quoteOptions.value = option;
const currentValue = formData.nodeParams[index].list[pIndex][type]
if (currentValue) {
formData.nodeParams[index].list[pIndex][type] = null
await nextTick();
formData.nodeParams[index].list[pIndex][type] = currentValue;
}
}
}
}; };
const nodeOperatingStatus = ref("NORMAL"); /**
const runtimes = ref(0); * 关闭节点运行状态浮层;范围仅限当前分支节点。
const inputJsonData = ref({}); */
const outputJsonData = ref({}); const closePopover = () => {
const errorInfoData = ref(''); nodeStateRef.value?.closePopover();
};
watch( watch(
() => props.properties, () => conditions.value.map((condition) => condition.id),
() => { () => nextTick(scheduleAnchorSync),
if (props.properties.nodeParams && props.properties.nodeParams.length > 0) { { immediate: true },
formData.nodeParams = props.properties.nodeParams;
} else {
if (formData.nodeParams.length === 0) {
addCondition(100);
}
}
nextTick(scheduleAnchorSync);
},
{
immediate: true,
deep: true,
}
); );
const nodeStateRef = ref(null);
const closePopover = () => {
if (nodeStateRef.value) {
nodeStateRef.value.closePopover();
}
}
const isEditing = ref(false)
const handleInputFocus = (e) => {
// 阻止事件冒泡到流程画布
e.stopPropagation()
e.preventDefault()
}
const handleInputKeydown = (e) => {
// 阻止事件冒泡到流程节点,避免被画布快捷键拦截
e.stopPropagation();
// 可选:明确放行 Ctrl+V(增强兼容性)
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.returnValue = true; // 允许默认粘贴行为
}
}
onMounted(() => { onMounted(() => {
emits("addAnchor", 0, "".concat(props.model.id, "_else"));
emits("bindRef", dynamicForm.value);
nextTick(() => { nextTick(() => {
scheduleAnchorSync(); scheduleAnchorSync();
resizeObserver = new ResizeObserver(scheduleAnchorSync); resizeObserver = new ResizeObserver(scheduleAnchorSync);
@ -502,36 +201,26 @@ onMounted(() => {
}); });
onFlowEvent("changeNodeState", (data) => { onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) { if (data.nodeId !== props.model.id) return;
nodeOperatingStatus.value = data.status; nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) { if (data.endTime && data.startTime) runtimes.value = data.endTime - data.startTime;
runtimes.value = data.endTime - data.startTime; errorInfoData.value = data?.message || "";
} try {
let inputData = {}; inputJsonData.value = JSON.parse(data.paramsIn) || {};
let output = {}; outputJsonData.value = JSON.parse(data.paramsOut) || {};
errorInfoData.value = data?.message || '' } catch {
try { inputJsonData.value = data.paramsIn || {};
inputData = JSON.parse(data.paramsIn) || {}; outputJsonData.value = data.paramsOut || {};
output = JSON.parse(data.paramsOut) || {};
} catch (error) {
inputData = data.paramsIn || {};
output = data.paramsOut || {};
}
inputJsonData.value = inputData;
outputJsonData.value = output;
emits("contentChange");
} }
emits("contentChange");
}); });
onFlowEvent("contentChange", (data) => { onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) { if (data.id !== props.model.id) return;
nodeOperatingStatus.value = "NORMAL"; nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {}; inputJsonData.value = {};
outputJsonData.value = {}; outputJsonData.value = {};
emits("contentChange");
emits("contentChange");
}
}); });
}); });
@ -540,179 +229,91 @@ onUnmounted(() => {
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame); if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
}); });
defineExpose({ closePopover }) defineExpose({ closePopover });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.node__container { .node__container {
width: 100%; width: 100%;
height: auto; min-height: 180px;
background: #fff;
padding: 16px; padding: 16px;
cursor: default;
border: 1px solid #dfe4ec; border: 1px solid #dfe4ec;
border-left: 4px solid var(--node-accent, #b7791f); border-left: 4px solid var(--node-accent, #b7791f);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09); background: #fff;
position: relative; // box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09);
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.16s ease, box-shadow 0.16s ease; cursor: default;
}
.condition_title_container { .condition-summary {
display: flex; margin-top: 14px;
align-items: center; padding-top: 12px;
justify-content: space-between; border-top: 1px solid #e8ebf0;
margin-top: 14px; }
padding-top: 12px;
border-top: 1px solid #e8ebf0;
.left { .condition-summary__title,
display: flex; .condition-summary__item {
align-items: center; display: flex;
align-items: center;
justify-content: space-between;
}
.space { .condition-summary__title {
width: 3px; margin-bottom: 8px;
height: 15px; color: #344054;
background: var(--node-accent, #b7791f); font-size: 13px;
border-radius: 3px; font-weight: 650;
} }
.title { .condition-summary__count {
margin-left: 8px; color: #98a2b3;
color: #344054; font-size: 12px;
font-size: 13px; font-weight: 400;
font-weight: 650; }
}
}
.el-button { .condition-summary__item {
width: 28px; min-height: 44px;
height: 28px; margin-top: 6px;
padding: 0; padding: 0 10px;
border-color: #dfe4ec; border: 1px solid #e4e8ef;
border-radius: 5px; border-radius: 6px;
} background: #f8fafc;
} position: relative;
.condition { &::after {
width: 100%; content: "";
height: auto; width: 17px;
margin: 8px 0; position: absolute;
padding: 11px 12px 5px; top: 50%;
border: 1px solid #e4e8ef; right: -18px;
border-radius: 7px; border-top: 1px solid var(--node-accent, #b7791f);
background-color: #f8fafc; pointer-events: none;
box-sizing: border-box;
position: relative;
&::after {
content: "";
width: 17px;
position: absolute;
top: 50%;
right: -18px;
border-top: 1px solid var(--node-accent, #b7791f);
pointer-events: none;
}
.title__container {
display: flex;
align-items: center;
justify-content: space-between;
.left {
display: flex;
align-items: center;
.space {
width: 3px;
height: 14px;
background: #3978c5;
border-radius: 3px;
}
.title {
margin-left: 8px;
color: #344054;
font-size: 12px;
font-weight: 650;
}
}
.el-button {
width: 26px;
height: 26px;
padding: 0;
border-radius: 5px;
}
}
.withOr {
display: flex;
align-items: center;
gap: 10px;
margin: 9px 0 7px;
.title {
color: #7a8494;
font-size: 12px;
}
:deep(.el-select) { width: 120px !important; }
}
:deep(.form__container) {
.el-form-item {
margin-right: 12px;
margin-bottom: 8px;
}
.el-input {
width: 105px;
}
.el-select {
width: 105px;
}
.el-cascader {
width: 105px;
}
}
}
.else {
display: flex;
align-items: center;
min-height: 40px;
padding: 8px 12px;
border: 1px solid #e4e8ef;
border-radius: 7px;
background-color: #f8fafc;
position: relative;
&::after {
content: "";
width: 17px;
position: absolute;
top: 50%;
right: -18px;
border-top: 1px solid var(--node-accent, #b7791f);
pointer-events: none;
}
.space {
width: 3px;
height: 14px;
background: #697386;
border-radius: 3px;
}
.title {
margin-left: 8px;
color: #344054;
font-size: 12px;
font-weight: 650;
}
} }
} }
.condition-summary__item--else {
background: #fffaf0;
}
.condition-summary__order {
width: 96px;
flex: 0 0 96px;
color: #3978c5;
font-size: 12px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.condition-summary__text {
flex: 1;
color: #7a8494;
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
text-align: right;
white-space: normal;
}
</style> </style>

View File

@ -1,3 +1,7 @@
<!--
* 文件说明:Vue Flow 连线渲染组件,负责路径展示及选中状态下的删除操作。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<BaseEdge <BaseEdge
:id="id" :id="id"
@ -54,6 +58,9 @@ const deleteActionStyle = computed(() => ({
transform: `translate(-50%, -50%) translate(${pathData.value[1]}px, ${pathData.value[2]}px)`, transform: `translate(-50%, -50%) translate(${pathData.value[1]}px, ${pathData.value[2]}px)`,
})); }));
/**
* 删除与 edge 相关的状态或数据,该影响范围仅限当前组件。
*/
const deleteEdge = () => { const deleteEdge = () => {
window.lf?.deleteEdge(props.id); window.lf?.deleteEdge(props.id);
window.lf?.clearSelectElements(); window.lf?.clearSelectElements();

View File

@ -1,7 +1,14 @@
<!--
* 文件说明:Vue Flow 节点壳组件,负责锚点、连接规则、分支位置及分组交互适配。
* 作用范围:仅服务于流程设计器模块。
-->
<template> <template>
<div <div
class="flow-node-shell" class="flow-node-shell"
:class="[`flow-node-shell--${flowType}`, { 'flow-node-shell--selected': selected }]" :class="[`flow-node-shell--${flowType}`, {
'flow-node-shell--selected': selected,
'flow-node-shell--queue-drop-active': queueDropActive,
}]"
> >
<Handle <Handle
v-if="showTarget" v-if="showTarget"
@ -12,7 +19,7 @@
:is-valid-connection="isValidLoopConnection" :is-valid-connection="isValidLoopConnection"
class="flow-handle flow-handle--target" class="flow-handle flow-handle--target"
/> />
<div class="flow-node-content" :class="{ 'flow-node-card': !isGroup && flowType !== 'branch' }"> <div class="flow-node-content" :class="{ 'flow-node-card': !isGroup }">
<component <component
:is="nodeComponent" :is="nodeComponent"
ref="componentRef" ref="componentRef"
@ -28,6 +35,7 @@
@sync-anchors="syncBranchAnchors" @sync-anchors="syncBranchAnchors"
@add-to-group="addToGroup" @add-to-group="addToGroup"
/> />
<div v-if="executionLabel" class="flow-node-executor">{{ executionLabel }}</div>
</div> </div>
<template v-if="flowType === 'branch'"> <template v-if="flowType === 'branch'">
@ -74,14 +82,19 @@ import CodeNode from "../nodes/function/codeNode.vue";
import CurrentLoopNode from "../nodes/function/currentLoop.vue"; import CurrentLoopNode from "../nodes/function/currentLoop.vue";
import SdAgentNode from "../nodes/function/sdAgent.vue"; import SdAgentNode from "../nodes/function/sdAgent.vue";
import RecognizeNode from "../nodes/function/recognize.vue"; import RecognizeNode from "../nodes/function/recognize.vue";
import DeviceUniversal from "../nodes/function/deviceUniversal.vue";
import ActionQueue from "../nodes/function/actionQueue.vue";
import SubFlowNode from "../nodes/function/subFlow.vue";
const props = defineProps({ const props = defineProps({
id: String, id: String,
data: Object, data: Object,
selected: Boolean, selected: Boolean,
queueDropActive: Boolean,
connectable: [Boolean, Number, String, Function], connectable: [Boolean, Number, String, Function],
}); });
// 画布节点类型到 Vue 组件的唯一映射表;新增节点类型时需要在这里接入渲染组件。
const componentMap = { const componentMap = {
start: StartNode, start: StartNode,
end: EndNode, end: EndNode,
@ -98,6 +111,10 @@ const componentMap = {
currentLoop: CurrentLoopNode, currentLoop: CurrentLoopNode,
sdAgent: SdAgentNode, sdAgent: SdAgentNode,
recognize: RecognizeNode, recognize: RecognizeNode,
deviceUniversal: DeviceUniversal,
device_universal: DeviceUniversal,
actionQueue: ActionQueue,
subFlow: SubFlowNode,
}; };
const flowStore = useFlowStore(); const flowStore = useFlowStore();
@ -105,6 +122,20 @@ const vueFlow = useVueFlow("main-flow");
const componentRef = ref(); const componentRef = ref();
const flowType = computed(() => props.data.flowType); const flowType = computed(() => props.data.flowType);
const properties = computed(() => props.data.properties || {}); const properties = computed(() => props.data.properties || {});
const executionLabel = computed(() => {
const roleKey = properties.value.executionTarget?.roleKey;
if (!roleKey) return "";
const graph = window.lf?.getGraphData?.();
const start = graph?.nodes?.find((node) => node.type === "start");
const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey);
if (!role) return "未配置机器人角色";
const bindings = properties.value.executionTarget?.deviceBindings || [];
if (bindings.length > 1) return `${role.displayName} · ${bindings.length} 个设备用途`;
const slotKey = properties.value.executionTarget?.deviceSlotKey
|| properties.value.executionTarget?.deviceBindings?.[0]?.slotKey;
const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey);
return slot ? `${role.displayName} · ${slot.displayName}` : role.displayName;
});
const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode); const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode);
const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value)); const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value));
const childNodeCount = computed(() => ( const childNodeCount = computed(() => (
@ -126,24 +157,42 @@ const branchAnchors = computed(() => {
return true; return true;
}); });
}); });
const model = computed(() => window.lf?.getNodeModelById(props.id)); const model = computed(() => window.lf?.getNodeModelById(props.id));
/**
* 获取与 loop scope 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} nodeId 流程节点 ID
*/
const getLoopScope = (nodeId) => { const getLoopScope = (nodeId) => {
const node = vueFlow.findNode(nodeId); const node = vueFlow.findNode(nodeId);
const parent = node?.parentNode ? vueFlow.findNode(node.parentNode) : null; const parent = node?.parentNode ? vueFlow.findNode(node.parentNode) : null;
return parent?.data?.flowType === "loop" ? parent.id : null; return parent?.data?.flowType === "loop" ? parent.id : null;
}; };
/**
* 判断与 valid loop connection 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} connection 待校验或创建的连接信息
*/
const isValidLoopConnection = (connection) => { const isValidLoopConnection = (connection) => {
if (!connection.source || !connection.target || connection.source === connection.target) return false; if (!connection.source || !connection.target || connection.source === connection.target) return false;
return getLoopScope(connection.source) === getLoopScope(connection.target); return getLoopScope(connection.source) === getLoopScope(connection.target);
}; };
/**
* 同步与 anchors 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} anchors 需要同步的锚点集合
*/
const syncAnchors = (anchors) => { const syncAnchors = (anchors) => {
window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors }); window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors });
vueFlow.updateNodeInternals([props.id]); vueFlow.updateNodeInternals([props.id]);
}; };
/**
* 新增与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} height 锚点纵向位置
* @param {*} anchorId 分支锚点 ID
*/
const addAnchor = (height, anchorId) => { const addAnchor = (height, anchorId) => {
const anchors = [...branchAnchors.value]; const anchors = [...branchAnchors.value];
if (!anchors.some((anchor) => anchor.id === anchorId)) { if (!anchors.some((anchor) => anchor.id === anchorId)) {
@ -152,8 +201,21 @@ const addAnchor = (height, anchorId) => {
} }
}; };
/**
* 移除与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} anchorId 分支锚点 ID
*/
const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId)); const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId));
/**
* 更新与 anchor 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} height 锚点纵向位置
* @param {*} anchorId 分支锚点 ID
*/
const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor)); const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor));
/**
* 同步与 branch anchors 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} positions 分支锚点位置集合
*/
const syncBranchAnchors = (positions = []) => { const syncBranchAnchors = (positions = []) => {
if (!positions.length) return; if (!positions.length) return;
const positionMap = new Map(positions.map((item) => [item.id, Number(item.height || 0)])); const positionMap = new Map(positions.map((item) => [item.id, Number(item.height || 0)]));
@ -174,11 +236,23 @@ const syncBranchAnchors = (positions = []) => {
if (changed) syncAnchors(anchors); if (changed) syncAnchors(anchors);
else vueFlow.updateNodeInternals([props.id]); else vueFlow.updateNodeInternals([props.id]);
}; };
/**
* 处理与 content changed 相关的状态或数据,该影响范围仅限当前组件。
*/
const contentChanged = () => { const contentChanged = () => {
vueFlow.updateNodeInternals([props.id]); vueFlow.updateNodeInternals([props.id]);
window.lf?.markChanged(); window.lf?.markChanged();
}; };
/**
* 绑定与 component 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} instance 节点组件实例
*/
const bindComponent = (instance) => window.lf?.registerComponentInstance(props.id, instance); const bindComponent = (instance) => window.lf?.registerComponentInstance(props.id, instance);
/**
* 新增与 to group 相关的状态或数据,该影响范围仅限当前组件。
* @param {*} childId 子节点 ID
* @param {*} dropCenter 拖放中心点坐标
*/
const addToGroup = (childId, dropCenter) => window.lf?.addToGroup(props.id, childId, dropCenter); const addToGroup = (childId, dropCenter) => window.lf?.addToGroup(props.id, childId, dropCenter);
onMounted(() => bindComponent(componentRef.value)); onMounted(() => bindComponent(componentRef.value));
@ -192,6 +266,11 @@ onMounted(() => bindComponent(componentRef.value));
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
&--queue-drop-active .flow-node-content {
border-radius: 8px;
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.35), 0 10px 24px rgba(47, 128, 237, 0.18);
}
&--start, &--start,
&--subStart { --node-accent: #239565; } &--subStart { --node-accent: #239565; }
@ -222,7 +301,11 @@ onMounted(() => bindComponent(componentRef.value));
box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.2), 0 10px 26px rgba(31, 41, 55, 0.12); box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.2), 0 10px 26px rgba(31, 41, 55, 0.12);
} }
&--selected.flow-node-shell--branch :deep(.node__container), &--selected.flow-node-shell--branch :deep(.node__container) {
border-color: #2f80ed;
box-shadow: none;
}
&--selected.flow-node-shell--loop :deep(.group__container), &--selected.flow-node-shell--loop :deep(.group__container),
&--selected.flow-node-shell--selectArea :deep(.group__container) { &--selected.flow-node-shell--selectArea :deep(.group__container) {
border-color: #2f80ed; border-color: #2f80ed;
@ -236,6 +319,16 @@ onMounted(() => bindComponent(componentRef.value));
box-sizing: border-box; box-sizing: border-box;
} }
.flow-node-executor {
overflow: hidden;
margin-top: 8px;
color: #667085;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-node-card { .flow-node-card {
width: 100%; width: 100%;
height: auto; height: auto;
@ -327,4 +420,5 @@ onMounted(() => bindComponent(componentRef.value));
&--branch:hover { transform: translate(50%, -50%) scale(1.18); } &--branch:hover { transform: translate(50%, -50%) scale(1.18); }
} }
</style> </style>

View File

@ -1,7 +1,15 @@
/**
* 文件说明:旧流程数据与 Vue Flow 状态、操作 API 之间的兼容适配层。
* 作用范围:仅服务于流程设计器模块。
*/
import { nextTick } from "vue"; import { nextTick } from "vue";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { MarkerType } from "@vue-flow/core"; import { MarkerType } from "@vue-flow/core";
/**
* 克隆 clone 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} value 待处理的新值
*/
const clone = (value) => JSON.parse(JSON.stringify(value ?? {})); const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
const GROUP_TYPES = new Set(["loop", "selectArea"]); const GROUP_TYPES = new Set(["loop", "selectArea"]);
@ -11,6 +19,9 @@ const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 };
const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 }; const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 };
const LOOP_MIN_SIZE = { width: 520, height: 360 }; const LOOP_MIN_SIZE = { width: 520, height: 360 };
/**
* 处理 loopChildExtent 对应的数据或交互;作用范围仅限画布适配层。
*/
const loopChildExtent = () => ({ const loopChildExtent = () => ({
range: "parent", range: "parent",
padding: [ padding: [
@ -21,6 +32,9 @@ const loopChildExtent = () => ({
], ],
}); });
/**
* 编组 groupChildExtent 对应的数据或交互;作用范围仅限画布适配层。
*/
const groupChildExtent = () => ({ const groupChildExtent = () => ({
range: "parent", range: "parent",
padding: [ padding: [
@ -31,13 +45,18 @@ const groupChildExtent = () => ({
], ],
}); });
/**
* 处理 nodeSize 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
const nodeSize = (node) => { const nodeSize = (node) => {
// 分支节点采用统一的标准卡片尺寸,忽略旧流程数据中遗留的大尺寸记录。
if (node.type === "branch") return { width: 372, height: 180 };
const width = Number(node.properties?.width || node.width); const width = Number(node.properties?.width || node.width);
const height = Number(node.properties?.height || node.height); const height = Number(node.properties?.height || node.height);
if (Number.isFinite(width) && Number.isFinite(height)) { if (Number.isFinite(width) && Number.isFinite(height)) {
return { width, height }; return { width, height };
} }
if (node.type === "branch") return { width: 748, height: 420 };
if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 }; if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 };
if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) { if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) {
return { width: 328, height: 120 }; return { width: 328, height: 120 };
@ -45,9 +64,21 @@ const nodeSize = (node) => {
return { width: 372, height: 180 }; return { width: 372, height: 180 };
}; };
/**
* 处理 edgeId 对应的数据或交互;作用范围仅限画布适配层。
*/
const edgeId = () => `edge_${uuid().replace(/-/g, "")}`; const edgeId = () => `edge_${uuid().replace(/-/g, "")}`;
/**
* 处理 nodeId 对应的数据或交互;作用范围仅限画布适配层。
*/
const nodeId = () => uuid().replace(/-/g, ""); const nodeId = () => uuid().replace(/-/g, "");
/**
* 处理 toVueNode 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
* @param {*} sizeOverride 调用方传入的 sizeOverride 参数
* @param {*} positionOverride 调用方传入的 positionOverride 参数
*/
const toVueNode = (node, sizeOverride, positionOverride) => { const toVueNode = (node, sizeOverride, positionOverride) => {
const normalized = clone(node); const normalized = clone(node);
normalized.id ||= nodeId(); normalized.id ||= nodeId();
@ -98,6 +129,10 @@ const toVueNode = (node, sizeOverride, positionOverride) => {
}; };
}; };
/**
* 处理 toVueEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} edge 调用方传入的 edge 参数
*/
const toVueEdge = (edge) => { const toVueEdge = (edge) => {
const normalized = clone(edge); const normalized = clone(edge);
normalized.id ||= edgeId(); normalized.id ||= edgeId();
@ -124,7 +159,13 @@ const toVueEdge = (edge) => {
}; };
}; };
/**
* 关联 attachChildrenToGroups 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} nodes 调用方传入的 nodes 参数
*/
const attachChildrenToGroups = (nodes) => { const attachChildrenToGroups = (nodes) => {
// 先建立节点索引和显式父子关系,再用旧数据中的 children 补齐缺失关系。
// 最后统一换算相对坐标,避免父节点尚未就绪时提前计算造成位置漂移。
const nodeLookup = new Map(nodes.map((node) => [node.id, node])); const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
const declaredParents = new Map(); const declaredParents = new Map();
@ -200,9 +241,19 @@ export const createFlowFacade = (store) => {
let history = []; let history = [];
let historyIndex = -1; let historyIndex = -1;
/**
* 获取 getNodes 对应的数据或交互;作用范围仅限画布适配层。
*/
const getNodes = () => store.getNodes.value || []; const getNodes = () => store.getNodes.value || [];
/**
* 获取 getEdges 对应的数据或交互;作用范围仅限画布适配层。
*/
const getEdges = () => store.getEdges.value || []; const getEdges = () => store.getEdges.value || [];
/**
* 处理 backendNode 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
const backendNode = (node) => { const backendNode = (node) => {
const result = clone(node.data.backend); const result = clone(node.data.backend);
const size = { const size = {
@ -242,6 +293,10 @@ export const createFlowFacade = (store) => {
return result; return result;
}; };
/**
* 处理 backendEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} edge 调用方传入的 edge 参数
*/
const backendEdge = (edge) => ({ const backendEdge = (edge) => ({
...clone(edge.data?.backend), ...clone(edge.data?.backend),
id: edge.id, id: edge.id,
@ -252,10 +307,18 @@ export const createFlowFacade = (store) => {
targetAnchorId: edge.targetHandle || `${edge.target}_3`, targetAnchorId: edge.targetHandle || `${edge.target}_3`,
}); });
/**
* 处理 emit 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} payload 事件数据
*/
const emit = (name, payload) => { const emit = (name, payload) => {
(handlers.get(name) || []).forEach((handler) => handler(payload)); (handlers.get(name) || []).forEach((handler) => handler(payload));
}; };
/**
* 处理 recordHistory 对应的数据或交互;作用范围仅限画布适配层。
*/
const recordHistory = () => { const recordHistory = () => {
if (historySuspended) return; if (historySuspended) return;
const graph = facade.getGraphData(); const graph = facade.getGraphData();
@ -267,12 +330,19 @@ export const createFlowFacade = (store) => {
historyIndex = history.length - 1; historyIndex = history.length - 1;
}; };
/**
* 更新 changed 对应的数据或交互;作用范围仅限画布适配层。
*/
const changed = () => { const changed = () => {
if (historySuspended) return; if (historySuspended) return;
recordHistory(); recordHistory();
emit("history:change", { data: facade.getGraphData() }); emit("history:change", { data: facade.getGraphData() });
}; };
/**
* 获取 getNodeRect 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
const getNodeRect = (node) => { const getNodeRect = (node) => {
const parent = node.parentNode ? store.findNode(node.parentNode) : null; const parent = node.parentNode ? store.findNode(node.parentNode) : null;
const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 }; const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 };
@ -287,9 +357,17 @@ export const createFlowFacade = (store) => {
}; };
}; };
/**
* 获取 getLoopLayoutState 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} group 调用方传入的 group 参数
*/
const getLoopLayoutState = (group) => { const getLoopLayoutState = (group) => {
const current = getNodeRect(group); const current = getNodeRect(group);
const existing = loopLayoutStates.get(group.id); const existing = loopLayoutStates.get(group.id);
/**
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} !existing 调用方传入的 !existing 参数
*/
if (!existing) { if (!existing) {
const state = { base: { ...current }, last: { ...current } }; const state = { base: { ...current }, last: { ...current } };
loopLayoutStates.set(group.id, state); loopLayoutStates.set(group.id, state);
@ -298,6 +376,10 @@ export const createFlowFacade = (store) => {
const sizeChanged = Math.abs(current.width - existing.last.width) > 1 const sizeChanged = Math.abs(current.width - existing.last.width) > 1
|| Math.abs(current.height - existing.last.height) > 1; || Math.abs(current.height - existing.last.height) > 1;
/**
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} sizeChanged 调用方传入的 sizeChanged 参数
*/
if (sizeChanged) { if (sizeChanged) {
existing.base = { ...current }; existing.base = { ...current };
} else { } else {
@ -310,6 +392,12 @@ export const createFlowFacade = (store) => {
return existing; return existing;
}; };
/**
* 布局 layoutLoop 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} group 调用方传入的 group 参数
* @param {*} activeChildId 调用方传入的 activeChildId 参数
* @param {*} activePosition 调用方传入的 activePosition 参数
*/
const layoutLoop = (group, activeChildId, activePosition) => { const layoutLoop = (group, activeChildId, activePosition) => {
const state = getLoopLayoutState(group); const state = getLoopLayoutState(group);
const children = getNodes().filter((node) => node.parentNode === group.id); const children = getNodes().filter((node) => node.parentNode === group.id);
@ -365,8 +453,16 @@ export const createFlowFacade = (store) => {
return true; return true;
}; };
/**
* 获取 getAnchors 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
const getAnchors = (node) => { const getAnchors = (node) => {
const type = node.data.flowType; const type = node.data.flowType;
/**
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} type 目标类型
*/
if (type === "branch") { if (type === "branch") {
return [ return [
{ id: `${node.id}_entry`, name: "left" }, { id: `${node.id}_entry`, name: "left" },
@ -382,6 +478,10 @@ export const createFlowFacade = (store) => {
]; ];
}; };
/**
* 处理 modelFor 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
const modelFor = (id) => { const modelFor = (id) => {
const node = store.findNode(id); const node = store.findNode(id);
if (!node) return null; if (!node) return null;
@ -415,31 +515,58 @@ export const createFlowFacade = (store) => {
}; };
const facade = { const facade = {
/**
* 订阅 on 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} handler 事件处理函数
*/
on(name, handler) { on(name, handler) {
const list = handlers.get(name) || []; const list = handlers.get(name) || [];
list.push(handler); list.push(handler);
handlers.set(name, list); handlers.set(name, list);
return () => facade.off(name, handler); return () => facade.off(name, handler);
}, },
/**
* 订阅 once 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} handler 事件处理函数
*/
once(name, handler) { once(name, handler) {
/**
* 处理 wrapped 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} payload 事件数据
*/
const wrapped = (payload) => { const wrapped = (payload) => {
facade.off(name, wrapped); facade.off(name, wrapped);
handler(payload); handler(payload);
}; };
facade.on(name, wrapped); facade.on(name, wrapped);
}, },
/**
* 取消订阅 off 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} name 名称或事件名称
* @param {*} handler 事件处理函数
*/
off(name, handler) { off(name, handler) {
if (!handler) handlers.delete(name); if (!handler) handlers.delete(name);
else handlers.set(name, (handlers.get(name) || []).filter((item) => item !== handler)); else handlers.set(name, (handlers.get(name) || []).filter((item) => item !== handler));
}, },
emit, emit,
markChanged: changed, markChanged: changed,
/**
* 获取 getGraphData 对应的数据或交互;作用范围仅限画布适配层。
*/
getGraphData() { getGraphData() {
return { return {
nodes: getNodes().map(backendNode), nodes: getNodes().map(backendNode),
edges: getEdges().map(backendEdge), edges: getEdges().map(backendEdge),
}; };
}, },
/**
* 渲染 render 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} graph 调用方传入的 graph 参数
* @param {*} options 行为配置
*/
render(graph, options = {}) { render(graph, options = {}) {
historySuspended = true; historySuspended = true;
loopLayoutStates.clear(); loopLayoutStates.clear();
@ -456,6 +583,9 @@ export const createFlowFacade = (store) => {
})) }))
: options.nodePositions; : options.nodePositions;
const data = toVueFlowData(graph, { nodeSizes, nodePositions }); const data = toVueFlowData(graph, { nodeSizes, nodePositions });
/**
* 处理 finishRender 对应的数据或交互;作用范围仅限画布适配层。
*/
const finishRender = () => { const finishRender = () => {
historySuspended = false; historySuspended = false;
if (options.resetHistory !== false) { if (options.resetHistory !== false) {
@ -465,6 +595,9 @@ export const createFlowFacade = (store) => {
} }
nextTick(() => store.updateNodeInternals()); nextTick(() => store.updateNodeInternals());
}; };
/**
* 处理 applyData 对应的数据或交互;作用范围仅限画布适配层。
*/
const applyData = () => { const applyData = () => {
store.setNodes(data.nodes); store.setNodes(data.nodes);
store.setEdges(data.edges); store.setEdges(data.edges);
@ -482,12 +615,20 @@ export const createFlowFacade = (store) => {
applyData(); applyData();
} }
}, },
/**
* 新增 addNode 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} node 当前流程节点
*/
addNode(node) { addNode(node) {
const vueNode = toVueNode(node); const vueNode = toVueNode(node);
store.addNodes(vueNode); store.addNodes(vueNode);
changed(); changed();
return backendNode(vueNode); return backendNode(vueNode);
}, },
/**
* 删除 deleteNode 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
deleteNode(id) { deleteNode(id) {
const target = store.findNode(id); const target = store.findNode(id);
if (target?.data?.flowType === "selectArea") { if (target?.data?.flowType === "selectArea") {
@ -513,6 +654,10 @@ export const createFlowFacade = (store) => {
changed(); changed();
if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId)); if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId));
}, },
/**
* 新增 addEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} edge 调用方传入的 edge 参数
*/
addEdge(edge) { addEdge(edge) {
const vueEdge = toVueEdge(edge); const vueEdge = toVueEdge(edge);
const source = store.findNode(vueEdge.source); const source = store.findNode(vueEdge.source);
@ -522,17 +667,34 @@ export const createFlowFacade = (store) => {
changed(); changed();
return backendEdge(vueEdge); return backendEdge(vueEdge);
}, },
/**
* 删除 deleteEdge 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
deleteEdge(id) { deleteEdge(id) {
store.removeEdges(id); store.removeEdges(id);
changed(); changed();
}, },
/**
* 删除 deleteEdgeByNodeId 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
deleteEdgeByNodeId(id) { deleteEdgeByNodeId(id) {
store.removeEdges(getEdges().filter((edge) => edge.source === id || edge.target === id)); store.removeEdges(getEdges().filter((edge) => edge.source === id || edge.target === id));
changed(); changed();
}, },
/**
* 获取 getProperties 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
getProperties(id) { getProperties(id) {
return clone(store.findNode(id)?.data?.properties || {}); return clone(store.findNode(id)?.data?.properties || {});
}, },
/**
* 设置 setProperties 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
* @param {*} properties 调用方传入的 properties 参数
*/
setProperties(id, properties) { setProperties(id, properties) {
const node = store.findNode(id); const node = store.findNode(id);
if (!node) return; if (!node) return;
@ -541,15 +703,25 @@ export const createFlowFacade = (store) => {
changed(); changed();
}, },
getNodeModelById: modelFor, getNodeModelById: modelFor,
/**
* 获取 getSelectElements 对应的数据或交互;作用范围仅限画布适配层。
*/
getSelectElements() { getSelectElements() {
return { return {
nodes: (store.getSelectedNodes.value || []).map(backendNode), nodes: (store.getSelectedNodes.value || []).map(backendNode),
edges: (store.getSelectedEdges.value || []).map(backendEdge), edges: (store.getSelectedEdges.value || []).map(backendEdge),
}; };
}, },
/**
* 清理 clearSelectElements 对应的数据或交互;作用范围仅限画布适配层。
*/
clearSelectElements() { clearSelectElements() {
store.removeSelectedElements(); store.removeSelectedElements();
}, },
/**
* 处理 selectElementById 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
*/
selectElementById(id) { selectElementById(id) {
store.removeSelectedElements(); store.removeSelectedElements();
if (!id) return; if (!id) return;
@ -558,36 +730,82 @@ export const createFlowFacade = (store) => {
const edge = store.findEdge(id); const edge = store.findEdge(id);
if (edge) store.addSelectedEdges([edge]); if (edge) store.addSelectedEdges([edge]);
}, },
/**
* 处理 selectElementsByIds 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} ids 调用方传入的 ids 参数
*/
selectElementsByIds(ids = []) {
store.removeSelectedElements();
const nodes = ids.map((id) => store.findNode(id)).filter(Boolean);
if (nodes.length) store.addSelectedNodes(nodes);
},
/**
* 处理 focusOn 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} { id } 调用方传入的 { id } 参数
*/
focusOn({ id }) { focusOn({ id }) {
const node = store.findNode(id); const node = store.findNode(id);
if (!node) return; if (!node) return;
const center = backendNode(node); const center = backendNode(node);
store.setCenter(center.x, center.y, { zoom: Math.max(store.viewport.value?.zoom || 1, 0.7), duration: 300 }); store.setCenter(center.x, center.y, { zoom: Math.max(store.viewport.value?.zoom || 1, 0.7), duration: 300 });
}, },
/**
* 处理 fitView 对应的数据或交互;作用范围仅限画布适配层。
*/
fitView() { fitView() {
return store.fitView({ padding: 0.18, duration: 300 }); return store.fitView({ padding: 0.18, duration: 300 });
}, },
/**
* 处理 translateCenter 对应的数据或交互;作用范围仅限画布适配层。
*/
translateCenter() { translateCenter() {
return facade.fitView(); return facade.fitView();
}, },
/**
* 缩放 zoom 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} scale 缩放比例
* @param {*} center 缩放中心
*/
zoom(scale, center) { zoom(scale, center) {
if (Array.isArray(center) && center.length === 2) { if (Array.isArray(center) && center.length === 2) {
return store.setCenter(center[0], center[1], { zoom: scale, duration: 250 }); return store.setCenter(center[0], center[1], { zoom: scale, duration: 250 });
} }
return store.zoomTo(scale, { duration: 250 }); return store.zoomTo(scale, { duration: 250 });
}, },
/**
* 获取 getPointByClient 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} x 客户端横坐标
* @param {*} y 客户端纵坐标
*/
getPointByClient(x, y) { getPointByClient(x, y) {
const point = store.screenToFlowCoordinate({ x, y }); const point = store.screenToFlowCoordinate({ x, y });
return { canvasOverlayPosition: point, domOverlayPosition: point }; return { canvasOverlayPosition: point, domOverlayPosition: point };
}, },
/**
* 新增 addToGroup 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} groupId 分组节点 ID
* @param {*} childId 子节点 ID
* @param {*} dropCenter 拖放中心坐标
*/
addToGroup(groupId, childId, dropCenter) { addToGroup(groupId, childId, dropCenter) {
const group = store.findNode(groupId); const group = store.findNode(groupId);
const child = store.findNode(childId); const child = store.findNode(childId);
if (!group || !child || GROUP_TYPES.has(child.data.flowType)) return; if (!group || !child || GROUP_TYPES.has(child.data.flowType)) return;
const previousParentId = child.parentNode || child.data?.properties?.parentId;
const graph = facade.getGraphData(); const graph = facade.getGraphData();
const groupData = graph.nodes.find((node) => node.id === groupId); const groupData = graph.nodes.find((node) => node.id === groupId);
const childData = graph.nodes.find((node) => node.id === childId); const childData = graph.nodes.find((node) => node.id === childId);
if (!groupData || !childData) return; if (!groupData || !childData) return;
// 节点从循环体外进入循环体后,原连线的两端已处于不同执行作用域,必须全部断开。
// 在重新渲染父子结构前直接过滤图数据,可保证入边、出边和层级变更在同一次更新中完成。
const enteringLoop = group.data.flowType === "loop" && previousParentId !== groupId;
if (enteringLoop) {
graph.edges = graph.edges.filter((edge) => (
edge.sourceNodeId !== childId && edge.targetNodeId !== childId
));
}
groupData.children = Array.from(new Set([...(groupData.children || []), childId])); groupData.children = Array.from(new Set([...(groupData.children || []), childId]));
childData.properties = { ...(childData.properties || {}), parentId: groupId }; childData.properties = { ...(childData.properties || {}), parentId: groupId };
if (dropCenter) { if (dropCenter) {
@ -605,7 +823,14 @@ export const createFlowFacade = (store) => {
changed(); changed();
}); });
}, },
/**
* 编组 groupNodes 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} groupNode 调用方传入的 groupNode 参数
* @param {*} childIds 子节点 ID 集合
*/
groupNodes(groupNode, childIds = []) { groupNodes(groupNode, childIds = []) {
// 先计算选中节点的外接矩形确定容器尺寸,再把子节点坐标转换为容器内相对坐标。
// 与分组外节点相连的边保持不变,此处仅更新节点层级和几何信息。
const graph = facade.getGraphData(); const graph = facade.getGraphData();
const selectedIds = new Set(childIds); const selectedIds = new Set(childIds);
const children = graph.nodes.filter((node) => ( const children = graph.nodes.filter((node) => (
@ -636,6 +861,10 @@ export const createFlowFacade = (store) => {
}); });
return clone(group); return clone(group);
}, },
/**
* 处理 ungroup 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} groupId 分组节点 ID
*/
ungroup(groupId) { ungroup(groupId) {
const graph = facade.getGraphData(); const graph = facade.getGraphData();
const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea"); const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea");
@ -663,6 +892,10 @@ export const createFlowFacade = (store) => {
}); });
return true; return true;
}, },
/**
* 准备 prepareLoopChildDrag 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} childId 子节点 ID
*/
prepareLoopChildDrag(childId) { prepareLoopChildDrag(childId) {
const child = store.findNode(childId); const child = store.findNode(childId);
const group = child?.parentNode ? store.findNode(child.parentNode) : null; const group = child?.parentNode ? store.findNode(child.parentNode) : null;
@ -675,16 +908,35 @@ export const createFlowFacade = (store) => {
const rect = getNodeRect(child); const rect = getNodeRect(child);
return { groupId: group.id, position: { x: rect.x, y: rect.y } }; return { groupId: group.id, position: { x: rect.x, y: rect.y } };
}, },
/**
* 布局 layoutLoopDuringDrag 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} groupId 分组节点 ID
* @param {*} childId 子节点 ID
* @param {*} position 调用方传入的 position 参数
*/
layoutLoopDuringDrag(groupId, childId, position) { layoutLoopDuringDrag(groupId, childId, position) {
const group = store.findNode(groupId); const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop" || !position) return; if (!group || group.data.flowType !== "loop" || !position) return;
layoutLoop(group, childId, position); layoutLoop(group, childId, position);
}, },
/**
* 调整 resizeGroupToChildren 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} groupId 分组节点 ID
* @param {*} options 行为配置
*/
resizeGroupToChildren(groupId, options = {}) { resizeGroupToChildren(groupId, options = {}) {
// 循环容器沿用统一的内边距和最小尺寸约束,并按配置决定是否记录变更。
// 拖拽过程可关闭变更标记,避免高频移动产生多余历史记录。
const group = store.findNode(groupId); const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop") return; if (!group || group.data.flowType !== "loop") return;
if (layoutLoop(group) && options.markChanged !== false) changed(); if (layoutLoop(group) && options.markChanged !== false) changed();
}, },
/**
* 移动 moveNodeBy 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
* @param {*} dx 横向位移
* @param {*} dy 纵向位移
*/
moveNodeBy(id, dx, dy) { moveNodeBy(id, dx, dy) {
const node = store.findNode(id); const node = store.findNode(id);
if (!node) return; if (!node) return;
@ -696,6 +948,9 @@ export const createFlowFacade = (store) => {
}); });
changed(); changed();
}, },
/**
* 撤销 undo 对应的数据或交互;作用范围仅限画布适配层。
*/
undo() { undo() {
if (historyIndex <= 0) return false; if (historyIndex <= 0) return false;
historyIndex -= 1; historyIndex -= 1;
@ -704,6 +959,9 @@ export const createFlowFacade = (store) => {
nextTick(() => emit("history:change", { data: facade.getGraphData() })); nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true; return true;
}, },
/**
* 重做 redo 对应的数据或交互;作用范围仅限画布适配层。
*/
redo() { redo() {
if (historyIndex >= history.length - 1) return false; if (historyIndex >= history.length - 1) return false;
historyIndex += 1; historyIndex += 1;
@ -712,6 +970,11 @@ export const createFlowFacade = (store) => {
nextTick(() => emit("history:change", { data: facade.getGraphData() })); nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true; return true;
}, },
/**
* 注册 registerComponentInstance 对应的数据或交互;作用范围仅限画布适配层。
* @param {*} id 目标元素 ID
* @param {*} instance 组件实例
*/
registerComponentInstance(id, instance) { registerComponentInstance(id, instance) {
if (instance) componentInstances.set(id, instance); if (instance) componentInstances.set(id, instance);
}, },

View File

@ -6,7 +6,7 @@
<span class="state-dot"></span> <span class="state-dot"></span>
CMVR 智能测试平台 CMVR 智能测试平台
</div> </div>
<h1>机器人智能测试管理系统</h1> <h1>招商车研机器人智能测试管理系统</h1>
<p class="hero-description"> <p class="hero-description">
连接设备、编排流程、执行测试与巡检任务,集中管理机器人全生命周期测试业务。 连接设备、编排流程、执行测试与巡检任务,集中管理机器人全生命周期测试业务。
</p> </p>

View File

@ -178,7 +178,7 @@ export const drawAdvancedAreaList = (canvasCtx, map, dataStore) => {
centroidWorldX /= vertices.length; // 求平均得到质心 X centroidWorldX /= vertices.length; // 求平均得到质心 X
centroidWorldY /= vertices.length; // 求平均得到质心 Y centroidWorldY /= vertices.length; // 求平均得到质心 Y
const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY, store); const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY, store);
canvasCtx.font = '500 8px "JetBrains Mono"'; // 标签字体 canvasCtx.font = '500 32px "JetBrains Mono"'; // 标签字体
canvasCtx.fillStyle = strokeColorCss; // 与描边同色 canvasCtx.fillStyle = strokeColorCss; // 与描边同色
canvasCtx.textAlign = 'center'; // 居中对齐 canvasCtx.textAlign = 'center'; // 居中对齐
canvasCtx.fillText(area.instanceName || '', centroidScreen.x, centroidScreen.y + 3); // 绘制文字 canvasCtx.fillText(area.instanceName || '', centroidScreen.x, centroidScreen.y + 3); // 绘制文字
@ -445,7 +445,7 @@ export const drawAdvancedPointList = (canvasCtx, map, dataStore) => {
} }
// ── 名称标签 ── // ── 名称标签 ──
canvasCtx.font = '500 7px "JetBrains Mono"'; canvasCtx.font = '500 12px "JetBrains Mono"';
canvasCtx.fillStyle = hexToCssRgba(pointColorHex, 0.7); canvasCtx.fillStyle = hexToCssRgba(pointColorHex, 0.7);
canvasCtx.textAlign = 'center'; canvasCtx.textAlign = 'center';
canvasCtx.fillText(advPoint.instanceName || '', pointScreen.x, pointScreen.y - diamondRadius - 4); canvasCtx.fillText(advPoint.instanceName || '', pointScreen.x, pointScreen.y - diamondRadius - 4);
@ -560,13 +560,14 @@ export const buildOccupancyGridImage = (parsedMap, showEdgeGlow) => {
} }
export class MobileRobot { export class MobileRobot {
constructor(id, name, x, y, angleRad = 0, color= "#00D4FF", imageUrl = null) { constructor(id, name, x, y, angleRad = 0, color = '#15ff00', imageUrl = null) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.x = x; // 世界坐标X (米) this.x = x; // 世界坐标X (米)
this.y = y; // 世界坐标Y (米) this.y = y; // 世界坐标Y (米)
this.angle = angleRad; // 朝向弧度 this.angle = angleRad; // 朝向弧度
this.imageUrl = imageUrl || '/bot.svg'; this.imageUrl = imageUrl || '/bot.svg';
this.color = color; // 颜色
// 预加载图片 (可选) // 预加载图片 (可选)
this.imgElement = null; this.imgElement = null;
if (this.imageUrl) { if (this.imageUrl) {
@ -596,8 +597,8 @@ export class MobileRobot {
} }
// 绘制到canvas上下文 (屏幕坐标) // 绘制到canvas上下文 (屏幕坐标)
draw(ctx, screenX, screenY, scale = 1) { draw(ctx, screenX, screenY, color, scale = 1) {
const size = 18; // 绘制大小px const size = 24; // 绘制大小px
if (this.imgElement && this.imgElement.complete) { if (this.imgElement && this.imgElement.complete) {
ctx.save(); ctx.save();
ctx.translate(screenX, screenY); ctx.translate(screenX, screenY);
@ -607,9 +608,9 @@ export class MobileRobot {
} }
// 名字标签 // 名字标签
ctx.font = '12px "JetBrains Mono"'; ctx.font = '12px "JetBrains Mono"';
ctx.fillStyle = '#a0c0f0'; ctx.fillStyle = color;
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
ctx.fillText(this.name, screenX - 30, screenY - 20); ctx.fillText(this.name, screenX, screenY - 20);
} }
// 更新位置 // 更新位置

View File

@ -1,643 +0,0 @@
<template>
<div class="monitor-container">
<div class="left-container">
<div class="robot-container">
<div class="title">选择机器人</div>
<div class="robot-box">
<div v-for="item, index in 4" class="robot" :class="{ active: activeBot === index }"
@click="changeBot(index)">
<div>
<SvgIcon name="bot" :color="colorList[index]" />
</div>
<div>
<div class="bot-name">机器人#01</div>
<div class="bot-local">厂区A-在线</div>
</div>
</div>
</div>
</div>
<div class="split-line"></div>
<div class="mode-container">
<div class="title">控制模式</div>
<div class="mode-box">
<div class="mode-item" :class="{ activeMode: activeMode === 'auto' }" @click="changeMode('auto')">
<SvgIcon name="cpu" :color="activeMode === 'auto' ? '#00FF88' : '#fff'" :size="16" />
<span class="label">自动巡检</span>
</div>
<div class="mode-item" :class="{ activeMode: activeMode === 'artificial' }"
@click="changeMode('artificial')">
<SvgIcon name="gamepad" :color="activeMode === 'artificial' ? '#00FF88' : '#fff'" :size="16" />
<span class="label">人工接管</span>
</div>
</div>
</div>
<div v-if="activeMode === 'artificial'" class="artificial-panel">
<el-radio-group v-model="activeController" size="small" @change="handlerController">
<el-radio :value="1">底盘</el-radio>
<el-radio :value="2">机械臂</el-radio>
<el-radio :value="3">相机</el-radio>
</el-radio-group>
<div class="controller-panel">人工控制面板</div>
<div v-if="activeController === 1" class="direction-controller-container">
<div class="title">
<SvgIcon name="navigation" color="00D4FF" :size="16" />
<span class="label">方向控制器</span>
</div>
<div class="direction-box">
<div class="direction-container">
<div class="item"></div>
<div class="item">
<SvgIcon name="chevron-up" :size="24" />
</div>
<div class="item"></div>
<div class="item">
<SvgIcon name="chevron-left" :size="24" />
</div>
<div class="item">
<SvgIcon name="chevron-stop" :size="52" />
</div>
<div class="item">
<SvgIcon name="chevron-right" :size="24" />
</div>
<div class="item"></div>
<div class="item">
<SvgIcon name="chevron-down" :size="24" />
</div>
<div class="item"></div>
</div>
</div>
<div class="speed-box">
<div class="label">移动速度</div>
<div class="speed">{{ speed }}m/s</div>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
<div v-if="activeController === 2" class="robotic-arm-controller-container">
<el-radio-group v-model="activeRoboticArm" @change="handlerArm" size="small">
<el-radio :value="1">末端控制</el-radio>
<el-radio :value="2">关节控制</el-radio>
<el-radio :value="3">姿态控制</el-radio>
</el-radio-group>
<div class="mode-tabs">
<div class="mode-tab active">左手</div>
<div class="mode-tab">右手</div>
</div>
<div class="robotic-arm-box">
<div class="robotic-arm-status">
<div class="coord-display">
<div class="coord-item">
<div class="coord-key key-x">X</div>
<div class="coord-val" id="coordX">+124.50</div>
</div>
<div class="coord-item">
<div class="coord-key key-y">Y</div>
<div class="coord-val" id="coordY">−38.20</div>
</div>
<div class="coord-item">
<div class="coord-key key-z">Z</div>
<div class="coord-val" id="coordZ">+502.00</div>
</div>
</div>
</div>
<div class="axis-group">
<!-- X Axis -->
<div class="axis-row x">
<div class="axis-label x">X</div>
<div class="axis-slider-wrap">
<div class="axis-slider-top">
<span class="axis-name">前后平移</span>
<span class="axis-value" id="valX">0.00</span>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
</div>
<!-- Y Axis -->
<div class="axis-row y">
<div class="axis-label y">Y</div>
<div class="axis-slider-wrap">
<div class="axis-slider-top">
<span class="axis-name">左右平移</span>
<span class="axis-value" id="valY">0.00</span>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
</div>
<!-- Z Axis -->
<div class="axis-row z">
<div class="axis-label z">Z</div>
<div class="axis-slider-wrap">
<div class="axis-slider-top">
<span class="axis-name">上下平移</span>
<span class="axis-value" id="valZ">0.00</span>
</div>
<el-slider v-model="speed" :max="2" :step="0.1" size="small" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="video-container">
<div v-if="activeMode === 'auto' || activeController === 1" class="map-container">地图</div>
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
<UrdfViewer
ref="viewerRef"
base-path="/inspection/elfin10/"
model-color="#cbcbcb"
:show-controls="true"
/>
</div>
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
</div>
<div class="right-container">
<div class="container-title">车载监控</div>
<div class="monitor-container"></div>
<div class="split-line"></div>
<div class="container-title">语音对话</div>
</div>
</div>
</template>
<script setup>
import SvgIcon from "@/components/SvgIcon";
import UrdfViewer from './UrdfView.vue'
const activeBot = ref(0)
const changeBot = (index) => {
activeBot.value = index
}
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fff']
const activeMode = ref('auto')
const changeMode = (mode) => {
activeMode.value = mode
}
const activeController = ref(1)
const speed = ref(0.8)
const activeRoboticArm = ref(1)
const handlerController = (value) => {
if (value === 2 && [1, 3].includes(activeRoboticArm.value)) {
}
}
const handlerArm = (value) => {
if ([1, 3].includes(value)) {
}
}
function onLoaded(robot) {
console.log('已加载关节:', Object.keys(robot.joints))
}
function onError(err) {
console.error('加载失败:', err)
}
</script>
<style lang="scss" scoped>
.monitor-container {
width: 100%;
height: 100%;
display: flex;
.left-container {
width: 360px;
height: 100%;
padding: 16px;
.robot-container {
.title {
color: #8899BB;
font-size: 14px;
margin-bottom: 16px;
}
.robot-box {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
.robot {
flex: 0 0 calc(50% - 10px);
background: #0F1E38;
height: 56px;
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 16px;
cursor: pointer;
border: 1px solid transparent;
.bot-name {
color: #fff;
font-size: 13px;
}
.bot-local {
color: #00d4ff;
font-size: 11px;
margin-top: 4px;
}
}
.active {
border-color: #00c2ff;
color: #fff;
box-shadow: 0 0 10px rgba(0, 194, 255, 0.5);
}
}
}
.split-line {
width: 100%;
height: 1px;
margin: 16px 0;
background: #1E3A5F;
}
.mode-container {
.title {
color: #8899BB;
font-size: 14px;
margin-bottom: 16px;
}
.mode-box {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
.mode-item {
height: 36px;
flex: 0 0 calc(50% - 10px);
background: #0F1E38;
border: 1px solid transparent;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
.label {
color: #8899BB;
font-size: 13px;
}
}
.activeMode {
border-color: #00FF88;
.label {
color: #00FF88;
}
}
}
}
.controller-panel {
padding: 16px;
color: #fff;
font-size: 16px;
}
.direction-controller-container {
padding: 16px;
height: 272px;
background: #0F1E38;
border-radius: 10px;
.title {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.label {
color: #fff;
font-size: 14px;
}
}
.direction-box {
display: flex;
align-items: center;
justify-content: center;
.direction-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
width: 164px;
height: 164px;
.item {
// flex: 0 0 calc(33.333% - 4px);
width: 52px;
height: 52px;
background: #1E3A5F;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.speed-box {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0;
.label {
color: #8899BB;
font-size: 12px;
}
.speed {
color: #00D4FF;
font-size: 12px;
}
}
:deep(.el-slider__button) {
scale: 0.5;
}
}
.robotic-arm-controller-container {
:deep(.el-radio-group) {
display: flex;
align-items: center;
justify-content: space-between;
}
.mode-tabs {
display: flex;
padding: 12px 20px 0;
gap: 6px;
.mode-tab {
flex: 1;
padding: 8px 0;
text-align: center;
font-size: 12px;
font-weight: 500;
color: #4a5580;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
transition: all 0.25s ease;
position: relative;
user-select: none;
&:hover {
color: #8b97bf;
background: #3b82f60d;
}
}
.active {
color: #3b82f6;
background: #111a36;
border-color: #1c2a52;
font-weight: 700;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
right: 20%;
height: 2px;
background: var(--accent);
border-radius: 1px;
box-shadow: 0 0 8px var(--accent-glow);
}
}
}
.robotic-arm-box {
.robotic-arm-status {
.coord-display {
display: flex;
gap: 8px;
padding: 12px;
background: #070b1a;
border: 1px solid #1c2a52;
border-radius: 8px;
margin-bottom: 16px;
.coord-item {
flex: 1;
text-align: center;
padding: 6px 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
.coord-key {
font-size: 12px;
font-weight: bold;
margin-bottom: 3px;
}
.key-x {
color: #f87171;
}
.key-y {
color: #34d399;
;
}
.key-z {
color: #38bdf8;
;
}
.coord-val {
font-size: 14px;
font-weight: bold;
color: #e8edf8;
}
}
}
}
.axis-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
.axis-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #0c1228;
border: 1px solid #1c2a52;
border-radius: 10px;
transition: all 0.2s ease;
&:hover {
border-color: #2a3f7a;
background: #3b82f608;
}
.axis-label {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.x {
background: #f8717126;
color: #f87171;
border: 1px solid #f871714d;
}
.y {
background: #34d39926;
color: #34d399;
border: 1px solid #34d3994d;
}
.z {
background: #38bdf826;
color: #38bdf8;
border: 1px solid #38bdf84d;
}
.axis-slider-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.axis-slider-top {
display: flex;
justify-content: space-between;
align-items: center;
.axis-name {
font-size: 11px;
color: #8b97bf;
font-weight: 500;
}
.axis-value {
font-size: 12px;
font-weight: 500;
color: #e8edf8;
min-width: 52px;
text-align: right;
}
}
:deep(.el-slider__button) {
scale: 0.5;
}
}
}
}
}
}
}
.video-container {
flex: 1;
border: 1px solid #1E3A5F;
border-top: 0;
.map-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
.model-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
.monitor-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #00c2ff;
font-size: 20px;
}
}
.right-container {
width: 360px;
height: 100%;
padding: 16px;
.container-title {
color: #fff;
font-size: 16px;
font-weight: bold;
margin-bottom: 16px;
}
.split-line {
width: 100%;
height: 1px;
margin: 16px 0;
background: #1E3A5F;
}
.monitor-container {
width: 100%;
height: 360px;
}
}
}
</style>

View File

@ -68,6 +68,10 @@ const props = defineProps({
robot: { robot: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
deviceId: {
type: String,
default: ''
} }
}) })
@ -103,8 +107,8 @@ const onPress = async (_dir) => {
} }
moveRobotData.value.vz = rotationAngle.value moveRobotData.value.vz = rotationAngle.value
const res = await moveRobot({ const res = await moveRobot({
terminalId: props.robot.terminalId, robotId: props.robot.robotId,
deviceId: props.robot.robotCode, deviceId: props.deviceId,
...moveRobotData.value ...moveRobotData.value
}) })
} }
@ -113,8 +117,8 @@ function onRelease() {
moveRobotData.value.vx = 0; moveRobotData.value.vx = 0;
moveRobotData.value.vy = 0; moveRobotData.value.vy = 0;
stopRobot({ stopRobot({
terminalId: props.robot.terminalId, robotId: props.robot.robotId,
deviceId: props.robot.robotCode, deviceId: props.deviceId,
}) })
} }
</script> </script>
@ -318,4 +322,4 @@ function onRelease() {
} }
} }
} }
</style> </style>

View File

@ -150,6 +150,10 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}) default: () => ({})
}, },
deviceId: {
type: String,
default: ''
},
type: { type: {
type: String, type: String,
default: 'end' default: 'end'
@ -174,8 +178,8 @@ const errorNum = ref(0)
const getPose = async (robot) => { const getPose = async (robot) => {
try { try {
const res = await getPoseApi({ const res = await getPoseApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: props.deviceId,
terminalId: robot.terminalId robotId: robot.robotId
}) })
if (res.code === 200) { if (res.code === 200) {
const { x, y, z, rx, ry, rz } = res.data const { x, y, z, rx, ry, rz } = res.data
@ -249,8 +253,8 @@ const move = async (axis, direction) => {
} }
speedLApi({ speedLApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: props.deviceId,
terminalId: props.robot.terminalId, robotId: props.robot.robotId,
duration: 60, duration: 60,
acceleration: speed.value + 0.1, acceleration: speed.value + 0.1,
...speedData ...speedData
@ -262,14 +266,14 @@ const move = async (axis, direction) => {
*/ */
const stop = async () => { const stop = async () => {
await stopMotionApi({ await stopMotionApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: props.deviceId,
terminalId: props.robot.terminalId robotId: props.robot.robotId
}) })
} }
const timer = ref(null) const timer = ref(null)
watch(() => props.robot, (newRobot) => { watch(() => props.robot, (newRobot) => {
if (newRobot && newRobot.terminalId) { if (newRobot && newRobot.robotId) {
if (timer.value) { if (timer.value) {
clearInterval(timer.value) clearInterval(timer.value)
} }
@ -278,6 +282,12 @@ watch(() => props.robot, (newRobot) => {
}, 100) }, 100)
} }
}, { immediate: true }) }, { immediate: true })
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
}
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.robotic-arm-box { .robotic-arm-box {
@ -406,4 +416,4 @@ watch(() => props.robot, (newRobot) => {
} }
} }
} }
</style> </style>

View File

@ -1,240 +0,0 @@
<template>
<div class="elfin-viewer">
<div ref="viewerContainer" class="viewer-container">
<!-- Three.js 渲染 -->
</div>
<div v-if="isLoading" class="loading-overlay">
<div class="spinner"></div>
<p>正在加载机器人模型...</p>
<p class="loading-detail">{{ loadingMessage }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick, reactive } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
import { XacroAdapter } from '@/adapters/XacroAdapter.js'
const props = defineProps({
basePath: { type: String, default: '/inspection/elfin10' },
modelColor: { type: String, default: '#cbcbcb' }
});
const viewerContainer = ref(null);
const isLoading = ref(true);
const loadingMessage = ref('初始化...');
let scene, camera, renderer, controls;
const animationId = ref(null);
const jointControls = reactive([]);
// ============ 场景初始化 ============
const initScene = () => {
const container = viewerContainer.value;
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
scene = new THREE.Scene();
scene.background = new THREE.Color('#000d2e');
camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
camera.position.set(0, 0, 4);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
container.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.target.set(0, 0.5, 0);
controls.update();
const ambient = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambient);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(5, 10, 7);
directionalLight.castShadow = true;
scene.add(directionalLight);
};
// ============ 关节控制(无需修改) ============
const updateJoint = (jointName, angle) => {
if (!robotInstance) return;
const joint = robotInstance.joints[jointName];
if (!joint) {
console.warn(`未找到关节: ${jointName}`);
return;
}
// ✅ 使用 setJointValue 触发变换矩阵更新
if (joint._jointType === 'revolute' || joint._jointType === 'continuous') {
joint.setJointValue(angle);
} else if (joint._jointType === 'prismatic') {
joint.setJointValue(angle);
}
// 同步滑块显示值
const control = jointControls.find(c => c.name === jointName);
if (control) control.value = angle;
};
const resetJoints = () => {
for (const joint of jointControls) {
joint.value = 0;
updateJoint(joint.name, 0);
}
};
const fileMap = new Map()
const loadFiles = async () => {
const fileNames = [
'elfin10.urdf.xacro',
'elfin10_gazebo.xacro',
'elfin10_transmission.xacro',
'meshes/elfin10/elfin_base.STL',
'meshes/elfin10/elfin_end_link.STL',
'meshes/elfin10/elfin_link1.STL',
'meshes/elfin10/elfin_link2.STL',
'meshes/elfin10/elfin_link3.STL',
'meshes/elfin10/elfin_link4.STL',
'meshes/elfin10/elfin_link5.STL',
'meshes/elfin10/elfin_link6.STL'
]
const fetchPromises = fileNames.map(async (name) => {
const url = `/inspection/elfin10/${name}`
const response = await fetch(url)
const blob = await response.blob()
return new File([blob], name, { type: blob.type })
})
const fileList = await Promise.all(fetchPromises)
fileList.forEach(item => {
fileMap.set('elfin10/' + item.name, item);
})
}
const currentModel = ref()
let robotInstance = null; // 保存 URDFRobot 对象
const loadRobot = async () => {
isLoading.value = true;
loadingMessage.value = '加载XACRO文件...';
const xacroUrl = `${props.basePath}elfin10.urdf.xacro`;
const response = await fetch(xacroUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const xacroContent = await response.text();
const file = fileMap.get('elfin10/elfin10.urdf.xacro')
const model = await XacroAdapter.parse(xacroContent, file.name, fileMap, file);
const robot = model.threeObject; // 这就是 URDFRobot
// 将机器人添加到场景
scene.add(robot); // ⚠️ 注意:是 robot.scene,不是 robot
robot.rotation.x = -Math.PI / 2;
isLoading.value = false;
currentModel.value = model
robotInstance = robot;
// 获取关节条目数组
let jointEntries = [];
if (robot.joints instanceof Map) {
jointEntries = [...robot.joints.entries()];
} else if (typeof robot.joints === 'object' && robot.joints !== null) {
jointEntries = Object.entries(robot.joints);
} else if (Array.isArray(robot.joints)) {
jointEntries = robot.joints.map((j, i) => [i, j]); // 索引作为名称
} else {
console.error('未知的 joints 结构:', robot.joints);
}
// 填充关节控制列表
for (const [name, joint] of jointEntries) {
if (joint._jointType === 'fixed') continue;
const limits = joint.limits || {};
let lower = limits.lower !== undefined ? limits.lower : -Math.PI;
let upper = limits.upper !== undefined ? limits.upper : Math.PI;
// 如果是连续关节,范围设为 -PI ~ PI
if (joint._jointType === 'continuous') {
lower = -Math.PI;
upper = Math.PI;
}
// 初始角度一般为 0
jointControls.push({
name,
min: lower,
max: upper,
value: 0, // 因为 joint.angle 初始为 0
});
}
};
// ============ 动画循环 ============
const animate = () => {
animationId.value = requestAnimationFrame(animate);
controls?.update();
renderer?.render(scene, camera);
};
// ============ 生命周期 ============
onMounted(async () => {
await nextTick();
await loadFiles()
initScene();
await loadRobot();
animate();
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
if (animationId.value) cancelAnimationFrame(animationId.value);
window.removeEventListener('resize', onResize);
renderer?.dispose();
controls?.dispose();
});
const onResize = () => {
const container = viewerContainer.value;
if (!container || !camera || !renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
};
defineExpose({ updateJoint, resetJoints, jointControls });
</script>
<style scoped>
.elfin-viewer {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: #0d0d1a;
}
.viewer-container {
width: 100%;
height: 100%;
}
.viewer-container canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
</style>

View File

@ -102,11 +102,14 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import { ElMessage, ElNotification } from 'element-plus' import { ElMessage, ElNotification } from 'element-plus'
import { setMicVolume, getMicVolume, setSpeakerVolume, getSpeakerVolume } from '@/api/inspection/cockpit' import { setMicVolume, getMicVolume, setSpeakerVolume, getSpeakerVolume } from '@/api/inspection/cockpit'
const props = defineProps({
robotId: { type: String, default: '' },
speakerDeviceId: { type: String, default: '' },
microphoneDeviceId: { type: String, default: '' }
})
// WebSocket 只承载控制 JSON 和二进制 PCM,不再使用 WebRTC、SDP 或 ICE。 // WebSocket 只承载控制 JSON 和二进制 PCM,不再使用 WebRTC、SDP 或 ICE。
const AUDIO_SERVER_URL = import.meta.env.VITE_AUDIO_SERVER_URL const AUDIO_SERVER_URL = import.meta.env.VITE_AUDIO_SERVER_URL
const TERMINAL_ID = 'c2f8a06826baf03843925c1a2a13bcfd'
const speakerDeviceId = import.meta.env.VITE_SPEAKER_DEVICE_ID
const micDeviceId = import.meta.env.VITE_MIC_DEVICE_ID
// 后端约定的 PCM 格式:S16LE、48kHz、单声道、10ms。 // 后端约定的 PCM 格式:S16LE、48kHz、单声道、10ms。
const PCM_SAMPLE_RATE = 48000 const PCM_SAMPLE_RATE = 48000
@ -324,9 +327,9 @@ function connectAudioServer(sessionToken) {
socket.onopen = () => { socket.onopen = () => {
sendControlMessage({ sendControlMessage({
type: 'join', type: 'join',
terminalId: TERMINAL_ID, robotId: props.robotId,
speakerDeviceId: speakerDeviceId, speakerDeviceId: props.speakerDeviceId,
micDeviceId: micDeviceId micDeviceId: props.microphoneDeviceId
}) })
} }
@ -519,6 +522,10 @@ async function setupAudioPipeline(stream) {
*/ */
async function startCall() { async function startCall() {
if (isCalling.value || isStarting.value || webSocket.value) return if (isCalling.value || isStarting.value || webSocket.value) return
if (!props.robotId || !props.speakerDeviceId || !props.microphoneDeviceId) {
ElMessage.warning('当前机器人缺少在线麦克风或扬声器')
return
}
const sessionToken = ++callSessionId const sessionToken = ++callSessionId
isStarting.value = true isStarting.value = true
@ -654,8 +661,8 @@ async function updateMicrophoneVolume(volume) {
isMicrophoneVolumeLoading.value = true isMicrophoneVolumeLoading.value = true
try { try {
const response = await setMicVolume({ const response = await setMicVolume({
deviceId: micDeviceId, deviceId: props.microphoneDeviceId,
terminalId: TERMINAL_ID, robotId: props.robotId,
volume: Math.round(volume) volume: Math.round(volume)
}) })
if (response.code !== 200) throw new Error(response.message || '设置机器人音量失败') if (response.code !== 200) throw new Error(response.message || '设置机器人音量失败')
@ -675,8 +682,8 @@ async function updateSpeakerVolume(volume) {
isSpeakerVolumeLoading.value = true isSpeakerVolumeLoading.value = true
try { try {
const response = await setSpeakerVolume({ const response = await setSpeakerVolume({
deviceId: speakerDeviceId, deviceId: props.speakerDeviceId,
terminalId: TERMINAL_ID, robotId: props.robotId,
volume: Math.round(volume) volume: Math.round(volume)
}) })
if (response.code !== 200) throw new Error(response.message || '设置扬声器音量失败') if (response.code !== 200) throw new Error(response.message || '设置扬声器音量失败')
@ -695,8 +702,8 @@ async function loadMicrophoneVolume({ silent = false } = {}) {
isMicrophoneVolumeLoading.value = true isMicrophoneVolumeLoading.value = true
try { try {
const response = await getMicVolume({ const response = await getMicVolume({
deviceId: micDeviceId, deviceId: props.microphoneDeviceId,
terminalId: TERMINAL_ID robotId: props.robotId
}) })
if (response.code !== 200) throw new Error(response.message || '获取机器人音量失败') if (response.code !== 200) throw new Error(response.message || '获取机器人音量失败')
const volume = Math.max(0, Math.min(100, Number(response.data) || 0)) const volume = Math.max(0, Math.min(100, Number(response.data) || 0))
@ -713,8 +720,8 @@ async function loadSpeakerVolume({ silent = false } = {}) {
isSpeakerVolumeLoading.value = true isSpeakerVolumeLoading.value = true
try { try {
const response = await getSpeakerVolume({ const response = await getSpeakerVolume({
deviceId: speakerDeviceId, deviceId: props.speakerDeviceId,
terminalId: TERMINAL_ID robotId: props.robotId
}) })
if (response.code !== 200) throw new Error(response.message || '获取扬声器音量失败') if (response.code !== 200) throw new Error(response.message || '获取扬声器音量失败')
const volume = Math.max(0, Math.min(100, Number(response.data) || 0)) const volume = Math.max(0, Math.min(100, Number(response.data) || 0))

View File

@ -11,7 +11,7 @@
</div> </div>
<div> <div>
<div class="bot-name">{{ item.robotName }}</div> <div class="bot-name">{{ item.robotName }}</div>
<div class="bot-local">{{ robotStatus[item.status] }}</div> <div class="bot-local" :class="agvRuntimeStatusClass(item.status)">{{ agvRuntimeStatusLabel(item.status) }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -38,7 +38,14 @@
<el-radio :value="3">相机</el-radio> <el-radio :value="3">相机</el-radio>
</el-radio-group> </el-radio-group>
<div class="controller-panel">人工控制面板</div> <div class="controller-panel">人工控制面板</div>
<DirectionControl v-if="activeController === 1" :robot="activeBotData" /> <div class="device-binding">
<span>设备随机器人自动切换</span>
<el-tag size="small" :type="activeDeviceId ? 'success' : 'danger'">
{{ activeDeviceId || '无匹配在线设备' }}
</el-tag>
</div>
<DirectionControl v-if="activeController === 1" :robot="activeBotData"
:device-id="deviceByKind(1)" />
<div v-if="activeController === 2" class="robotic-arm-controller-container"> <div v-if="activeController === 2" class="robotic-arm-controller-container">
<el-radio-group v-model="activeRoboticArm" size="small"> <el-radio-group v-model="activeRoboticArm" size="small">
<el-radio :value="1">末端控制</el-radio> <el-radio :value="1">末端控制</el-radio>
@ -47,7 +54,8 @@
</el-radio-group> </el-radio-group>
<RoboticArm :type="activeRoboticArm === 1 ? 'end' : 'posture'" :robot="activeBotData" <RoboticArm :type="activeRoboticArm === 1 ? 'end' : 'posture'" :robot="activeBotData"
v-if="[1, 3].includes(activeRoboticArm)" /> :device-id="deviceByKind(2)"
v-if="activeController === 2 && [1, 3].includes(activeRoboticArm)" />
<div class="control-panel" v-if="activeRoboticArm === 2"> <div class="control-panel" v-if="activeRoboticArm === 2">
<div class="joint-list"> <div class="joint-list">
@ -92,10 +100,10 @@
</div> </div>
<div class="video-container"> <div class="video-container">
<div v-if="activeMode === 'auto' || activeController === 1" class="map-container"> <div v-if="activeMode === 'auto' || activeController === 1" class="map-container">
<MapCanvas :robotList="robotList" ref="mapCanvasRef" /> <MapCanvas v-if="robotList.length > 0" :robotList="currentRobot" ref="mapCanvasRef" />
</div> </div>
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container"> <div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
<UrdfViewer ref="urdfViewerRef" base-path="/inspection/elfin10/" /> <UrdfViewer ref="urdfViewerRef" modelPath="/inspection/elfin10/elfin10.urdf.xacro" />
</div> </div>
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div> <div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
</div> </div>
@ -107,33 +115,51 @@
<div class="split-line"></div> <div class="split-line"></div>
<div class="container-title">语音对话</div> <div class="container-title">语音对话</div>
<div class="voice-container"> <div class="voice-container">
<VoiceConversation /> <VoiceConversation :robot-id="activeBotData.robotId"
:microphone-device-id="deviceByKind(9)" :speaker-device-id="deviceByKind(13)" />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, nextTick } from "vue"; import { computed, onMounted, onUnmounted, nextTick } from "vue";
import { ElMessage } from 'element-plus'
import MapCanvas from "../MapCanvas.vue";
import SvgIcon from "@/components/SvgIcon"; import SvgIcon from "@/components/SvgIcon";
import UrdfViewer from './UrdfView.vue' import UrdfViewer from '../UrdfView.vue'
import DirectionControl from './DirectionControl.vue' import DirectionControl from './DirectionControl.vue'
import RoboticArm from "./RoboticArm.vue"; import RoboticArm from "./RoboticArm.vue";
import { getRobotList } from '@/api/inspection/robot' import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi, clearFault } from '@/api/inspection/cockpit' import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi, clearFault } from '@/api/inspection/cockpit'
import { Remove, CirclePlus } from '@element-plus/icons-vue' import { Remove, CirclePlus } from '@element-plus/icons-vue'
import VoiceConversation from "./VoiceConversation.vue"; import VoiceConversation from "./VoiceConversation.vue";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'
const robotList = ref([]) const robotList = ref([])
const robotDevices = ref([])
const deviceByKind = (kind) => robotDevices.value.find(device => device.deviceKind === kind)?.deviceId || ''
const activeDeviceId = computed(() => deviceByKind(activeController.value === 2 ? 2 : activeController.value === 3 ? 5 : 1))
const fetchRobotList = async () => { const loadRobotDevices = async (robotId) => {
robotDevices.value = []
if (!robotId) return
const response = await getRobotDevicesByRobotId(robotId)
robotDevices.value = Array.isArray(response.data) ? response.data : []
}
const fetchRobotList = async (refreshDevices = true) => {
try { try {
const res = await getRobotList({ const res = await getRobotList({
robotType: 2, robotType: 2,
}) })
if (res.code === 200) { if (res.code === 200) {
const currentRobotId = activeBotData.value.robotId
robotList.value = res.rows robotList.value = res.rows
activeBotData.value = robotList.value[0] || {} const currentIndex = robotList.value.findIndex(item => item.robotId === currentRobotId)
activeBot.value = currentIndex >= 0 ? currentIndex : 0
activeBotData.value = robotList.value[activeBot.value] || {}
if (refreshDevices) await loadRobotDevices(activeBotData.value.robotId)
} else { } else {
console.error('获取机器人列表失败:', res.message) console.error('获取机器人列表失败:', res.message)
} }
@ -142,20 +168,18 @@ const fetchRobotList = async () => {
} }
} }
const robotStatus = {
0: '在线',
1: '离线',
2: '充电中',
3: '巡检中'
}
const activeBot = ref(0) const activeBot = ref(0)
const activeBotData = ref({}) const activeBotData = ref({})
const changeBot = (index) => { const changeBot = async (index) => {
activeBot.value = index activeBot.value = index
activeBotData.value = robotList.value[activeBot.value] || {} activeBotData.value = robotList.value[activeBot.value] || {}
await loadRobotDevices(activeBotData.value.robotId)
} }
const currentRobot = computed(() => {
return [robotList.value[activeBot.value]] || []
})
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fff'] const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fff']
const activeMode = ref('auto') const activeMode = ref('auto')
@ -176,15 +200,19 @@ const animationId = ref(null)
const handlerController = (value) => { const handlerController = (value) => {
if (value === 2) { if (value === 2) {
if (!deviceByKind(2)) {
ElMessage.warning('当前机器人没有在线机械臂')
return
}
// 开启机械臂使能 // 开启机械臂使能
torqueOnApi({ torqueOnApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: deviceByKind(2),
terminalId: activeBotData.value.terminalId robotId: activeBotData.value.robotId
}).catch(error => { }).catch(error => {
console.error('开启机械臂使能失败:', error) console.error('开启机械臂使能失败:', error)
clearFault({ clearFault({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: deviceByKind(2),
terminalId: activeBotData.value.terminalId robotId: activeBotData.value.robotId
}) })
}) })
jointControls.value = urdfViewerRef.value.jointControls jointControls.value = urdfViewerRef.value.jointControls
@ -219,8 +247,8 @@ const errCount = ref(0)
const getJointState = async (robot) => { const getJointState = async (robot) => {
try { try {
const res = await getJointStateApi({ const res = await getJointStateApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: deviceByKind(2),
terminalId: robot.terminalId robotId: robot.robotId
}) })
if (res.code === 200) { if (res.code === 200) {
setTimeout(() => { setTimeout(() => {
@ -256,8 +284,8 @@ const updateJoint = async (jointName, _dir) => {
const index = jointControls.value.findIndex(joint => joint.name === jointName) const index = jointControls.value.findIndex(joint => joint.name === jointName)
velocities[index] = jointSpeed.value * (_dir === '+' ? 1 : -1) velocities[index] = jointSpeed.value * (_dir === '+' ? 1 : -1)
const res = await speedJApi({ const res = await speedJApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: deviceByKind(2),
terminalId: activeBotData.value.terminalId, robotId: activeBotData.value.robotId,
acceleration: jointSpeed.value + 0.1, acceleration: jointSpeed.value + 0.1,
duration: 60, duration: 60,
velocities: velocities velocities: velocities
@ -269,18 +297,24 @@ const updateJoint = async (jointName, _dir) => {
*/ */
const stop = async () => { const stop = async () => {
await stopMotionApi({ await stopMotionApi({
deviceId: import.meta.env.VITE_INSPECTION_ARM_DEVICE_ID, deviceId: deviceByKind(2),
terminalId: activeBotData.value.terminalId robotId: activeBotData.value.robotId
}) })
} }
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8') const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
let robotRefreshTimer
onMounted(() => { onMounted(() => {
fetchRobotList() fetchRobotList()
robotRefreshTimer = window.setInterval(() => {
if (!document.hidden) fetchRobotList(false)
}, 500)
}) })
onUnmounted(() => { onUnmounted(() => {
window.clearInterval(robotRefreshTimer)
if (animationId.value) { if (animationId.value) {
clearInterval(animationId.value) clearInterval(animationId.value)
} }
@ -319,10 +353,10 @@ onUnmounted(() => {
background: #0F1E38; background: #0F1E38;
height: 56px; height: 56px;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 12px;
margin-bottom: 16px; margin-bottom: 16px;
cursor: pointer; cursor: pointer;
border: 1px solid transparent; border: 1px solid transparent;
@ -333,10 +367,34 @@ onUnmounted(() => {
} }
.bot-local { .bot-local {
color: #00d4ff;
font-size: 11px; font-size: 11px;
margin-top: 4px; margin-top: 4px;
} }
.online {
color: #0f8;
}
.unknown,
.offline,
.stopped {
color: #cbd5e1;
}
.manual,
.running {
color: #00d4ff;
}
.charging,
.paused {
color: #ffb300;
}
.fault,
.emergency {
color: #ff6b6d;
}
} }
.active { .active {
@ -481,6 +539,16 @@ onUnmounted(() => {
} }
} }
.device-binding {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 10px 0 14px;
color: #64748b;
font-size: 12px;
}
.control-panel { .control-panel {
margin-top: 12px; margin-top: 12px;
background: #0F1E38; background: #0F1E38;
@ -551,4 +619,4 @@ onUnmounted(() => {
} }
} }
} }
</style> </style>

View File

@ -73,6 +73,11 @@ const applicationState = {
let mapCanvas; // 主渲染画布 DOM 元素 let mapCanvas; // 主渲染画布 DOM 元素
let canvasCtx; // 2D 渲染上下文 let canvasCtx; // 2D 渲染上下文
// 保存当前动画帧 ID,组件卸载时用于停止 Canvas 渲染循环。
let animationFrameId;
// 地图请求版本号:当多个地图请求并发时,只允许最后一次请求更新画布。
let mapLoadVersion = 0;
const init = () => { const init = () => {
mapCanvas = document.getElementById('map-canvas'); mapCanvas = document.getElementById('map-canvas');
canvasCtx = mapCanvas.getContext('2d'); canvasCtx = mapCanvas.getContext('2d');
@ -186,7 +191,7 @@ function renderFrame(timestamp) {
// 如果地图未加载,跳过绘制 // 如果地图未加载,跳过绘制
if (!applicationState.isMapLoaded) { if (!applicationState.isMapLoaded) {
requestAnimationFrame(renderFrame); // 继续请求下一帧 animationFrameId = requestAnimationFrame(renderFrame); // 继续请求下一帧
return; return;
} }
@ -236,7 +241,7 @@ function renderFrame(timestamp) {
drawMapBoundary(canvasCtx, map); drawMapBoundary(canvasCtx, map);
// 请求下一帧渲染 // 请求下一帧渲染
requestAnimationFrame(renderFrame); animationFrameId = requestAnimationFrame(renderFrame);
} }
const drawAllRobots = (ctx) => { const drawAllRobots = (ctx) => {
@ -247,7 +252,7 @@ const drawAllRobots = (ctx) => {
if (screenPos.x + 20 < 0 || screenPos.x - 20 > inspectionStore.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > inspectionStore.canvasHeightPx) { if (screenPos.x + 20 < 0 || screenPos.x - 20 > inspectionStore.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > inspectionStore.canvasHeightPx) {
continue; continue;
} }
robot.draw(ctx, screenPos.x, screenPos.y); robot.draw(ctx, screenPos.x, screenPos.y, robot.color, 1);
} }
} }
@ -313,7 +318,7 @@ const updateTooltipDisplay = (hitResult) => {
if (hitResult.kind === 'robot') { if (hitResult.kind === 'robot') {
const robot = hitResult.robot; const robot = hitResult.robot;
title = `${robot.name}`; title = `${robot.name}`;
details = `<div class="tooltip-detail">位置: (${robot.x.toFixed(3)}, ${robot.y.toFixed(3)})</div> details = `<div class="tooltip-detail">位置: (${robot.x}, ${robot.y})</div>
<div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div> <div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div>
<div class="tooltip-detail">ID: ${robot.id}</div>`; <div class="tooltip-detail">ID: ${robot.id}</div>`;
} }
@ -349,11 +354,40 @@ const centerCanvasView = () => {
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2; inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
} }
const loadSourceMap = async () => { /**
* 从机器人列表中选出地图数据来源。
* 当前沿用原有规则:使用第一台已连接的机器人获取地图。
*/
const getConnectedRobot = (robotList = props.robotList) => {
return robotList.find(item => item?.connectStatus == 1)
}
/**
* 生成地图来源的稳定标识。
* 机器人轮询时位置等字段会不断变化,但机器人 ID 和地图名称不变,
* 因此监听该标识可以避免位置更新触发底图重新加载。
*/
const getMapIdentity = (robotList = props.robotList) => {
const robot = getConnectedRobot(robotList)
return robot ? `${robot.id ?? ''}:${robot.robotMapName ?? ''}` : ''
}
/**
* 请求并加载机器人当前使用的地图。
* @param {Object} connectedRobot 用于提供地图的已连接机器人
*/
const loadSourceMap = async (connectedRobot = getConnectedRobot()) => {
if (!connectedRobot) return;
// 记录本次请求版本。若等待响应期间又发起了新请求,则丢弃本次旧结果。
const currentLoadVersion = ++mapLoadVersion;
const res = await getMapJson({ const res = await getMapJson({
robotId: props.robotList[0].id, robotId: connectedRobot.id,
mapName: props.robotList[0].robotMapName mapName: connectedRobot.robotMapName
}); });
// 防止较慢返回的旧地图覆盖用户刚切换的新地图。
if (currentLoadVersion !== mapLoadVersion) return;
if (res.code === 200) { if (res.code === 200) {
try { try {
const rawJsonObject = JSON.parse(res.data); // 解析 JSON const rawJsonObject = JSON.parse(res.data); // 解析 JSON
@ -366,46 +400,79 @@ const loadSourceMap = async () => {
}; };
/** /**
* * 将接口返回的 "x,y,angle" 字符串转换为数值坐标。
* @param id 机器人编号 * 无位置或包含非法数值时返回 null,避免无效机器人进入绘制列表。
* @param name 机器人名称
* @param angleRad 角度
* @param color 颜色
*/ */
const addRobot = (id, name,x, y, angleRad, color) => { const parseRobotPosition = (currentPosition) => {
const map = applicationState.parsedMap; if (!currentPosition) return null
// 放置于地图中央附近 const values = String(currentPosition).split(',').map(Number)
// const centerX = (map.originX + map.maxX) / 2; if (values.length < 3 || values.some(value => !Number.isFinite(value))) return null
// const centerY = (map.originY + map.maxY) / 2; return values
const robot = new MobileRobot(id, name, x, y, angleRad, color);
applicationState.robots.push(robot);
} }
const moveRobot = (robot, dx, dy, dtheta = 0) => { /**
robot.x += dx; * 将最新机器人列表同步到 Canvas 使用的机器人实例中。
robot.y += dy; *
robot.angle += dtheta; * 已存在的机器人会复用原 MobileRobot 实例,仅修改位置和名称;新增机器人
* 创建实例,接口中已移除或没有有效位置的机器人不会进入 nextRobots。
* 该函数不会加载地图或重置相机,因此可安全用于高频轮询。
*/
const syncRobots = (robotList) => {
// 用 ID 建立索引,避免为每台机器人遍历整个旧列表。
const previousRobots = new Map(applicationState.robots.map(robot => [robot.id, robot]));
const nextRobots = [];
robotList.forEach(item => {
const position = parseRobotPosition(item.currentPosition)
if (!position) return
const [x, y, angle] = position
const robot = previousRobots.get(item.id)
if (robot) {
// 保留原实例和颜色,只更新轮询产生的动态信息。
robot.name = item.robotName
robot.x = x
robot.y = y
robot.angle = angle
nextRobots.push(robot)
return
}
// 列表中首次出现的机器人需要创建绘制实例并分配颜色。
nextRobots.push(new MobileRobot(
item.id,
item.robotName,
x,
y,
angle,
colorList[nextRobots.length % colorList.length]
))
})
// 整体替换绘制列表,同时自然移除本轮接口中不存在的机器人。
applicationState.robots = nextRobots
} }
onMounted(() => { onMounted(() => {
init() init()
window.addEventListener('resize', resizeCanvasToContainer);
resizeCanvasToContainer();
bindEvent()
syncRobots(props.robotList)
loadSourceMap()
animationFrameId = requestAnimationFrame(renderFrame);
}) })
watch(() => props.robotList, const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#ff5100', '#fff', '#15ff00', '#ff00d4', '#00d4ff']
(newVal) => {
if (props.robotList.length > 0) { // 轮询只同步机器人实例,不重新请求、解析或重置地图。
loadSourceMap() watch(() => props.robotList, syncRobots)
requestAnimationFrame(renderFrame);
window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化 // 只有用于加载地图的机器人或地图名称改变时,才重新加载底图。
resizeCanvasToContainer(); // 初始调用一次 watch(
setTimeout(() => { () => getMapIdentity(props.robotList),
bindEvent() (newIdentity, oldIdentity) => {
props.robotList.forEach(item => { if (newIdentity && newIdentity !== oldIdentity) loadSourceMap()
const [x, y, angle] = item.currentPosition.split(',')
addRobot(item.id, item.robotName, x, y, angle, '#00D4FF')
})
}, 300)
}
} }
) )
@ -416,6 +483,9 @@ defineExpose({
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', resizeCanvasToContainer) window.removeEventListener('resize', resizeCanvasToContainer)
// 停止渲染循环,并使仍在进行中的地图请求全部失效。
cancelAnimationFrame(animationFrameId)
mapLoadVersion += 1
canvasCtx = null canvasCtx = null
}) })
</script> </script>
@ -454,4 +524,4 @@ onUnmounted(() => {
} }
} }
} }
</style> </style>

View File

@ -7,7 +7,7 @@
<div class="title-box"> <div class="title-box">
<div><SvgIcon name="bot" :color="index === 0 ? '#00D4FF' : '#FFB300'" /></div> <div><SvgIcon name="bot" :color="index === 0 ? '#00D4FF' : '#FFB300'" /></div>
<div class="title">{{ item.robotName }}</div> <div class="title">{{ item.robotName }}</div>
<div class="state online">{{ robotStatus[item.status] }}</div> <div class="state" :class="agvRuntimeStatusClass(item.status)">{{ agvRuntimeStatusLabel(item.status) }}</div>
</div> </div>
<div class="info-box"> <div class="info-box">
<div class="item-box"> <div class="item-box">
@ -61,7 +61,7 @@
</div> </div>
<div class="map-box"> <div class="map-box">
<MapCanvas :robotList="robotList" ref="mapCanvasRef" /> <MapCanvas v-if="robotList.length > 0" :robotList="robotList" ref="mapCanvasRef" />
</div> </div>
</div> </div>
<div class="right-container"> <div class="right-container">
@ -86,19 +86,12 @@ import MapCanvas from "./MapCanvas.vue";
import { onMounted, onUnmounted } from "vue"; import { onMounted, onUnmounted } from "vue";
import { getRobotList } from '@/api/inspection/robot' import { getRobotList } from '@/api/inspection/robot'
import { getRunTaskList } from '@/api/inspection/runTask' import { getRunTaskList } from '@/api/inspection/runTask'
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'
console.log('sss', import.meta.env.VITE_INSPECTION_TYPE)
const robotList = ref([]) const robotList = ref([])
const robotStatus = {
"0": "在线",
"1": "离线",
"2": "充电中",
"3": "巡检中"
}
const getRobot = async () => { const getRobot = async () => {
let robotType let robotType
if (import.meta.env.VITE_INSPECTION_TYPE = 'inspection') { if (import.meta.env.VITE_INSPECTION_TYPE === 'inspection') {
robotType = '2' robotType = '2'
} else { } else {
robotType = '1' robotType = '1'
@ -110,6 +103,7 @@ const getRobot = async () => {
if (res.code === 200) { if (res.code === 200) {
robotList.value = res.rows robotList.value = res.rows
// getRobot()
} }
} }
@ -124,7 +118,7 @@ const taskStatus = {
} }
const getRunningTask = async () => { const getRunningTask = async () => {
let taskType let taskType
if (import.meta.env.VITE_INSPECTION_TYPE = 'inspection') { if (import.meta.env.VITE_INSPECTION_TYPE === 'inspection') {
taskType = '1' taskType = '1'
} else { } else {
taskType = '2' taskType = '2'
@ -160,9 +154,18 @@ const centerCanvasView = () => {
} }
} }
let robotRefreshTimer
onMounted(() => { onMounted(() => {
getRobot() getRobot()
getRunningTask() getRunningTask()
robotRefreshTimer = window.setInterval(() => {
if (!document.hidden) getRobot()
}, 5000)
})
onUnmounted(() => {
window.clearInterval(robotRefreshTimer)
}) })
</script> </script>
@ -179,6 +182,7 @@ onMounted(() => {
.robot-container { .robot-container {
max-height: 50%; max-height: 50%;
overflow: hidden;
overflow-y: auto; overflow-y: auto;
.robot-state { .robot-state {
@ -222,6 +226,31 @@ onMounted(() => {
background: #0f83; background: #0f83;
color: #0f8; color: #0f8;
} }
.unknown,
.offline,
.stopped {
background: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
.manual,
.running {
background: rgba(0, 212, 255, 0.18);
color: #00d4ff;
}
.charging,
.paused {
background: rgba(255, 179, 0, 0.2);
color: #ffb300;
}
.fault,
.emergency {
background: rgba(255, 77, 79, 0.2);
color: #ff6b6d;
}
} }
.info-box { .info-box {
@ -401,4 +430,4 @@ onMounted(() => {
} }
} }
} }
</style> </style>

View File

@ -8,13 +8,13 @@
<div><SvgIcon name="bot" color="#fff" /></div> <div><SvgIcon name="bot" color="#fff" /></div>
<div class="bot-name">全部</div> <div class="bot-name">全部</div>
</div> </div>
<div v-for="item, index in 4" class="robot" :class="{ active: activeBot === index + 1 }" @click="changeBot(index + 1)"> <div v-for="item, index in robotList" class="robot" :class="{ active: activeBot === index + 1 }" @click="changeBot(index + 1)">
<div> <div>
<SvgIcon name="bot" :color="colorList[index]" /> <SvgIcon name="bot" :color="colorList[index]" />
</div> </div>
<div> <div>
<div class="bot-name">机器人#0{{ index + 1}}</div> <div class="bot-name">{{ item.robotName }}</div>
<div class="bot-local">厂区A-在线</div> <div class="bot-local" :class="agvRuntimeStatusClass(item.status)">{{ agvRuntimeStatusLabel(item.status) }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -41,15 +41,38 @@
<script setup> <script setup>
import SvgIcon from "@/components/SvgIcon"; import SvgIcon from "@/components/SvgIcon";
import IPlayer from "@/components/IPlayer/index.vue"; import IPlayer from "@/components/IPlayer/index.vue";
import { getRobotList } from '@/api/inspection/robot'
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8') const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fd00ff'] const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fd00ff']
const robotList = ref([])
const activeBot = ref(0) const activeBot = ref(0)
const changeBot = (index) => { const changeBot = (index) => {
activeBot.value = index activeBot.value = index
} }
const fetchRobotList = async () => {
try {
const res = await getRobotList({
robotType: 2,
})
if (res.code === 200) {
robotList.value = res.rows
} else {
console.error('获取机器人列表失败:', res.message)
}
} catch (error) {
console.error('获取机器人列表失败:', error)
}
}
onMounted(() => {
fetchRobotList()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page-container {
@ -90,10 +113,34 @@ const changeBot = (index) => {
} }
.bot-local { .bot-local {
color: #00d4ff;
font-size: 11px; font-size: 11px;
margin-top: 4px; margin-top: 4px;
} }
.online {
color: #0f8;
}
.unknown,
.offline,
.stopped {
color: #cbd5e1;
}
.manual,
.running {
color: #00d4ff;
}
.charging,
.paused {
color: #ffb300;
}
.fault,
.emergency {
color: #ff6b6d;
}
} }
.active { .active {

Some files were not shown because too many files have changed in this diff Show More