diff --git a/src/views/flow/components/TestRun.vue b/src/views/flow/components/TestRun.vue index dc5b567..47daa2c 100644 --- a/src/views/flow/components/TestRun.vue +++ b/src/views/flow/components/TestRun.vue @@ -209,7 +209,9 @@ const handleRobotChange = async (robotId) => { /** 获取在线机器人列表 */ const loadRobotOptions = async () => { - const res = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' }) + // 试运行不需要要有连接状态为连接的,因为可以选择未连接的机器人进行试运行来保存流程数据 + // , connectStatus: '1' + const res = await getRobotList({ pageNum: 1, pageSize: 1000 }) if (res.code === 200) { robotIdOptions.value = res.rows?.map((item) => { return { diff --git a/src/views/inspection/cockpit/components/ManualTakeover/index.vue b/src/views/inspection/cockpit/components/ManualTakeover/index.vue index 4cbb293..d78e55e 100644 --- a/src/views/inspection/cockpit/components/ManualTakeover/index.vue +++ b/src/views/inspection/cockpit/components/ManualTakeover/index.vue @@ -11,7 +11,7 @@
{{ item.robotName }}
-
{{ agvRuntimeStatusLabel(item.status) }}
+
{{ agvRuntimeStatusLabel(item.status) }}
@@ -134,7 +134,7 @@ import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi, clea import { Remove, CirclePlus } from '@element-plus/icons-vue' import VoiceConversation from "./VoiceConversation.vue"; import IPlayer from "@/components/IPlayer/index.vue"; -import { agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus' +import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus' const robotList = ref([]) const robotDevices = ref([]) @@ -310,7 +310,7 @@ onMounted(() => { fetchRobotList() robotRefreshTimer = window.setInterval(() => { if (!document.hidden) fetchRobotList(false) - }, 5000) + }, 500) }) onUnmounted(() => { @@ -353,10 +353,10 @@ onUnmounted(() => { background: #0F1E38; height: 56px; border-radius: 8px; - padding: 16px; + padding: 12px; display: flex; align-items: center; - gap: 20px; + gap: 12px; margin-bottom: 16px; cursor: pointer; border: 1px solid transparent; @@ -367,10 +367,34 @@ onUnmounted(() => { } .bot-local { - color: #00d4ff; font-size: 11px; margin-top: 4px; } + + .online { + color: #0f8; + } + + .unknown, + .offline, + .stopped { + color: #cbd5e1; + } + + .manual, + .running { + color: #00d4ff; + } + + .charging, + .paused { + color: #ffb300; + } + + .fault, + .emergency { + color: #ff6b6d; + } } .active { diff --git a/src/views/inspection/cockpit/components/MapCanvas.vue b/src/views/inspection/cockpit/components/MapCanvas.vue index b33de48..0d943d6 100644 --- a/src/views/inspection/cockpit/components/MapCanvas.vue +++ b/src/views/inspection/cockpit/components/MapCanvas.vue @@ -73,6 +73,11 @@ const applicationState = { let mapCanvas; // 主渲染画布 DOM 元素 let canvasCtx; // 2D 渲染上下文 +// 保存当前动画帧 ID,组件卸载时用于停止 Canvas 渲染循环。 +let animationFrameId; +// 地图请求版本号:当多个地图请求并发时,只允许最后一次请求更新画布。 +let mapLoadVersion = 0; + const init = () => { mapCanvas = document.getElementById('map-canvas'); canvasCtx = mapCanvas.getContext('2d'); @@ -186,7 +191,7 @@ function renderFrame(timestamp) { // 如果地图未加载,跳过绘制 if (!applicationState.isMapLoaded) { - requestAnimationFrame(renderFrame); // 继续请求下一帧 + animationFrameId = requestAnimationFrame(renderFrame); // 继续请求下一帧 return; } @@ -236,7 +241,7 @@ function renderFrame(timestamp) { drawMapBoundary(canvasCtx, map); // 请求下一帧渲染 - requestAnimationFrame(renderFrame); + animationFrameId = requestAnimationFrame(renderFrame); } const drawAllRobots = (ctx) => { @@ -349,14 +354,40 @@ const centerCanvasView = () => { inspectionStore.camera.centerY = (map.originY + map.maxY) / 2; } -const loadSourceMap = async () => { - const connectedRobot = props.robotList.find(item => item.connectStatus == 1); +/** + * 从机器人列表中选出地图数据来源。 + * 当前沿用原有规则:使用第一台已连接的机器人获取地图。 + */ +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({ robotId: connectedRobot.id, mapName: connectedRobot.robotMapName }); + // 防止较慢返回的旧地图覆盖用户刚切换的新地图。 + if (currentLoadVersion !== mapLoadVersion) return; if (res.code === 200) { try { const rawJsonObject = JSON.parse(res.data); // 解析 JSON @@ -369,19 +400,9 @@ const loadSourceMap = async () => { }; /** - * - * @param id 机器人编号 - * @param name 机器人名称 - * @param angleRad 角度 - * @param color 颜色 + * 将接口返回的 "x,y,angle" 字符串转换为数值坐标。 + * 无位置或包含非法数值时返回 null,避免无效机器人进入绘制列表。 */ -const addRobot = (id, name,x, y, angleRad, color) => { - const map = applicationState.parsedMap; - // 放置于地图中央附近 - const robot = new MobileRobot(id, name, x, y, angleRad, color); - applicationState.robots.push(robot); -} - const parseRobotPosition = (currentPosition) => { if (!currentPosition) return null const values = String(currentPosition).split(',').map(Number) @@ -389,55 +410,69 @@ const parseRobotPosition = (currentPosition) => { return values } -const moveRobot = (robot, dx, dy, dtheta = 0) => { - robot.x += dx; - robot.y += dy; - robot.angle += dtheta; +/** + * 将最新机器人列表同步到 Canvas 使用的机器人实例中。 + * + * 已存在的机器人会复用原 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(() => { init() - if (props.robotList.length > 0) { - loadSourceMap() - requestAnimationFrame(renderFrame); - window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化 - resizeCanvasToContainer(); // 初始调用一次 - setTimeout(() => { - bindEvent() - props.robotList.forEach(item => { - const position = parseRobotPosition(item.currentPosition) - if (!position) return - const [x, y, angle] = position - addRobot(item.id, item.robotName, x, y, angle, colorList[applicationState.robots.length % colorList.length]) - }) - }, 300) - } + window.addEventListener('resize', resizeCanvasToContainer); + resizeCanvasToContainer(); + bindEvent() + syncRobots(props.robotList) + loadSourceMap() + animationFrameId = requestAnimationFrame(renderFrame); }) const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#ff5100', '#fff', '#15ff00', '#ff00d4', '#00d4ff'] -watch(() => props.robotList, - (newVal) => { - if (newVal.length > 0) { - const ids = applicationState.robots.map(r => r.id); - props.robotList.forEach(item => { - const position = parseRobotPosition(item.currentPosition) - if (!position) return - const [x, y, angle] = position - if (!ids.includes(item.id)) { - addRobot(item.id, item.robotName, x, y, angle, colorList[applicationState.robots.length % colorList.length]) - } else { - applicationState.robots.forEach(robot => { - if (robot.id === item.id) { - // moveRobot(robot, parseFloat(x) - robot.x, parseFloat(y) - robot.y, parseFloat(angle) - robot.angle) - robot.x = x - robot.y = y - robot.angle = angle - } - }) - } - }) - } +// 轮询只同步机器人实例,不重新请求、解析或重置地图。 +watch(() => props.robotList, syncRobots) + +// 只有用于加载地图的机器人或地图名称改变时,才重新加载底图。 +watch( + () => getMapIdentity(props.robotList), + (newIdentity, oldIdentity) => { + if (newIdentity && newIdentity !== oldIdentity) loadSourceMap() } ) @@ -448,6 +483,9 @@ defineExpose({ onUnmounted(() => { window.removeEventListener('resize', resizeCanvasToContainer) + // 停止渲染循环,并使仍在进行中的地图请求全部失效。 + cancelAnimationFrame(animationFrameId) + mapLoadVersion += 1 canvasCtx = null }) diff --git a/src/views/inspection/cockpit/components/Monitor.vue b/src/views/inspection/cockpit/components/Monitor.vue index ee1ab54..345aa9b 100644 --- a/src/views/inspection/cockpit/components/Monitor.vue +++ b/src/views/inspection/cockpit/components/Monitor.vue @@ -13,8 +13,8 @@
-
机器人#0{{ index + 1}}
-
厂区A-在线
+
{{ item.robotName }}
+
{{ agvRuntimeStatusLabel(item.status) }}
@@ -42,6 +42,7 @@ import SvgIcon from "@/components/SvgIcon"; 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') @@ -112,10 +113,34 @@ onMounted(() => { } .bot-local { - color: #00d4ff; font-size: 11px; margin-top: 4px; } + + .online { + color: #0f8; + } + + .unknown, + .offline, + .stopped { + color: #cbd5e1; + } + + .manual, + .running { + color: #00d4ff; + } + + .charging, + .paused { + color: #ffb300; + } + + .fault, + .emergency { + color: #ff6b6d; + } } .active { diff --git a/src/views/inspection/task/index.vue b/src/views/inspection/task/index.vue index b70b0e9..d77b916 100644 --- a/src/views/inspection/task/index.vue +++ b/src/views/inspection/task/index.vue @@ -122,7 +122,7 @@
待选择点位
+ ghostClass="dragClass" :group="{ name: 'people', pull: 'clone', put: false}" itemKey="name">