Files
robot_manager/src/views/dashboard1/pages/IntegratedCenterPage.vue
huaqiang 3dc1a1d313 feat(dashboard1): 首页地图支持三维点云展示,机器人配置支持上传 PCD
- 设备管理机器人配置弹窗新增点云 PCD 上传,地址保存到 ros_car.mapPcdUrl
- 首页地图新增 2D/3D 切换,三维视图动态加载 three.js 渲染 PCD 点云
- 三维视图:按 z 直方图估算地面使小车与路径贴地、四轮小车模型、地面绘制巡检路径、放大预览支持 WASD 移动视角
- 地面按占用栅格生成连续平面,并剔除地面带内的点云,避免重复渲染
- PCD 解析支持 ascii/binary/binary_compressed,按流畅/标准/精细三档均匀抽稀
- 二维放大保留原拖拽平移与滚轮缩放
2026-09-26 23:38:37 +08:00

2251 lines
76 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { NImage } from "naive-ui";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
type Ref,
} from "vue";
import {
getRecentAlarmList,
type RosInspectionRecordLineImage,
type RosInspectionRecordLineImageQueryDTO,
} from "@/api/alarm";
import {
dispatchRosInspectionTask,
getCurrentRosInspection,
getRosCarList,
getRosInspectionTaskDetail,
startRosCharging,
stopDispatchRosInspectionTask,
undockRosCharging,
type RosChargingResult,
} from "@/api/rosCar";
import { getToken } from "@/utils/auth";
import { parseRosMapYaml } from "@/utils/rosMapYaml";
import { BASE_URL } from "@/utils/request";
import { getRecentAlertPhoto } from "../shared/alertPhotos";
import usePointCloud3D from "../hooks/usePointCloud3D";
// 地图上的机器人标记图标(设计稿 巡检机器人_03,8 倍图,放大时不易模糊)
import robotIcon from "@/assets/robot-marker-8x.png";
// 未配置地图时展示的默认隧道示意图
import emptyMapImage from "@/assets/emptyMap.png";
const defaultProject = "西江隧道";
type DriveMode = "阿克曼" | "旋转" | "横移";
// 视频监控的摄像头通道:白光镜头 / 红外镜头
type CameraChannelKey = "white" | "infrared";
const cameraChannelKeys: CameraChannelKey[] = ["white", "infrared"];
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
// 地图视图模式:二维栅格地图 / 三维点云地图
type MapViewMode = "2d" | "3d";
// 三维点云质量档位:直接影响渲染点数与帧率
type PointCloudQuality = "low" | "normal" | "high";
const pointCloudQualityOptions: Array<{ value: PointCloudQuality; label: string; maxPoints: number }> = [
{ value: "low", label: "流畅", maxPoints: 300000 },
{ value: "normal", label: "标准", maxPoints: 800000 },
{ value: "high", label: "精细", maxPoints: 2000000 },
];
type ControlMessageType = "cmd_vel" | "ptz_control" | "lift_control";
type PtzAction = "up" | "down" | "left" | "right" | "stop";
interface RosCarRecord {
id?: string;
deviceKey?: string;
deviceNo?: string;
name?: string;
batteryLevel?: number;
status?: number;
projectName?: string;
poseX?: number;
poseY?: number;
poseYaw?: number;
mapPgmUrl?: string;
mapYamlUrl?: string;
mapPcdUrl?: string;
cameraWebrtcUrl?: string;
cameraWebrtcUrl2?: string;
liftHeight?: number;
liftDeviceStatusDescription?: string;
dockStatus?: string;
dockActive?: boolean;
}
interface RosCarListResult {
data?: RosCarRecord[];
}
interface RosCarListResponse {
data?: RosCarListResult;
}
interface RecentAlarmListResponse {
data?: RosInspectionRecordLineImage[];
}
interface CarStatusPushData {
deviceKey?: string;
liftHeight?: number;
liftDeviceStatusDescription?: string;
batteryLevel?: number;
voltage?: number;
poseX?: number;
poseY?: number;
poseYaw?: number;
mapPgmUrl?: string;
mapYamlUrl?: string;
mapPcdUrl?: string;
status?: number;
signal?: string;
dockStatus?: string;
dockActive?: boolean;
}
interface CarVelocityPushData {
deviceKey?: string;
velocity?: {
linearX?: number;
angularZ?: number;
};
}
type ControlSocketMessageType =
| "control_result"
| "car_status"
| "car_velocity"
| "inspection_update"
| "inspection_record_stopped"
| "warning_update";
interface ControlSocketMessage {
type?: ControlSocketMessageType | string;
data?: {
accepted?: boolean;
message?: string;
} & CarStatusPushData & CarVelocityPushData;
}
interface DeviceItem {
id: string;
carId: string;
deviceKey: string;
name: string;
status: DeviceStatus;
location: string;
battery: string;
task: string;
linearSpeed: string;
angularSpeed: string;
liftHeight: string;
liftStatus: string;
poseX?: number;
poseY?: number;
poseYaw?: number;
mapPgmUrl: string;
mapYamlUrl: string;
mapPcdUrl: string;
whiteLightUrl: string;
infraredUrl: string;
dockStatus?: string;
dockActive?: boolean;
}
interface ProjectItem {
id: number;
name: string;
devices: DeviceItem[];
}
interface NavPoint {
x: number;
y: number;
}
interface NavScenario {
routePath: string;
lanePath: string;
upperEdgePath: string;
lowerEdgePath: string;
upperWallPoints: NavPoint[];
lowerWallPoints: NavPoint[];
checkPoints: Array<{ x: number; y: number; label: string }>;
}
interface RosMapMeta {
resolution: number;
origin: [number, number, number];
}
interface RosMapState extends RosMapMeta {
imageUrl: string;
width: number;
height: number;
viewBox: string;
}
interface RobotMapPoint {
x: number;
y: number;
yaw: number;
angle: number;
}
interface InspectionRouteNode {
type?: "via_point" | "action_point";
poseX: number;
poseY: number;
nodeIndex: number;
}
interface InspectionTaskDetail {
id?: string;
nodes?: InspectionRouteNode[];
}
interface InspectionTaskDetailResponse {
data?: InspectionTaskDetail;
}
interface RosInspectionRecord {
taskId?: string;
roundCount?: number;
currentRoundIndex?: number;
startTime?: string;
status?: number;
currentNodeIndex?: number;
totalActionCount?: number;
}
interface CurrentInspectionResponse {
data?: RosInspectionRecord | null;
}
interface RosDispatchResultResponse {
data?: {
recordId?: string;
message?: string;
};
msg?: string;
}
interface RosChargingResultResponse {
data?: RosChargingResult;
msg?: string;
}
interface AlertItem {
id: string;
title: string;
result: string;
time: string;
photo: string;
}
const emptyDevice: DeviceItem = {
id: "--",
carId: "",
deviceKey: "",
name: "暂无设备",
status: "离线",
location: "位置未上报",
battery: "--",
task: "暂无任务",
linearSpeed: "--",
angularSpeed: "--",
liftHeight: "--",
liftStatus: "--",
poseX: undefined,
poseY: undefined,
poseYaw: undefined,
mapPgmUrl: "",
mapYamlUrl: "",
mapPcdUrl: "",
whiteLightUrl: "",
infraredUrl: "",
};
const projectDevices = ref<ProjectItem[]>([
{
id: 1,
name: defaultProject,
devices: [],
},
]);
// 将接口的小车状态码转换为集成中心展示状态
function normalizeDeviceStatus(status?: number): DeviceStatus {
const statusMap: Record<number, DeviceStatus> = {
0: "空闲",
1: "巡检",
2: "离线",
3: "充电中",
};
return statusMap[status ?? 2] ?? "离线";
}
// 根据状态生成当前任务说明
function buildDeviceTask(status: DeviceStatus) {
const taskMap: Record<DeviceStatus, string> = {
空闲: "待命",
巡检: "巡检中",
离线: "离线",
充电中: "回库充电",
};
return taskMap[status];
}
// 将设备管理列表接口数据转换为集成中心设备卡片数据
function normalizeDeviceRecord(car: RosCarRecord, index: number): DeviceItem {
const status = normalizeDeviceStatus(car.status);
const location = typeof car.poseX === "number" && typeof car.poseY === "number"
? `X:${car.poseX.toFixed(2)} / Y:${car.poseY.toFixed(2)}`
: "位置未上报";
const carId = car.id || "";
const deviceKey = car.deviceKey || car.deviceNo || car.id || `ROBOT-${index + 1}`;
return {
id: car.deviceNo || car.id || deviceKey,
carId,
deviceKey,
name: car.name || "--",
status,
location,
battery: typeof car.batteryLevel === "number" ? `${car.batteryLevel}%` : "--",
task: buildDeviceTask(status),
linearSpeed: "--",
angularSpeed: "--",
liftHeight: typeof car.liftHeight === "number" ? String(car.liftHeight) : "--",
liftStatus: car.liftDeviceStatusDescription || "--",
poseX: car.poseX,
poseY: car.poseY,
poseYaw: car.poseYaw,
mapPgmUrl: car.mapPgmUrl || "",
mapYamlUrl: car.mapYamlUrl || "",
mapPcdUrl: car.mapPcdUrl || "",
whiteLightUrl: car.cameraWebrtcUrl || "",
infraredUrl: car.cameraWebrtcUrl2 || "",
dockStatus: car.dockStatus,
dockActive: car.dockActive ?? false,
};
}
// 复用设备管理页的小车分页列表数据刷新集成中心设备列表
async function fetchDeviceList() {
try {
const response = (await getRosCarList({
page: 1,
limit: 100,
query: {},
})) as RosCarListResponse;
const rows = response.data?.data ?? [];
const projectName = rows[0]?.projectName || defaultProject;
const devices = rows.map((car, index) => normalizeDeviceRecord(car, index));
projectDevices.value = [
{
id: 1,
name: projectName,
devices,
},
];
if (!devices.some((device) => device.id === activeDeviceId.value)) {
activeDeviceId.value = devices[0]?.id ?? "";
syncLiftTargetFromDevice(devices[0]);
}
} catch (error) {
console.error(error);
}
}
// 请求近期告警接口并刷新监管中心告警卡片
async function fetchRecentAlerts() {
alertLoading.value = true;
alertError.value = "";
const query: RosInspectionRecordLineImageQueryDTO = {
recognizeType: recognizeTypeCodeMap[activeAlertTab.value],
};
try {
const response = (await getRecentAlarmList(query)) as RecentAlarmListResponse;
const rows = Array.isArray(response.data) ? response.data : [];
recentAlerts.value = rows.map((record, index) => normalizeAlertRecord(record, index));
} catch (error) {
alertError.value = "近期告警加载失败";
recentAlerts.value = [];
console.error(error);
} finally {
alertLoading.value = false;
}
}
// 将后端识别记录转换为监管中心告警卡片数据
function normalizeAlertRecord(record: RosInspectionRecordLineImage, index: number): AlertItem {
const title = getRecognizeTypeText(record.recognizeType);
return {
id: record.id || record.recordActionId || record.recordId || `AL-${index + 1}`,
title,
result: record.warningValue || record.recognitionResult || record.failReason || "--",
time: record.captureTime || record.createTime || "--",
photo: buildFileAccessUrl(record.imageUrl || "") || getRecentAlertPhoto(title),
};
}
function getRecognizeTypeText(type?: number) {
return type ? recognizeTypeMap[type] ?? "未知告警" : "未知告警";
}
// 相对文件地址通过接口代理访问,避免开发环境直接请求后端静态路径失败
function buildFileAccessUrl(fileUrl: string) {
if (
!fileUrl ||
/^(https?:)?\/\//i.test(fileUrl) ||
fileUrl.startsWith("blob:") ||
fileUrl.startsWith("data:")
) {
return fileUrl;
}
if (fileUrl === BASE_URL || fileUrl.startsWith(`${BASE_URL}/`)) {
return fileUrl;
}
return `${BASE_URL}${fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`}`;
}
function readPgmToken(bytes: Uint8Array, cursor: { index: number }) {
while (cursor.index < bytes.length) {
const code = bytes[cursor.index];
if (code === 35) {
while (cursor.index < bytes.length && bytes[cursor.index] !== 10) {
cursor.index += 1;
}
} else if (code <= 32) {
cursor.index += 1;
} else {
break;
}
}
const start = cursor.index;
while (cursor.index < bytes.length && bytes[cursor.index] > 32) {
cursor.index += 1;
}
return new TextDecoder("ascii").decode(bytes.slice(start, cursor.index));
}
// 将 ROS PGM 黑白占据栅格重绘为大屏风格底图
function buildStyledPgmMap(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
const cursor = { index: 0 };
const magic = readPgmToken(bytes, cursor);
const width = Number(readPgmToken(bytes, cursor));
const height = Number(readPgmToken(bytes, cursor));
const maxValue = Number(readPgmToken(bytes, cursor));
if (magic !== "P5" || !width || !height || !maxValue) {
throw new Error("暂只支持 P5 二进制 PGM 地图");
}
while (cursor.index < bytes.length && bytes[cursor.index] <= 32) {
cursor.index += 1;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
throw new Error("浏览器不支持 Canvas 地图渲染");
}
canvas.width = width;
canvas.height = height;
const imageData = context.createImageData(width, height);
const twoBytePixel = maxValue > 255;
for (let index = 0; index < width * height; index += 1) {
const sourceIndex = cursor.index + (twoBytePixel ? index * 2 : index);
const rawValue = twoBytePixel
? ((bytes[sourceIndex] ?? 0) << 8) + (bytes[sourceIndex + 1] ?? 0)
: bytes[sourceIndex] ?? 0;
const gray = Math.round((rawValue / maxValue) * 255);
const targetIndex = index * 4;
if (gray < 80) {
imageData.data[targetIndex] = 70;
imageData.data[targetIndex + 1] = 226;
imageData.data[targetIndex + 2] = 255;
imageData.data[targetIndex + 3] = 220;
} else if (gray > 220) {
imageData.data[targetIndex] = 16;
imageData.data[targetIndex + 1] = 78;
imageData.data[targetIndex + 2] = 124;
imageData.data[targetIndex + 3] = 128;
} else {
imageData.data[targetIndex] = 9;
imageData.data[targetIndex + 1] = 22;
imageData.data[targetIndex + 2] = 42;
imageData.data[targetIndex + 3] = 42;
}
}
context.putImageData(imageData, 0, 0);
return {
imageUrl: canvas.toDataURL("image/png"),
width,
height,
};
}
const recognizeTypeMap: Record<number, string> = {
100: "局部高温",
1: "电缆表面破损",
2: "桥架断裂",
3: "隧道积水",
4: "墙体渗漏",
};
const recognizeTypeCodeMap = Object.fromEntries(
Object.entries(recognizeTypeMap).map(([value, label]) => [label, Number(value)]),
) as Record<string, number>;
const recentAlerts = ref<AlertItem[]>([]);
const mapScale = ref(1);
const mapOffset = ref({ x: 0, y: 0 });
const mapDragState = ref({
dragging: false,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
});
const rosMap = ref<RosMapState | null>(null);
// 三维点云地图:与二维地图共用同一套 map 坐标系
const mapViewMode = ref<MapViewMode>("2d");
const pointCloudQuality = ref<PointCloudQuality>("normal");
const pointCloudMaxPoints = computed(
() => pointCloudQualityOptions.find((option) => option.value === pointCloudQuality.value)?.maxPoints ?? 800000,
);
const map3dContainerRef = ref<HTMLDivElement | null>(null);
const {
loading: pointCloudLoading,
error: pointCloudError,
info: pointCloudInfo,
mount: mountPointCloud,
loadPointCloud,
setRoute: setPointCloudRoute,
setRobotPose: setPointCloudRobotPose,
setKeyboardNavigation: setPointCloudKeyboardNavigation,
resetView: resetPointCloudView,
} = usePointCloud3D(map3dContainerRef);
const mapLoading = ref(false);
const mapError = ref("");
const routeNodes = ref<InspectionRouteNode[]>([]);
const routeLoading = ref(false);
const routeError = ref("");
const inspectionTaskId = ref("");
const currentInspection = ref<RosInspectionRecord | null>(null);
const inspectionLoading = ref(false);
const inspectionActionLoading = ref(false);
const chargingActionLoading = ref(false);
const undockingActionLoading = ref(false);
const lastDispatchTime = ref("");
const alertLoading = ref(false);
const alertError = ref("");
const alertTabs = ["局部高温", "电缆表面破损", "桥架断裂", "隧道积水", "墙体渗漏"] as const;
const activeAlertTab = ref<(typeof alertTabs)[number]>("局部高温");
const activeDeviceId = ref("");
const driveModeTabs: DriveMode[] = ["阿克曼", "旋转", "横移"];
const activeDriveMode = ref<DriveMode>("阿克曼");
const presetPointOptions = Array.from(
{ length: 10 },
(_, index) => `预置点${index + 1}`,
);
const selectedPresetPoint = ref("预置点1");
const controlMode = ref<"人工接管" | "自主巡检">("自主巡检");
const maxLiftHeight = 800;
const targetLiftHeight = ref("1200");
const navMapSvgRef = ref<SVGSVGElement | null>(null);
const controlWsStatus = ref("未连接");
const controlError = ref("");
// 视频监控同时展示白光/红外两路摄像头,各自持有独立的播放器与拉流状态
const whiteVideoRef = ref<HTMLVideoElement | null>(null);
const infraredVideoRef = ref<HTMLVideoElement | null>(null);
const cameraVideoRefs: Record<CameraChannelKey, Ref<HTMLVideoElement | null>> = {
white: whiteVideoRef,
infrared: infraredVideoRef,
};
const cameraLoading = reactive<Record<CameraChannelKey, boolean>>({
white: false,
infrared: false,
});
const cameraError = reactive<Record<CameraChannelKey, string>>({
white: "",
infrared: "",
});
// WebRTC 连接对象不参与响应式代理,避免 Proxy 包裹导致原生方法调用异常
const cameraStreams: Record<
CameraChannelKey,
{ requestId: number; peerConnection: RTCPeerConnection | null }
> = {
white: { requestId: 0, peerConnection: null },
infrared: { requestId: 0, peerConnection: null },
};
let controlSocket: WebSocket | null = null;
let controlRequestSeed = 0;
let controlReconnectTimer: number | null = null;
let controlSocketManualClose = false;
let chassisControlTimer: number | null = null;
let ptzControlTimer: number | null = null;
// let inspectionPollTimer: number | null = null;
let chassisControlling = false;
let ptzControlling = false;
const navScenario: NavScenario = {
routePath:
"M 106 318 L 154 306 L 208 292 L 256 278 L 306 264 L 358 258 L 414 258 L 468 248 L 522 241 L 578 234 L 638 226",
lanePath:
"M 82 270 L 136 258 L 190 244 L 244 230 L 296 216 L 350 208 L 408 208 L 464 198 L 518 190 L 576 182 L 642 174 L 656 188 L 650 278 L 586 286 L 528 294 L 472 302 L 414 312 L 358 312 L 304 320 L 252 334 L 198 348 L 142 360 L 88 370 L 74 356 Z",
upperEdgePath:
"M 82 270 L 136 258 L 190 244 L 244 230 L 296 216 L 350 208 L 408 208 L 464 198 L 518 190 L 576 182 L 642 174",
lowerEdgePath:
"M 88 370 L 142 360 L 198 348 L 252 334 L 304 320 L 358 312 L 414 312 L 472 302 L 528 294 L 586 286 L 650 278",
upperWallPoints: [
{ x: 64, y: 164 },
{ x: 84, y: 150 },
{ x: 104, y: 142 },
{ x: 126, y: 136 },
{ x: 148, y: 132 },
{ x: 172, y: 126 },
{ x: 196, y: 122 },
{ x: 224, y: 118 },
{ x: 252, y: 116 },
{ x: 280, y: 112 },
{ x: 312, y: 110 },
{ x: 344, y: 108 },
{ x: 378, y: 108 },
{ x: 412, y: 110 },
{ x: 448, y: 112 },
{ x: 484, y: 114 },
{ x: 520, y: 116 },
{ x: 556, y: 120 },
{ x: 590, y: 126 },
{ x: 622, y: 134 },
{ x: 650, y: 144 },
{ x: 676, y: 158 },
{ x: 116, y: 170 },
{ x: 214, y: 156 },
{ x: 326, y: 146 },
{ x: 446, y: 148 },
{ x: 566, y: 160 },
{ x: 638, y: 172 },
],
lowerWallPoints: [
{ x: 72, y: 380 },
{ x: 96, y: 392 },
{ x: 122, y: 404 },
{ x: 150, y: 410 },
{ x: 180, y: 416 },
{ x: 214, y: 420 },
{ x: 248, y: 422 },
{ x: 284, y: 424 },
{ x: 322, y: 426 },
{ x: 360, y: 426 },
{ x: 400, y: 424 },
{ x: 440, y: 420 },
{ x: 480, y: 414 },
{ x: 520, y: 408 },
{ x: 558, y: 400 },
{ x: 594, y: 392 },
{ x: 628, y: 384 },
{ x: 660, y: 374 },
{ x: 700, y: 366 },
{ x: 126, y: 376 },
{ x: 238, y: 392 },
{ x: 354, y: 396 },
{ x: 474, y: 388 },
{ x: 590, y: 372 },
],
checkPoints: [
{ x: 106, y: 318, label: "起点" },
{ x: 358, y: 258, label: "途经点" },
{ x: 638, y: 226, label: "终点" },
],
};
const currentProject = computed(() => projectDevices.value[0]);
const currentDevices = computed(() => currentProject.value?.devices ?? []);
const currentRobot = computed(
() => currentDevices.value.find((device) => device.id === activeDeviceId.value) ?? currentDevices.value[0] ?? emptyDevice,
);
// 白光/红外两路摄像头的当前播放地址
const cameraUrls = computed(() => ({
white: currentRobot.value.whiteLightUrl,
infrared: currentRobot.value.infraredUrl,
}));
const currentScenario = computed(() => navScenario);
const hasRobotPose = computed(
() => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number",
);
// 当前机器人是否已在后台配置点云文件,未配置时三维视图给出提示
const hasPointCloud = computed(() => Boolean(currentRobot.value.mapPcdUrl));
// 回充状态文案:由 charging_dock 节点的 /dock_status 上报,未上报时给出中性提示
const chargingStatusText = computed(() => {
const device = currentRobot.value;
if (device.dockActive) {
return device.dockStatus || "自动回充进行中";
}
return device.dockStatus || "回充状态未上报";
});
const chargingButtonText = computed(() => {
if (currentRobot.value.dockActive) {
return "回充中...";
}
return chargingActionLoading.value ? "处理中..." : "自动回充";
});
const undockingButtonText = computed(() => {
if (currentRobot.value.dockActive) {
return "处理中...";
}
return undockingActionLoading.value ? "处理中..." : "充电脱离";
});
// 将 ROS 米制坐标转换成地图图片像素坐标,路线和机器人定位共用同一套转换
function rosPoseToMapPoint(poseX: number, poseY: number, poseYaw = 0): RobotMapPoint | null {
if (!rosMap.value) {
return null;
}
const x = (poseX - rosMap.value.origin[0]) / rosMap.value.resolution;
const y = rosMap.value.height - (poseY - rosMap.value.origin[1]) / rosMap.value.resolution;
return {
x: Math.min(Math.max(x, 0), rosMap.value.width),
y: Math.min(Math.max(y, 0), rosMap.value.height),
yaw: poseYaw,
angle: -poseYaw * 180 / Math.PI,
};
}
const robotMapPoint = computed(() => {
if (!hasRobotPose.value) {
return null;
}
return rosPoseToMapPoint(
currentRobot.value.poseX as number,
currentRobot.value.poseY as number,
currentRobot.value.poseYaw ?? 0,
);
});
// 机器人图标尺寸:宽度固定为地图点位直径的两倍(点位半径 3 → 直径 6 → 图标宽 12),
// 与点位共用同一坐标系,缩放时与点保持固定比例
const robotIconWidth = computed(() => (rosMap.value ? 12 : 28));
const robotIconHeight = computed(() => robotIconWidth.value * (30 / 55));
const mapContentTransform = computed(() => {
if (!rosMap.value) {
return "";
}
const centerX = rosMap.value.width / 2;
const centerY = rosMap.value.height / 2;
return `translate(${mapOffset.value.x}, ${mapOffset.value.y}) translate(${centerX}, ${centerY}) scale(${mapScale.value}) translate(${-centerX}, ${-centerY})`;
});
const routeMapPoints = computed(() => routeNodes.value
.map((node) => rosPoseToMapPoint(node.poseX, node.poseY))
.filter((point): point is RobotMapPoint => Boolean(point)));
const realRoutePath = computed(() => routeMapPoints.value
.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
.join(" "));
const realCheckPoints = computed(() => routeMapPoints.value.map((point, index) => ({
...point,
label: index === 0 ? "起点" : index === routeMapPoints.value.length - 1 ? "终点" : "",
})));
const inspectionRunning = computed(() => currentInspection.value?.status === 0);
const inspectionStatusText = computed(() => inspectionRunning.value ? "巡检中" : "空闲");
const currentRoundText = computed(() => {
if (!inspectionRunning.value) {
return "--/--";
}
return `${currentInspection.value?.currentRoundIndex ?? "--"}/${currentInspection.value?.roundCount ?? "--"}`;
});
const currentInspectionPointText = computed(() => {
if (!inspectionRunning.value) {
return "--/--";
}
return `${currentInspection.value?.currentNodeIndex ?? "--"}/${currentInspection.value?.totalActionCount ?? "--"}`;
});
const filteredRecentAlerts = computed(() =>
recentAlerts.value.filter((alert) => alert.title === activeAlertTab.value),
);
// 加载当前机器人携带的 PGM/YAML 地图,并转换为可叠加真实坐标的前端地图
async function loadRobotMap(device: DeviceItem) {
const mapPgmUrl = device.mapPgmUrl;
const mapYamlUrl = device.mapYamlUrl;
rosMap.value = null;
mapError.value = "";
if (!mapPgmUrl || !mapYamlUrl) {
mapError.value = "当前机器人未配置地图文件";
return;
}
mapLoading.value = true;
try {
const [pgmResponse, yamlResponse] = await Promise.all([
fetch(buildFileAccessUrl(mapPgmUrl)),
fetch(buildFileAccessUrl(mapYamlUrl)),
]);
if (!pgmResponse.ok) {
throw new Error("地图 PGM 文件访问失败");
}
if (!yamlResponse.ok) {
throw new Error("地图 YAML 文件访问失败");
}
const styledMap = buildStyledPgmMap(await pgmResponse.arrayBuffer());
const parsedMeta = parseRosMapYaml(await yamlResponse.text());
const meta: RosMapMeta = {
resolution: parsedMeta.resolution ?? 0.05,
origin: parsedMeta.origin ?? [0, 0, 0],
};
rosMap.value = {
...meta,
...styledMap,
viewBox: `0 0 ${styledMap.width} ${styledMap.height}`,
};
} catch (error) {
console.error(error);
mapError.value = error instanceof Error ? error.message : "机器人地图加载失败";
} finally {
mapLoading.value = false;
}
}
// 调用巡检任务详情接口,将路线节点渲染到真实 ROS 地图上,并记录下发所需 taskId
async function loadRobotRoute(device: DeviceItem) {
routeNodes.value = [];
routeError.value = "";
inspectionTaskId.value = "";
const cardId = device.carId || device.id;
if (!cardId || cardId === "--") {
return;
}
routeLoading.value = true;
try {
const response = (await getRosInspectionTaskDetail(cardId)) as InspectionTaskDetailResponse;
inspectionTaskId.value = response.data?.id || "";
routeNodes.value = [...(response.data?.nodes ?? [])]
.filter((node) => typeof node.poseX === "number" && typeof node.poseY === "number")
.sort((current, next) => current.nodeIndex - next.nodeIndex);
} catch (error) {
console.error(error);
routeError.value = "巡检路线加载失败";
} finally {
routeLoading.value = false;
}
}
// 刷新当前巡检状态,非执行中统一按空闲展示
async function fetchCurrentInspection(silent = false) {
const carId = currentRobot.value.carId || currentRobot.value.id;
if (!carId || carId === "--") {
currentInspection.value = null;
return;
}
if (!silent) {
inspectionLoading.value = true;
}
try {
const response = (await getCurrentRosInspection(carId)) as CurrentInspectionResponse;
const record = response.data?.status === 0 ? response.data : null;
currentInspection.value = record;
if (response.data?.startTime) {
lastDispatchTime.value = response.data.startTime;
}
} catch (error) {
console.error(error);
} finally {
inspectionLoading.value = false;
}
}
// 巡检状态轮询暂时关闭,后续需要恢复时取消下面代码注释
// function stopInspectionPolling() {
// if (inspectionPollTimer !== null) {
// window.clearInterval(inspectionPollTimer);
// inspectionPollTimer = null;
// }
// }
// function startInspectionPolling() {
// stopInspectionPolling();
// fetchCurrentInspection();
// inspectionPollTimer = window.setInterval(() => {
// fetchCurrentInspection(true);
// }, 10000);
// }
function getDispatchTaskId() {
return currentInspection.value?.taskId || inspectionTaskId.value;
}
function showConfirmDialog(title: string, content: string, onConfirm: () => void | Promise<void>) {
window.$modal.warning({
title,
content,
positiveText: "确认",
negativeText: "取消",
maskClosable: false,
onPositiveClick: onConfirm,
});
}
async function runInspectionAction(action: "dispatch" | "stop") {
const taskId = getDispatchTaskId();
if (!taskId) {
window.$message.warning("未获取到巡检任务ID,请先在设备管理中保存巡检任务");
return;
}
inspectionActionLoading.value = true;
try {
const response = action === "dispatch"
? await dispatchRosInspectionTask(taskId) as RosDispatchResultResponse
: await stopDispatchRosInspectionTask(taskId) as RosDispatchResultResponse;
const message = response.data?.message || response.msg || (action === "dispatch" ? "巡检任务已下发" : "已停止巡检任务");
window.$message.success(message);
await fetchCurrentInspection();
} catch (error) {
console.error(error);
} finally {
inspectionActionLoading.value = false;
}
}
function confirmDispatchInspection() {
showConfirmDialog("下发巡检", "确认下发当前巡检任务吗?", () => runInspectionAction("dispatch"));
}
function confirmStopInspection() {
showConfirmDialog("停止巡检", "确认停止当前巡检任务吗?", () => runInspectionAction("stop"));
}
// 手动触发一次自动回充;需要小车在线且车端已启动回充节点
async function runStartCharging() {
const carId = currentRobot.value.carId;
if (!carId) {
window.$message.warning("未获取到小车ID,请先在设备管理中确认小车已保存");
return;
}
chargingActionLoading.value = true;
try {
const response = (await startRosCharging(carId)) as RosChargingResultResponse;
const accepted = response.data?.accepted ?? false;
const message = response.data?.message
|| response.msg
|| (accepted ? "已触发自动回充" : "触发自动回充失败");
if (accepted) {
window.$message.success(message);
} else {
window.$message.warning(message);
}
} catch (error) {
console.error(error);
} finally {
chargingActionLoading.value = false;
}
}
function confirmStartCharging() {
showConfirmDialog("自动回充", "确认让小车执行自动回充吗?", () => runStartCharging());
}
// 手动触发一次脱离充电桩;用于充电完成后手动让小车离开充电桩
async function runUndockCharging() {
const carId = currentRobot.value.carId;
if (!carId) {
window.$message.warning("未获取到小车ID,请先在设备管理中确认小车已保存");
return;
}
undockingActionLoading.value = true;
try {
const response = (await undockRosCharging(carId)) as RosChargingResultResponse;
const accepted = response.data?.accepted ?? false;
const message = response.data?.message
|| response.msg
|| (accepted ? "已触发脱离充电桩" : "触发脱离充电桩失败");
if (accepted) {
window.$message.success(message);
} else {
window.$message.warning(message);
}
} catch (error) {
console.error(error);
} finally {
undockingActionLoading.value = false;
}
}
function confirmUndockCharging() {
showConfirmDialog("充电脱离", "确认让小车脱离充电桩吗?", () => runUndockCharging());
}
// 切换设备时将当前设备已有升降柱高度回显到目标高度输入框
function syncLiftTargetFromDevice(device?: DeviceItem) {
if (!device || device.liftHeight === "--") {
return;
}
targetLiftHeight.value = device.liftHeight;
}
// 根据当前页面地址生成控制 WebSocket 地址,开发环境由 Vite 代理转发 /ws
function buildControlWsUrl() {
const token = getToken();
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = new URL(`${wsProtocol}//${window.location.host}/ws/ros`);
if (token) {
wsUrl.searchParams.set("token", token);
}
return wsUrl.toString();
}
// 用推送中的设备标识查找当前设备列表索引
function findDeviceIndexByKey(deviceKey?: string) {
if (!deviceKey) {
return -1;
}
return currentDevices.value.findIndex((device) => device.deviceKey === deviceKey || device.id === deviceKey);
}
// 将小车状态推送合并到设备列表,实时刷新电量、位置、状态和视频地址
function applyCarStatusPush(data: CarStatusPushData) {
const deviceIndex = findDeviceIndexByKey(data.deviceKey);
if (deviceIndex < 0) {
return;
}
const currentDevice = currentDevices.value[deviceIndex];
const status = typeof data.status === "number"
? normalizeDeviceStatus(data.status)
: currentDevice.status;
const location = typeof data.poseX === "number" && typeof data.poseY === "number"
? `X:${data.poseX.toFixed(2)} / Y:${data.poseY.toFixed(2)}`
: currentDevice.location;
const nextDevice: DeviceItem = {
...currentDevice,
deviceKey: data.deviceKey || currentDevice.deviceKey,
status,
location,
battery: typeof data.batteryLevel === "number" ? `${data.batteryLevel}%` : currentDevice.battery,
task: buildDeviceTask(status),
liftHeight: typeof data.liftHeight === "number" ? String(data.liftHeight) : currentDevice.liftHeight,
liftStatus: data.liftDeviceStatusDescription || currentDevice.liftStatus,
poseX: typeof data.poseX === "number" ? data.poseX : currentDevice.poseX,
poseY: typeof data.poseY === "number" ? data.poseY : currentDevice.poseY,
poseYaw: typeof data.poseYaw === "number" ? data.poseYaw : currentDevice.poseYaw,
mapPgmUrl: data.mapPgmUrl || currentDevice.mapPgmUrl,
mapYamlUrl: data.mapYamlUrl || currentDevice.mapYamlUrl,
mapPcdUrl: data.mapPcdUrl || currentDevice.mapPcdUrl,
dockStatus: typeof data.dockStatus === "string" ? data.dockStatus : currentDevice.dockStatus,
dockActive: typeof data.dockActive === "boolean" ? data.dockActive : currentDevice.dockActive,
};
projectDevices.value[0].devices.splice(deviceIndex, 1, nextDevice);
if (!activeDeviceId.value) {
activeDeviceId.value = nextDevice.id;
}
}
// 将小车速度推送合并到设备列表,实时刷新线速度和角速度
function applyCarVelocityPush(data: CarVelocityPushData) {
const deviceIndex = findDeviceIndexByKey(data.deviceKey);
if (deviceIndex < 0 || !data.velocity) {
return;
}
const currentDevice = currentDevices.value[deviceIndex];
const nextDevice: DeviceItem = {
...currentDevice,
linearSpeed: typeof data.velocity.linearX === "number"
? `${data.velocity.linearX.toFixed(2)}m/s`
: currentDevice.linearSpeed,
angularSpeed: typeof data.velocity.angularZ === "number"
? `${data.velocity.angularZ.toFixed(2)}rad/s`
: currentDevice.angularSpeed,
};
projectDevices.value[0].devices.splice(deviceIndex, 1, nextDevice);
}
// 控制 WebSocket 异常断开后延迟重连,避免刷新进入页面后保持未连接状态
function scheduleControlReconnect() {
if (controlSocketManualClose || controlReconnectTimer !== null) {
return;
}
controlWsStatus.value = "重连中";
controlReconnectTimer = window.setTimeout(() => {
controlReconnectTimer = null;
connectControlSocket();
}, 2000);
}
// 建立浏览器到 ROS 控制网关的 WebSocket 连接
function connectControlSocket() {
if (
controlSocket &&
(controlSocket.readyState === WebSocket.OPEN || controlSocket.readyState === WebSocket.CONNECTING)
) {
return controlSocket;
}
controlSocketManualClose = false;
controlError.value = "";
controlWsStatus.value = "连接中";
controlSocket = new WebSocket(buildControlWsUrl());
controlSocket.onopen = () => {
controlWsStatus.value = "已连接";
};
controlSocket.onmessage = (event) => {
if (event.data === "ping") {
controlSocket?.send("pong");
return;
}
if (event.data === "pong") {
return;
}
try {
const message = JSON.parse(event.data) as ControlSocketMessage;
if (message.type === "control_result" && message.data?.accepted === false) {
controlError.value = message.data.message || "控制命令未被接受";
return;
}
if (message.type === "car_status" && message.data) {
applyCarStatusPush(message.data);
return;
}
if (message.type === "car_velocity" && message.data) {
applyCarVelocityPush(message.data);
return;
}
if (message.type === "inspection_update" || message.type === "inspection_record_stopped") {
fetchCurrentInspection(true);
return;
}
if (message.type === "warning_update") {
fetchRecentAlerts();
}
} catch (error) {
console.error(error);
}
};
controlSocket.onerror = () => {
controlError.value = "控制 WebSocket 连接异常";
};
controlSocket.onclose = () => {
controlWsStatus.value = "未连接";
controlSocket = null;
scheduleControlReconnect();
};
return controlSocket;
}
// 关闭控制 WebSocket,页面卸载时释放连接
function closeControlSocket() {
controlSocketManualClose = true;
if (controlReconnectTimer !== null) {
window.clearTimeout(controlReconnectTimer);
controlReconnectTimer = null;
}
if (!controlSocket) {
controlWsStatus.value = "未连接";
return;
}
controlSocket.close();
controlSocket = null;
controlWsStatus.value = "未连接";
}
// 通过 WS 下发机器人远程控制消息
function sendControlMessage(type: ControlMessageType, data: Record<string, string | number>) {
if (!currentRobot.value.deviceKey) {
controlError.value = "当前机器人缺少设备标识";
return;
}
const socket = connectControlSocket();
const payload = {
type,
requestId: `${type}-${Date.now()}-${controlRequestSeed += 1}`,
data: {
deviceKey: currentRobot.value.deviceKey,
...data,
},
};
const sendPayload = () => {
controlError.value = "";
socket.send(JSON.stringify(payload));
};
if (socket.readyState === WebSocket.OPEN) {
sendPayload();
return;
}
socket.addEventListener("open", sendPayload, { once: true });
}
// 停止指定摄像头的 WebRTC 拉流并释放播放器资源
function stopCameraStream(key: CameraChannelKey) {
const stream = cameraStreams[key];
stream.requestId += 1;
cameraLoading[key] = false;
if (stream.peerConnection) {
stream.peerConnection.close();
stream.peerConnection = null;
}
const video = cameraVideoRefs[key].value;
if (video) {
video.srcObject = null;
}
}
// 停止全部摄像头拉流
function stopAllCameraStreams() {
cameraChannelKeys.forEach((key) => stopCameraStream(key));
}
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
function buildWebrtcApiUrl(streamUrl: string) {
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
const url = new URL(normalizedUrl);
return `${url.origin}${url.pathname}${url.search}`;
}
// 等待本地 ICE 候选收集完成,确保发给流媒体服务的 offer 信息完整
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) {
if (peerConnection.iceGatheringState === "complete") {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const handleIceGatheringStateChange = () => {
if (peerConnection.iceGatheringState === "complete") {
peerConnection.removeEventListener("icegatheringstatechange", handleIceGatheringStateChange);
resolve();
}
};
peerConnection.addEventListener("icegatheringstatechange", handleIceGatheringStateChange);
});
}
// 通过 WebRTC offer/answer 拉取指定摄像头的视频流
async function playCameraStream(key: CameraChannelKey) {
stopCameraStream(key);
cameraError[key] = "";
const streamUrl = cameraUrls.value[key];
if (!streamUrl) {
return;
}
if (!window.RTCPeerConnection) {
cameraError[key] = "当前浏览器不支持 WebRTC 播放";
return;
}
const stream = cameraStreams[key];
const requestId = stream.requestId;
const peerConnection = new RTCPeerConnection();
stream.peerConnection = peerConnection;
cameraLoading[key] = true;
peerConnection.addTransceiver("video", { direction: "recvonly" });
peerConnection.addTransceiver("audio", { direction: "recvonly" });
peerConnection.ontrack = (event) => {
if (requestId !== stream.requestId || !cameraVideoRefs[key].value) {
return;
}
cameraVideoRefs[key].value.srcObject = event.streams[0];
};
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
await waitForIceGatheringComplete(peerConnection);
const response = await fetch(buildWebrtcApiUrl(streamUrl), {
method: "POST",
headers: {
"Content-Type": "application/sdp",
},
body: peerConnection.localDescription?.sdp ?? offer.sdp,
});
if (!response.ok) {
throw new Error(`拉流失败:${response.status}`);
}
const responseText = await response.text();
let answer = responseText;
try {
const responseData = JSON.parse(responseText) as { code?: number; sdp?: string; msg?: string };
if (responseData.code && responseData.code !== 0) {
throw new Error(responseData.msg || "视频流拉取失败");
}
answer = responseData.sdp || responseText;
} catch (error) {
if (responseText.trim().startsWith("{")) {
throw error;
}
}
if (requestId !== stream.requestId) {
return;
}
await peerConnection.setRemoteDescription({
type: "answer",
sdp: answer,
});
} catch (error) {
if (requestId === stream.requestId) {
cameraError[key] = error instanceof Error ? error.message : "视频流拉取失败";
stopCameraStream(key);
}
} finally {
if (requestId === stream.requestId) {
cameraLoading[key] = false;
}
}
}
// 地图放大预览:把地图舞台切换为全屏浮层
const mapPreviewVisible = ref(false);
function openMapPreview() {
mapPreviewVisible.value = true;
}
function closeMapPreview() {
mapPreviewVisible.value = false;
}
// 视频画面全屏:使用浏览器原生 Fullscreen API,进入浏览器顶层,避免被页面布局/包含块影响
const whiteScreenRef = ref<HTMLElement | null>(null);
const infraredScreenRef = ref<HTMLElement | null>(null);
const videoScreenRefs: Record<CameraChannelKey, Ref<HTMLElement | null>> = {
white: whiteScreenRef,
infrared: infraredScreenRef,
};
// 当前处于全屏的摄像头通道,null 表示未全屏
const videoFullscreenKey = ref<CameraChannelKey | null>(null);
// 进入/退出指定摄像头画面的全屏
async function toggleVideoFullscreen(key: CameraChannelKey) {
const element = videoScreenRefs[key].value;
if (!element) {
return;
}
if (document.fullscreenElement === element) {
await document.exitFullscreen();
return;
}
await element.requestFullscreen();
}
// 同步原生全屏状态,用于切换按钮图标
function handleVideoFullscreenChange() {
const current = document.fullscreenElement;
videoFullscreenKey.value =
cameraChannelKeys.find((key) => videoScreenRefs[key].value === current) ?? null;
}
// Esc 关闭预览
function handleMapPreviewKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && mapPreviewVisible.value) {
closeMapPreview();
}
}
onMounted(() => {
fetchDeviceList();
fetchRecentAlerts();
connectControlSocket();
// startInspectionPolling();
window.addEventListener("blur", stopContinuousControl);
window.addEventListener("keydown", handleMapPreviewKeydown);
document.addEventListener("fullscreenchange", handleVideoFullscreenChange);
});
onBeforeUnmount(() => {
stopAllCameraStreams();
stopContinuousControl();
// stopInspectionPolling();
closeControlSocket();
window.removeEventListener("blur", stopContinuousControl);
window.removeEventListener("keydown", handleMapPreviewKeydown);
document.removeEventListener("fullscreenchange", handleVideoFullscreenChange);
});
// 切换机器人或视频地址变化时,两路摄像头各自重新拉流
watch(
cameraUrls,
() => {
nextTick(() => {
cameraChannelKeys.forEach((key) => playCameraStream(key));
});
},
);
watch(
[
() => currentRobot.value.id,
() => currentRobot.value.carId,
() => currentRobot.value.mapPgmUrl,
() => currentRobot.value.mapYamlUrl,
],
() => {
currentInspection.value = null;
lastDispatchTime.value = "";
loadRobotMap(currentRobot.value);
loadRobotRoute(currentRobot.value);
fetchCurrentInspection();
},
{ immediate: true },
);
// 切到三维视图(或点云地址、质量档位变化)时惰性初始化 WebGL 场景并按档位加载点云
watch(
[mapViewMode, () => currentRobot.value.mapPcdUrl, pointCloudMaxPoints],
async ([mode, mapPcdUrl, maxPoints]) => {
if (mode !== "3d") {
return;
}
await nextTick();
// 首次进入三维视图时动态加载 three.js 并初始化场景
await mountPointCloud();
await loadPointCloud(mapPcdUrl ? buildFileAccessUrl(mapPcdUrl) : "", maxPoints);
},
{ immediate: true },
);
// 三维地图放大预览时开启 WASD 键盘漫游,退出放大或切回二维后关闭
watch(
[mapPreviewVisible, mapViewMode],
([previewVisible, mode]) => {
setPointCloudKeyboardNavigation(previewVisible && mode === "3d");
},
{ immediate: true },
);
// 巡检路线同步到三维视图,在地面绘制路径与节点
watch(
routeNodes,
() => {
setPointCloudRoute(routeNodes.value.map((node) => ({ x: node.poseX, y: node.poseY })));
},
{ immediate: true },
);
// 机器人实时位姿同步到三维视图标记
watch(
[() => currentRobot.value.poseX, () => currentRobot.value.poseY, () => currentRobot.value.poseYaw],
() => {
setPointCloudRobotPose(hasRobotPose.value
? {
x: currentRobot.value.poseX as number,
y: currentRobot.value.poseY as number,
yaw: currentRobot.value.poseYaw ?? 0,
}
: null);
},
{ immediate: true },
);
function switchAlertTab(tab: (typeof alertTabs)[number]) {
activeAlertTab.value = tab;
fetchRecentAlerts();
}
// 点击设备列表中的机器人后,同步切换控制对象和监控视频源
function selectDevice(device: DeviceItem) {
activeDeviceId.value = device.id;
syncLiftTargetFromDevice(device);
}
function switchDriveMode(mode: DriveMode) {
activeDriveMode.value = mode;
}
// 调整真实 ROS 地图整体缩放,不影响底部状态浮层,倍率越高放大步进越大
function changeMapScale(delta: number) {
const factor = delta > 0 ? 1.1 : 1 / 1.1;
mapScale.value = Math.min(15, Math.max(0.55, Number((mapScale.value * factor).toFixed(2))));
}
function resetMapScale() {
mapScale.value = 1;
mapOffset.value = { x: 0, y: 0 };
}
// 鼠标滚轮以光标为缩放中心缩放地图内容,按住拖拽平移地图视野
function handleMapWheel(event: WheelEvent) {
event.preventDefault();
if (!rosMap.value || !navMapSvgRef.value) {
return;
}
const factor = event.deltaY > 0 ? 1 / 1.1 : 1.1;
const nextScale = Math.min(15, Math.max(0.55, Number((mapScale.value * factor).toFixed(2))));
if (nextScale === mapScale.value) {
return;
}
// 缩放前后保持光标下的地图点位置不动,需要同步平移视野
const rect = navMapSvgRef.value.getBoundingClientRect();
const pointerX = (event.clientX - rect.left) * (rosMap.value.width / rect.width);
const pointerY = (event.clientY - rect.top) * (rosMap.value.height / rect.height);
const centerX = rosMap.value.width / 2;
const centerY = rosMap.value.height / 2;
const keepRatio = 1 - nextScale / mapScale.value;
mapOffset.value = {
x: mapOffset.value.x + (pointerX - centerX) * keepRatio,
y: mapOffset.value.y + (pointerY - centerY) * keepRatio,
};
mapScale.value = nextScale;
}
function startMapDrag(event: PointerEvent) {
if (!rosMap.value || event.button !== 0) {
return;
}
mapDragState.value = {
dragging: true,
startX: event.clientX,
startY: event.clientY,
originX: mapOffset.value.x,
originY: mapOffset.value.y,
};
}
function moveMapDrag(event: PointerEvent) {
if (!mapDragState.value.dragging || !rosMap.value || !navMapSvgRef.value) {
return;
}
const rect = navMapSvgRef.value.getBoundingClientRect();
const svgDeltaX = (event.clientX - mapDragState.value.startX) * (rosMap.value.width / rect.width);
const svgDeltaY = (event.clientY - mapDragState.value.startY) * (rosMap.value.height / rect.height);
mapOffset.value = {
x: mapDragState.value.originX + svgDeltaX,
y: mapDragState.value.originY + svgDeltaY,
};
}
function stopMapDrag() {
mapDragState.value.dragging = false;
}
// 单次下发底盘速度控制
function sendCmdVel(linearX: number, angularZ: number) {
sendControlMessage("cmd_vel", { linearX, angularZ });
}
// 按住底盘按钮时高频下发速度控制,保持 ROS cmd_vel 持续刷新
function startCmdVel(linearX: number, angularZ: number) {
if (chassisControlTimer !== null) {
window.clearInterval(chassisControlTimer);
}
chassisControlling = true;
sendCmdVel(linearX, angularZ);
chassisControlTimer = window.setInterval(() => {
sendCmdVel(linearX, angularZ);
}, 50);
}
// 停止底盘运动,仅在发生过底盘控制后发送停止,避免刷新/失焦产生无效停止消息
function stopCmdVel() {
if (chassisControlTimer !== null) {
window.clearInterval(chassisControlTimer);
chassisControlTimer = null;
}
if (!chassisControlling) {
return;
}
chassisControlling = false;
sendControlMessage("cmd_vel", { linearX: 0, angularZ: 0 });
}
// 单次下发云台方向控制
function sendPtzControl(action: PtzAction) {
sendControlMessage("ptz_control", { action });
}
// 按住云台按钮时高频下发方向控制,保持云台连续动作
function startPtzControl(action: PtzAction) {
if (ptzControlTimer !== null) {
window.clearInterval(ptzControlTimer);
}
ptzControlling = true;
sendPtzControl(action);
ptzControlTimer = window.setInterval(() => {
sendPtzControl(action);
}, 50);
}
// 停止云台运动,仅在发生过云台控制后发送 stop
function stopPtzControl() {
if (ptzControlTimer !== null) {
window.clearInterval(ptzControlTimer);
ptzControlTimer = null;
}
if (!ptzControlling) {
return;
}
ptzControlling = false;
sendControlMessage("ptz_control", { action: "stop" });
}
// 页面失焦时停止连续控制动作,避免机器人持续运动
function stopContinuousControl() {
stopCmdVel();
stopPtzControl();
}
function toggleControlMode() {
controlMode.value =
controlMode.value === "人工接管" ? "自主巡检" : "人工接管";
}
function jumpToPresetPoint() {
selectedPresetPoint.value = selectedPresetPoint.value;
}
// 下发升降柱控制命令,control_type 参照 WS 控制协议
function sendLiftControl(controlType: number, height = 0) {
sendControlMessage("lift_control", {
control_type: controlType,
height,
});
}
function resetLiftHeight() {
targetLiftHeight.value = "0";
sendLiftControl(0);
}
function raiseLiftHeight() {
sendLiftControl(1);
}
function lowerLiftHeight() {
sendLiftControl(2);
}
function stopLiftHeight() {
sendLiftControl(3);
}
function runToTargetHeight() {
const normalizedHeight = targetLiftHeight.value.replace(/[^\d]/g, "");
const height = Math.min(maxLiftHeight, Number(normalizedHeight) || 0);
targetLiftHeight.value = String(height);
sendLiftControl(4, height);
}
function getStatusClass(status: DeviceItem["status"]) {
const classMap: Record<DeviceItem["status"], string> = {
空闲: "status-idle",
巡检: "status-patrol",
离线: "status-offline",
充电中: "status-charging",
};
return classMap[status];
}
</script>
<template>
<div class="center">
<div class="left">
<div class="card-1 device-card">
<div class="title">设备列表</div>
<div class="device-panel">
<div class="device-list">
<template v-if="currentDevices.length">
<div v-for="device in currentDevices" :key="device.id"
:class="['device-item', { active: currentRobot.id === device.id }]" @click="selectDevice(device)">
<div class="device-main">
<div class="device-name">{{ device.name }}</div>
<div :class="['device-status', getStatusClass(device.status)]">
{{ device.status }}
</div>
</div>
<div class="device-meta">
<span>{{ device.id }}</span>
<span>{{ device.battery }}</span>
</div>
</div>
</template>
<div v-else class="device-empty">暂无设备数据</div>
</div>
</div>
</div>
<div class="card-2 alert-card">
<div class="title">近期告警</div>
<div class="alert-panel">
<div class="alert-tabs">
<button v-for="tab in alertTabs" :key="tab" :class="['alert-tab', { active: activeAlertTab === tab }]"
type="button" @click="switchAlertTab(tab)">
{{ tab }}
</button>
</div>
<div class="alert-list">
<div v-if="alertLoading" class="alert-empty">近期告警加载中...</div>
<div v-else-if="alertError" class="alert-empty">{{ alertError }}</div>
<template v-else-if="filteredRecentAlerts.length">
<div v-for="alert in filteredRecentAlerts" :key="alert.id" class="alert-item">
<div class="alert-content">
<NImage
class="alert-thumbnail"
:src="alert.photo"
:alt="alert.title"
object-fit="cover"
:previewed-img-props="{ style: { maxWidth: '82vw', maxHeight: '82vh', objectFit: 'contain' } }"
/>
<div class="alert-info">
<div class="alert-summary">
<div class="alert-field">
<div class="alert-label">告警类型</div>
<div class="alert-title">{{ alert.title }}</div>
</div>
<div class="alert-field alert-field-right">
<div class="alert-label">识别结果</div>
<div class="alert-result">{{ alert.result }}</div>
</div>
</div>
<div class="alert-time-row">
<span class="alert-label">告警时间</span>
<span class="alert-time">{{ alert.time }}</span>
</div>
</div>
</div>
</div>
</template>
<div v-else class="alert-empty">当前筛选下暂无告警</div>
</div>
</div>
</div>
</div>
<div class="middle">
<!-- card- 前缀复用与其他面板一致的卡片标题样式 -->
<div class="card-map nav-map-panel">
<div class="map-card-head">
<div class="title">管廊实时地图</div>
<!-- 二维栅格地图 / 三维点云地图切换 -->
<div class="map-view-switch">
<button :class="['map-view-btn', { active: mapViewMode === '2d' }]" type="button"
@click="mapViewMode = '2d'">
2D 地图
</button>
<button :class="['map-view-btn', { active: mapViewMode === '3d' }]" type="button"
@click="mapViewMode = '3d'">
3D 点云
</button>
</div>
</div>
<!-- 预览遮罩:点击空白处也可关闭 -->
<div v-if="mapPreviewVisible" class="map-preview-mask" @click="closeMapPreview"></div>
<div :class="['nav-map-stage', { 'preview-mode': mapPreviewVisible }]">
<!-- 地图放大预览:点击后地图舞台切换为全屏浮层 -->
<button v-if="!mapPreviewVisible" class="map-preview-btn" type="button" title="预览"
aria-label="预览" @click.stop="openMapPreview">
<svg class="map-preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button v-else class="map-preview-close" type="button" title="退出预览" aria-label="退出预览"
@click.stop="closeMapPreview">
<svg class="map-preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
</svg>
</button>
<svg v-if="mapViewMode === '2d' && rosMap" ref="navMapSvgRef" :class="['nav-map-svg', 'real-map-svg', { dragging: mapDragState.dragging }]" :viewBox="rosMap.viewBox" preserveAspectRatio="xMidYMid meet"
@wheel="handleMapWheel" @pointerdown="startMapDrag" @pointermove="moveMapDrag" @pointerup="stopMapDrag"
@pointerleave="stopMapDrag" @pointercancel="stopMapDrag">
<defs>
<filter id="robotGlow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 路线遮罩:在巡检点位置挖空,避免路线伸进点位圆内 -->
<mask id="routePointMask" maskUnits="userSpaceOnUse" x="0" y="0" :width="rosMap.width"
:height="rosMap.height">
<rect x="0" y="0" :width="rosMap.width" :height="rosMap.height" fill="#fff" />
<circle v-for="(point, index) in realCheckPoints" :key="`route-mask-${index}`" :cx="point.x"
:cy="point.y" r="5" fill="#000" />
</mask>
</defs>
<g class="real-map-content" :transform="mapContentTransform">
<image class="ros-map-image" :href="rosMap.imageUrl" x="0" y="0" :width="rosMap.width" :height="rosMap.height" />
<path v-if="realRoutePath" class="route-line" :d="realRoutePath" mask="url(#routePointMask)" />
<g class="checkpoint-layer">
<g v-for="(point, index) in realCheckPoints" :key="index" class="checkpoint"
:transform="`translate(${point.x}, ${point.y})`">
<circle r="3"></circle>
<text v-if="point.label" x="7" y="2.5">{{ point.label }}</text>
</g>
</g>
<g v-if="robotMapPoint" class="robot-layer real-robot-layer"
:transform="`translate(${robotMapPoint.x}, ${robotMapPoint.y}) rotate(${robotMapPoint.angle})`">
<!-- 机器人光圈:由内向外扩散的三层圆环(基准半径约车位宽的 0.75 倍) -->
<circle class="robot-ring ring-1" :r="robotIconWidth * 0.75" />
<circle class="robot-ring ring-2" :r="robotIconWidth * 0.75" />
<circle class="robot-ring ring-3" :r="robotIconWidth * 0.75" />
<!-- 机器人标记:图标尺寸随地图分辨率等比,并随地图一起缩放 -->
<image class="robot-icon" :href="robotIcon" :x="-robotIconWidth / 2" :y="-robotIconHeight / 2"
:width="robotIconWidth" :height="robotIconHeight" />
</g>
</g>
</svg>
<!-- 未配置地图:使用默认隧道示意图,机器人叠加在图中车辆位置 -->
<svg v-else-if="mapViewMode === '2d'" class="nav-map-svg" viewBox="0 0 1398 467" preserveAspectRatio="xMidYMid meet">
<image class="empty-map-image" :href="emptyMapImage" x="0" y="0" width="1398" height="467" />
<g class="robot-layer" transform="translate(1216, 266)">
<!-- 机器人光圈:由内向外扩散的三层圆环 -->
<circle class="robot-ring ring-1" r="21" />
<circle class="robot-ring ring-2" r="21" />
<circle class="robot-ring ring-3" r="21" />
<!-- 机器人标记使用设计稿图标(28x15) -->
<image class="robot-icon" :href="robotIcon" x="-14" y="-8" width="28" height="15" />
</g>
</svg>
<!-- 三维点云地图:WebGL 容器常驻,仅切换显示,避免来回切换时反复重建场景 -->
<div v-show="mapViewMode === '3d'" ref="map3dContainerRef" class="nav-map-3d"></div>
<div v-if="mapViewMode === '3d' && pointCloudInfo" class="nav-map-3d-info">
{{ pointCloudInfo }}
</div>
<!-- 放大预览下的三维操作提示 -->
<div v-if="mapViewMode === '3d' && mapPreviewVisible" class="nav-map-3d-hint">
WASD 移动视角 · 拖拽旋转 · 滚轮缩放
</div>
<template v-if="mapViewMode === '2d'">
<div v-if="mapLoading" class="map-state-tip">地图加载中...</div>
<div v-else-if="mapError" class="map-state-tip">{{ mapError }}</div>
<div v-else-if="routeLoading" class="map-state-tip">路线加载中...</div>
<div v-else-if="routeError" class="map-state-tip">{{ routeError }}</div>
</template>
<template v-else>
<div v-if="pointCloudLoading" class="map-state-tip">点云加载中...</div>
<div v-else-if="pointCloudError" class="map-state-tip">{{ pointCloudError }}</div>
<div v-else-if="!hasPointCloud" class="map-state-tip">
当前机器人未配置点云文件,请在设备管理 - 机器人配置中上传 PCD
</div>
</template>
<!-- <div class="nav-map-overlay top-left">ROS 实时地图</div>
<div class="nav-map-overlay top-right">真实坐标路径跟踪</div> -->
<div v-if="rosMap && mapViewMode === '2d'" class="map-scale-controls">
<button type="button" @click="changeMapScale(-0.05)">-</button>
<span>{{ Math.round(mapScale * 100) }}%</span>
<button type="button" @click="changeMapScale(0.05)">+</button>
<button type="button" @click="resetMapScale">重置</button>
</div>
<div v-else-if="mapViewMode === '3d'" class="map-scale-controls">
<!-- 点云质量档位:点数越多越还原,但帧率越低 -->
<button v-for="option in pointCloudQualityOptions" :key="option.value"
:class="['map-quality-btn', { active: pointCloudQuality === option.value }]"
:title="`渲染上限 ${option.maxPoints.toLocaleString('en-US')} 点`" type="button"
@click="pointCloudQuality = option.value">
{{ option.label }}
</button>
<button type="button" @click="resetPointCloudView">重置视角</button>
</div>
<div class="nav-map-overlay bottom-left">
机器人编号:{{ currentRobot.id }} / {{ currentRobot.task }}
</div>
<div class="nav-map-overlay bottom-right">
当前位置 {{ currentRobot.location }} / 电量 {{ currentRobot.battery }} / 线速度
{{ currentRobot.linearSpeed }} / 角速度 {{ currentRobot.angularSpeed }}
</div>
</div>
</div>
</div>
<div class="right">
<div class="card-1 video-card">
<div class="card-header">
<div class="title">视频监控</div>
</div>
<div class="video-panel">
<!-- 白光镜头:16:9 -->
<div ref="whiteScreenRef" class="video-screen">
<video v-if="cameraUrls.white" ref="whiteVideoRef" :key="cameraUrls.white"
class="monitor-video" autoplay muted playsinline></video>
<div class="scan-line"></div>
<div class="video-overlay top-right">白光镜头</div>
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
<!-- 全屏按钮:点击后进入/退出浏览器原生全屏 -->
<button v-if="videoFullscreenKey !== 'white'" class="video-fullscreen-btn" type="button" title="全屏"
aria-label="全屏" @click.stop="toggleVideoFullscreen('white')">
<svg class="video-fullscreen-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button v-else class="video-fullscreen-close" type="button" title="退出全屏" aria-label="退出全屏"
@click.stop="toggleVideoFullscreen('white')">✕</button>
<div v-if="cameraLoading.white" class="video-empty">视频流连接中...</div>
<div v-else-if="cameraError.white" class="video-empty">{{ cameraError.white }}</div>
<div v-else-if="!cameraUrls.white" class="video-empty">当前机器人暂无白光视频地址</div>
</div>
<!-- 红外镜头:16:9 -->
<div ref="infraredScreenRef" class="video-screen">
<video v-if="cameraUrls.infrared" ref="infraredVideoRef" :key="cameraUrls.infrared"
class="monitor-video infrared" autoplay muted playsinline></video>
<div class="scan-line"></div>
<div class="video-overlay top-right">红外镜头</div>
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
<!-- 全屏按钮:点击后进入/退出浏览器原生全屏 -->
<button v-if="videoFullscreenKey !== 'infrared'" class="video-fullscreen-btn" type="button" title="全屏"
aria-label="全屏" @click.stop="toggleVideoFullscreen('infrared')">
<svg class="video-fullscreen-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button v-else class="video-fullscreen-close" type="button" title="退出全屏" aria-label="退出全屏"
@click.stop="toggleVideoFullscreen('infrared')">✕</button>
<div v-if="cameraLoading.infrared" class="video-empty">视频流连接中...</div>
<div v-else-if="cameraError.infrared" class="video-empty">{{ cameraError.infrared }}</div>
<div v-else-if="!cameraUrls.infrared" class="video-empty">当前机器人暂无红外视频地址</div>
</div>
</div>
</div>
<div class="card-2 control-card">
<div class="card-header">
<div class="title">远程控制</div>
<button class="mode-toggle-btn" type="button" @click="toggleControlMode">
<span :class="['mode-toggle-chip', { active: controlMode === '自主巡检' }]">
自主巡检
</span>
<span :class="['mode-toggle-chip', { active: controlMode === '人工接管' }]">
人工接管
</span>
</button>
</div>
<div class="control-panel">
<div class="control-status">
<div class="status-item">
<span class="status-label">控制对象</span>
<span class="status-value">{{ currentRobot.id }}</span>
</div>
<div class="status-item">
<span class="status-label">当前模式</span>
<span class="status-value">{{ controlMode }}</span>
</div>
</div>
<div v-if="controlMode === '人工接管'" class="control-main">
<div class="control-top-row">
<div class="vehicle-section">
<div class="section-title control-block-title">底盘驱动</div>
<!-- <div class="drive-mode-tabs">
<button
v-for="mode in driveModeTabs"
:key="mode"
:class="['drive-mode-tab', { active: activeDriveMode === mode }]"
type="button"
@click="switchDriveMode(mode)"
>
{{ mode }}
</button>
</div> -->
<div class="vehicle-pad">
<button class="drive-btn drive-up" type="button" @pointerdown.prevent="startCmdVel(0.25, 0)"
@pointerup="stopCmdVel" @pointerleave="stopCmdVel" @pointercancel="stopCmdVel">
<span class="drive-arrow">↑</span>
<span class="drive-label">前进</span>
</button>
<button class="drive-btn drive-left" type="button" @pointerdown.prevent="startCmdVel(0.25, 0.25)"
@pointerup="stopCmdVel" @pointerleave="stopCmdVel" @pointercancel="stopCmdVel">
<span class="drive-arrow">←</span>
<span class="drive-label">左转</span>
</button>
<div class="drive-core" aria-hidden="true"></div>
<button class="drive-btn drive-right" type="button" @pointerdown.prevent="startCmdVel(0.25, -0.25)"
@pointerup="stopCmdVel" @pointerleave="stopCmdVel" @pointercancel="stopCmdVel">
<span class="drive-arrow">→</span>
<span class="drive-label">右转</span>
</button>
<button class="drive-btn drive-down" type="button" @pointerdown.prevent="startCmdVel(-0.25, 0)"
@pointerup="stopCmdVel" @pointerleave="stopCmdVel" @pointercancel="stopCmdVel">
<span class="drive-arrow">↓</span>
<span class="drive-label">后退</span>
</button>
</div>
</div>
<div class="control-section gimbal-section">
<div class="section-title">云台控制</div>
<!-- <div class="gimbal-preset-panel">
<label class="gimbal-preset-group">
<select v-model="selectedPresetPoint" class="gimbal-preset-select">
<option
v-for="preset in presetPointOptions"
:key="preset"
:value="preset"
>
{{ preset }}
</option>
</select>
</label>
<button class="gimbal-preset-btn" type="button" @click="jumpToPresetPoint">
跳转
</button>
</div> -->
<div class="vehicle-pad gimbal-wheel">
<button class="drive-btn drive-up" type="button" @pointerdown.prevent="startPtzControl('up')"
@pointerup="stopPtzControl" @pointerleave="stopPtzControl"
@pointercancel="stopPtzControl">
<span class="drive-arrow">↑</span>
<span class="drive-label">上</span>
</button>
<button class="drive-btn drive-left" type="button" @pointerdown.prevent="startPtzControl('left')"
@pointerup="stopPtzControl" @pointerleave="stopPtzControl"
@pointercancel="stopPtzControl">
<span class="drive-arrow">←</span>
<span class="drive-label">左</span>
</button>
<div class="drive-core" aria-hidden="true"></div>
<button class="drive-btn drive-right" type="button" @pointerdown.prevent="startPtzControl('right')"
@pointerup="stopPtzControl" @pointerleave="stopPtzControl"
@pointercancel="stopPtzControl">
<span class="drive-arrow">→</span>
<span class="drive-label">右</span>
</button>
<button class="drive-btn drive-down" type="button" @pointerdown.prevent="startPtzControl('down')"
@pointerup="stopPtzControl" @pointerleave="stopPtzControl"
@pointercancel="stopPtzControl">
<span class="drive-arrow">↓</span>
<span class="drive-label">下</span>
</button>
</div>
</div>
</div>
<div class="control-section lift-section">
<div class="section-title">升降柱控制</div>
<div class="lift-control-panel">
<label class="lift-input-group">
<span class="lift-input-label">目标高度</span>
<div class="lift-input-shell">
<input
v-model="targetLiftHeight"
class="lift-input"
type="text"
inputmode="numeric"
maxlength="4"
:placeholder="`0-${maxLiftHeight}`"
/>
<span class="lift-unit">mm</span>
</div>
</label>
<div class="lift-action-row">
<button class="lift-submit-btn" type="button" @click="resetLiftHeight">
复位
</button>
<button class="lift-submit-btn" type="button" @click="raiseLiftHeight">
上升
</button>
<button class="lift-submit-btn" type="button" @click="lowerLiftHeight">
下降
</button>
<button class="lift-submit-btn" type="button" @click="stopLiftHeight">
停止
</button>
<button class="lift-submit-btn lift-submit-wide" type="button" @click="runToTargetHeight">
运行到指定高度
</button>
</div>
<div class="lift-status-panel">
<div class="lift-status-item">
<span class="lift-status-label">实时状态</span>
<span class="lift-status-value">{{ currentRobot.liftStatus }}</span>
</div>
<div class="lift-status-item">
<span class="lift-status-label">当前高度</span>
<span class="lift-status-value">{{ currentRobot.liftHeight }} mm</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="inspection-panel">
<div class="inspection-state-card">
<div class="inspection-state-head">
<span class="inspection-state-label">巡检状态</span>
<span :class="['inspection-state-value', { running: inspectionRunning }]">
{{ inspectionStatusText }}
</span>
</div>
<div class="inspection-state-sub">
{{ inspectionLoading ? "状态刷新中..." : "收到巡检事件后自动刷新" }}
</div>
</div>
<div class="inspection-info-grid">
<div class="inspection-info-item">
<span class="inspection-info-label">上次下发时间</span>
<span class="inspection-info-value">{{ lastDispatchTime || "--" }}</span>
</div>
<template v-if="inspectionRunning">
<div class="inspection-info-item">
<span class="inspection-info-label">当前轮次/总轮次</span>
<span class="inspection-info-value">{{ currentRoundText }}</span>
</div>
<div class="inspection-info-item">
<span class="inspection-info-label">当前巡检点/总巡检点</span>
<span class="inspection-info-value">{{ currentInspectionPointText }}</span>
</div>
</template>
</div>
<button
v-if="inspectionRunning"
class="inspection-action-btn danger"
type="button"
:disabled="inspectionActionLoading"
@click="confirmStopInspection"
>
{{ inspectionActionLoading ? "处理中..." : "停止巡检" }}
</button>
<button
v-else
class="inspection-action-btn"
type="button"
:disabled="inspectionActionLoading"
@click="confirmDispatchInspection"
>
{{ inspectionActionLoading ? "处理中..." : "下发巡检" }}
</button>
<div class="charging-status-row">
<span :class="['charging-status-dot', { active: currentRobot.dockActive }]"></span>
<span class="charging-status-text">{{ chargingStatusText }}</span>
</div>
<button
class="inspection-action-btn charging"
type="button"
:disabled="chargingActionLoading || currentRobot.dockActive"
@click="confirmStartCharging"
>
{{ chargingButtonText }}
</button>
<button
class="inspection-action-btn undock"
type="button"
:disabled="undockingActionLoading || currentRobot.dockActive"
@click="confirmUndockCharging"
>
{{ undockingButtonText }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>