Files
robot_manager/src/views/dashboard1/pages/IntegratedCenterPage.vue
2026-07-21 17:51:29 +08:00

1851 lines
59 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script lang="ts" setup>
import { NImage } from "naive-ui";
import { computed, nextTick, onBeforeUnmount, onMounted, 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>