feat: 机器人管理和地图管理

This commit is contained in:
zhanghao 2026-05-29 13:54:45 +08:00
parent 4de5e21929
commit 4e8998216f
10 changed files with 915 additions and 75 deletions

View File

@ -7,8 +7,6 @@ VITE_APP_ENV = 'development'
# 招商车研物联网平台/开发环境
VITE_APP_BASE_API = '/dev-api'
VITE_API_URL = http://192.168.0.10:13080
VITE_WS_URL = ws://192.168.0.10:13080/ws
# VITE_API_URL = http://10.148.108.95:13080 //杨溪IP
# VITE_API_URL = http://10.148.108.58:13080 //赵培利IP
VITE_API_URL = http://192.168.28.10:13080
VITE_WS_URL = ws://192.168.28.10:13080/ws

View File

@ -13,5 +13,5 @@ VITE_BUILD_COMPRESS = gzip
#node-red 服务地址
VITE_NODE_RED_URL = 'http://10.148.108.59:13080'
VITE_API_URL = http://192.168.0.100:13080
VITE_WS_URL = ws://192.168.0.100:13080/ws
VITE_API_URL = http://192.168.28.10:13080
VITE_WS_URL = ws://192.168.28.10:13080/ws

View File

@ -23,10 +23,11 @@ function hexToCssRgba(hexString, alpha) {
* @param {number} worldY - 世界坐标 Y
* @returns {{x: number, y: number}} Canvas 上的屏幕像素坐标
*/
export const worldToScreen = (worldX, worldY) => {
const camera = inspectionStore.camera;
const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + inspectionStore.canvasWidthPx / 2; // 水平偏移 + 居中
const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + inspectionStore.canvasHeightPx / 2; // 垂直翻转 + 居中
export const worldToScreen = (worldX, worldY, dataStore) => {
const store = dataStore || inspectionStore;
const camera = store.camera
const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + store.canvasWidthPx / 2; // 水平偏移 + 居中
const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + store.canvasHeightPx / 2; // 垂直翻转 + 居中
return { x: screenX, y: screenY };
};
@ -37,10 +38,11 @@ export const worldToScreen = (worldX, worldY) => {
* @param {number} screenY - Canvas 像素 Y
* @returns {{x: number, y: number}} 世界坐标
*/
export const screenToWorld = (screenX, screenY) => {
const camera = inspectionStore.camera;
const worldX = (screenX - inspectionStore.canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; // 水平反算
const worldY = -(screenY - inspectionStore.canvasHeightPx / 2) / camera.pixelsPerMeter + camera.centerY; // 垂直反算(带翻转)
export const screenToWorld = (screenX, screenY, dataStore) => {
const store = dataStore || inspectionStore;
const camera = store.camera;
const worldX = (screenX - store.canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; // 水平反算
const worldY = -(screenY - store.canvasHeightPx / 2) / camera.pixelsPerMeter + camera.centerY; // 垂直反算(带翻转)
return { x: worldX, y: worldY };
};
@ -79,13 +81,15 @@ export const toCssRgba = (colorObj, alphaOverride) => {
* 将预渲染的离屏 Canvas 图像绘制到主 Canvas 并根据当前地图范围进行缩放和平移
* @param {*} canvasCtx
* @param {*} map
* @param {*} gridOffscreen
*/
export const drawGrid = (canvasCtx, map) => {
const topLeftScreen = worldToScreen(map.originX, map.maxY); // 图像左上角(世界坐标→屏幕)
const bottomRightScreen = worldToScreen(map.maxX, map.originY); // 图像右下角(世界坐标→屏幕)
export const drawGrid = (canvasCtx, map, dataStore) => {
const store = dataStore || inspectionStore;
const topLeftScreen = worldToScreen(map.originX, map.maxY, store); // 图像左上角(世界坐标→屏幕)
const bottomRightScreen = worldToScreen(map.maxX, map.originY, store); // 图像右下角(世界坐标→屏幕)
canvasCtx.imageSmoothingEnabled = false; // 关闭抗锯齿(保持像素清晰)
canvasCtx.drawImage(
inspectionStore.gridOffscreen, // 离屏 Canvas 图像源
store.gridOffscreen, // 离屏 Canvas 图像源
topLeftScreen.x, topLeftScreen.y, // 目标左上角
bottomRightScreen.x - topLeftScreen.x, // 目标宽度
bottomRightScreen.y - topLeftScreen.y // 目标高度
@ -98,10 +102,11 @@ export const drawGrid = (canvasCtx, map) => {
* @param {*} canvasWidthPx
* @param {*} canvasHeightPx
*/
export const drawGridlines = (canvasCtx) => {
export const drawGridlines = (canvasCtx, dataStore) => {
// 计算当前可视范围的世界坐标
const viewTopLeft = screenToWorld(0, 0); // 视口左上角世界坐标
const viewBottomRight = screenToWorld(inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); // 视口右下角世界坐标
const store = dataStore || inspectionStore;
const viewTopLeft = screenToWorld(0, 0, store); // 视口左上角世界坐标
const viewBottomRight = screenToWorld(store.canvasWidthPx, store.canvasHeightPx, store); // 视口右下角世界坐标
const viewMinX = Math.floor(Math.min(viewTopLeft.x, viewBottomRight.x)); // 可视X最小整数
const viewMaxX = Math.ceil(Math.max(viewTopLeft.x, viewBottomRight.x)); // 可视X最大整数
const viewMinY = Math.floor(Math.min(viewTopLeft.y, viewBottomRight.y)); // 可视Y最小整数
@ -112,14 +117,14 @@ export const drawGridlines = (canvasCtx) => {
canvasCtx.lineWidth = 0.5; // 细线
canvasCtx.beginPath();
for (let worldX = viewMinX; worldX <= viewMaxX; worldX++) { // 逐列绘制垂直线
const screenPos = worldToScreen(worldX, 0);
const screenPos = worldToScreen(worldX, 0, store);
canvasCtx.moveTo(screenPos.x, 0); // 从画布顶部
canvasCtx.lineTo(screenPos.x, inspectionStore.canvasHeightPx); // 到画布底部
canvasCtx.lineTo(screenPos.x, store.canvasHeightPx); // 到画布底部
}
for (let worldY = viewMinY; worldY <= viewMaxY; worldY++) { // 逐行绘制水平线
const screenPos = worldToScreen(0, worldY);
const screenPos = worldToScreen(0, worldY, store);
canvasCtx.moveTo(0, screenPos.y); // 从画布左边
canvasCtx.lineTo(inspectionStore.canvasWidthPx, screenPos.y); // 到画布右边
canvasCtx.lineTo(store.canvasWidthPx, screenPos.y); // 到画布右边
}
canvasCtx.stroke();
}
@ -128,8 +133,10 @@ export const drawGridlines = (canvasCtx) => {
* 绘制高级区域列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawAdvancedAreaList = (canvasCtx, map) => {
export const drawAdvancedAreaList = (canvasCtx, map, dataStore) => {
const store = dataStore || inspectionStore;
for (let areaIndex = 0; areaIndex < map.advancedAreas.length; areaIndex++) {
const area = map.advancedAreas[areaIndex]; // 当前区域
const vertices = area.posGroup || []; // 多边形顶点数组
@ -155,10 +162,10 @@ export const drawAdvancedAreaList = (canvasCtx, map) => {
canvasCtx.strokeStyle = strokeColorCss; // 设置描边色
canvasCtx.lineWidth = 1.2; // 描边宽度
canvasCtx.beginPath();
const firstVertexScreen = worldToScreen(vertices[0].x, vertices[0].y); // 第一个顶点的屏幕位置
const firstVertexScreen = worldToScreen(vertices[0].x, vertices[0].y, store); // 第一个顶点的屏幕位置
canvasCtx.moveTo(firstVertexScreen.x, firstVertexScreen.y); // 移动到起点
for (let i = 1; i < vertices.length; i++) { // 依次连接后续顶点
const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y);
const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y, store);
canvasCtx.lineTo(vertexScreen.x, vertexScreen.y);
}
canvasCtx.closePath(); // 闭合路径
@ -170,7 +177,7 @@ export const drawAdvancedAreaList = (canvasCtx, map) => {
for (const v of vertices) { centroidWorldX += v.x; centroidWorldY += v.y; } // 累加顶点坐标
centroidWorldX /= vertices.length; // 求平均得到质心 X
centroidWorldY /= vertices.length; // 求平均得到质心 Y
const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY);
const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY, store);
canvasCtx.font = '500 8px "JetBrains Mono"'; // 标签字体
canvasCtx.fillStyle = strokeColorCss; // 与描边同色
canvasCtx.textAlign = 'center'; // 居中对齐
@ -183,14 +190,15 @@ export const drawAdvancedAreaList = (canvasCtx, map) => {
* @param {*} canvasCtx
* @param {*} map
*/
export const drawAdvancedLineList = (canvasCtx, map) => {
export const drawAdvancedLineList = (canvasCtx, map, dataStore) => {
const store = dataStore || inspectionStore;
for (let advLineIndex = 0; advLineIndex < map.advancedLines.length; advLineIndex++) {
const advLine = map.advancedLines[advLineIndex]; // 当前高级线
const lineData = advLine.line; // 内部的 line 对象
if (!lineData || !lineData.startPos || !lineData.endPos) continue; // 缺少端点则跳过
const startScreen = worldToScreen(lineData.startPos.x, lineData.startPos.y);
const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y);
const startScreen = worldToScreen(lineData.startPos.x, lineData.startPos.y, store);
const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y, store);
// ── 根据 className 决定颜色和线型 ──
let lineColorHex = '#ff6e40'; // 默认橙色
@ -233,12 +241,14 @@ export const drawAdvancedLineList = (canvasCtx, map) => {
* 绘制基础直线列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawNormalLines = (canvasCtx, map) => {
export const drawNormalLines = (canvasCtx, map, dataStore) => {
const store = dataStore || inspectionStore;
for (let lineIndex = 0; lineIndex < map.normalLines.length; lineIndex++) {
const line = map.normalLines[lineIndex]; // 当前直线
const startScreen = worldToScreen(line.startPos.x, line.startPos.y); // 起点屏幕位置
const endScreen = worldToScreen(line.endPos.x, line.endPos.y); // 终点屏幕位置
const startScreen = worldToScreen(line.startPos.x, line.startPos.y, store); // 起点屏幕位置
const endScreen = worldToScreen(line.endPos.x, line.endPos.y, store); // 终点屏幕位置
// 绘制发光直线
canvasCtx.save();
@ -272,8 +282,10 @@ export const drawNormalLines = (canvasCtx, map) => {
* 绘制高级曲线列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawAdvancedCurveList = (canvasCtx, map) => {
export const drawAdvancedCurveList = (canvasCtx, map, dataStore) => {
const store = dataStore || inspectionStore;
for (let curveIndex = 0; curveIndex < map.advancedCurves.length; curveIndex++) {
const curve = map.advancedCurves[curveIndex]; // 当前曲线
@ -285,10 +297,10 @@ export const drawAdvancedCurveList = (canvasCtx, map) => {
if (!startPoint || !endPoint) continue; // 缺少端点则跳过
// 转换为屏幕坐标
const startScreen = worldToScreen(startPoint.x, startPoint.y);
const endScreen = worldToScreen(endPoint.x, endPoint.y);
const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y) : null; // 可能不存在
const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y) : null;
const startScreen = worldToScreen(startPoint.x, startPoint.y, store);
const endScreen = worldToScreen(endPoint.x, endPoint.y, store);
const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y, store) : null; // 可能不存在
const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y, store) : null;
// ── 绘制贝塞尔曲线(虚线)──
canvasCtx.save();
@ -357,16 +369,18 @@ export const drawAdvancedCurveList = (canvasCtx, map) => {
* 绘制高级点列表
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawAdvancedPointList = (canvasCtx, map) => {
export const drawAdvancedPointList = (canvasCtx, map, dataStore) => {
const store = dataStore || inspectionStore;
for (let pointIndex = 0; pointIndex < map.advancedPoints.length; pointIndex++) {
const advPoint = map.advancedPoints[pointIndex]; // 当前高级点
const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y); // 屏幕位置
const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y, store); // 屏幕位置
// 判断是否被鼠标悬停
const isHovered = inspectionStore.hoveredElement &&
inspectionStore.hoveredElement.kind === 'point' &&
inspectionStore.hoveredElement.index === pointIndex;
const isHovered = store.hoveredElement &&
store.hoveredElement.kind === 'point' &&
store.hoveredElement.index === pointIndex;
// ── 根据 className 决定颜色 ──
let pointColorHex = '#ffd600'; // 默认琥珀色
@ -385,7 +399,7 @@ export const drawAdvancedPointList = (canvasCtx, map) => {
// ── 绘制菱形标记 ──
const diamondRadius = isHovered ? 8 : 5; // 悬停时放大
const pulseAlpha = isHovered ? Math.sin(inspectionStore.animationTimeSeconds * 5) * 0.2 + 0.8 : 1; // 悬停时脉冲闪烁
const pulseAlpha = isHovered ? Math.sin(store.animationTimeSeconds * 5) * 0.2 + 0.8 : 1; // 悬停时脉冲闪烁
canvasCtx.save();
canvasCtx.globalAlpha = pulseAlpha; // 应用透明度
@ -442,10 +456,12 @@ export const drawAdvancedPointList = (canvasCtx, map) => {
* 绘制地图边界
* @param {*} canvasCtx
* @param {*} map
* @param {*} dataStore
*/
export const drawMapBoundary = (canvasCtx, map) => {
const boundaryTopLeft = worldToScreen(map.originX, map.maxY); // 边界左上角屏幕位置
const boundaryBottomRight = worldToScreen(map.maxX, map.originY); // 边界右下角屏幕位置
export const drawMapBoundary = (canvasCtx, map, dataStore) => {
const store = dataStore || inspectionStore;
const boundaryTopLeft = worldToScreen(map.originX, map.maxY, store); // 边界左上角屏幕位置
const boundaryBottomRight = worldToScreen(map.maxX, map.originY, store); // 边界右下角屏幕位置
canvasCtx.strokeStyle = 'rgba(0,229,160,0.1)'; // 极淡的青绿色
canvasCtx.lineWidth = 1;
canvasCtx.setLineDash([5, 4]); // 虚线
@ -543,7 +559,6 @@ export const buildOccupancyGridImage = (parsedMap, showEdgeGlow) => {
return offscreenCanvas;
}
// import bot from '@/assets/icons/svg/bot.svg'
export class MobileRobot {
constructor(id, name, x, y, angleRad = 0, color= "#00D4FF", imageUrl = null) {
this.id = id;
@ -560,23 +575,28 @@ export class MobileRobot {
}
async init(color, imageUrl) {
console.log('imageUrl', imageUrl)
const response = await fetch(imageUrl);
let svgText = await response.text();
console.log('svgText', svgText)
// 替换颜色
svgText = svgText.replace(/currentColor/g, color);
svgText = svgText.replace(/fill="[^"]*"/g, `fill="${color}"`);
svgText = svgText.replace(/stroke="[^"]*"/g, `stroke="${color}"`);
// 创建Blob URL
const blob = new Blob([svgText], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
this.imgElement = new Image();
this.imgElement.src = url;
}
// 命中检测: 检查世界坐标是否在机器人区域内 (半径0.35米)
hitTest(worldX, worldY, pixelsPerMeter){
const dx = worldX - this.x;
const dy = worldY - this.y;
const distance = Math.hypot(dx, dy);
const hitRadius = 0.35; // 米
return distance < hitRadius;
}
// 绘制到canvas上下文 (屏幕坐标)
draw(ctx, screenX, screenY, scale = 1) {
console.log('imgElement', this.imgElement)
const size = 18; // 绘制大小px
if (this.imgElement && this.imgElement.complete) {
ctx.save();
@ -586,10 +606,10 @@ export class MobileRobot {
ctx.restore();
}
// 名字标签
ctx.font = '8px "JetBrains Mono"';
ctx.font = '12px "JetBrains Mono"';
ctx.fillStyle = '#a0c0f0';
ctx.shadowBlur = 0;
ctx.fillText(this.name, screenX - 10, screenY - 10);
ctx.fillText(this.name, screenX - 30, screenY - 20);
}
// 更新位置

View File

@ -1,6 +1,10 @@
<template>
<div class="container">
<canvas id="map-canvas"></canvas>
<div id="tooltip">
<div class="tooltip-title"></div>
<div class="tooltip-detail"></div>
</div>
</div>
</template>
@ -26,7 +30,6 @@ import { useInspectionStore } from "@/store/modules/inspection";
const inspectionStore = useInspectionStore();
/**
* 应用全局状态对象集中管理所有可变数据
* - parsedMap: 解析后的地图数据结构
@ -157,7 +160,6 @@ async function loadMapIntoApplication(parsedMap) {
}
/**
* 主渲染函数 requestAnimationFrame 循环调用
* 按层顺序绘制栅格 网格线 区域 基础线 高级线 曲线 边界
@ -230,7 +232,6 @@ function renderFrame(timestamp) {
const drawAllRobots = (ctx) => {
if (!applicationState.layerVisibility.robots) return;
for (const robot of applicationState.robots) {
console.log(1222)
const screenPos = worldToScreen(robot.x, robot.y);
//
if (screenPos.x + 20 < 0 || screenPos.x - 20 > inspectionStore.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > inspectionStore.canvasHeightPx) {
@ -256,6 +257,9 @@ const bindEvent = () => {
inspectionStore.camera.centerX -= mouseEvent.movementX / inspectionStore.camera.pixelsPerMeter; //
inspectionStore.camera.centerY += mouseEvent.movementY / inspectionStore.camera.pixelsPerMeter; // Y
}
let hit = hitTestRobots(worldPos.x, worldPos.y);
updateTooltipDisplay(hit);
});
//
@ -286,6 +290,55 @@ const bindEvent = () => {
}, { passive: false }); // passive:false preventDefault
}
const updateTooltipDisplay = (hitResult) => {
const tooltipEl = document.getElementById('tooltip');
if (!hitResult) {
tooltipEl.style.display = 'none';
inspectionStore.hoveredElement = null;
return;
}
inspectionStore.hoveredElement = hitResult;
let title = '', details = '';
if (hitResult.kind === 'robot') {
const robot = hitResult.robot;
title = `${robot.name}`;
details = `<div class="tooltip-detail">位置: (${robot.x.toFixed(3)}, ${robot.y.toFixed(3)})</div>
<div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div>
<div class="tooltip-detail">ID: ${robot.id}</div>`;
}
tooltipEl.innerHTML = `<div class="tooltip-title" style="color:#00e5a0">${title}</div>${details}`;
tooltipEl.style.display = 'block';
tooltipEl.style.left = Math.min(applicationState.mouseState.screenX + 14, inspectionStore.canvasWidthPx - 220) + 'px';
tooltipEl.style.top = Math.max(4, applicationState.mouseState.screenY - 8) + 'px';
}
const hitTestRobots = (worldX, worldY) => {
for (let i = 0; i < applicationState.robots.length; i++) {
const robot = applicationState.robots[i];
if (robot.hitTest(worldX, worldY)) {
return { kind: 'robot', index: i, robot: robot };
}
}
return null;
}
const zoomCanvas = (zoomIn) => {
const zoomFactor = zoomIn ? 1.12 : 0.88;
const worldBefore = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor));
const worldAfter = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
inspectionStore.camera.centerX += worldBefore.x - worldAfter.x;
inspectionStore.camera.centerY += worldBefore.y - worldAfter.y;
}
const centerCanvasView = () => {
const map = applicationState.parsedMap;
if (!map) return;
inspectionStore.camera.centerX = (map.originX + map.maxX) / 2;
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
}
const loadSourceMap = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`加载失败:${response.status}`);
@ -295,7 +348,7 @@ const loadSourceMap = async (url) => {
/**
* 加载 smap JSON 文件
*/
async function loadUserFile() {
const loadUserFile = async () => {
try {
const fileTextContent = await loadSourceMap('/3.smap');
const rawJsonObject = JSON.parse(fileTextContent); // JSON
@ -306,14 +359,12 @@ async function loadUserFile() {
}
}
const addRobot = () => {
const addRobot = (id, name, angleRad, color) => {
const map = applicationState.parsedMap;
console.log('map', map)
//
const centerX = (map.originX + map.maxX) / 2;
const centerY = (map.originY + map.maxY) / 2;
const id = 0;
const robot = new MobileRobot(id, `Robot${id}`, centerX + (Math.random() - 0.5) * 3, centerY + (Math.random() - 0.5) * 3, 0);
const robot = new MobileRobot(id, name, centerX + (Math.random() - 0.5) * 10, centerY + (Math.random() - 0.5) * 10, angleRad, color);
applicationState.robots.push(robot);
}
@ -332,8 +383,15 @@ onMounted(() => {
resizeCanvasToContainer(); //
setTimeout(() => {
bindEvent()
addRobot()
}, 100)
addRobot(1, '巡检机器人#1', 0, '#00D4FF')
addRobot(2, '巡检机器人#2', 0, '#FFB300')
addRobot(3, '巡检机器人#3', 0, '#00FF88')
}, 300)
})
defineExpose({
zoomCanvas,
centerCanvasView
})
onUnmounted(() => {
@ -346,5 +404,34 @@ onUnmounted(() => {
.container {
width: 100%;
height: 100%;
position: relative;
#tooltip {
position: absolute;
display: none;
background: #0b1120;
border: 1px solid #2a3a5c;
padding: 8px 12px;
border-radius: 3px;
pointer-events: none;
z-index: 20;
font-size: 10px;
min-width: 180px;
max-width: 280px;
box-shadow: 0 4px 16px rgba(0, 0, 0, .5);
.tooltip-title {
font-weight: 700;
font-size: 11px;
margin-bottom: 5px;
}
:deep(.tooltip-detail) {
color: #94a3b8;
padding: 2px 0;
font-size: 9px;
word-break: break-all;
}
}
}
</style>

View File

@ -49,19 +49,19 @@
</div>
<div class="map-controller">
<div class="btn-container">
<SvgIcon name="zoom-in" color='#00D4FF' :size="16" />
<SvgIcon name="zoom-in" color='#00D4FF' :size="16" @click="zoomCanvas(true)" />
</div>
<div class="btn-container">
<SvgIcon name="zoom-out" color='#00D4FF' :size="16" />
<SvgIcon name="zoom-out" color='#00D4FF' :size="16" @click="zoomCanvas(false)" />
</div>
<div class="btn-container">
<SvgIcon name="locate-fixed" color='#00D4FF' :size="16" />
<SvgIcon name="locate-fixed" color='#00D4FF' :size="16" @click="centerCanvasView" />
</div>
</div>
</div>
<div class="map-box">
<MapCanvas />
<MapCanvas ref="mapCanvasRef" />
</div>
</div>
<div class="right-container">
@ -87,7 +87,19 @@ import { onMounted, onUnmounted } from "vue";
const robotState = ref('online')
const mapCanvasRef = ref(null)
const zoomCanvas = (isZoomIn) => {
if (mapCanvasRef.value) {
mapCanvasRef.value.zoomCanvas(isZoomIn);
}
}
const centerCanvasView = () => {
if (mapCanvasRef.value) {
mapCanvasRef.value.centerCanvasView();
}
}
</script>
<style lang="scss">

View File

@ -0,0 +1,3 @@
<template>
<div>流程管理</div>
</template>

View File

@ -1,3 +1,485 @@
<template>
<div>地图管理</div>
<div class="app-container">
<div ref="topContainerRef">
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="地图名称" prop="corpusName">
<el-input v-model="queryParams.corpusName" placeholder="请输入地图名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="地图描述" prop="voiceType">
<el-input v-model="queryParams.voiceType" placeholder="请输入地图描述" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd">新增</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" height="100%" style="width: 100%" :data="tableDataList">
<el-table-column label="地图名称" align="center" prop="corpusName" />
<el-table-column label="地图描述" align="center" prop="textContent" min-width="160" show-overflow-tooltip />
<el-table-column label="创建人" align="center" prop="status" />
<el-table-column label="创建时间" align="center" prop="status" />
<el-table-column label="操作" class-name="small-padding fixed-width" width="200" fixed="right">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize" @pagination="getList" />
</div>
<!-- 添加或修改语料库对话框 -->
<el-dialog :title="title" v-model="open" width="580px" append-to-body>
<el-form ref="corpusRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="地图名称" prop="corpusName">
<el-input v-model="form.corpusName" placeholder="请输入地图名称" />
</el-form-item>
<el-form-item label="地图描述" prop="textContent">
<el-input v-model="form.textContent" type="textarea" placeholder="请输入地图描述" />
</el-form-item>
<el-form-item label="选择机器人地图" prop="dialect">
<el-col :span="11">
<el-form-item prop="date1">
<el-select v-model="form.robotId" placeholder="请选择机器人" clearable style="width: 100%">
<el-option label="机器人1" value="zh"></el-option>
<el-option label="机器人2" value="en"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col class="text-center" :span="2">
<span class="text-gray-500">-</span>
</el-col>
<el-col :span="11">
<el-form-item prop="date2">
<el-select v-model="form.mapId" placeholder="请选择地图" clearable style="width: 100%" @change="mapChange">
<el-option label="地图1" value="zh"></el-option>
<el-option label="地图2" value="en"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-form-item>
<div class="map-container">
<canvas id="mapCanvas"></canvas>
</div>
</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>
</template>
<script setup name="Corpus">
import { useContainerHeight } from "@/hooks/tableHeight";
import { onMounted } from "vue";
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from '../cockpit/canvasUtils';
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const tableDataList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const total = ref(0);
const title = ref("");
const data = reactive({
form: {
type: "WAKEUP",
},
queryParams: {
pageNum: 1,
pageSize: 10,
},
rules: {
corpusName: [
{ required: true, message: "请输入语料名称", trigger: "change" },
],
textContent: [
{ required: true, message: "请输入语音文本", trigger: "blur" },
],
voiceType: [{ required: true, message: "请选择音色", trigger: "blur" }],
dialect: [{ required: true, message: "请选择语种", trigger: "blur" }],
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询列表 */
function getList() {
loading.value = true;
tableDataList.value = [];
total.value = 0;
loading.value = false;
}
//
function cancel() {
open.value = false;
reset();
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
window.removeEventListener('resize', resizeCanvasToContainer)
canvasCtx = null
}
//
function reset() {
form.value = {
};
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
handleQuery();
}
/** 新增按钮操作 */
function handleAdd() {
reset();
open.value = true;
title.value = "添加地图";
setTimeout(() => {
init();
}, 300);
}
/** 修改按钮操作 */
function handleUpdate(row) {
}
/** 提交按钮 */
function submitForm() {
}
/** 删除按钮操作 */
function handleDelete(row) {
}
const loadSourceMap = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`加载失败:${response.status}`);
return response.text();
};
function parseSmapJson(rawJson) {
// header
const header = rawJson.header || rawJson; // header
const minimumPos = header.minPos || { x: 0, y: 0 }; //
const maximumPos = header.maxPos || { x: 1, y: 1 }; //
const resolution = header.resolution || 0.05; //
//
const worldWidth = maximumPos.x - minimumPos.x; //
const worldHeight = maximumPos.y - minimumPos.y; //
const gridWidthPx = Math.ceil(worldWidth / resolution) + 1; //
const gridHeightPx = Math.ceil(worldHeight / resolution) + 1;//
//
const parsedMap = {
name: header.mapName || 'Untitled', //
version: header.version || '', //
mapType: header.mapType || '', // "2D-Map"
resolution: resolution, // /
originX: minimumPos.x, // X
originY: minimumPos.y, // Y
maxX: maximumPos.x, // X
maxY: maximumPos.y, // Y
gridWidth: gridWidthPx, //
gridHeight: gridHeightPx, //
// normalPosList:
normalPositions: rawJson.normalPosList || [],
// normalLineList: {startPos:{x,y}, endPos:{x,y}}
normalLines: rawJson.normalLineList || [],
// advancedPointList: {className, instanceName, pos:{x,y}, dir, desc, property:[]}
advancedPoints: rawJson.advancedPointList || [],
// advancedLineList: {className, instanceName, line:{startPos, endPos}}
advancedLines: rawJson.advancedLineList || [],
// advancedCurveList: {className, instanceName, controlPos1, controlPos2, startPos, endPos}
advancedCurves: rawJson.advancedCurveList || [],
// advancedAreaList: {className, instanceName, dir, posGroup:[], attribute:{}}
advancedAreas: rawJson.advancedAreaList || [],
};
return parsedMap;
}
const applicationState = {
parsedMap: null, //
gridOffscreen: null, // Canvas
hoveredElement: null, // {kind, index}
animationTimeSeconds: 0, //
camera: { //
centerX: 0, // X
centerY: 0, // Y
pixelsPerMeter: 40, //
},
canvasWidthPx: 0, // Canvas CSS
canvasHeightPx: 0, // Canvas CSS
devicePixelRatio: 1, // DPI > 1
mouseState: { //
screenX: 0, // Canvas X
screenY: 0, // Canvas Y
worldX: 0, // X
worldY: 0, // Y
isDragging: false, //
},
isMapLoaded: false, // ,
layerVisibility: { //
grid: true, //
edges: true, //
gridlines: true, // 1线
normalLines: true, // normalLineList
advLines: true, // advancedLineList
curves: true, // advancedCurveList
points: true, // advancedPointList
areas: true, // advancedAreaList
}
};
let mapCanvas; // DOM
let canvasCtx; // 2D
const init = () => {
mapCanvas = document.getElementById('mapCanvas');
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); //
console.log('Container size:', containerRect.width, 'x', containerRect.height);
devicePixelRatio = window.devicePixelRatio || 1; //
applicationState.canvasWidthPx = containerRect.width; //
applicationState.canvasHeightPx = containerRect.height; // = -
mapCanvas.width = applicationState.canvasWidthPx * devicePixelRatio; // Canvas
mapCanvas.height = applicationState.canvasHeightPx * devicePixelRatio; // Canvas
mapCanvas.style.width = applicationState.canvasWidthPx + 'px'; // CSS
mapCanvas.style.height = applicationState.canvasHeightPx + 'px'; // CSS
}
async function loadMapIntoApplication(parsedMap) {
await new Promise(resolve => setTimeout(resolve, 30)); // UI
//
applicationState.parsedMap = parsedMap;
//
applicationState.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
//
applicationState.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; //
applicationState.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; //
// 使
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; //
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; //
const fitScaleX = applicationState.canvasWidthPx / worldWidthMeters; //
const fitScaleY = applicationState.canvasHeightPx / worldHeightMeters; //
applicationState.camera.pixelsPerMeter = Math.max(1, Math.min(fitScaleX, fitScaleY) * 0.88); // 12%
// UI
applicationState.isMapLoaded = true;
}
const bindEvent = () => {
//
mapCanvas.addEventListener('mousemove', (mouseEvent) => {
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas
applicationState.mouseState.screenX = mouseEvent.clientX - canvasRect.left; // Canvas X
applicationState.mouseState.screenY = mouseEvent.clientY - canvasRect.top; // Canvas Y
//
const worldPos = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState);
applicationState.mouseState.worldX = worldPos.x;
applicationState.mouseState.worldY = worldPos.y;
//
if (applicationState.mouseState.isDragging) {
applicationState.camera.centerX -= mouseEvent.movementX / applicationState.camera.pixelsPerMeter; //
applicationState.camera.centerY += mouseEvent.movementY / applicationState.camera.pixelsPerMeter; // Y
}
});
//
mapCanvas.addEventListener('mousedown', () => {
applicationState.mouseState.isDragging = true; //
});
//
mapCanvas.addEventListener('mouseup', () => {
applicationState.mouseState.isDragging = false; //
});
// Tooltip
mapCanvas.addEventListener('mouseleave', () => {
applicationState.mouseState.isDragging = false;
});
//
mapCanvas.addEventListener('wheel', (wheelEvent) => {
wheelEvent.preventDefault(); //
const zoomFactor = wheelEvent.deltaY > 0 ? 0.88 : 1.12; // ==
const mouseWorldBefore = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); //
applicationState.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor)); //
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); //
// 使
applicationState.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
applicationState.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
}, { passive: false }); // passive:false preventDefault
}
/**
* 加载 smap JSON 文件
*/
const loadUserFile = async () => {
try {
const fileTextContent = await loadSourceMap('/3.smap');
const rawJsonObject = JSON.parse(fileTextContent); // JSON
const parsedMap = parseSmapJson(rawJsonObject); //
await loadMapIntoApplication(parsedMap); //
} catch (parseError) {
alert('Parse error: ' + parseError.message); //
}
}
// requestAnimationFrame ID
let animationId = null;
function renderFrame(timestamp) {
console.log("渲染中")
//
applicationState.animationTimeSeconds = (timestamp || 0) / 1000;
// Canvas
canvasCtx.setTransform(applicationState.devicePixelRatio, 0, 0, applicationState.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, applicationState.canvasWidthPx, applicationState.canvasHeightPx); //
//
if (!applicationState.isMapLoaded) {
animationId = requestAnimationFrame(renderFrame); //
return;
}
const map = applicationState.parsedMap; //
const layers = applicationState.layerVisibility; //
// 1:
if (layers.grid && applicationState.gridOffscreen) {
drawGrid(canvasCtx, map, applicationState);
}
// 2: 线1
if (layers.gridlines) {
drawGridlines(canvasCtx, applicationState)
}
// 3: advancedAreaList
if (layers.areas) {
drawAdvancedAreaList(canvasCtx, map, applicationState)
}
// 4: normalLineList线 #00e5ff
if (layers.normalLines) {
drawNormalLines(canvasCtx, map, applicationState);
}
// 5: advancedLineList线 ForbiddenLine
if (layers.advLines) {
drawAdvancedLineList(canvasCtx, map, applicationState);
}
// 6: advancedCurveList线绿 #76ff03
if (layers.curves) {
drawAdvancedCurveList(canvasCtx, map, applicationState);
}
// 7: advancedPointList
if (layers.points) {
drawAdvancedPointList(canvasCtx, map, applicationState);
}
// 线
drawMapBoundary(canvasCtx, map, applicationState);
//
animationId = requestAnimationFrame(renderFrame);
}
const mapChange = () => {
loadUserFile();
window.addEventListener('resize', resizeCanvasToContainer); //
resizeCanvasToContainer();
if (animationId) {
cancelAnimationFrame(animationId);
}
animationId = requestAnimationFrame(renderFrame);
setTimeout(() => {
bindEvent()
}, 100)
}
onMounted(() => {
getList();
});
</script>
<style lang="scss" scoped>
.map-container {
width: 100%;
height: 300px;
border: 1px solid #ccc;
}
</style>

View File

@ -1,3 +0,0 @@
<template>
<div>点位管理</div>
</template>

View File

@ -0,0 +1,241 @@
<template>
<div class="app-container">
<div ref="topContainerRef">
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="100px">
<el-form-item label="机器人名称" prop="name">
<el-input v-model="queryParams.name" placeholder="请输入机器人名称" clearable
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="IP地址" prop="ipAddress">
<el-input v-model="queryParams.ipAddress" placeholder="请输入IP地址" clearable
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd">新增</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" height="100%" style="width: 100%" :data="tableDataList">
<el-table-column label="机器人名称" align="center" prop="name" />
<el-table-column label="IP地址" align="center" prop="ipAddress" min-width="160" show-overflow-tooltip />
<el-table-column label="电量" align="center" prop="battery" />
<el-table-column label="状态" align="center" prop="status" />
<el-table-column label="当前地图" align="center" prop="currentMap" />
<el-table-column label="操作" class-name="small-padding fixed-width" width="200" fixed="right">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize" @pagination="getList" />
</div>
<!-- 添加或修改语料库对话框 -->
<el-dialog :title="title" v-model="open" width="580px" append-to-body>
<el-form :model="form" :rules="rules" label-width="100px">
<el-form-item label="机器人名称" prop="name">
<el-input v-model="form.name" placeholder="请输入机器人名称" />
</el-form-item>
<el-form-item label="IP地址" prop="ipAddress">
<el-input class="test" v-model="form.ipAddress" placeholder="请输入IP地址" @input="ipChange">
<template #append>
<div class="connection" :class="{ success: connectionStatus }" @click="testConnection">
{{ connectionStatus ? '连接成功' : '测试连接' }}
</div>
</template>
</el-input>
</el-form-item>
<el-form-item label="地图选择" prop="currentMap">
<el-select v-model="form.currentMap" placeholder="请选择地图" clearable style="width: 100%">
<el-option v-for="map in mapList" :key="map.value" :label="map.label" :value="map.value" />
</el-select>
</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>
</template>
<script setup name="Corpus">
import { ElMessage } from "element-plus";
import { useContainerHeight } from "@/hooks/tableHeight";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const tableDataList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const total = ref(0);
const title = ref("");
const mapList = ref([
{ label: "地图1", value: "map1" },
{ label: "地图2", value: "map2" },
{ label: "地图3", value: "map3" },
])
const data = reactive({
form: {
name: "",
ipAddress: "",
currentMap: ""
},
queryParams: {
pageNum: 1,
pageSize: 10,
},
rules: {
name: [
{ required: true, message: "请输入机器人名称", trigger: "change" },
],
ipAddress: [
{ required: true, message: "请输入IP地址", trigger: "blur" },
]
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询语料库列表 */
function getList() {
loading.value = true;
tableDataList.value = [];
total.value = 0;
loading.value = false;
}
//
function cancel() {
open.value = false;
reset();
}
//
function reset() {
form.value = {
name: "",
ipAddress: "",
currentMap: ""
};
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
handleQuery();
}
/** 新增按钮操作 */
function handleAdd() {
reset();
open.value = true;
title.value = "添加机器人";
}
const connectionStatus = ref(false);
const testConnection = () => {
//
if (form.value.ipAddress) {
if (Math.random() > 0.5) {
connectionStatus.value = true;
ElMessage.success("连接成功!");
} else {
connectionStatus.value = false;
ElMessage.error("连接失败!");
}
} else {
ElMessage.error("请输入IP地址进行测试连接");
}
}
const ipChange = () => {
connectionStatus.value = false; // IP
}
/** 修改按钮操作 */
function handleUpdate(row) {
}
/** 提交按钮 */
function submitForm() {
}
/** 删除按钮操作 */
function handleDelete(row) {
}
getList();
</script>
<style lang="scss" scoped>
.test {
position: relative;
:deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px var(--el-input-border-color, var(--el-border-color)) inset;
&::after {
content: '';
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 1px;
background-color: #fff; /* 覆盖右边阴影,需要与背景色相同 */
}
}
:deep(.el-input-group__append) {
background-color: transparent;
.connection {
background: #dcdfdf;
font-size: 12px;
padding: 0 10px;
border-radius: 4px;
cursor: pointer;
height: calc(100% - 8px);
display: flex;
align-items: center;
}
.success {
background: #67c23a;
color: #fff;
}
}
}
</style>

View File

@ -51,7 +51,7 @@ export default defineConfig(({mode, command}) => {
//李小龙 http://192.168.0.5:13080
// dev http://10.148.20.34:13080
// target: VITE_API_URL,
target: command === 'build' ? VITE_API_URL : 'http://10.148.121.100:13080',
target: command === 'build' ? VITE_API_URL : 'http://192.168.28.10:13080',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/dev-api/, '')
}