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