Files
robot_manager/src/views/dashboard1/pages/IntegratedCenterPage.vue

1421 lines
52 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 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>