1421 lines
52 KiB
Vue
1421 lines
52 KiB
Vue
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import {
|
||
getRecentAlarmList
|
||
} from "@/api/alarm";
|
||
import {
|
||
dispatchRosInspectionTask,
|
||
getCurrentRosInspection,
|
||
getRosCarList,
|
||
getRosInspectionTaskDetail,
|
||
stopDispatchRosInspectionTask
|
||
} from "@/api/rosCar";
|
||
import { getToken } from "@/utils/auth";
|
||
import { BASE_URL } from "@/utils/request";
|
||
import { getRecentAlertPhoto } from "../shared/alertPhotos";
|
||
const defaultProject = "西江隧道";
|
||
const emptyDevice = {
|
||
id: "--",
|
||
carId: "",
|
||
deviceKey: "",
|
||
name: "暂无设备",
|
||
status: "离线",
|
||
location: "位置未上报",
|
||
battery: "--",
|
||
task: "暂无任务",
|
||
linearSpeed: "--",
|
||
angularSpeed: "--",
|
||
liftHeight: "--",
|
||
liftStatus: "--",
|
||
poseX: void 0,
|
||
poseY: void 0,
|
||
poseYaw: void 0,
|
||
mapPgmUrl: "",
|
||
mapYamlUrl: "",
|
||
whiteLightUrl: "",
|
||
infraredUrl: ""
|
||
};
|
||
const projectDevices = ref([
|
||
{
|
||
id: 1,
|
||
name: defaultProject,
|
||
devices: []
|
||
}
|
||
]);
|
||
function normalizeDeviceStatus(status) {
|
||
const statusMap = {
|
||
0: "空闲",
|
||
1: "巡检",
|
||
2: "离线",
|
||
3: "充电中"
|
||
};
|
||
return statusMap[status ?? 2] ?? "离线";
|
||
}
|
||
function buildDeviceTask(status) {
|
||
const taskMap = {
|
||
空闲: "待命",
|
||
巡检: "巡检中",
|
||
离线: "离线",
|
||
充电中: "回库充电"
|
||
};
|
||
return taskMap[status];
|
||
}
|
||
function normalizeDeviceRecord(car, index) {
|
||
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 || "",
|
||
whiteLightUrl: car.cameraWebrtcUrl || "",
|
||
infraredUrl: car.cameraWebrtcUrl2 || ""
|
||
};
|
||
}
|
||
async function fetchDeviceList() {
|
||
try {
|
||
const response = await getRosCarList({
|
||
page: 1,
|
||
limit: 100,
|
||
query: {}
|
||
});
|
||
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 = {
|
||
recognizeType: recognizeTypeCodeMap[activeAlertTab.value]
|
||
};
|
||
try {
|
||
const response = await getRecentAlarmList(query);
|
||
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, index) {
|
||
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) {
|
||
return type ? recognizeTypeMap[type] ?? "未知告警" : "未知告警";
|
||
}
|
||
function buildFileAccessUrl(fileUrl) {
|
||
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 parseRosMapYaml(content) {
|
||
const resolution = Number(content.match(/^\s*resolution\s*:\s*([^\s#]+)\s*$/m)?.[1]);
|
||
const originValue = content.match(/^\s*origin\s*:\s*\[([^\]]+)\]\s*$/m)?.[1];
|
||
const origin = originValue?.split(",").map((value) => Number(value.trim())).slice(0, 3);
|
||
return {
|
||
resolution: Number.isFinite(resolution) && resolution > 0 ? resolution : 0.05,
|
||
origin: origin?.length === 3 && origin.every((value) => Number.isFinite(value)) ? origin : [0, 0, 0]
|
||
};
|
||
}
|
||
function readPgmToken(bytes, cursor) {
|
||
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));
|
||
}
|
||
function buildStyledPgmMap(buffer) {
|
||
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 = {
|
||
100: "局部高温",
|
||
1: "电缆表面破损",
|
||
2: "桥架断裂",
|
||
3: "隧道积水",
|
||
4: "墙体裂缝"
|
||
};
|
||
const recognizeTypeCodeMap = Object.fromEntries(
|
||
Object.entries(recognizeTypeMap).map(([value, label]) => [label, Number(value)])
|
||
);
|
||
const recentAlerts = ref([]);
|
||
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(null);
|
||
const mapLoading = ref(false);
|
||
const mapError = ref("");
|
||
const routeNodes = ref([]);
|
||
const routeLoading = ref(false);
|
||
const routeError = ref("");
|
||
const inspectionTaskId = ref("");
|
||
const currentInspection = ref(null);
|
||
const inspectionLoading = ref(false);
|
||
const inspectionActionLoading = ref(false);
|
||
const lastDispatchTime = ref("");
|
||
const alertLoading = ref(false);
|
||
const alertError = ref("");
|
||
const alertTabs = ["局部高温", "电缆表面破损", "桥架断裂", "隧道积水", "墙体裂缝"];
|
||
const activeAlertTab = ref("局部高温");
|
||
const activeCameraMode = ref("white");
|
||
const activeDeviceId = ref("");
|
||
const driveModeTabs = ["阿克曼", "旋转", "横移"];
|
||
const activeDriveMode = ref("阿克曼");
|
||
const presetPointOptions = Array.from(
|
||
{ length: 10 },
|
||
(_, index) => `预置点${index + 1}`
|
||
);
|
||
const selectedPresetPoint = ref("预置点1");
|
||
const controlMode = ref("自主巡检");
|
||
const maxLiftHeight = 800;
|
||
const targetLiftHeight = ref("1200");
|
||
const monitorVideoRef = ref(null);
|
||
const navMapSvgRef = ref(null);
|
||
const streamLoading = ref(false);
|
||
const streamError = ref("");
|
||
const controlWsStatus = ref("未连接");
|
||
const controlError = ref("");
|
||
let streamPeerConnection = null;
|
||
let streamRequestId = 0;
|
||
let controlSocket = null;
|
||
let controlRequestSeed = 0;
|
||
let controlReconnectTimer = null;
|
||
let controlSocketManualClose = false;
|
||
let chassisControlTimer = null;
|
||
let ptzControlTimer = null;
|
||
let chassisControlling = false;
|
||
let ptzControlling = false;
|
||
const 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 currentMonitorVideo = computed(
|
||
() => activeCameraMode.value === "white" ? currentRobot.value.whiteLightUrl : currentRobot.value.infraredUrl
|
||
);
|
||
const currentScenario = computed(() => navScenario);
|
||
const hasRobotPose = computed(
|
||
() => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number"
|
||
);
|
||
function rosPoseToMapPoint(poseX, poseY, poseYaw = 0) {
|
||
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,
|
||
currentRobot.value.poseY,
|
||
currentRobot.value.poseYaw ?? 0
|
||
);
|
||
});
|
||
const robotMarkerScale = computed(() => {
|
||
if (!rosMap.value) {
|
||
return 1;
|
||
}
|
||
return 1 / mapScale.value;
|
||
});
|
||
const mapMeterGrid = computed(() => {
|
||
if (!rosMap.value) {
|
||
return null;
|
||
}
|
||
const spacing = 1 / rosMap.value.resolution;
|
||
const offsetX = (-rosMap.value.origin[0] / rosMap.value.resolution % spacing + spacing) % spacing;
|
||
const offsetY = ((rosMap.value.height + rosMap.value.origin[1] / rosMap.value.resolution) % spacing + spacing) % spacing;
|
||
return {
|
||
spacing,
|
||
offsetX,
|
||
offsetY
|
||
};
|
||
});
|
||
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) => 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)
|
||
);
|
||
async function loadRobotMap(device) {
|
||
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 meta = parseRosMapYaml(await yamlResponse.text());
|
||
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;
|
||
}
|
||
}
|
||
async function loadRobotRoute(device) {
|
||
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);
|
||
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);
|
||
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 getDispatchTaskId() {
|
||
return currentInspection.value?.taskId || inspectionTaskId.value;
|
||
}
|
||
function showConfirmDialog(title, content, onConfirm) {
|
||
window.$modal.warning({
|
||
title,
|
||
content,
|
||
positiveText: "确认",
|
||
negativeText: "取消",
|
||
maskClosable: false,
|
||
onPositiveClick: onConfirm
|
||
});
|
||
}
|
||
async function runInspectionAction(action) {
|
||
const taskId = getDispatchTaskId();
|
||
if (!taskId) {
|
||
window.$message.warning("未获取到巡检任务ID\,请先在设备管理中保存巡检任务");
|
||
return;
|
||
}
|
||
inspectionActionLoading.value = true;
|
||
try {
|
||
const response = action === "dispatch" ? await dispatchRosInspectionTask(taskId) : await stopDispatchRosInspectionTask(taskId);
|
||
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"));
|
||
}
|
||
function syncLiftTargetFromDevice(device) {
|
||
if (!device || device.liftHeight === "--") {
|
||
return;
|
||
}
|
||
targetLiftHeight.value = device.liftHeight;
|
||
}
|
||
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) {
|
||
if (!deviceKey) {
|
||
return -1;
|
||
}
|
||
return currentDevices.value.findIndex((device) => device.deviceKey === deviceKey || device.id === deviceKey);
|
||
}
|
||
function applyCarStatusPush(data) {
|
||
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 = {
|
||
...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
|
||
};
|
||
projectDevices.value[0].devices.splice(deviceIndex, 1, nextDevice);
|
||
if (!activeDeviceId.value) {
|
||
activeDeviceId.value = nextDevice.id;
|
||
}
|
||
}
|
||
function applyCarVelocityPush(data) {
|
||
const deviceIndex = findDeviceIndexByKey(data.deviceKey);
|
||
if (deviceIndex < 0 || !data.velocity) {
|
||
return;
|
||
}
|
||
const currentDevice = currentDevices.value[deviceIndex];
|
||
const nextDevice = {
|
||
...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);
|
||
}
|
||
function scheduleControlReconnect() {
|
||
if (controlSocketManualClose || controlReconnectTimer !== null) {
|
||
return;
|
||
}
|
||
controlWsStatus.value = "重连中";
|
||
controlReconnectTimer = window.setTimeout(() => {
|
||
controlReconnectTimer = null;
|
||
connectControlSocket();
|
||
}, 2e3);
|
||
}
|
||
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);
|
||
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;
|
||
}
|
||
function closeControlSocket() {
|
||
controlSocketManualClose = true;
|
||
if (controlReconnectTimer !== null) {
|
||
window.clearTimeout(controlReconnectTimer);
|
||
controlReconnectTimer = null;
|
||
}
|
||
if (!controlSocket) {
|
||
controlWsStatus.value = "未连接";
|
||
return;
|
||
}
|
||
controlSocket.close();
|
||
controlSocket = null;
|
||
controlWsStatus.value = "未连接";
|
||
}
|
||
function sendControlMessage(type, data) {
|
||
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 });
|
||
}
|
||
function stopMonitorStream() {
|
||
streamRequestId += 1;
|
||
streamLoading.value = false;
|
||
if (streamPeerConnection) {
|
||
streamPeerConnection.close();
|
||
streamPeerConnection = null;
|
||
}
|
||
if (monitorVideoRef.value) {
|
||
monitorVideoRef.value.srcObject = null;
|
||
}
|
||
}
|
||
function buildWebrtcApiUrl(streamUrl) {
|
||
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
|
||
const url = new URL(normalizedUrl);
|
||
return `${url.origin}${url.pathname}${url.search}`;
|
||
}
|
||
function waitForIceGatheringComplete(peerConnection) {
|
||
if (peerConnection.iceGatheringState === "complete") {
|
||
return Promise.resolve();
|
||
}
|
||
return new Promise((resolve) => {
|
||
const handleIceGatheringStateChange = () => {
|
||
if (peerConnection.iceGatheringState === "complete") {
|
||
peerConnection.removeEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||
resolve();
|
||
}
|
||
};
|
||
peerConnection.addEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||
});
|
||
}
|
||
async function playMonitorStream() {
|
||
stopMonitorStream();
|
||
streamError.value = "";
|
||
if (!currentMonitorVideo.value) {
|
||
return;
|
||
}
|
||
if (!window.RTCPeerConnection) {
|
||
streamError.value = "当前浏览器不支持 WebRTC 播放";
|
||
return;
|
||
}
|
||
const requestId = streamRequestId;
|
||
const peerConnection = new RTCPeerConnection();
|
||
streamPeerConnection = peerConnection;
|
||
streamLoading.value = true;
|
||
peerConnection.addTransceiver("video", { direction: "recvonly" });
|
||
peerConnection.addTransceiver("audio", { direction: "recvonly" });
|
||
peerConnection.ontrack = (event) => {
|
||
if (requestId !== streamRequestId || !monitorVideoRef.value) {
|
||
return;
|
||
}
|
||
monitorVideoRef.value.srcObject = event.streams[0];
|
||
};
|
||
try {
|
||
const offer = await peerConnection.createOffer();
|
||
await peerConnection.setLocalDescription(offer);
|
||
await waitForIceGatheringComplete(peerConnection);
|
||
const response = await fetch(buildWebrtcApiUrl(currentMonitorVideo.value), {
|
||
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);
|
||
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 !== streamRequestId) {
|
||
return;
|
||
}
|
||
await peerConnection.setRemoteDescription({
|
||
type: "answer",
|
||
sdp: answer
|
||
});
|
||
} catch (error) {
|
||
if (requestId === streamRequestId) {
|
||
streamError.value = error instanceof Error ? error.message : "视频流拉取失败";
|
||
stopMonitorStream();
|
||
}
|
||
} finally {
|
||
if (requestId === streamRequestId) {
|
||
streamLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
onMounted(() => {
|
||
fetchDeviceList();
|
||
fetchRecentAlerts();
|
||
connectControlSocket();
|
||
window.addEventListener("blur", stopContinuousControl);
|
||
});
|
||
onBeforeUnmount(() => {
|
||
stopMonitorStream();
|
||
stopContinuousControl();
|
||
closeControlSocket();
|
||
window.removeEventListener("blur", stopContinuousControl);
|
||
});
|
||
watch(
|
||
currentMonitorVideo,
|
||
() => {
|
||
nextTick(() => {
|
||
playMonitorStream();
|
||
});
|
||
}
|
||
);
|
||
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 }
|
||
);
|
||
function switchAlertTab(tab) {
|
||
activeAlertTab.value = tab;
|
||
fetchRecentAlerts();
|
||
}
|
||
function switchCameraMode(mode) {
|
||
activeCameraMode.value = mode;
|
||
}
|
||
function selectDevice(device) {
|
||
activeDeviceId.value = device.id;
|
||
syncLiftTargetFromDevice(device);
|
||
}
|
||
function switchDriveMode(mode) {
|
||
activeDriveMode.value = mode;
|
||
}
|
||
function changeMapScale(delta) {
|
||
mapScale.value = Math.min(3, Math.max(0.55, Number((mapScale.value + delta).toFixed(2))));
|
||
}
|
||
function resetMapScale() {
|
||
mapScale.value = 1;
|
||
mapOffset.value = { x: 0, y: 0 };
|
||
}
|
||
function handleMapWheel(event) {
|
||
event.preventDefault();
|
||
changeMapScale(event.deltaY > 0 ? -0.05 : 0.05);
|
||
}
|
||
function startMapDrag(event) {
|
||
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) {
|
||
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, angularZ) {
|
||
sendControlMessage("cmd_vel", { linearX, angularZ });
|
||
}
|
||
function startCmdVel(linearX, angularZ) {
|
||
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) {
|
||
sendControlMessage("ptz_control", { action });
|
||
}
|
||
function startPtzControl(action) {
|
||
if (ptzControlTimer !== null) {
|
||
window.clearInterval(ptzControlTimer);
|
||
}
|
||
ptzControlling = true;
|
||
sendPtzControl(action);
|
||
ptzControlTimer = window.setInterval(() => {
|
||
sendPtzControl(action);
|
||
}, 50);
|
||
}
|
||
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;
|
||
}
|
||
function sendLiftControl(controlType, 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) {
|
||
const classMap = {
|
||
空闲: "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">
|
||
<div class="nav-map-panel">
|
||
<div class="nav-map-stage">
|
||
<svg v-if="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>
|
||
<pattern
|
||
v-if="mapMeterGrid"
|
||
id="meterGrid"
|
||
patternUnits="userSpaceOnUse"
|
||
:x="mapMeterGrid.offsetX"
|
||
:y="mapMeterGrid.offsetY"
|
||
:width="mapMeterGrid.spacing"
|
||
:height="mapMeterGrid.spacing"
|
||
>
|
||
<path :d="`M ${mapMeterGrid.spacing} 0 L 0 0 0 ${mapMeterGrid.spacing}`" class="meter-grid-line" />
|
||
</pattern>
|
||
</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" />
|
||
<rect v-if="mapMeterGrid" class="meter-grid-fill" x="0" y="0" :width="rosMap.width" :height="rosMap.height" fill="url(#meterGrid)" />
|
||
<path v-if="realRoutePath" class="route-glow" :d="realRoutePath" />
|
||
<path v-if="realRoutePath" class="route-line" :d="realRoutePath" />
|
||
<g class="checkpoint-layer">
|
||
<g v-for="point in realCheckPoints" :key="`${point.label}-${point.x}-${point.y}`" class="checkpoint"
|
||
:transform="`translate(${point.x}, ${point.y})`">
|
||
<circle r="3"></circle>
|
||
<text 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}) scale(${robotMarkerScale})`">
|
||
<circle class="robot-shadow" r="6.5" />
|
||
<circle class="robot-body" r="5.8" />
|
||
<path class="robot-head" d="M -1.6 -5.4 L 4.8 0 L -1.6 5.4 Z" />
|
||
</g>
|
||
</g>
|
||
</svg>
|
||
<svg v-else class="nav-map-svg" viewBox="0 0 760 420" preserveAspectRatio="xMidYMid meet">
|
||
<g class="grid-layer">
|
||
<line v-for="line in 7" :key="`h-${line}`" :x1="40" :y1="line * 52" :x2="720" :y2="line * 52" />
|
||
<line v-for="line in 12" :key="`v-${line}`" :x1="line * 56" :y1="40" :x2="line * 56" :y2="380" />
|
||
</g>
|
||
<path class="corridor-lane" :d="currentScenario.lanePath" />
|
||
<path class="corridor-edge" :d="currentScenario.upperEdgePath" />
|
||
<path class="corridor-edge" :d="currentScenario.lowerEdgePath" />
|
||
<g class="wall-layer wall-upper">
|
||
<circle v-for="(point, index) in currentScenario.upperWallPoints" :key="`upper-wall-${index}`"
|
||
:cx="point.x" :cy="point.y" :r="index % 4 === 0 ? 3.4 : 2.4" />
|
||
</g>
|
||
<g class="wall-layer wall-lower">
|
||
<circle v-for="(point, index) in currentScenario.lowerWallPoints" :key="`lower-wall-${index}`"
|
||
:cx="point.x" :cy="point.y" :r="index % 5 === 0 ? 3.2 : 2.3" />
|
||
</g>
|
||
<path class="route-glow" :d="currentScenario.routePath" />
|
||
<path class="route-line" :d="currentScenario.routePath" />
|
||
<g class="checkpoint-layer">
|
||
<g v-for="point in currentScenario.checkPoints" :key="point.label" class="checkpoint"
|
||
:transform="`translate(${point.x}, ${point.y})`">
|
||
<circle r="7"></circle>
|
||
<text x="12" y="4">{{ point.label }}</text>
|
||
</g>
|
||
</g>
|
||
<g class="robot-layer">
|
||
<circle class="robot-shadow" r="18" cx="358" cy="258" />
|
||
<circle class="robot-body" r="10" cx="358" cy="258" />
|
||
<path class="robot-head" d="M 355 249 L 366 258 L 355 267 Z" />
|
||
</g>
|
||
</svg>
|
||
<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>
|
||
<div class="nav-map-overlay top-left">ROS 实时地图</div>
|
||
<div class="nav-map-overlay top-right">真实坐标路径跟踪</div>
|
||
<div v-if="rosMap" 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 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 class="camera-switch header-switch">
|
||
<button :class="['camera-btn', { active: activeCameraMode === 'white' }]" type="button"
|
||
@click="switchCameraMode('white')">
|
||
白光
|
||
</button>
|
||
<button :class="['camera-btn', { active: activeCameraMode === 'infrared' }]" type="button"
|
||
@click="switchCameraMode('infrared')">
|
||
红外
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="video-panel">
|
||
<div class="video-screen">
|
||
<video v-if="currentMonitorVideo" ref="monitorVideoRef" :key="currentMonitorVideo"
|
||
:class="['monitor-video', { infrared: activeCameraMode === 'infrared' }]" autoplay muted
|
||
playsinline></video>
|
||
<div class="scan-line"></div>
|
||
<!-- <div class="video-overlay top-left">实时视频流</div>
|
||
<div class="video-overlay top-right">
|
||
{{ activeCameraMode === "white" ? "白光镜头" : "红外镜头" }}
|
||
</div> -->
|
||
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
|
||
<div v-if="streamLoading" class="video-empty">视频流连接中...</div>
|
||
<div v-else-if="streamError" class="video-empty">{{ streamError }}</div>
|
||
<div v-else-if="!currentMonitorVideo" 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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|