1851 lines
59 KiB
Vue
1851 lines
59 KiB
Vue
<script lang="ts" setup>
|
||
import { NImage } from "naive-ui";
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import {
|
||
getRecentAlarmList,
|
||
type RosInspectionRecordLineImage,
|
||
type RosInspectionRecordLineImageQueryDTO,
|
||
} from "@/api/alarm";
|
||
import {
|
||
dispatchRosInspectionTask,
|
||
getCurrentRosInspection,
|
||
getRosCarList,
|
||
getRosInspectionTaskDetail,
|
||
stopDispatchRosInspectionTask,
|
||
} from "@/api/rosCar";
|
||
import { getToken } from "@/utils/auth";
|
||
import { parseRosMapYaml } from "@/utils/rosMapYaml";
|
||
import { BASE_URL } from "@/utils/request";
|
||
import { getRecentAlertPhoto } from "../shared/alertPhotos";
|
||
|
||
const defaultProject = "西江隧道";
|
||
|
||
type DriveMode = "阿克曼" | "旋转" | "横移";
|
||
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
|
||
type ControlMessageType = "cmd_vel" | "ptz_control" | "lift_control";
|
||
type PtzAction = "up" | "down" | "left" | "right" | "stop";
|
||
|
||
interface RosCarRecord {
|
||
id?: string;
|
||
deviceKey?: string;
|
||
deviceNo?: string;
|
||
name?: string;
|
||
batteryLevel?: number;
|
||
status?: number;
|
||
projectName?: string;
|
||
poseX?: number;
|
||
poseY?: number;
|
||
poseYaw?: number;
|
||
mapPgmUrl?: string;
|
||
mapYamlUrl?: string;
|
||
cameraWebrtcUrl?: string;
|
||
cameraWebrtcUrl2?: string;
|
||
liftHeight?: number;
|
||
liftDeviceStatusDescription?: string;
|
||
}
|
||
|
||
interface RosCarListResult {
|
||
data?: RosCarRecord[];
|
||
}
|
||
|
||
interface RosCarListResponse {
|
||
data?: RosCarListResult;
|
||
}
|
||
|
||
interface RecentAlarmListResponse {
|
||
data?: RosInspectionRecordLineImage[];
|
||
}
|
||
|
||
interface CarStatusPushData {
|
||
deviceKey?: string;
|
||
liftHeight?: number;
|
||
liftDeviceStatusDescription?: string;
|
||
batteryLevel?: number;
|
||
voltage?: number;
|
||
poseX?: number;
|
||
poseY?: number;
|
||
poseYaw?: number;
|
||
mapPgmUrl?: string;
|
||
mapYamlUrl?: string;
|
||
status?: number;
|
||
signal?: string;
|
||
}
|
||
|
||
interface CarVelocityPushData {
|
||
deviceKey?: string;
|
||
velocity?: {
|
||
linearX?: number;
|
||
angularZ?: number;
|
||
};
|
||
}
|
||
|
||
type ControlSocketMessageType =
|
||
| "control_result"
|
||
| "car_status"
|
||
| "car_velocity"
|
||
| "inspection_update"
|
||
| "inspection_record_stopped"
|
||
| "warning_update";
|
||
|
||
interface ControlSocketMessage {
|
||
type?: ControlSocketMessageType | string;
|
||
data?: {
|
||
accepted?: boolean;
|
||
message?: string;
|
||
} & CarStatusPushData & CarVelocityPushData;
|
||
}
|
||
|
||
interface DeviceItem {
|
||
id: string;
|
||
carId: string;
|
||
deviceKey: string;
|
||
name: string;
|
||
status: DeviceStatus;
|
||
location: string;
|
||
battery: string;
|
||
task: string;
|
||
linearSpeed: string;
|
||
angularSpeed: string;
|
||
liftHeight: string;
|
||
liftStatus: string;
|
||
poseX?: number;
|
||
poseY?: number;
|
||
poseYaw?: number;
|
||
mapPgmUrl: string;
|
||
mapYamlUrl: string;
|
||
whiteLightUrl: string;
|
||
infraredUrl: string;
|
||
}
|
||
|
||
interface ProjectItem {
|
||
id: number;
|
||
name: string;
|
||
devices: DeviceItem[];
|
||
}
|
||
|
||
interface NavPoint {
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
interface NavScenario {
|
||
routePath: string;
|
||
lanePath: string;
|
||
upperEdgePath: string;
|
||
lowerEdgePath: string;
|
||
upperWallPoints: NavPoint[];
|
||
lowerWallPoints: NavPoint[];
|
||
checkPoints: Array<{ x: number; y: number; label: string }>;
|
||
}
|
||
|
||
interface RosMapMeta {
|
||
resolution: number;
|
||
origin: [number, number, number];
|
||
}
|
||
|
||
interface RosMapState extends RosMapMeta {
|
||
imageUrl: string;
|
||
width: number;
|
||
height: number;
|
||
viewBox: string;
|
||
}
|
||
|
||
interface RobotMapPoint {
|
||
x: number;
|
||
y: number;
|
||
yaw: number;
|
||
angle: number;
|
||
}
|
||
|
||
interface InspectionRouteNode {
|
||
type?: "via_point" | "action_point";
|
||
poseX: number;
|
||
poseY: number;
|
||
nodeIndex: number;
|
||
}
|
||
|
||
interface InspectionTaskDetail {
|
||
id?: string;
|
||
nodes?: InspectionRouteNode[];
|
||
}
|
||
|
||
interface InspectionTaskDetailResponse {
|
||
data?: InspectionTaskDetail;
|
||
}
|
||
|
||
interface RosInspectionRecord {
|
||
taskId?: string;
|
||
roundCount?: number;
|
||
currentRoundIndex?: number;
|
||
startTime?: string;
|
||
status?: number;
|
||
currentNodeIndex?: number;
|
||
totalActionCount?: number;
|
||
}
|
||
|
||
interface CurrentInspectionResponse {
|
||
data?: RosInspectionRecord | null;
|
||
}
|
||
|
||
interface RosDispatchResultResponse {
|
||
data?: {
|
||
recordId?: string;
|
||
message?: string;
|
||
};
|
||
msg?: string;
|
||
}
|
||
|
||
interface AlertItem {
|
||
id: string;
|
||
title: string;
|
||
result: string;
|
||
time: string;
|
||
photo: string;
|
||
}
|
||
|
||
const emptyDevice: DeviceItem = {
|
||
id: "--",
|
||
carId: "",
|
||
deviceKey: "",
|
||
name: "暂无设备",
|
||
status: "离线",
|
||
location: "位置未上报",
|
||
battery: "--",
|
||
task: "暂无任务",
|
||
linearSpeed: "--",
|
||
angularSpeed: "--",
|
||
liftHeight: "--",
|
||
liftStatus: "--",
|
||
poseX: undefined,
|
||
poseY: undefined,
|
||
poseYaw: undefined,
|
||
mapPgmUrl: "",
|
||
mapYamlUrl: "",
|
||
whiteLightUrl: "",
|
||
infraredUrl: "",
|
||
};
|
||
const projectDevices = ref<ProjectItem[]>([
|
||
{
|
||
id: 1,
|
||
name: defaultProject,
|
||
devices: [],
|
||
},
|
||
]);
|
||
|
||
// 将接口的小车状态码转换为集成中心展示状态
|
||
function normalizeDeviceStatus(status?: number): DeviceStatus {
|
||
const statusMap: Record<number, DeviceStatus> = {
|
||
0: "空闲",
|
||
1: "巡检",
|
||
2: "离线",
|
||
3: "充电中",
|
||
};
|
||
|
||
return statusMap[status ?? 2] ?? "离线";
|
||
}
|
||
|
||
// 根据状态生成当前任务说明
|
||
function buildDeviceTask(status: DeviceStatus) {
|
||
const taskMap: Record<DeviceStatus, string> = {
|
||
空闲: "待命",
|
||
巡检: "巡检中",
|
||
离线: "离线",
|
||
充电中: "回库充电",
|
||
};
|
||
|
||
return taskMap[status];
|
||
}
|
||
|
||
// 将设备管理列表接口数据转换为集成中心设备卡片数据
|
||
function normalizeDeviceRecord(car: RosCarRecord, index: number): DeviceItem {
|
||
const status = normalizeDeviceStatus(car.status);
|
||
const location = typeof car.poseX === "number" && typeof car.poseY === "number"
|
||
? `X:${car.poseX.toFixed(2)} / Y:${car.poseY.toFixed(2)}`
|
||
: "位置未上报";
|
||
|
||
const carId = car.id || "";
|
||
const deviceKey = car.deviceKey || car.deviceNo || car.id || `ROBOT-${index + 1}`;
|
||
|
||
return {
|
||
id: car.deviceNo || car.id || deviceKey,
|
||
carId,
|
||
deviceKey,
|
||
name: car.name || "--",
|
||
status,
|
||
location,
|
||
battery: typeof car.batteryLevel === "number" ? `${car.batteryLevel}%` : "--",
|
||
task: buildDeviceTask(status),
|
||
linearSpeed: "--",
|
||
angularSpeed: "--",
|
||
liftHeight: typeof car.liftHeight === "number" ? String(car.liftHeight) : "--",
|
||
liftStatus: car.liftDeviceStatusDescription || "--",
|
||
poseX: car.poseX,
|
||
poseY: car.poseY,
|
||
poseYaw: car.poseYaw,
|
||
mapPgmUrl: car.mapPgmUrl || "",
|
||
mapYamlUrl: car.mapYamlUrl || "",
|
||
whiteLightUrl: car.cameraWebrtcUrl || "",
|
||
infraredUrl: car.cameraWebrtcUrl2 || "",
|
||
};
|
||
}
|
||
|
||
// 复用设备管理页的小车分页列表数据刷新集成中心设备列表
|
||
async function fetchDeviceList() {
|
||
try {
|
||
const response = (await getRosCarList({
|
||
page: 1,
|
||
limit: 100,
|
||
query: {},
|
||
})) as RosCarListResponse;
|
||
const rows = response.data?.data ?? [];
|
||
const projectName = rows[0]?.projectName || defaultProject;
|
||
|
||
const devices = rows.map((car, index) => normalizeDeviceRecord(car, index));
|
||
|
||
projectDevices.value = [
|
||
{
|
||
id: 1,
|
||
name: projectName,
|
||
devices,
|
||
},
|
||
];
|
||
|
||
if (!devices.some((device) => device.id === activeDeviceId.value)) {
|
||
activeDeviceId.value = devices[0]?.id ?? "";
|
||
syncLiftTargetFromDevice(devices[0]);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
// 请求近期告警接口并刷新监管中心告警卡片
|
||
async function fetchRecentAlerts() {
|
||
alertLoading.value = true;
|
||
alertError.value = "";
|
||
|
||
const query: RosInspectionRecordLineImageQueryDTO = {
|
||
recognizeType: recognizeTypeCodeMap[activeAlertTab.value],
|
||
};
|
||
|
||
try {
|
||
const response = (await getRecentAlarmList(query)) as RecentAlarmListResponse;
|
||
const rows = Array.isArray(response.data) ? response.data : [];
|
||
|
||
recentAlerts.value = rows.map((record, index) => normalizeAlertRecord(record, index));
|
||
} catch (error) {
|
||
alertError.value = "近期告警加载失败";
|
||
recentAlerts.value = [];
|
||
console.error(error);
|
||
} finally {
|
||
alertLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// 将后端识别记录转换为监管中心告警卡片数据
|
||
function normalizeAlertRecord(record: RosInspectionRecordLineImage, index: number): AlertItem {
|
||
const title = getRecognizeTypeText(record.recognizeType);
|
||
|
||
return {
|
||
id: record.id || record.recordActionId || record.recordId || `AL-${index + 1}`,
|
||
title,
|
||
result: record.warningValue || record.recognitionResult || record.failReason || "--",
|
||
time: record.captureTime || record.createTime || "--",
|
||
photo: buildFileAccessUrl(record.imageUrl || "") || getRecentAlertPhoto(title),
|
||
};
|
||
}
|
||
|
||
function getRecognizeTypeText(type?: number) {
|
||
return type ? recognizeTypeMap[type] ?? "未知告警" : "未知告警";
|
||
}
|
||
|
||
// 相对文件地址通过接口代理访问,避免开发环境直接请求后端静态路径失败
|
||
function buildFileAccessUrl(fileUrl: string) {
|
||
if (
|
||
!fileUrl ||
|
||
/^(https?:)?\/\//i.test(fileUrl) ||
|
||
fileUrl.startsWith("blob:") ||
|
||
fileUrl.startsWith("data:")
|
||
) {
|
||
return fileUrl;
|
||
}
|
||
|
||
if (fileUrl === BASE_URL || fileUrl.startsWith(`${BASE_URL}/`)) {
|
||
return fileUrl;
|
||
}
|
||
|
||
return `${BASE_URL}${fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`}`;
|
||
}
|
||
|
||
function readPgmToken(bytes: Uint8Array, cursor: { index: number }) {
|
||
while (cursor.index < bytes.length) {
|
||
const code = bytes[cursor.index];
|
||
|
||
if (code === 35) {
|
||
while (cursor.index < bytes.length && bytes[cursor.index] !== 10) {
|
||
cursor.index += 1;
|
||
}
|
||
} else if (code <= 32) {
|
||
cursor.index += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
const start = cursor.index;
|
||
while (cursor.index < bytes.length && bytes[cursor.index] > 32) {
|
||
cursor.index += 1;
|
||
}
|
||
|
||
return new TextDecoder("ascii").decode(bytes.slice(start, cursor.index));
|
||
}
|
||
|
||
// 将 ROS PGM 黑白占据栅格重绘为大屏风格底图
|
||
function buildStyledPgmMap(buffer: ArrayBuffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
const cursor = { index: 0 };
|
||
const magic = readPgmToken(bytes, cursor);
|
||
const width = Number(readPgmToken(bytes, cursor));
|
||
const height = Number(readPgmToken(bytes, cursor));
|
||
const maxValue = Number(readPgmToken(bytes, cursor));
|
||
|
||
if (magic !== "P5" || !width || !height || !maxValue) {
|
||
throw new Error("暂只支持 P5 二进制 PGM 地图");
|
||
}
|
||
|
||
while (cursor.index < bytes.length && bytes[cursor.index] <= 32) {
|
||
cursor.index += 1;
|
||
}
|
||
|
||
const canvas = document.createElement("canvas");
|
||
const context = canvas.getContext("2d");
|
||
|
||
if (!context) {
|
||
throw new Error("浏览器不支持 Canvas 地图渲染");
|
||
}
|
||
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const imageData = context.createImageData(width, height);
|
||
const twoBytePixel = maxValue > 255;
|
||
|
||
for (let index = 0; index < width * height; index += 1) {
|
||
const sourceIndex = cursor.index + (twoBytePixel ? index * 2 : index);
|
||
const rawValue = twoBytePixel
|
||
? ((bytes[sourceIndex] ?? 0) << 8) + (bytes[sourceIndex + 1] ?? 0)
|
||
: bytes[sourceIndex] ?? 0;
|
||
const gray = Math.round((rawValue / maxValue) * 255);
|
||
const targetIndex = index * 4;
|
||
|
||
if (gray < 80) {
|
||
imageData.data[targetIndex] = 70;
|
||
imageData.data[targetIndex + 1] = 226;
|
||
imageData.data[targetIndex + 2] = 255;
|
||
imageData.data[targetIndex + 3] = 220;
|
||
} else if (gray > 220) {
|
||
imageData.data[targetIndex] = 16;
|
||
imageData.data[targetIndex + 1] = 78;
|
||
imageData.data[targetIndex + 2] = 124;
|
||
imageData.data[targetIndex + 3] = 128;
|
||
} else {
|
||
imageData.data[targetIndex] = 9;
|
||
imageData.data[targetIndex + 1] = 22;
|
||
imageData.data[targetIndex + 2] = 42;
|
||
imageData.data[targetIndex + 3] = 42;
|
||
}
|
||
}
|
||
|
||
context.putImageData(imageData, 0, 0);
|
||
return {
|
||
imageUrl: canvas.toDataURL("image/png"),
|
||
width,
|
||
height,
|
||
};
|
||
}
|
||
|
||
const recognizeTypeMap: Record<number, string> = {
|
||
100: "局部高温",
|
||
1: "电缆表面破损",
|
||
2: "桥架断裂",
|
||
3: "隧道积水",
|
||
4: "墙体裂缝",
|
||
};
|
||
const recognizeTypeCodeMap = Object.fromEntries(
|
||
Object.entries(recognizeTypeMap).map(([value, label]) => [label, Number(value)]),
|
||
) as Record<string, number>;
|
||
|
||
const recentAlerts = ref<AlertItem[]>([]);
|
||
const mapScale = ref(1);
|
||
const mapOffset = ref({ x: 0, y: 0 });
|
||
const mapDragState = ref({
|
||
dragging: false,
|
||
startX: 0,
|
||
startY: 0,
|
||
originX: 0,
|
||
originY: 0,
|
||
});
|
||
const rosMap = ref<RosMapState | null>(null);
|
||
const mapLoading = ref(false);
|
||
const mapError = ref("");
|
||
const routeNodes = ref<InspectionRouteNode[]>([]);
|
||
const routeLoading = ref(false);
|
||
const routeError = ref("");
|
||
const inspectionTaskId = ref("");
|
||
const currentInspection = ref<RosInspectionRecord | null>(null);
|
||
const inspectionLoading = ref(false);
|
||
const inspectionActionLoading = ref(false);
|
||
const lastDispatchTime = ref("");
|
||
const alertLoading = ref(false);
|
||
const alertError = ref("");
|
||
const alertTabs = ["局部高温", "电缆表面破损", "桥架断裂", "隧道积水", "墙体裂缝"] as const;
|
||
const activeAlertTab = ref<(typeof alertTabs)[number]>("局部高温");
|
||
const activeCameraMode = ref<"white" | "infrared">("white");
|
||
const activeDeviceId = ref("");
|
||
const driveModeTabs: DriveMode[] = ["阿克曼", "旋转", "横移"];
|
||
const activeDriveMode = ref<DriveMode>("阿克曼");
|
||
const presetPointOptions = Array.from(
|
||
{ length: 10 },
|
||
(_, index) => `预置点${index + 1}`,
|
||
);
|
||
const selectedPresetPoint = ref("预置点1");
|
||
const controlMode = ref<"人工接管" | "自主巡检">("自主巡检");
|
||
const maxLiftHeight = 800;
|
||
const targetLiftHeight = ref("1200");
|
||
const monitorVideoRef = ref<HTMLVideoElement | null>(null);
|
||
const navMapSvgRef = ref<SVGSVGElement | null>(null);
|
||
const streamLoading = ref(false);
|
||
const streamError = ref("");
|
||
const controlWsStatus = ref("未连接");
|
||
const controlError = ref("");
|
||
let streamPeerConnection: RTCPeerConnection | null = null;
|
||
let streamRequestId = 0;
|
||
let controlSocket: WebSocket | null = null;
|
||
let controlRequestSeed = 0;
|
||
let controlReconnectTimer: number | null = null;
|
||
let controlSocketManualClose = false;
|
||
let chassisControlTimer: number | null = null;
|
||
let ptzControlTimer: number | null = null;
|
||
// let inspectionPollTimer: number | null = null;
|
||
let chassisControlling = false;
|
||
let ptzControlling = false;
|
||
|
||
const navScenario: NavScenario = {
|
||
routePath:
|
||
"M 106 318 L 154 306 L 208 292 L 256 278 L 306 264 L 358 258 L 414 258 L 468 248 L 522 241 L 578 234 L 638 226",
|
||
lanePath:
|
||
"M 82 270 L 136 258 L 190 244 L 244 230 L 296 216 L 350 208 L 408 208 L 464 198 L 518 190 L 576 182 L 642 174 L 656 188 L 650 278 L 586 286 L 528 294 L 472 302 L 414 312 L 358 312 L 304 320 L 252 334 L 198 348 L 142 360 L 88 370 L 74 356 Z",
|
||
upperEdgePath:
|
||
"M 82 270 L 136 258 L 190 244 L 244 230 L 296 216 L 350 208 L 408 208 L 464 198 L 518 190 L 576 182 L 642 174",
|
||
lowerEdgePath:
|
||
"M 88 370 L 142 360 L 198 348 L 252 334 L 304 320 L 358 312 L 414 312 L 472 302 L 528 294 L 586 286 L 650 278",
|
||
upperWallPoints: [
|
||
{ x: 64, y: 164 },
|
||
{ x: 84, y: 150 },
|
||
{ x: 104, y: 142 },
|
||
{ x: 126, y: 136 },
|
||
{ x: 148, y: 132 },
|
||
{ x: 172, y: 126 },
|
||
{ x: 196, y: 122 },
|
||
{ x: 224, y: 118 },
|
||
{ x: 252, y: 116 },
|
||
{ x: 280, y: 112 },
|
||
{ x: 312, y: 110 },
|
||
{ x: 344, y: 108 },
|
||
{ x: 378, y: 108 },
|
||
{ x: 412, y: 110 },
|
||
{ x: 448, y: 112 },
|
||
{ x: 484, y: 114 },
|
||
{ x: 520, y: 116 },
|
||
{ x: 556, y: 120 },
|
||
{ x: 590, y: 126 },
|
||
{ x: 622, y: 134 },
|
||
{ x: 650, y: 144 },
|
||
{ x: 676, y: 158 },
|
||
{ x: 116, y: 170 },
|
||
{ x: 214, y: 156 },
|
||
{ x: 326, y: 146 },
|
||
{ x: 446, y: 148 },
|
||
{ x: 566, y: 160 },
|
||
{ x: 638, y: 172 },
|
||
],
|
||
lowerWallPoints: [
|
||
{ x: 72, y: 380 },
|
||
{ x: 96, y: 392 },
|
||
{ x: 122, y: 404 },
|
||
{ x: 150, y: 410 },
|
||
{ x: 180, y: 416 },
|
||
{ x: 214, y: 420 },
|
||
{ x: 248, y: 422 },
|
||
{ x: 284, y: 424 },
|
||
{ x: 322, y: 426 },
|
||
{ x: 360, y: 426 },
|
||
{ x: 400, y: 424 },
|
||
{ x: 440, y: 420 },
|
||
{ x: 480, y: 414 },
|
||
{ x: 520, y: 408 },
|
||
{ x: 558, y: 400 },
|
||
{ x: 594, y: 392 },
|
||
{ x: 628, y: 384 },
|
||
{ x: 660, y: 374 },
|
||
{ x: 700, y: 366 },
|
||
{ x: 126, y: 376 },
|
||
{ x: 238, y: 392 },
|
||
{ x: 354, y: 396 },
|
||
{ x: 474, y: 388 },
|
||
{ x: 590, y: 372 },
|
||
],
|
||
checkPoints: [
|
||
{ x: 106, y: 318, label: "起点" },
|
||
{ x: 358, y: 258, label: "途经点" },
|
||
{ x: 638, y: 226, label: "终点" },
|
||
],
|
||
};
|
||
|
||
const currentProject = computed(() => projectDevices.value[0]);
|
||
const currentDevices = computed(() => currentProject.value?.devices ?? []);
|
||
const currentRobot = computed(
|
||
() => currentDevices.value.find((device) => device.id === activeDeviceId.value) ?? currentDevices.value[0] ?? emptyDevice,
|
||
);
|
||
const 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",
|
||
);
|
||
|
||
// 将 ROS 米制坐标转换成地图图片像素坐标,路线和机器人定位共用同一套转换
|
||
function rosPoseToMapPoint(poseX: number, poseY: number, poseYaw = 0): RobotMapPoint | null {
|
||
if (!rosMap.value) {
|
||
return null;
|
||
}
|
||
|
||
const x = (poseX - rosMap.value.origin[0]) / rosMap.value.resolution;
|
||
const y = rosMap.value.height - (poseY - rosMap.value.origin[1]) / rosMap.value.resolution;
|
||
|
||
return {
|
||
x: Math.min(Math.max(x, 0), rosMap.value.width),
|
||
y: Math.min(Math.max(y, 0), rosMap.value.height),
|
||
yaw: poseYaw,
|
||
angle: -poseYaw * 180 / Math.PI,
|
||
};
|
||
}
|
||
|
||
const robotMapPoint = computed(() => {
|
||
if (!hasRobotPose.value) {
|
||
return null;
|
||
}
|
||
|
||
return rosPoseToMapPoint(
|
||
currentRobot.value.poseX as number,
|
||
currentRobot.value.poseY as number,
|
||
currentRobot.value.poseYaw ?? 0,
|
||
);
|
||
});
|
||
const robotMarkerScale = computed(() => {
|
||
if (!rosMap.value) {
|
||
return 1;
|
||
}
|
||
|
||
return 1 / mapScale.value;
|
||
});
|
||
const mapContentTransform = computed(() => {
|
||
if (!rosMap.value) {
|
||
return "";
|
||
}
|
||
|
||
const centerX = rosMap.value.width / 2;
|
||
const centerY = rosMap.value.height / 2;
|
||
|
||
return `translate(${mapOffset.value.x}, ${mapOffset.value.y}) translate(${centerX}, ${centerY}) scale(${mapScale.value}) translate(${-centerX}, ${-centerY})`;
|
||
});
|
||
const routeMapPoints = computed(() => routeNodes.value
|
||
.map((node) => rosPoseToMapPoint(node.poseX, node.poseY))
|
||
.filter((point): point is RobotMapPoint => Boolean(point)));
|
||
const realRoutePath = computed(() => routeMapPoints.value
|
||
.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
||
.join(" "));
|
||
const realCheckPoints = computed(() => routeMapPoints.value.map((point, index) => ({
|
||
...point,
|
||
label: index === 0 ? "起点" : index === routeMapPoints.value.length - 1 ? "终点" : "途经点",
|
||
})));
|
||
const inspectionRunning = computed(() => currentInspection.value?.status === 0);
|
||
const inspectionStatusText = computed(() => inspectionRunning.value ? "巡检中" : "空闲");
|
||
const currentRoundText = computed(() => {
|
||
if (!inspectionRunning.value) {
|
||
return "--/--";
|
||
}
|
||
|
||
return `${currentInspection.value?.currentRoundIndex ?? "--"}/${currentInspection.value?.roundCount ?? "--"}`;
|
||
});
|
||
const currentInspectionPointText = computed(() => {
|
||
if (!inspectionRunning.value) {
|
||
return "--/--";
|
||
}
|
||
|
||
return `${currentInspection.value?.currentNodeIndex ?? "--"}/${currentInspection.value?.totalActionCount ?? "--"}`;
|
||
});
|
||
|
||
const filteredRecentAlerts = computed(() =>
|
||
recentAlerts.value.filter((alert) => alert.title === activeAlertTab.value),
|
||
);
|
||
|
||
// 加载当前机器人携带的 PGM/YAML 地图,并转换为可叠加真实坐标的前端地图
|
||
async function loadRobotMap(device: DeviceItem) {
|
||
const mapPgmUrl = device.mapPgmUrl;
|
||
const mapYamlUrl = device.mapYamlUrl;
|
||
|
||
rosMap.value = null;
|
||
mapError.value = "";
|
||
|
||
if (!mapPgmUrl || !mapYamlUrl) {
|
||
mapError.value = "当前机器人未配置地图文件";
|
||
return;
|
||
}
|
||
|
||
mapLoading.value = true;
|
||
|
||
try {
|
||
const [pgmResponse, yamlResponse] = await Promise.all([
|
||
fetch(buildFileAccessUrl(mapPgmUrl)),
|
||
fetch(buildFileAccessUrl(mapYamlUrl)),
|
||
]);
|
||
|
||
if (!pgmResponse.ok) {
|
||
throw new Error("地图 PGM 文件访问失败");
|
||
}
|
||
|
||
if (!yamlResponse.ok) {
|
||
throw new Error("地图 YAML 文件访问失败");
|
||
}
|
||
|
||
const styledMap = buildStyledPgmMap(await pgmResponse.arrayBuffer());
|
||
const parsedMeta = parseRosMapYaml(await yamlResponse.text());
|
||
const meta: RosMapMeta = {
|
||
resolution: parsedMeta.resolution ?? 0.05,
|
||
origin: parsedMeta.origin ?? [0, 0, 0],
|
||
};
|
||
|
||
rosMap.value = {
|
||
...meta,
|
||
...styledMap,
|
||
viewBox: `0 0 ${styledMap.width} ${styledMap.height}`,
|
||
};
|
||
} catch (error) {
|
||
console.error(error);
|
||
mapError.value = error instanceof Error ? error.message : "机器人地图加载失败";
|
||
} finally {
|
||
mapLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// 调用巡检任务详情接口,将路线节点渲染到真实 ROS 地图上,并记录下发所需 taskId
|
||
async function loadRobotRoute(device: DeviceItem) {
|
||
routeNodes.value = [];
|
||
routeError.value = "";
|
||
inspectionTaskId.value = "";
|
||
|
||
const cardId = device.carId || device.id;
|
||
|
||
if (!cardId || cardId === "--") {
|
||
return;
|
||
}
|
||
|
||
routeLoading.value = true;
|
||
|
||
try {
|
||
const response = (await getRosInspectionTaskDetail(cardId)) as InspectionTaskDetailResponse;
|
||
inspectionTaskId.value = response.data?.id || "";
|
||
routeNodes.value = [...(response.data?.nodes ?? [])]
|
||
.filter((node) => typeof node.poseX === "number" && typeof node.poseY === "number")
|
||
.sort((current, next) => current.nodeIndex - next.nodeIndex);
|
||
} catch (error) {
|
||
console.error(error);
|
||
routeError.value = "巡检路线加载失败";
|
||
} finally {
|
||
routeLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// 刷新当前巡检状态,非执行中统一按空闲展示
|
||
async function fetchCurrentInspection(silent = false) {
|
||
const carId = currentRobot.value.carId || currentRobot.value.id;
|
||
|
||
if (!carId || carId === "--") {
|
||
currentInspection.value = null;
|
||
return;
|
||
}
|
||
|
||
if (!silent) {
|
||
inspectionLoading.value = true;
|
||
}
|
||
|
||
try {
|
||
const response = (await getCurrentRosInspection(carId)) as CurrentInspectionResponse;
|
||
const record = response.data?.status === 0 ? response.data : null;
|
||
|
||
currentInspection.value = record;
|
||
|
||
if (response.data?.startTime) {
|
||
lastDispatchTime.value = response.data.startTime;
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
inspectionLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// 巡检状态轮询暂时关闭,后续需要恢复时取消下面代码注释
|
||
// function stopInspectionPolling() {
|
||
// if (inspectionPollTimer !== null) {
|
||
// window.clearInterval(inspectionPollTimer);
|
||
// inspectionPollTimer = null;
|
||
// }
|
||
// }
|
||
|
||
// function startInspectionPolling() {
|
||
// stopInspectionPolling();
|
||
// fetchCurrentInspection();
|
||
// inspectionPollTimer = window.setInterval(() => {
|
||
// fetchCurrentInspection(true);
|
||
// }, 10000);
|
||
// }
|
||
|
||
function getDispatchTaskId() {
|
||
return currentInspection.value?.taskId || inspectionTaskId.value;
|
||
}
|
||
|
||
function showConfirmDialog(title: string, content: string, onConfirm: () => void | Promise<void>) {
|
||
window.$modal.warning({
|
||
title,
|
||
content,
|
||
positiveText: "确认",
|
||
negativeText: "取消",
|
||
maskClosable: false,
|
||
onPositiveClick: onConfirm,
|
||
});
|
||
}
|
||
|
||
async function runInspectionAction(action: "dispatch" | "stop") {
|
||
const taskId = getDispatchTaskId();
|
||
|
||
if (!taskId) {
|
||
window.$message.warning("未获取到巡检任务ID,请先在设备管理中保存巡检任务");
|
||
return;
|
||
}
|
||
|
||
inspectionActionLoading.value = true;
|
||
|
||
try {
|
||
const response = action === "dispatch"
|
||
? await dispatchRosInspectionTask(taskId) as RosDispatchResultResponse
|
||
: await stopDispatchRosInspectionTask(taskId) as RosDispatchResultResponse;
|
||
const message = response.data?.message || response.msg || (action === "dispatch" ? "巡检任务已下发" : "已停止巡检任务");
|
||
|
||
window.$message.success(message);
|
||
await fetchCurrentInspection();
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
inspectionActionLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function confirmDispatchInspection() {
|
||
showConfirmDialog("下发巡检", "确认下发当前巡检任务吗?", () => runInspectionAction("dispatch"));
|
||
}
|
||
|
||
function confirmStopInspection() {
|
||
showConfirmDialog("停止巡检", "确认停止当前巡检任务吗?", () => runInspectionAction("stop"));
|
||
}
|
||
|
||
// 切换设备时将当前设备已有升降柱高度回显到目标高度输入框
|
||
function syncLiftTargetFromDevice(device?: DeviceItem) {
|
||
if (!device || device.liftHeight === "--") {
|
||
return;
|
||
}
|
||
|
||
targetLiftHeight.value = device.liftHeight;
|
||
}
|
||
|
||
// 根据当前页面地址生成控制 WebSocket 地址,开发环境由 Vite 代理转发 /ws
|
||
function buildControlWsUrl() {
|
||
const token = getToken();
|
||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||
const wsUrl = new URL(`${wsProtocol}//${window.location.host}/ws/ros`);
|
||
|
||
if (token) {
|
||
wsUrl.searchParams.set("token", token);
|
||
}
|
||
|
||
return wsUrl.toString();
|
||
}
|
||
|
||
// 用推送中的设备标识查找当前设备列表索引
|
||
function findDeviceIndexByKey(deviceKey?: string) {
|
||
if (!deviceKey) {
|
||
return -1;
|
||
}
|
||
|
||
return currentDevices.value.findIndex((device) => device.deviceKey === deviceKey || device.id === deviceKey);
|
||
}
|
||
|
||
// 将小车状态推送合并到设备列表,实时刷新电量、位置、状态和视频地址
|
||
function applyCarStatusPush(data: CarStatusPushData) {
|
||
const deviceIndex = findDeviceIndexByKey(data.deviceKey);
|
||
|
||
if (deviceIndex < 0) {
|
||
return;
|
||
}
|
||
|
||
const currentDevice = currentDevices.value[deviceIndex];
|
||
const status = typeof data.status === "number"
|
||
? normalizeDeviceStatus(data.status)
|
||
: currentDevice.status;
|
||
const location = typeof data.poseX === "number" && typeof data.poseY === "number"
|
||
? `X:${data.poseX.toFixed(2)} / Y:${data.poseY.toFixed(2)}`
|
||
: currentDevice.location;
|
||
const nextDevice: DeviceItem = {
|
||
...currentDevice,
|
||
deviceKey: data.deviceKey || currentDevice.deviceKey,
|
||
status,
|
||
location,
|
||
battery: typeof data.batteryLevel === "number" ? `${data.batteryLevel}%` : currentDevice.battery,
|
||
task: buildDeviceTask(status),
|
||
liftHeight: typeof data.liftHeight === "number" ? String(data.liftHeight) : currentDevice.liftHeight,
|
||
liftStatus: data.liftDeviceStatusDescription || currentDevice.liftStatus,
|
||
poseX: typeof data.poseX === "number" ? data.poseX : currentDevice.poseX,
|
||
poseY: typeof data.poseY === "number" ? data.poseY : currentDevice.poseY,
|
||
poseYaw: typeof data.poseYaw === "number" ? data.poseYaw : currentDevice.poseYaw,
|
||
mapPgmUrl: data.mapPgmUrl || currentDevice.mapPgmUrl,
|
||
mapYamlUrl: data.mapYamlUrl || currentDevice.mapYamlUrl,
|
||
};
|
||
|
||
projectDevices.value[0].devices.splice(deviceIndex, 1, nextDevice);
|
||
|
||
if (!activeDeviceId.value) {
|
||
activeDeviceId.value = nextDevice.id;
|
||
}
|
||
}
|
||
|
||
// 将小车速度推送合并到设备列表,实时刷新线速度和角速度
|
||
function applyCarVelocityPush(data: CarVelocityPushData) {
|
||
const deviceIndex = findDeviceIndexByKey(data.deviceKey);
|
||
|
||
if (deviceIndex < 0 || !data.velocity) {
|
||
return;
|
||
}
|
||
|
||
const currentDevice = currentDevices.value[deviceIndex];
|
||
const nextDevice: DeviceItem = {
|
||
...currentDevice,
|
||
linearSpeed: typeof data.velocity.linearX === "number"
|
||
? `${data.velocity.linearX.toFixed(2)}m/s`
|
||
: currentDevice.linearSpeed,
|
||
angularSpeed: typeof data.velocity.angularZ === "number"
|
||
? `${data.velocity.angularZ.toFixed(2)}rad/s`
|
||
: currentDevice.angularSpeed,
|
||
};
|
||
|
||
projectDevices.value[0].devices.splice(deviceIndex, 1, nextDevice);
|
||
}
|
||
|
||
// 控制 WebSocket 异常断开后延迟重连,避免刷新进入页面后保持未连接状态
|
||
function scheduleControlReconnect() {
|
||
if (controlSocketManualClose || controlReconnectTimer !== null) {
|
||
return;
|
||
}
|
||
|
||
controlWsStatus.value = "重连中";
|
||
controlReconnectTimer = window.setTimeout(() => {
|
||
controlReconnectTimer = null;
|
||
connectControlSocket();
|
||
}, 2000);
|
||
}
|
||
|
||
// 建立浏览器到 ROS 控制网关的 WebSocket 连接
|
||
function connectControlSocket() {
|
||
if (
|
||
controlSocket &&
|
||
(controlSocket.readyState === WebSocket.OPEN || controlSocket.readyState === WebSocket.CONNECTING)
|
||
) {
|
||
return controlSocket;
|
||
}
|
||
|
||
controlSocketManualClose = false;
|
||
controlError.value = "";
|
||
controlWsStatus.value = "连接中";
|
||
controlSocket = new WebSocket(buildControlWsUrl());
|
||
|
||
controlSocket.onopen = () => {
|
||
controlWsStatus.value = "已连接";
|
||
};
|
||
|
||
controlSocket.onmessage = (event) => {
|
||
if (event.data === "ping") {
|
||
controlSocket?.send("pong");
|
||
return;
|
||
}
|
||
|
||
if (event.data === "pong") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const message = JSON.parse(event.data) as ControlSocketMessage;
|
||
|
||
if (message.type === "control_result" && message.data?.accepted === false) {
|
||
controlError.value = message.data.message || "控制命令未被接受";
|
||
return;
|
||
}
|
||
|
||
if (message.type === "car_status" && message.data) {
|
||
applyCarStatusPush(message.data);
|
||
return;
|
||
}
|
||
|
||
if (message.type === "car_velocity" && message.data) {
|
||
applyCarVelocityPush(message.data);
|
||
return;
|
||
}
|
||
|
||
if (message.type === "inspection_update" || message.type === "inspection_record_stopped") {
|
||
fetchCurrentInspection(true);
|
||
return;
|
||
}
|
||
|
||
if (message.type === "warning_update") {
|
||
fetchRecentAlerts();
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
};
|
||
|
||
controlSocket.onerror = () => {
|
||
controlError.value = "控制 WebSocket 连接异常";
|
||
};
|
||
|
||
controlSocket.onclose = () => {
|
||
controlWsStatus.value = "未连接";
|
||
controlSocket = null;
|
||
scheduleControlReconnect();
|
||
};
|
||
|
||
return controlSocket;
|
||
}
|
||
|
||
// 关闭控制 WebSocket,页面卸载时释放连接
|
||
function closeControlSocket() {
|
||
controlSocketManualClose = true;
|
||
|
||
if (controlReconnectTimer !== null) {
|
||
window.clearTimeout(controlReconnectTimer);
|
||
controlReconnectTimer = null;
|
||
}
|
||
|
||
if (!controlSocket) {
|
||
controlWsStatus.value = "未连接";
|
||
return;
|
||
}
|
||
|
||
controlSocket.close();
|
||
controlSocket = null;
|
||
controlWsStatus.value = "未连接";
|
||
}
|
||
|
||
// 通过 WS 下发机器人远程控制消息
|
||
function sendControlMessage(type: ControlMessageType, data: Record<string, string | number>) {
|
||
if (!currentRobot.value.deviceKey) {
|
||
controlError.value = "当前机器人缺少设备标识";
|
||
return;
|
||
}
|
||
|
||
const socket = connectControlSocket();
|
||
const payload = {
|
||
type,
|
||
requestId: `${type}-${Date.now()}-${controlRequestSeed += 1}`,
|
||
data: {
|
||
deviceKey: currentRobot.value.deviceKey,
|
||
...data,
|
||
},
|
||
};
|
||
const sendPayload = () => {
|
||
controlError.value = "";
|
||
socket.send(JSON.stringify(payload));
|
||
};
|
||
|
||
if (socket.readyState === WebSocket.OPEN) {
|
||
sendPayload();
|
||
return;
|
||
}
|
||
|
||
socket.addEventListener("open", sendPayload, { once: true });
|
||
}
|
||
|
||
// 停止当前 WebRTC 拉流并释放播放器资源
|
||
function stopMonitorStream() {
|
||
streamRequestId += 1;
|
||
streamLoading.value = false;
|
||
|
||
if (streamPeerConnection) {
|
||
streamPeerConnection.close();
|
||
streamPeerConnection = null;
|
||
}
|
||
|
||
if (monitorVideoRef.value) {
|
||
monitorVideoRef.value.srcObject = null;
|
||
}
|
||
}
|
||
|
||
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
|
||
function buildWebrtcApiUrl(streamUrl: string) {
|
||
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
|
||
const url = new URL(normalizedUrl);
|
||
|
||
return `${url.origin}${url.pathname}${url.search}`;
|
||
}
|
||
|
||
// 等待本地 ICE 候选收集完成,确保发给流媒体服务的 offer 信息完整
|
||
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) {
|
||
if (peerConnection.iceGatheringState === "complete") {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
return new Promise<void>((resolve) => {
|
||
const handleIceGatheringStateChange = () => {
|
||
if (peerConnection.iceGatheringState === "complete") {
|
||
peerConnection.removeEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||
resolve();
|
||
}
|
||
};
|
||
|
||
peerConnection.addEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||
});
|
||
}
|
||
|
||
// 通过 WebRTC offer/answer 拉取当前机器人视频流
|
||
async function 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) as { code?: number; sdp?: string; msg?: string };
|
||
|
||
if (responseData.code && responseData.code !== 0) {
|
||
throw new Error(responseData.msg || "视频流拉取失败");
|
||
}
|
||
|
||
answer = responseData.sdp || responseText;
|
||
} catch (error) {
|
||
if (responseText.trim().startsWith("{")) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (requestId !== 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();
|
||
// startInspectionPolling();
|
||
window.addEventListener("blur", stopContinuousControl);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
stopMonitorStream();
|
||
stopContinuousControl();
|
||
// stopInspectionPolling();
|
||
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: (typeof alertTabs)[number]) {
|
||
activeAlertTab.value = tab;
|
||
fetchRecentAlerts();
|
||
}
|
||
|
||
function switchCameraMode(mode: "white" | "infrared") {
|
||
activeCameraMode.value = mode;
|
||
}
|
||
|
||
// 点击设备列表中的机器人后,同步切换控制对象和监控视频源
|
||
function selectDevice(device: DeviceItem) {
|
||
activeDeviceId.value = device.id;
|
||
syncLiftTargetFromDevice(device);
|
||
}
|
||
|
||
function switchDriveMode(mode: DriveMode) {
|
||
activeDriveMode.value = mode;
|
||
}
|
||
|
||
// 调整真实 ROS 地图整体缩放,不影响底部状态浮层
|
||
function changeMapScale(delta: number) {
|
||
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: WheelEvent) {
|
||
event.preventDefault();
|
||
changeMapScale(event.deltaY > 0 ? -0.05 : 0.05);
|
||
}
|
||
|
||
function startMapDrag(event: PointerEvent) {
|
||
if (!rosMap.value || event.button !== 0) {
|
||
return;
|
||
}
|
||
|
||
mapDragState.value = {
|
||
dragging: true,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
originX: mapOffset.value.x,
|
||
originY: mapOffset.value.y,
|
||
};
|
||
}
|
||
|
||
function moveMapDrag(event: PointerEvent) {
|
||
if (!mapDragState.value.dragging || !rosMap.value || !navMapSvgRef.value) {
|
||
return;
|
||
}
|
||
|
||
const rect = navMapSvgRef.value.getBoundingClientRect();
|
||
const svgDeltaX = (event.clientX - mapDragState.value.startX) * (rosMap.value.width / rect.width);
|
||
const svgDeltaY = (event.clientY - mapDragState.value.startY) * (rosMap.value.height / rect.height);
|
||
|
||
mapOffset.value = {
|
||
x: mapDragState.value.originX + svgDeltaX,
|
||
y: mapDragState.value.originY + svgDeltaY,
|
||
};
|
||
}
|
||
|
||
function stopMapDrag() {
|
||
mapDragState.value.dragging = false;
|
||
}
|
||
|
||
// 单次下发底盘速度控制
|
||
function sendCmdVel(linearX: number, angularZ: number) {
|
||
sendControlMessage("cmd_vel", { linearX, angularZ });
|
||
}
|
||
|
||
// 按住底盘按钮时高频下发速度控制,保持 ROS cmd_vel 持续刷新
|
||
function startCmdVel(linearX: number, angularZ: number) {
|
||
if (chassisControlTimer !== null) {
|
||
window.clearInterval(chassisControlTimer);
|
||
}
|
||
|
||
chassisControlling = true;
|
||
sendCmdVel(linearX, angularZ);
|
||
chassisControlTimer = window.setInterval(() => {
|
||
sendCmdVel(linearX, angularZ);
|
||
}, 50);
|
||
}
|
||
|
||
// 停止底盘运动,仅在发生过底盘控制后发送停止,避免刷新/失焦产生无效停止消息
|
||
function stopCmdVel() {
|
||
if (chassisControlTimer !== null) {
|
||
window.clearInterval(chassisControlTimer);
|
||
chassisControlTimer = null;
|
||
}
|
||
|
||
if (!chassisControlling) {
|
||
return;
|
||
}
|
||
|
||
chassisControlling = false;
|
||
sendControlMessage("cmd_vel", { linearX: 0, angularZ: 0 });
|
||
}
|
||
|
||
// 单次下发云台方向控制
|
||
function sendPtzControl(action: PtzAction) {
|
||
sendControlMessage("ptz_control", { action });
|
||
}
|
||
|
||
// 按住云台按钮时高频下发方向控制,保持云台连续动作
|
||
function startPtzControl(action: PtzAction) {
|
||
if (ptzControlTimer !== null) {
|
||
window.clearInterval(ptzControlTimer);
|
||
}
|
||
|
||
ptzControlling = true;
|
||
sendPtzControl(action);
|
||
ptzControlTimer = window.setInterval(() => {
|
||
sendPtzControl(action);
|
||
}, 50);
|
||
}
|
||
|
||
// 停止云台运动,仅在发生过云台控制后发送 stop
|
||
function stopPtzControl() {
|
||
if (ptzControlTimer !== null) {
|
||
window.clearInterval(ptzControlTimer);
|
||
ptzControlTimer = null;
|
||
}
|
||
|
||
if (!ptzControlling) {
|
||
return;
|
||
}
|
||
|
||
ptzControlling = false;
|
||
sendControlMessage("ptz_control", { action: "stop" });
|
||
}
|
||
|
||
// 页面失焦时停止连续控制动作,避免机器人持续运动
|
||
function stopContinuousControl() {
|
||
stopCmdVel();
|
||
stopPtzControl();
|
||
}
|
||
|
||
function toggleControlMode() {
|
||
controlMode.value =
|
||
controlMode.value === "人工接管" ? "自主巡检" : "人工接管";
|
||
}
|
||
|
||
function jumpToPresetPoint() {
|
||
selectedPresetPoint.value = selectedPresetPoint.value;
|
||
}
|
||
|
||
// 下发升降柱控制命令,control_type 参照 WS 控制协议
|
||
function sendLiftControl(controlType: number, height = 0) {
|
||
sendControlMessage("lift_control", {
|
||
control_type: controlType,
|
||
height,
|
||
});
|
||
}
|
||
|
||
function resetLiftHeight() {
|
||
targetLiftHeight.value = "0";
|
||
sendLiftControl(0);
|
||
}
|
||
|
||
function raiseLiftHeight() {
|
||
sendLiftControl(1);
|
||
}
|
||
|
||
function lowerLiftHeight() {
|
||
sendLiftControl(2);
|
||
}
|
||
|
||
function stopLiftHeight() {
|
||
sendLiftControl(3);
|
||
}
|
||
|
||
function runToTargetHeight() {
|
||
const normalizedHeight = targetLiftHeight.value.replace(/[^\d]/g, "");
|
||
const height = Math.min(maxLiftHeight, Number(normalizedHeight) || 0);
|
||
|
||
targetLiftHeight.value = String(height);
|
||
sendLiftControl(4, height);
|
||
}
|
||
|
||
function getStatusClass(status: DeviceItem["status"]) {
|
||
const classMap: Record<DeviceItem["status"], string> = {
|
||
空闲: "status-idle",
|
||
巡检: "status-patrol",
|
||
离线: "status-offline",
|
||
充电中: "status-charging",
|
||
};
|
||
|
||
return classMap[status];
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="center">
|
||
<div class="left">
|
||
<div class="card-1 device-card">
|
||
<div class="title">设备列表</div>
|
||
<div class="device-panel">
|
||
<div class="device-list">
|
||
<template v-if="currentDevices.length">
|
||
<div v-for="device in currentDevices" :key="device.id"
|
||
:class="['device-item', { active: currentRobot.id === device.id }]" @click="selectDevice(device)">
|
||
<div class="device-main">
|
||
<div class="device-name">{{ device.name }}</div>
|
||
<div :class="['device-status', getStatusClass(device.status)]">
|
||
{{ device.status }}
|
||
</div>
|
||
</div>
|
||
<div class="device-meta">
|
||
<span>{{ device.id }}</span>
|
||
<span>{{ device.battery }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="device-empty">暂无设备数据</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-2 alert-card">
|
||
<div class="title">近期告警</div>
|
||
<div class="alert-panel">
|
||
<div class="alert-tabs">
|
||
<button v-for="tab in alertTabs" :key="tab" :class="['alert-tab', { active: activeAlertTab === tab }]"
|
||
type="button" @click="switchAlertTab(tab)">
|
||
{{ tab }}
|
||
</button>
|
||
</div>
|
||
<div class="alert-list">
|
||
<div v-if="alertLoading" class="alert-empty">近期告警加载中...</div>
|
||
<div v-else-if="alertError" class="alert-empty">{{ alertError }}</div>
|
||
<template v-else-if="filteredRecentAlerts.length">
|
||
<div v-for="alert in filteredRecentAlerts" :key="alert.id" class="alert-item">
|
||
<div class="alert-content">
|
||
<NImage
|
||
class="alert-thumbnail"
|
||
:src="alert.photo"
|
||
:alt="alert.title"
|
||
object-fit="cover"
|
||
:previewed-img-props="{ style: { maxWidth: '82vw', maxHeight: '82vh', objectFit: 'contain' } }"
|
||
/>
|
||
<div class="alert-info">
|
||
<div class="alert-summary">
|
||
<div class="alert-field">
|
||
<div class="alert-label">告警类型</div>
|
||
<div class="alert-title">{{ alert.title }}</div>
|
||
</div>
|
||
<div class="alert-field alert-field-right">
|
||
<div class="alert-label">识别结果</div>
|
||
<div class="alert-result">{{ alert.result }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="alert-time-row">
|
||
<span class="alert-label">告警时间</span>
|
||
<span class="alert-time">{{ alert.time }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="alert-empty">当前筛选下暂无告警</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="middle">
|
||
<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>
|
||
</defs>
|
||
<g class="real-map-content" :transform="mapContentTransform">
|
||
<image class="ros-map-image" :href="rosMap.imageUrl" x="0" y="0" :width="rosMap.width" :height="rosMap.height" />
|
||
<path v-if="realRoutePath" class="route-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>
|