Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb8f2ae26e | |||
| 1db8bac454 |
2
.vscode/extensions.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
|
||||
20
components.d.ts
vendored
@@ -1,20 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Chart: typeof import('./src/components/chart/index.vue')['default']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModalProvider: typeof import('naive-ui')['NModalProvider']
|
||||
ProviderHelper: typeof import('./src/components/ProviderHelper.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2367
package-lock.json
generated
21
package.json
@@ -5,34 +5,27 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^20.4.4",
|
||||
"@types/three": "^0.180.0",
|
||||
"axios": "^1.18.1",
|
||||
"echarts": "^5.4.3",
|
||||
"echarts-liquidfill": "^3.1.0",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"naive-ui": "^2.44.1",
|
||||
"ol": "^7.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"prettier": "^3.0.0",
|
||||
"three": "^0.180.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
"vue": "^3.2.47",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fluent": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.6",
|
||||
"@vue/compiler-sfc": "^3.5.39",
|
||||
"@vitejs/plugin-vue": "^4.1.0",
|
||||
"@vitejs/plugin-vue-jsx": "^1.3.3",
|
||||
"less": "^4.1.3",
|
||||
"naive-ui": "^2.44.1",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-vue-components": "^28.7.0",
|
||||
"vite": "8.1.4",
|
||||
"vue-tsc": "^3.3.9"
|
||||
"vite": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 MiB |
BIN
public/image.png
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
public/room.png
Normal file
|
After Width: | Height: | Size: 999 B |
25
src/App.vue
@@ -1,40 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const DESIGN_WIDTH = 1920;
|
||||
const DESIGN_HEIGHT = 1080;
|
||||
|
||||
const viewportWidth = ref(0);
|
||||
const viewportHeight = ref(0);
|
||||
const scale = ref(1);
|
||||
|
||||
function updateScreenAdapter() {
|
||||
viewportWidth.value = window.innerWidth;
|
||||
viewportHeight.value = window.innerHeight;
|
||||
|
||||
const widthScale = viewportWidth.value / DESIGN_WIDTH;
|
||||
const heightScale = viewportHeight.value / DESIGN_HEIGHT;
|
||||
scale.value = Math.min(widthScale, heightScale);
|
||||
}
|
||||
|
||||
const adapterStyle = computed(() => {
|
||||
const scaledWidth = DESIGN_WIDTH * scale.value;
|
||||
const scaledHeight = DESIGN_HEIGHT * scale.value;
|
||||
|
||||
return {
|
||||
width: `${DESIGN_WIDTH}px`,
|
||||
height: `${DESIGN_HEIGHT}px`,
|
||||
left: `${(viewportWidth.value - scaledWidth) / 2}px`,
|
||||
top: `${(viewportHeight.value - scaledHeight) / 2}px`,
|
||||
transform: `scale(${scale.value})`,
|
||||
transform: `scale(${scale.value})`
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updateScreenAdapter();
|
||||
window.addEventListener("resize", updateScreenAdapter);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", updateScreenAdapter);
|
||||
});
|
||||
@@ -43,18 +35,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="screen-adapter">
|
||||
<div class="screen-adapter__inner" :style="adapterStyle">
|
||||
<n-loading-bar-provider>
|
||||
<n-modal-provider>
|
||||
<n-dialog-provider>
|
||||
<n-message-provider>
|
||||
<n-message-provider>
|
||||
<provider-helper />
|
||||
<router-view />
|
||||
</n-message-provider>
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-modal-provider>
|
||||
</n-loading-bar-provider>
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
18
src/api/alarm.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from "@/utils/request";
|
||||
function getRecentAlarmList(data) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionRecordLineImage/warnList",
|
||||
data
|
||||
});
|
||||
}
|
||||
function getAlarmPage(data) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionRecordLineImage/list",
|
||||
method: "POST",
|
||||
data
|
||||
});
|
||||
}
|
||||
export {
|
||||
getAlarmPage,
|
||||
getRecentAlarmList
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
import type { ApiResult, OrderItem, PageParam, PageResult } from "@/types/api";
|
||||
import type { RosCar } from "./rosCar";
|
||||
|
||||
/**
|
||||
* 告警列表查询条件,对应后端 com.haizhiyustc.ros.dto.RosInspectionRecordLineImageQueryDTO
|
||||
*/
|
||||
export interface RosInspectionRecordLineImageQueryDTO {
|
||||
/** 识别类型 */
|
||||
recognizeType?: number | string;
|
||||
/** 开始时间 */
|
||||
startTime?: string;
|
||||
/** 结束时间 */
|
||||
endTime?: string;
|
||||
/** 是否预警 */
|
||||
warning?: boolean;
|
||||
/** 关键字 */
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export type RosInspectionRecordLineImagePageQuery =
|
||||
Partial<RosInspectionRecordLineImage> & {
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 巡检路线抓拍图片识别记录,对应后端 com.haizhiyustc.ros.entity.RosInspectionRecordLineImage
|
||||
* recognitionStatus: 0待执行 1执行中 2执行成功 3执行失败
|
||||
*/
|
||||
export interface RosInspectionRecordLineImage {
|
||||
/** 巡检记录id */
|
||||
recordId: string;
|
||||
/** 巡检设备id快照 */
|
||||
carId?: string;
|
||||
/** 关联的小车信息 */
|
||||
car?: RosCar;
|
||||
/** 抓拍动作id */
|
||||
recordActionId: string;
|
||||
/** 所属任务id */
|
||||
taskId?: string;
|
||||
/** 识别类型 */
|
||||
recognizeType?: number;
|
||||
/** 图片访问地址 */
|
||||
imageUrl?: string;
|
||||
/** 抓拍时间 */
|
||||
captureTime?: string;
|
||||
/** 抓拍时的小车位姿x */
|
||||
poseX?: number;
|
||||
/** 抓拍时的小车位姿y */
|
||||
poseY?: number;
|
||||
/** 抓拍时的小车朝向 */
|
||||
poseYaw?: number;
|
||||
/** 告警位置对应的里程点标识(后端按告警位姿在巡检路径上投影匹配) */
|
||||
mileageLabel?: string;
|
||||
/** 识别状态:0待执行 1执行中 2执行成功 3执行失败 */
|
||||
recognitionStatus?: number;
|
||||
/** AI识别提示词 */
|
||||
prompt?: string;
|
||||
/** AI识别结果 */
|
||||
recognitionResult?: string;
|
||||
/** 是否预警 */
|
||||
warning?: boolean;
|
||||
/** 预警值 */
|
||||
warningValue?: string;
|
||||
/** 失败原因 */
|
||||
failReason?: string;
|
||||
/** 开始识别时间 */
|
||||
startTime?: string;
|
||||
/** 结束识别时间 */
|
||||
endTime?: string;
|
||||
/** 创建时间 */
|
||||
createTime?: string;
|
||||
/** 更新时间 */
|
||||
updateTime?: string;
|
||||
/** 主键id */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// 查询近期告警列表
|
||||
// 返回告警列表,对应后端 warnList 接口
|
||||
// 返回: ApiResult<List<RosInspectionRecordLineImage>>
|
||||
export function getRecentAlarmList(data: RosInspectionRecordLineImageQueryDTO) {
|
||||
return request<ApiResult<RosInspectionRecordLineImage[]>>({
|
||||
url: "/admin/rosInspectionRecordLineImage/warnList",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 分页查询告警列表
|
||||
export function getAlarmPage(data: PageParam<RosInspectionRecordLineImagePageQuery>) {
|
||||
return request<ApiResult<PageResult<RosInspectionRecordLineImage>>>({
|
||||
url: "/admin/rosInspectionRecordLineImage/list",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
import type { ApiResult } from "@/types/api";
|
||||
|
||||
/**
|
||||
* 里程点(设备级标定物,独立于巡检任务与轮次)
|
||||
* 对应后端 com.haizhiyustc.ros.entity.RosMileagePoint
|
||||
*/
|
||||
export interface RosMileagePoint {
|
||||
/** 主键id,新增时不传 */
|
||||
id?: string;
|
||||
/** 所属巡检设备id */
|
||||
carId: string;
|
||||
/** 里程点标识,人工填写,如 a区-01 */
|
||||
mileageLabel: string;
|
||||
/** 位姿x,单位米 */
|
||||
poseX?: number;
|
||||
/** 位姿y,单位米 */
|
||||
poseY?: number;
|
||||
/** 备注 */
|
||||
remark?: string;
|
||||
/** 创建时间 */
|
||||
createTime?: string;
|
||||
/** 更新时间 */
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
// 查询指定设备的里程点列表
|
||||
export function getMileagePointList(carId: string) {
|
||||
return request<ApiResult<RosMileagePoint[]>>({
|
||||
url: "/admin/rosMileagePoint/list",
|
||||
params: { carId },
|
||||
});
|
||||
}
|
||||
|
||||
// 保存里程点(有 id 为更新,无 id 为新增)
|
||||
export function saveMileagePoint(data: RosMileagePoint) {
|
||||
return request<ApiResult<RosMileagePoint>>({
|
||||
url: "/admin/rosMileagePoint/save",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除里程点
|
||||
export function removeMileagePoint(id: string) {
|
||||
return request<ApiResult<null>>({
|
||||
url: "/admin/rosMileagePoint/remove",
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
61
src/api/rosCar.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import request from "@/utils/request";
|
||||
function getRosCarList(data) {
|
||||
return request({
|
||||
url: "/admin/rosCar/list",
|
||||
data
|
||||
});
|
||||
}
|
||||
function saveRosInspectionTask(data) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/save",
|
||||
data
|
||||
});
|
||||
}
|
||||
function getRosInspectionTaskDetail(cardId) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/detail",
|
||||
params: { cardId }
|
||||
});
|
||||
}
|
||||
function getCurrentRosInspection(carId) {
|
||||
return request({
|
||||
url: "/admin/rosCar/currentInspection",
|
||||
params: { carId }
|
||||
});
|
||||
}
|
||||
function dispatchRosInspectionTask(taskId) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/dispatch",
|
||||
params: { taskId }
|
||||
});
|
||||
}
|
||||
function stopDispatchRosInspectionTask(taskId) {
|
||||
return request({
|
||||
url: "/admin/rosInspectionTask/stopDispatch",
|
||||
params: { taskId }
|
||||
});
|
||||
}
|
||||
function uploadFile(file) {
|
||||
const data = new FormData();
|
||||
data.append("file", file);
|
||||
return request({
|
||||
url: "/admin/file/upload",
|
||||
data
|
||||
});
|
||||
}
|
||||
function updateRosCarMapUrls(data) {
|
||||
return request({
|
||||
url: "/admin/rosCar/updateMapUrls",
|
||||
data
|
||||
});
|
||||
}
|
||||
export {
|
||||
dispatchRosInspectionTask,
|
||||
getCurrentRosInspection,
|
||||
getRosCarList,
|
||||
getRosInspectionTaskDetail,
|
||||
saveRosInspectionTask,
|
||||
stopDispatchRosInspectionTask,
|
||||
updateRosCarMapUrls,
|
||||
uploadFile
|
||||
};
|
||||
@@ -1,376 +0,0 @@
|
||||
import request from "@/utils/request";
|
||||
import type { ApiResult, PageParam, PageResult } from "@/types/api";
|
||||
|
||||
/**
|
||||
* 升降柱状态,对应后端 com.haizhiyustc.ros.dto.RosLiftDTO
|
||||
*/
|
||||
export interface RosLift {
|
||||
/** 升降柱接口是否调用成功 */
|
||||
liftSuccess?: boolean;
|
||||
/** 升降柱接口返回码 */
|
||||
liftCode?: number;
|
||||
/** 升降柱接口返回消息 */
|
||||
liftMessage?: string;
|
||||
/** 升降柱设备状态码 */
|
||||
liftDeviceStatusCode?: string;
|
||||
/** 升降柱设备状态描述 */
|
||||
liftDeviceStatusDescription?: string;
|
||||
/** 升降柱错误码 */
|
||||
liftErrorCode?: string;
|
||||
/** 升降柱错误描述 */
|
||||
liftErrorDescription?: string;
|
||||
/** 升降柱高度 */
|
||||
liftHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ROS 小车,对应后端 com.haizhiyustc.ros.entity.RosCar
|
||||
* status: 0空闲 1巡检 2离线 3充电中
|
||||
*/
|
||||
export interface RosCar {
|
||||
/** 主键id */
|
||||
id?: string;
|
||||
/** 设备唯一标识 */
|
||||
deviceKey?: string;
|
||||
/** 设备编号 */
|
||||
deviceNo?: string;
|
||||
/** 小车信号 */
|
||||
signal?: string;
|
||||
/** 小车名称 */
|
||||
name?: string;
|
||||
/** 电量百分比 */
|
||||
batteryLevel?: number;
|
||||
/** 电压 */
|
||||
voltage?: number;
|
||||
/** 当前状态:0空闲 1巡检 2离线 3充电中 */
|
||||
status?: number;
|
||||
/** 所属项目名称 */
|
||||
projectName?: string;
|
||||
/** 当前 X 坐标 */
|
||||
poseX?: number;
|
||||
/** 当前 Y 坐标 */
|
||||
poseY?: number;
|
||||
/** 当前朝向 */
|
||||
poseYaw?: number;
|
||||
/** 相机 WebRTC 地址 */
|
||||
cameraWebrtcUrl?: string;
|
||||
/** 相机编号 */
|
||||
cameraNo?: string;
|
||||
/** 红外相机 WebRTC 地址 */
|
||||
cameraWebrtcUrl2?: string;
|
||||
/** 红外相机编号 */
|
||||
cameraNo2?: string;
|
||||
/** 云台预制点 */
|
||||
panTiltPreset?: number;
|
||||
/** 地图 PGM 文件 URL 地址 */
|
||||
mapPgmUrl?: string;
|
||||
/** 地图 YAML 文件 URL 地址 */
|
||||
mapYamlUrl?: string;
|
||||
/** 地图 PCD 点云文件 URL 地址(前端三维地图展示用) */
|
||||
mapPcdUrl?: string;
|
||||
/** 升降柱高度 */
|
||||
liftHeight?: number;
|
||||
/** 升降柱状态 */
|
||||
lift?: RosLift;
|
||||
/** 充电点 X 坐标 */
|
||||
dockX?: number;
|
||||
/** 充电点 Y 坐标 */
|
||||
dockY?: number;
|
||||
/** 充电点朝向 */
|
||||
dockYaw?: number;
|
||||
/** 预对接点 X 坐标 */
|
||||
preDockX?: number;
|
||||
/** 预对接点 Y 坐标 */
|
||||
preDockY?: number;
|
||||
/** 预对接点朝向 */
|
||||
preDockYaw?: number;
|
||||
/** 是否开启自动回充 */
|
||||
autoRechargeEnabled?: boolean;
|
||||
/** 自动回充低电量阈值(%) */
|
||||
lowBatteryThreshold?: number;
|
||||
/** 自动回充状态文本 */
|
||||
dockStatus?: string;
|
||||
/** 自动回充是否进行中 */
|
||||
dockActive?: boolean;
|
||||
/** 创建时间 */
|
||||
createTime?: string;
|
||||
/** 更新时间 */
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 巡检记录,对应后端 com.haizhiyustc.ros.entity.RosInspectionRecord
|
||||
* status: 0执行中 1执行完成 2执行失败 3已停止
|
||||
*/
|
||||
export interface RosInspectionRecord {
|
||||
/** 主键id */
|
||||
id?: string;
|
||||
/** 所属巡检任务id */
|
||||
taskId?: string;
|
||||
/** 巡检设备id */
|
||||
carId?: string;
|
||||
/** 巡检说明快照 */
|
||||
remark?: string;
|
||||
/** 全局限速快照 */
|
||||
maxVelocity?: number;
|
||||
/** 插值密度快照(米/点) */
|
||||
interpolationDensity?: number;
|
||||
/** 巡检地图名快照 */
|
||||
mapName?: string;
|
||||
/** 巡检轮数快照 */
|
||||
roundCount?: number;
|
||||
/** 当前执行轮次,从1开始 */
|
||||
currentRoundIndex?: number;
|
||||
/** 开始时间 */
|
||||
startTime?: string;
|
||||
/** 结束时间 */
|
||||
endTime?: string;
|
||||
/** 执行状态:0执行中 1执行完成 2执行失败 3已停止 */
|
||||
status?: number;
|
||||
/** 失败原因 */
|
||||
failReason?: string;
|
||||
/** 当前执行节点id */
|
||||
currentNodeId?: string;
|
||||
/** 当前执行的动作id */
|
||||
currentActionId?: string;
|
||||
/** 开始执行时的小车电量 */
|
||||
startBatteryLevel?: number;
|
||||
/** 执行结束后小车电量 */
|
||||
endBatteryLevel?: number;
|
||||
/** 开始执行时的小车位姿x */
|
||||
startPoseX?: number;
|
||||
/** 开始执行时的小车位姿y */
|
||||
startPoseY?: number;
|
||||
/** 执行结束时的小车位姿x */
|
||||
endPoseX?: number;
|
||||
/** 执行结束时的小车位姿y */
|
||||
endPoseY?: number;
|
||||
/** 总动作数 */
|
||||
totalActionCount?: number;
|
||||
/** 当前节点序号 */
|
||||
currentNodeIndex?: number;
|
||||
/** 创建时间 */
|
||||
createTime?: string;
|
||||
/** 更新时间 */
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 巡检任务节点模板,对应后端 com.haizhiyustc.ros.dto.RosInspectionTaskNodeDTO
|
||||
* type: via_point 途经点 / action_point 任务点
|
||||
*/
|
||||
export interface RosInspectionTaskNodeDTO {
|
||||
/** 节点类型:via_point 途经点 / action_point 任务点 */
|
||||
type?: string;
|
||||
/** 位姿x */
|
||||
poseX?: number;
|
||||
/** 位姿y */
|
||||
poseY?: number;
|
||||
/** 节点顺序 */
|
||||
nodeIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 巡检任务轮次模板,对应后端 com.haizhiyustc.ros.dto.RosInspectionTaskRoundDTO
|
||||
* direction: forward 正走 / backward 倒走
|
||||
*/
|
||||
export interface RosInspectionTaskRoundDTO {
|
||||
/** 轮次,从1开始 */
|
||||
roundIndex?: number;
|
||||
/** 本轮行走方向:forward 正走 / backward 倒走 */
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 巡检任务动作模板,对应后端 RosInspectionTaskDetailDTO.RosInspectionActionDTO
|
||||
* command: 0升降柱 1云台旋转 2抓拍识别
|
||||
*/
|
||||
export interface RosInspectionActionDTO {
|
||||
/** 节点顺序 */
|
||||
nodeIndex?: number;
|
||||
/** 轮次 */
|
||||
roundIndex?: number;
|
||||
/** 动作类型:0升降柱 1云台旋转 2抓拍识别 */
|
||||
command?: number;
|
||||
/** 动作参数 */
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 巡检任务模板详情,对应后端 com.haizhiyustc.ros.dto.RosInspectionTaskDetailDTO
|
||||
*/
|
||||
export interface RosInspectionTaskDetailDTO {
|
||||
/** 主键id */
|
||||
id?: string;
|
||||
/** 巡检设备id */
|
||||
carId?: string;
|
||||
/** 巡检说明 */
|
||||
remark?: string;
|
||||
/** 巡检状态:0待执行 1执行中 2已停止 */
|
||||
status?: number;
|
||||
/** 全局限速 */
|
||||
maxVelocity?: number;
|
||||
/** ROS2端生成平滑曲线时的插值密度(米/点) */
|
||||
interpolationDensity?: number;
|
||||
/** 巡检地图名 */
|
||||
mapName?: string;
|
||||
/** 巡检轮数 */
|
||||
roundCount?: number;
|
||||
/** 巡检节点模板列表 */
|
||||
nodes?: RosInspectionTaskNodeDTO[];
|
||||
/** 巡检轮次模板列表 */
|
||||
rounds?: RosInspectionTaskRoundDTO[];
|
||||
/** 巡检动作模板列表 */
|
||||
actions?: RosInspectionActionDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 巡检任务下发结果,对应后端 com.haizhiyustc.ros.dto.RosDispatchResultDTO
|
||||
*/
|
||||
export interface RosDispatchResultDTO {
|
||||
/** 巡检记录id */
|
||||
recordId?: string;
|
||||
/** 小车是否接受本次下发 */
|
||||
accepted?: boolean;
|
||||
/** 下发结果说明 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小车分页查询条件,对应后端 com.haizhiyustc.ros.entity.RosCar 的字段过滤
|
||||
*/
|
||||
export type RosCarQuery = Partial<RosCar> & {
|
||||
/** 关键字 */
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
// 小车分页列表
|
||||
export function getRosCarList(data: PageParam<RosCarQuery>) {
|
||||
return request<ApiResult<PageResult<RosCar>>>({
|
||||
url: "/admin/rosCar/list",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 保存巡检任务模板
|
||||
export function saveRosInspectionTask(data: unknown) {
|
||||
return request<ApiResult<null>>({
|
||||
url: "/admin/rosInspectionTask/save",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 巡检任务模板详情
|
||||
export function getRosInspectionTaskDetail(cardId: string) {
|
||||
return request<ApiResult<RosInspectionTaskDetailDTO>>({
|
||||
url: "/admin/rosInspectionTask/detail",
|
||||
params: { cardId },
|
||||
});
|
||||
}
|
||||
|
||||
// 查询小车当前巡检状态
|
||||
export function getCurrentRosInspection(carId: string) {
|
||||
return request<ApiResult<RosInspectionRecord>>({
|
||||
url: "/admin/rosCar/currentInspection",
|
||||
params: { carId },
|
||||
});
|
||||
}
|
||||
|
||||
// 下发巡检任务
|
||||
export function dispatchRosInspectionTask(taskId: string) {
|
||||
return request<ApiResult<RosDispatchResultDTO>>({
|
||||
url: "/admin/rosInspectionTask/dispatch",
|
||||
params: { taskId },
|
||||
});
|
||||
}
|
||||
|
||||
// 停止已下发的巡检任务
|
||||
export function stopDispatchRosInspectionTask(taskId: string) {
|
||||
return request<ApiResult<RosDispatchResultDTO>>({
|
||||
url: "/admin/rosInspectionTask/stopDispatch",
|
||||
params: { taskId },
|
||||
});
|
||||
}
|
||||
|
||||
// 上传文件并返回后端保存后的访问地址
|
||||
export function uploadFile(file: File) {
|
||||
const data = new FormData();
|
||||
|
||||
data.append("file", file);
|
||||
|
||||
return request<ApiResult<string>>({
|
||||
url: "/admin/file/upload",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新小车地图文件地址
|
||||
export function updateRosCarMapUrls(data: {
|
||||
id: string;
|
||||
mapPgmUrl?: string;
|
||||
mapYamlUrl?: string;
|
||||
mapPcdUrl?: string;
|
||||
}) {
|
||||
return request<ApiResult<null>>({
|
||||
url: "/admin/rosCar/updateMapUrls",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 小车充电点与自动回充配置更新参数
|
||||
*/
|
||||
export interface RosCarDockConfigUpdate {
|
||||
/** 主键id */
|
||||
id: string;
|
||||
/** 充电点 X 坐标 */
|
||||
dockX?: number;
|
||||
/** 充电点 Y 坐标 */
|
||||
dockY?: number;
|
||||
/** 充电点朝向 */
|
||||
dockYaw?: number;
|
||||
/** 预对接点 X 坐标 */
|
||||
preDockX?: number;
|
||||
/** 预对接点 Y 坐标 */
|
||||
preDockY?: number;
|
||||
/** 预对接点朝向 */
|
||||
preDockYaw?: number;
|
||||
/** 是否开启自动回充 */
|
||||
autoRechargeEnabled?: boolean;
|
||||
/** 自动回充低电量阈值(%) */
|
||||
lowBatteryThreshold?: number;
|
||||
}
|
||||
|
||||
// 更新小车充电点与自动回充配置
|
||||
export function updateRosCarDockConfig(data: RosCarDockConfigUpdate) {
|
||||
return request<ApiResult<null>>({
|
||||
url: "/admin/rosCar/updateDockConfig",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发回充结果,对应后端 com.haizhiyustc.ros.dto.RosChargingResultDTO
|
||||
*/
|
||||
export interface RosChargingResult {
|
||||
/** 小车是否接受本次触发 */
|
||||
accepted?: boolean;
|
||||
/** 触发结果说明 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// 手动触发小车自动回充
|
||||
export function startRosCharging(carId: string) {
|
||||
return request<ApiResult<RosChargingResult>>({
|
||||
url: "/admin/rosCar/startCharging",
|
||||
params: { carId },
|
||||
});
|
||||
}
|
||||
|
||||
// 手动触发小车脱离充电桩
|
||||
export function undockRosCharging(carId: string) {
|
||||
return request<ApiResult<RosChargingResult>>({
|
||||
url: "/admin/rosCar/undockCharging",
|
||||
params: { carId },
|
||||
});
|
||||
}
|
||||
52
src/api/user.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import request, { BASE_URL } from '@/utils/request'
|
||||
export function getPublicKey() {
|
||||
return request({
|
||||
url: 'admin/account/publicKey'
|
||||
})
|
||||
}
|
||||
export function login(data) {
|
||||
return request({
|
||||
url: 'admin/account/login',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function captcha() {
|
||||
return BASE_URL + '/admin/account/captcha'
|
||||
}
|
||||
export function getInfo() {
|
||||
return request({
|
||||
url: 'admin/account/info',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInfo(data) {
|
||||
return request({
|
||||
url: 'admin/account/update',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePwd(data) {
|
||||
return request({
|
||||
url: 'admin/account/updatePwd',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 发送重置密码验证码
|
||||
export function getVerifyCode(data) {
|
||||
return request({
|
||||
url: '/admin/sysUser/getVerifyCode',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
//忘记密码
|
||||
export function resetUserPassword(data) {
|
||||
return request({
|
||||
url: '/admin/sysUser/resetUserPassword',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
105
src/api/user.ts
@@ -1,105 +0,0 @@
|
||||
import request, { BASE_URL } from '@/utils/request'
|
||||
import type { ApiResult } from '@/types/api'
|
||||
|
||||
/**
|
||||
* 登录返回,对应后端 com.haizhiyustc.system.vo.SysUserLoginVO
|
||||
*/
|
||||
export interface LoginResult {
|
||||
/** 用户id */
|
||||
userId: string;
|
||||
/** 登录令牌 */
|
||||
token: string;
|
||||
/** 令牌请求头名称 */
|
||||
tokenName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户个人资料,对应后端 UserInfoVO.UserInfoProfile
|
||||
*/
|
||||
export interface UserProfile {
|
||||
/** 账号id */
|
||||
id?: string;
|
||||
/** 头像 */
|
||||
avatar?: string;
|
||||
/** 登录账号 */
|
||||
account?: string;
|
||||
/** 账户名 */
|
||||
name?: string;
|
||||
/** 性别 */
|
||||
gender?: string;
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 状态 */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户信息,对应后端 com.haizhiyustc.system.vo.UserInfoVO
|
||||
*/
|
||||
export interface UserInfoVO {
|
||||
/** 账号名 */
|
||||
name?: string;
|
||||
/** 角色 */
|
||||
role?: string;
|
||||
/** 角色列表 */
|
||||
roles?: string[];
|
||||
/** 权限列表 */
|
||||
permissions?: string[];
|
||||
/** 用户个人资料 */
|
||||
profile?: UserProfile;
|
||||
}
|
||||
|
||||
export function getPublicKey() {
|
||||
return request<ApiResult<string>>({
|
||||
url: 'admin/account/publicKey'
|
||||
})
|
||||
}
|
||||
|
||||
export function login(data: Record<string, unknown>) {
|
||||
return request<ApiResult<LoginResult>>({
|
||||
url: 'admin/account/login',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function captcha(): string {
|
||||
return BASE_URL + '/admin/account/captcha'
|
||||
}
|
||||
|
||||
export function getInfo() {
|
||||
return request<ApiResult<UserInfoVO>>({
|
||||
url: 'admin/account/info',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateInfo(data: Record<string, unknown>) {
|
||||
return request<ApiResult<unknown>>({
|
||||
url: 'admin/account/update',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePwd(data: Record<string, unknown>) {
|
||||
return request<ApiResult<unknown>>({
|
||||
url: 'admin/account/updatePwd',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 发送重置密码验证码
|
||||
export function getVerifyCode(data: Record<string, unknown>) {
|
||||
return request<ApiResult<unknown>>({
|
||||
url: '/admin/sysUser/getVerifyCode',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
//忘记密码
|
||||
export function resetUserPassword(data: Record<string, unknown>) {
|
||||
return request<ApiResult<unknown>>({
|
||||
url: '/admin/sysUser/resetUserPassword',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
Before Width: | Height: | Size: 436 KiB |
@@ -1,8 +0,0 @@
|
||||
@font-face {
|
||||
/*给字体命名*/
|
||||
font-family: 'YouSheBiaoTiYuan';
|
||||
/*引入字体文件*/
|
||||
src: url('./YouSheBiaoTiYuan.ttf');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 722 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 134 KiB |
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useDialog, useLoadingBar, useMessage } from 'naive-ui'
|
||||
|
||||
/**
|
||||
* naive-ui的弹窗类的都没有提供全局方法, 这里挂载到window对象上
|
||||
*/
|
||||
export default {
|
||||
name: 'ProviderHelper',
|
||||
setup() {
|
||||
window.$message = useMessage()
|
||||
window.$loadingBar = useLoadingBar()
|
||||
window.$modal = useDialog()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,42 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { init } from "echarts/core";
|
||||
import type { EChartsOption } from "echarts";
|
||||
|
||||
let chart: ReturnType<typeof init> | null = null;
|
||||
|
||||
const chartRef = ref<HTMLElement | undefined>();
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
let chart = null;
|
||||
let resizeObserver = null;
|
||||
const chartRef = ref();
|
||||
const props = defineProps({
|
||||
option: {
|
||||
type: Object as () => EChartsOption | {},
|
||||
required: true,
|
||||
},
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
function applyOption(option?: EChartsOption | Record<string, never>) {
|
||||
function applyOption(option) {
|
||||
if (!chart || !option) {
|
||||
return;
|
||||
}
|
||||
|
||||
chart.clear();
|
||||
chart.setOption(option);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chart = init(chartRef.value, "t-theme");
|
||||
chart = echarts.init(chartRef.value, "t-theme");
|
||||
applyOption(props.option);
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
chart?.resize();
|
||||
});
|
||||
resizeObserver.observe(chartRef.value);
|
||||
nextTick(() => {
|
||||
chart?.resize();
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.option,
|
||||
(value) => {
|
||||
applyOption(value);
|
||||
},
|
||||
{ deep: true },
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
if (chart) {
|
||||
chart.dispose();
|
||||
chart = null;
|
||||
|
||||
@@ -8,8 +8,8 @@ const color = [
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#94c7fd" },
|
||||
{ offset: 0.5, color: "#3b99fb" },
|
||||
{ offset: 1, color: "#004ca0" },
|
||||
],
|
||||
{ offset: 1, color: "#004ca0" }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
@@ -20,8 +20,8 @@ const color = [
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#b070c0" },
|
||||
{ offset: 0.5, color: "#b070c0" },
|
||||
{ offset: 1, color: "#5d235d" },
|
||||
],
|
||||
{ offset: 1, color: "#5d235d" }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
@@ -32,8 +32,8 @@ const color = [
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#7eedd6" },
|
||||
{ offset: 0.5, color: "#3be8bb" },
|
||||
{ offset: 1, color: "#00b38a" },
|
||||
],
|
||||
{ offset: 1, color: "#00b38a" }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
@@ -44,8 +44,8 @@ const color = [
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#fff1f0" },
|
||||
{ offset: 0.5, color: "#ff9494" },
|
||||
{ offset: 1, color: "#ff4d4f" },
|
||||
],
|
||||
{ offset: 1, color: "#ff4d4f" }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
@@ -56,8 +56,8 @@ const color = [
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#fffbe6" },
|
||||
{ offset: 0.5, color: "#ffe58f" },
|
||||
{ offset: 1, color: "#ffbb96" },
|
||||
],
|
||||
{ offset: 1, color: "#ffbb96" }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
@@ -68,8 +68,8 @@ const color = [
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#ff3333" },
|
||||
{ offset: 0.5, color: "#ff0000" },
|
||||
{ offset: 1, color: "#cc0000" },
|
||||
],
|
||||
{ offset: 1, color: "#cc0000" }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
@@ -80,25 +80,24 @@ const color = [
|
||||
colorStops: [
|
||||
{ offset: 0, color: "#ffe6f0" },
|
||||
{ offset: 0.5, color: "#ffb3d9" },
|
||||
{ offset: 1, color: "#ff85c0" },
|
||||
],
|
||||
},
|
||||
{ offset: 1, color: "#ff85c0" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default {
|
||||
color,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: "#fff",
|
||||
color: "#fff"
|
||||
},
|
||||
legend: {
|
||||
textStyle: {
|
||||
color: "#fff",
|
||||
},
|
||||
color: "#fff"
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
textStyle: {
|
||||
align: "left",
|
||||
},
|
||||
},
|
||||
align: "left"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,35 +1,30 @@
|
||||
import { Map } from "ol";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import { Fill, Stroke, Style } from "ol/style";
|
||||
import { FeatureLike } from "ol/Feature";
|
||||
import { MultiPolygon } from "ol/geom";
|
||||
|
||||
export default function (mapInstance: Map) {
|
||||
export default function (mapInstance) {
|
||||
const vectorSource = new VectorSource({
|
||||
url: "https://geo.datav.aliyun.com/areas_v3/bound/330000.json",
|
||||
format: new GeoJSON(),
|
||||
format: new GeoJSON()
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const layer = new VectorLayer({
|
||||
zIndex: -1,
|
||||
source: vectorSource,
|
||||
style: (feature: FeatureLike) => {
|
||||
const geometry = (feature.getGeometry() as MultiPolygon).clone();
|
||||
style: (feature) => {
|
||||
const geometry = feature.getGeometry().clone();
|
||||
geometry.translate(0, -0.04 * i);
|
||||
return new Style({
|
||||
fill: new Fill({
|
||||
color: "rgba(176, 166, 132,0.6)",
|
||||
color: "rgba(176, 166, 132,0.6)"
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: "rgba(176, 166, 132,0.6)",
|
||||
width: 1,
|
||||
width: 1
|
||||
}),
|
||||
geometry,
|
||||
geometry
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
mapInstance.addLayer(layer);
|
||||
}
|
||||
@@ -7,33 +7,28 @@ import { Fill, Stroke, Style, Text } from "ol/style";
|
||||
import { getBottomLeft, getCenter, getHeight, getWidth } from "ol/extent";
|
||||
import materialJpg from "@/assets/map-material.jpg";
|
||||
import { toContext } from "ol/render";
|
||||
import { MultiPolygon } from "ol/geom";
|
||||
import { defaults } from "ol/interaction";
|
||||
|
||||
export default function useMap(target: string) {
|
||||
function useMap(target) {
|
||||
const mapInstance = new Map({
|
||||
view: new View({
|
||||
projection: "EPSG:4326",
|
||||
zoom: 12,
|
||||
center: [0, 0],
|
||||
center: [0, 0]
|
||||
}),
|
||||
controls: [],
|
||||
interactions: defaults({
|
||||
dragPan: false,
|
||||
mouseWheelZoom: false,
|
||||
}),
|
||||
mouseWheelZoom: false
|
||||
})
|
||||
});
|
||||
|
||||
const vectorSource = new VectorSource({
|
||||
url: "https://geo.datav.aliyun.com/areas_v3/bound/330000_full.json",
|
||||
format: new GeoJSON(),
|
||||
format: new GeoJSON()
|
||||
});
|
||||
|
||||
const customStyle = new Style({
|
||||
renderer: function (pixelCoordinates, state) {
|
||||
renderer: function(pixelCoordinates, state) {
|
||||
const context = state.context;
|
||||
const multiPolygon = state.geometry.clone() as MultiPolygon;
|
||||
// @ts-ignore
|
||||
const multiPolygon = state.geometry.clone();
|
||||
multiPolygon.setCoordinates(pixelCoordinates);
|
||||
const extent = multiPolygon.getExtent();
|
||||
const width = getWidth(extent);
|
||||
@@ -44,14 +39,14 @@ export default function useMap(target: string) {
|
||||
}
|
||||
context.save();
|
||||
const renderContext = toContext(context, {
|
||||
pixelRatio: 1,
|
||||
pixelRatio: 1
|
||||
});
|
||||
renderContext.setFillStrokeStyle(
|
||||
new Fill(),
|
||||
new Stroke({
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
width: 3,
|
||||
}),
|
||||
width: 3
|
||||
})
|
||||
);
|
||||
renderContext.drawMultiPolygon(multiPolygon);
|
||||
context.clip();
|
||||
@@ -60,9 +55,8 @@ export default function useMap(target: string) {
|
||||
const bottom = bottomLeft[1];
|
||||
context.drawImage(flag, left, bottom, width, height);
|
||||
context.restore();
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const layer = new VectorLayer({
|
||||
source: vectorSource,
|
||||
style: (feature) => {
|
||||
@@ -72,40 +66,35 @@ export default function useMap(target: string) {
|
||||
text: new Text({
|
||||
text: feature.get("name"),
|
||||
fill: new Fill({
|
||||
color: "#fff",
|
||||
color: "#fff"
|
||||
}),
|
||||
scale: 1.5,
|
||||
}),
|
||||
zIndex: 1,
|
||||
scale: 1.5
|
||||
}),
|
||||
zIndex: 1
|
||||
})
|
||||
];
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
vectorSource.on("addfeature", function (event) {
|
||||
vectorSource.on("addfeature", function(event) {
|
||||
const feature = event.feature;
|
||||
const img = new Image();
|
||||
img.src = materialJpg;
|
||||
img.onload = function () {
|
||||
img.onload = function() {
|
||||
feature?.set("material", img);
|
||||
};
|
||||
});
|
||||
|
||||
vectorSource.on("featuresloadend", (event) => {
|
||||
const view = mapInstance.getView();
|
||||
const extent = event.target.getExtent();
|
||||
view.fit(extent, { padding: [0, 30, 0, 0] });
|
||||
|
||||
view.setCenter(getCenter(extent));
|
||||
});
|
||||
|
||||
mapInstance.addLayer(layer);
|
||||
|
||||
onMounted(() => {
|
||||
mapInstance.setTarget(target);
|
||||
});
|
||||
|
||||
return {
|
||||
mapInstance,
|
||||
mapInstance
|
||||
};
|
||||
}
|
||||
export default useMap;
|
||||
@@ -2,23 +2,19 @@ import { createApp } from "vue";
|
||||
import "./style.less";
|
||||
import App from "./App.vue";
|
||||
import Chart from "./components/chart/index.vue";
|
||||
import { registerTheme } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import * as echarts from "echarts";
|
||||
import echartsConfig from "./config/echarts.config";
|
||||
import naive, { createDiscreteApi } from "naive-ui";
|
||||
import { createPinia } from "pinia";
|
||||
import router from "./router";
|
||||
import "./router/routerGuard";
|
||||
|
||||
// CanvasRenderer 注册到全局,所有图表都需要
|
||||
import { use } from "echarts/core";
|
||||
use([CanvasRenderer]);
|
||||
|
||||
registerTheme("t-theme", echartsConfig);
|
||||
|
||||
import '../src/assets/font/YouSheBiaoTiHei.css';
|
||||
echarts.registerTheme("t-theme", echartsConfig);
|
||||
const { message, dialog } = createDiscreteApi(["message", "dialog"]);
|
||||
window.$message = message;
|
||||
window.$modal = dialog;
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(naive);
|
||||
app.component("TChart", Chart);
|
||||
|
||||
app.mount("#app");
|
||||
@@ -1,16 +1,13 @@
|
||||
import { getToken } from "@/utils/auth";
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
type RouteRecordRaw,
|
||||
createWebHashHistory
|
||||
} from "vue-router";
|
||||
|
||||
const dashboardDefaultPath = "/dashboard/integrated-center";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
redirect: () => (getToken() ? dashboardDefaultPath : "/login"),
|
||||
redirect: () => getToken() ? dashboardDefaultPath : "/login"
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
@@ -20,61 +17,67 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "integrated-center",
|
||||
name: "IntegratedCenter",
|
||||
component: () =>
|
||||
import("@/views/dashboard1/pages/IntegratedCenterPage.vue"),
|
||||
meta: { title: "监管中心", tabId: 1, },
|
||||
component: () => import("@/views/dashboard1/pages/IntegratedCenterPage.vue"),
|
||||
meta: { title: "监管中心", tabId: 1 }
|
||||
},
|
||||
{
|
||||
path: "equipment-management",
|
||||
name: "EquipmentManagement",
|
||||
component: () =>
|
||||
import("@/views/dashboard1/pages/EquipmentManagementPage.vue"),
|
||||
meta: { title: "设备管理", tabId: 2, },
|
||||
component: () => import("@/views/dashboard1/pages/EquipmentManagementPage.vue"),
|
||||
meta: { title: "设备管理", tabId: 2 }
|
||||
},
|
||||
{
|
||||
path: "video-center",
|
||||
name: "VideoCenter",
|
||||
component: () => import("@/views/dashboard1/pages/VideoCenterPage.vue"),
|
||||
meta: { title: "视频中心", tabId: 3, },
|
||||
meta: { title: "视频中心", tabId: 3 }
|
||||
},
|
||||
{
|
||||
path: "alarm-management",
|
||||
name: "AlarmManagement",
|
||||
component: () =>
|
||||
import("@/views/dashboard1/pages/AlarmManagementPage.vue"),
|
||||
meta: { title: "告警管理", tabId: 4, },
|
||||
component: () => import("@/views/dashboard1/pages/AlarmManagementPage.vue"),
|
||||
meta: { title: "告警管理", tabId: 4 }
|
||||
},
|
||||
{
|
||||
path: "patrol-plan",
|
||||
name: "PatrolPlan",
|
||||
component: () => import("@/views/dashboard1/pages/PatrolPlanPage.vue"),
|
||||
meta: { title: "巡检记录", tabId: 5, },
|
||||
meta: { title: "巡检记录", tabId: 5 }
|
||||
},
|
||||
{
|
||||
path: "data-report",
|
||||
name: "DataReport",
|
||||
component: () => import("@/views/dashboard1/pages/DataReportPage.vue"),
|
||||
meta: { title: "统计分析", tabId: 6, },
|
||||
},
|
||||
],
|
||||
meta: { title: "统计分析", tabId: 6 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
component: () => import("@/views/login/index.vue"),
|
||||
meta: { title: "登录" },
|
||||
meta: { title: "登录" }
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: dashboardDefaultPath,
|
||||
},
|
||||
redirect: dashboardDefaultPath
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
routes
|
||||
});
|
||||
const whiteList = ["/login"];
|
||||
router.beforeEach((to) => {
|
||||
const hasToken = Boolean(getToken());
|
||||
if (!hasToken && !whiteList.includes(to.path)) {
|
||||
return {
|
||||
path: "/login",
|
||||
query: { redirect: to.fullPath }
|
||||
};
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export function resetRouter() {
|
||||
// 当前项目暂无动态 addRoute 路由,保留该方法兼容登出流程。
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import router from './index'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const whiteList = ['/login'] // no redirect whitelist
|
||||
|
||||
// const setPageTitle = function (meta) {
|
||||
// let title = '海灵标'
|
||||
// if (meta && meta.title) {
|
||||
// title = `${meta.title}`
|
||||
// }
|
||||
// document.title = title
|
||||
// }
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from) => {
|
||||
$loadingBar.start()
|
||||
// setPageTitle(to.meta)
|
||||
const hasToken = getToken()
|
||||
const userStore = useUserStore()
|
||||
if (hasToken) {
|
||||
if (to.path === '/login') {
|
||||
$loadingBar.finish()
|
||||
return { path: '/' }
|
||||
} else {
|
||||
const hasGetUserInfo = userStore.name
|
||||
if (hasGetUserInfo !== '') {
|
||||
return
|
||||
} else {
|
||||
try {
|
||||
// get user info
|
||||
const infoVO = await userStore.getInfo()
|
||||
return { ...to, replace: true }
|
||||
} catch (error) {
|
||||
await userStore.resetToken()
|
||||
// remove token and go to login page to re-login
|
||||
console.error(error)
|
||||
$loadingBar.error()
|
||||
return `/login?redirect=${to.path}`
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* has no token */
|
||||
if (whiteList.indexOf(to.path) !== -1) {
|
||||
// in the free login whitelist, go directly
|
||||
return
|
||||
} else {
|
||||
$loadingBar.finish()
|
||||
return `/login?redirect=${to.path}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
if (to.matched.length === 0) {
|
||||
router.push('/404')
|
||||
// fixme 不能自动跳转404, 需要引导
|
||||
}
|
||||
$loadingBar.finish()
|
||||
})
|
||||
5
src/store/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export function setupStore(app) {
|
||||
app.use(createPinia())
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import type { App } from 'vue'
|
||||
|
||||
export function setupStore(app: App) {
|
||||
app.use(createPinia())
|
||||
}
|
||||
@@ -1,31 +1,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { useDark } from '@vueuse/core'
|
||||
|
||||
const sidebarStatusKey = 'sidebar_status'
|
||||
|
||||
interface AppState {
|
||||
/** 侧边栏是否展开,持久化在 localStorage */
|
||||
sidebarStatus: boolean
|
||||
/** 当前设备类型,desktop/mobile */
|
||||
device: string
|
||||
/** 应用配置 */
|
||||
config: Record<string, unknown>
|
||||
/** 页面刷新标记,reloadPage 控制 true/false 触发重新渲染 */
|
||||
reloadFlag: boolean
|
||||
/** 是否暗色模式 */
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: (): AppState => ({
|
||||
state() {
|
||||
return {
|
||||
sidebarStatus: localStorage.getItem(sidebarStatusKey) ? localStorage.getItem(sidebarStatusKey) === '1' : true,
|
||||
device: 'desktop',
|
||||
config: {},
|
||||
reloadFlag: true,
|
||||
isDark: !window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
}),
|
||||
isDark: !useDark(),
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
sidebar(): boolean {
|
||||
sidebar() {
|
||||
return this.sidebarStatus
|
||||
}
|
||||
},
|
||||
@@ -38,11 +28,11 @@ export const useAppStore = defineStore('app', {
|
||||
localStorage.setItem(sidebarStatusKey, '0')
|
||||
}
|
||||
},
|
||||
closeSideBar(withoutAnimation: boolean) {
|
||||
closeSideBar(withoutAnimation) {
|
||||
localStorage.setItem(sidebarStatusKey, '0')
|
||||
this.sidebarStatus = false
|
||||
},
|
||||
toggleDevice(device: string) {
|
||||
toggleDevice(device) {
|
||||
this.device = device
|
||||
},
|
||||
async reloadPage() {
|
||||
112
src/store/modules/user.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { getToken, removeToken, setToken } from "@/utils/auth.js";
|
||||
import { getInfo, login as doLogin } from "@/api/user.js";
|
||||
import { defineStore } from "pinia";
|
||||
import { resetRouter } from "@/router";
|
||||
|
||||
export const useUserStore = defineStore("user", {
|
||||
state() {
|
||||
return {
|
||||
userInfo: {
|
||||
token: getToken() || "",
|
||||
id: "",
|
||||
avatar: "",
|
||||
name: "",
|
||||
profile: {},
|
||||
permissions: [],
|
||||
dictMap: {},
|
||||
dictTreeMap: {},
|
||||
dictIdMap: {},
|
||||
dynamicAttrs: [],
|
||||
companyLogStatus: null,
|
||||
attrIds: [],
|
||||
regionIds: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
getters: {
|
||||
userId() {
|
||||
return this.userInfo?.id || "";
|
||||
},
|
||||
name() {
|
||||
return this.userInfo?.name || "";
|
||||
},
|
||||
avatar() {
|
||||
return this.userInfo?.profile?.avatar || "";
|
||||
},
|
||||
permissions() {
|
||||
return this.userInfo?.permissions || [];
|
||||
},
|
||||
dictMap() {
|
||||
return this.userInfo?.dictMap || {};
|
||||
},
|
||||
dictTreeMap() {
|
||||
return this.userInfo?.dictTreeMap || {};
|
||||
},
|
||||
dictIdMap() {
|
||||
return this.userInfo?.dictIdMap || {};
|
||||
},
|
||||
dynamicAttrs() {
|
||||
return this.userInfo?.dynamicAttrs || [];
|
||||
},
|
||||
companyLogStatus() {
|
||||
return this.userInfo?.companyLogStatus || 0;
|
||||
},
|
||||
attrIds() {
|
||||
return this.userInfo?.attrIds || [];
|
||||
},
|
||||
regionIds() {
|
||||
return this.userInfo?.regionIds || [];
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login(userInfo) {
|
||||
const res = await doLogin(userInfo);
|
||||
const { token, tokenName } = res.data;
|
||||
this.userInfo.token = token;
|
||||
setToken(token, tokenName);
|
||||
return true;
|
||||
},
|
||||
async getInfo() {
|
||||
const res = await getInfo();
|
||||
const data = res.data;
|
||||
const {
|
||||
name,
|
||||
profile,
|
||||
permissions,
|
||||
dynamicAttrs,
|
||||
attrIds,
|
||||
companyLogStatus,
|
||||
regionIds,
|
||||
} = data;
|
||||
|
||||
// 保留当前的 token,避免被覆盖
|
||||
const currentToken = this.userInfo.token || getToken();
|
||||
|
||||
this.userInfo = {
|
||||
token: currentToken,
|
||||
id: profile.id,
|
||||
name,
|
||||
profile,
|
||||
permissions,
|
||||
dynamicAttrs,
|
||||
attrIds,
|
||||
companyLogStatus,
|
||||
regionIds,
|
||||
};
|
||||
return data;
|
||||
},
|
||||
// user logout
|
||||
async logout() {
|
||||
await this.resetToken();
|
||||
await resetRouter();
|
||||
return true;
|
||||
},
|
||||
async resetToken() {
|
||||
this.userInfo = {
|
||||
profile: {},
|
||||
permissions: [],
|
||||
};
|
||||
removeToken();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { getToken, removeToken, setToken } from "@/utils/auth";
|
||||
import { getInfo, login as doLogin } from "@/api/user";
|
||||
import type { UserInfoVO } from "@/api/user";
|
||||
import { defineStore } from "pinia";
|
||||
import { resetRouter } from "@/router";
|
||||
|
||||
// store 侧用户信息:后端返回结构 + 登录令牌,token 独立维护
|
||||
type UserInfo = Partial<UserInfoVO> & {
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export const useUserStore = defineStore("user", {
|
||||
state: (): { userInfo: UserInfo } => ({
|
||||
userInfo: {
|
||||
token: getToken() || "",
|
||||
name: "",
|
||||
profile: {},
|
||||
permissions: [],
|
||||
},
|
||||
}),
|
||||
getters: {
|
||||
userId(): string {
|
||||
return this.userInfo?.profile?.id || "";
|
||||
},
|
||||
name(): string {
|
||||
return this.userInfo?.name || "";
|
||||
},
|
||||
avatar(): string {
|
||||
return this.userInfo?.profile?.avatar || "";
|
||||
},
|
||||
permissions(): string[] {
|
||||
return this.userInfo?.permissions || [];
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login(userInfo: Record<string, unknown>) {
|
||||
const res = await doLogin(userInfo);
|
||||
const { token, tokenName } = res.data;
|
||||
this.userInfo.token = token;
|
||||
setToken(token, tokenName);
|
||||
return true;
|
||||
},
|
||||
async getInfo() {
|
||||
const res = await getInfo();
|
||||
const data = res.data;
|
||||
const { name, profile, permissions } = data;
|
||||
|
||||
// 保留当前的 token,避免被覆盖
|
||||
const currentToken = this.userInfo.token || getToken();
|
||||
|
||||
this.userInfo = {
|
||||
token: currentToken,
|
||||
name,
|
||||
profile,
|
||||
permissions,
|
||||
};
|
||||
return data;
|
||||
},
|
||||
// user logout
|
||||
async logout() {
|
||||
await this.resetToken();
|
||||
await resetRouter();
|
||||
return true;
|
||||
},
|
||||
async resetToken() {
|
||||
this.userInfo = {
|
||||
profile: {},
|
||||
permissions: [],
|
||||
};
|
||||
removeToken();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,3 @@
|
||||
// 大屏设计稿字体:优设标题黑 / 优设标题圆
|
||||
@font-face {
|
||||
font-family: "YouSheBiaoTiHei";
|
||||
src: url("./assets/font/YousheBiaoTiHei.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "YouSheBiaoTiYuan";
|
||||
src: url("./assets/font/YouSheBiaoTiYuan.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 后端统一返回结构,对应后端 com.haizhiyustc.common.web.api.ApiResult
|
||||
* code: 200 成功 / 401 未登录或登录过期 / 500 失败
|
||||
*/
|
||||
export interface ApiResult<T = unknown> {
|
||||
/** 状态码 */
|
||||
code: number;
|
||||
/** 返回的数据 */
|
||||
data: T;
|
||||
/** 说明信息 */
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页结果,对应后端 easy-query 的 EasyPageResult(仅返回 data 与 total)
|
||||
*/
|
||||
export interface PageResult<T> {
|
||||
/** 符合条件的总记录数 */
|
||||
total?: number;
|
||||
/** 当前页数据列表 */
|
||||
data?: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序项,对应后端 com.haizhiyustc.common.crud.bean.OrderItem
|
||||
*/
|
||||
export interface OrderItem {
|
||||
/** 排序属性名 */
|
||||
property: string;
|
||||
/** 关联表序号 */
|
||||
tableIndex?: number;
|
||||
/** 是否升序,默认 false 降序 */
|
||||
asc?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页请求参数,对应后端 com.haizhiyustc.common.crud.bean.PageParam
|
||||
*/
|
||||
export interface PageParam<T> {
|
||||
/** 页码,从1开始 */
|
||||
page?: number;
|
||||
/** 每页条数 */
|
||||
limit?: number;
|
||||
/** 排序列表 */
|
||||
orderList?: OrderItem[];
|
||||
/** 查询条件 */
|
||||
query?: T;
|
||||
}
|
||||
20
src/types/js-modules.d.ts
vendored
@@ -1,20 +0,0 @@
|
||||
interface Window {
|
||||
$message: {
|
||||
error(message: string, options?: Record<string, unknown>): void;
|
||||
success(message: string, options?: Record<string, unknown>): void;
|
||||
warning(message: string, options?: Record<string, unknown>): void;
|
||||
info(message: string, options?: Record<string, unknown>): void;
|
||||
};
|
||||
$modal: {
|
||||
create(options: Record<string, unknown>): unknown;
|
||||
warning(options: Record<string, unknown>): unknown;
|
||||
};
|
||||
$loadingBar: {
|
||||
start(): void;
|
||||
finish(): void;
|
||||
error(): void;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
declare var $loadingBar: Window["$loadingBar"];
|
||||
@@ -1,10 +1,10 @@
|
||||
const TokenKey = "token";
|
||||
const TOKEN_NAME_KEY = "tokenName";
|
||||
|
||||
let token: string | null = null;
|
||||
let tokenName: string | null = null;
|
||||
let token = null;
|
||||
let tokenName = null;
|
||||
|
||||
export function getToken(): string | null {
|
||||
export function getToken() {
|
||||
const storedToken = token || window.localStorage.getItem(TokenKey);
|
||||
if (!storedToken || storedToken === "null" || storedToken === "undefined") {
|
||||
token = null;
|
||||
@@ -15,21 +15,21 @@ export function getToken(): string | null {
|
||||
return token;
|
||||
}
|
||||
|
||||
export function setToken(tokenValue: string, tokenNameValue?: string): void {
|
||||
export function setToken(tokenValue, tokenNameValue) {
|
||||
token = tokenValue;
|
||||
tokenName = tokenNameValue || "Authorization";
|
||||
window.localStorage.setItem(TokenKey, token);
|
||||
window.localStorage.setItem(TOKEN_NAME_KEY, tokenName);
|
||||
}
|
||||
|
||||
export function removeToken(): void {
|
||||
export function removeToken() {
|
||||
token = null;
|
||||
tokenName = null;
|
||||
window.localStorage.removeItem(TokenKey);
|
||||
window.localStorage.removeItem(TOKEN_NAME_KEY);
|
||||
}
|
||||
|
||||
export function getTokenName(): string | null {
|
||||
export function getTokenName() {
|
||||
tokenName = tokenName || window.localStorage.getItem(TOKEN_NAME_KEY);
|
||||
return tokenName;
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
function generateNumbers(min: number, max: number, count: number): number[];
|
||||
function generateNumbers(min: number, max: number): number;
|
||||
function generateNumbers(
|
||||
min: number,
|
||||
max: number,
|
||||
count?: number,
|
||||
): number | number[] {
|
||||
function generateNumbers(min, max, count) {
|
||||
if (!count) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
@@ -15,5 +9,6 @@ function generateNumbers(
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
export { generateNumbers };
|
||||
export {
|
||||
generateNumbers
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { JSEncrypt } from 'jsencrypt'
|
||||
import { getPublicKey } from '@/api/user'
|
||||
|
||||
export async function getPasswordPublicKey(): Promise<string> {
|
||||
export async function getPasswordPublicKey() {
|
||||
const res = await getPublicKey()
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function encryptPassword(plainText: string, publicKey: string): string {
|
||||
export function encryptPassword(plainText, publicKey) {
|
||||
const encryptor = new JSEncrypt()
|
||||
encryptor.setPublicKey(formatPublicKey(publicKey))
|
||||
const encrypted = encryptor.encrypt(plainText)
|
||||
@@ -16,19 +16,16 @@ export function encryptPassword(plainText: string, publicKey: string): string {
|
||||
return encrypted
|
||||
}
|
||||
|
||||
export async function encryptPasswordPayload<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
fields: Array<keyof T>,
|
||||
): Promise<T> {
|
||||
export async function encryptPasswordPayload(payload, fields) {
|
||||
const publicKey = await getPasswordPublicKey()
|
||||
const nextPayload = { ...payload }
|
||||
for (const field of fields) {
|
||||
nextPayload[field] = encryptPassword(String(payload[field] || ''), publicKey) as T[keyof T]
|
||||
nextPayload[field] = encryptPassword(payload[field] || '', publicKey)
|
||||
}
|
||||
return nextPayload
|
||||
}
|
||||
|
||||
function formatPublicKey(publicKey: string): string {
|
||||
function formatPublicKey(publicKey) {
|
||||
if (!publicKey) {
|
||||
return ''
|
||||
}
|
||||
574
src/utils/pcd.ts
@@ -1,574 +0,0 @@
|
||||
/**
|
||||
* PCD 点云文件解析工具(仅取 x/y/z 三个坐标字段,供前端三维地图渲染使用)。
|
||||
*
|
||||
* 支持 DATA ascii / binary / binary_compressed 三种存储方式,
|
||||
* 后者按 PCL 约定需要先做 LZF 解压,且解压后是字段分块的 SOA 布局。
|
||||
*/
|
||||
|
||||
/** 解析结果 */
|
||||
export interface ParsedPointCloud {
|
||||
/** 扁平存放的点坐标,顺序为 x, y, z ...(单位:米) */
|
||||
positions: Float32Array;
|
||||
/** 降采样后保留的点数量 */
|
||||
pointCount: number;
|
||||
/** 文件中的原始点数量 */
|
||||
rawPointCount: number;
|
||||
/** 点云包围盒,用于相机自适应取景 */
|
||||
bounds: {
|
||||
min: [number, number, number];
|
||||
max: [number, number, number];
|
||||
};
|
||||
/** 估算出的地面高度(z,单位:米),用于把机器人模型与巡检路径贴合地面 */
|
||||
groundZ: number;
|
||||
}
|
||||
|
||||
interface PcdField {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PcdHeader {
|
||||
fields: PcdField[];
|
||||
points: number;
|
||||
dataType: string;
|
||||
/** 头部结束位置(数据区起始字节偏移) */
|
||||
dataOffset: number;
|
||||
}
|
||||
|
||||
/** 单个字段占用的字节数 */
|
||||
function fieldByteSize(field: PcdField) {
|
||||
return field.size * field.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 PCD 头部;latin1 解码保证一个字符对应一个字节,便于把字符下标当作字节偏移使用。
|
||||
*/
|
||||
function parseHeader(bytes: Uint8Array): PcdHeader {
|
||||
const headLength = Math.min(bytes.length, 65536);
|
||||
const headText = new TextDecoder("latin1").decode(bytes.subarray(0, headLength));
|
||||
const dataMatch = /(?:^|\r?\n)\s*DATA\s+([A-Za-z_]+)\s*(?:\r?\n)/.exec(headText);
|
||||
|
||||
if (!dataMatch) {
|
||||
throw new Error("PCD 文件缺少 DATA 段声明");
|
||||
}
|
||||
|
||||
const headerText = headText.slice(0, dataMatch.index);
|
||||
const valuesOf = (key: string) => {
|
||||
const match = headerText.match(new RegExp(`^\\s*${key}\\s+(.+)$`, "m"));
|
||||
|
||||
return match ? match[1].trim().split(/\s+/) : [];
|
||||
};
|
||||
|
||||
const names = valuesOf("FIELDS");
|
||||
const sizes = valuesOf("SIZE").map(Number);
|
||||
const types = valuesOf("TYPE");
|
||||
const counts = valuesOf("COUNT").map(Number);
|
||||
const points = Number(valuesOf("POINTS")[0] || 0);
|
||||
|
||||
if (!names.length || names.length !== sizes.length || names.length !== types.length) {
|
||||
throw new Error("PCD 头部字段描述不完整");
|
||||
}
|
||||
|
||||
const fields: PcdField[] = names.map((name, index) => ({
|
||||
name: name.toLowerCase(),
|
||||
size: sizes[index] || 4,
|
||||
type: (types[index] || "F").toUpperCase(),
|
||||
count: counts[index] || 1,
|
||||
}));
|
||||
|
||||
const xyzFields = fields.filter((field) => field.name === "x" || field.name === "y" || field.name === "z");
|
||||
if (xyzFields.length < 3) {
|
||||
throw new Error("PCD 文件缺少 x/y/z 坐标字段");
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
points: points || Number(valuesOf("WIDTH")[0] || 0) * Number(valuesOf("HEIGHT")[0] || 1),
|
||||
dataType: dataMatch[1].toLowerCase(),
|
||||
dataOffset: dataMatch.index + dataMatch[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
/** LZF 解压(PCL binary_compressed 使用的算法) */
|
||||
function decompressLzf(input: Uint8Array, outputLength: number): Uint8Array {
|
||||
const output = new Uint8Array(outputLength);
|
||||
let inputIndex = 0;
|
||||
let outputIndex = 0;
|
||||
|
||||
while (inputIndex < input.length) {
|
||||
const control = input[inputIndex];
|
||||
inputIndex += 1;
|
||||
|
||||
if (control < 32) {
|
||||
const length = control + 1;
|
||||
if (outputIndex + length > outputLength) {
|
||||
throw new Error("PCD 点云数据解压越界");
|
||||
}
|
||||
|
||||
output.set(input.subarray(inputIndex, inputIndex + length), outputIndex);
|
||||
inputIndex += length;
|
||||
outputIndex += length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let length = control >> 5;
|
||||
let reference = outputIndex - ((control & 0x1f) << 8) - 1;
|
||||
|
||||
if (length === 7) {
|
||||
length += input[inputIndex];
|
||||
inputIndex += 1;
|
||||
}
|
||||
|
||||
reference -= input[inputIndex];
|
||||
inputIndex += 1;
|
||||
|
||||
if (reference < 0 || outputIndex + length + 2 > outputLength) {
|
||||
throw new Error("PCD 点云数据解压引用越界");
|
||||
}
|
||||
|
||||
output[outputIndex] = output[reference];
|
||||
output[outputIndex + 1] = output[reference + 1];
|
||||
outputIndex += 2;
|
||||
reference += 2;
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
output[outputIndex] = output[reference];
|
||||
outputIndex += 1;
|
||||
reference += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/** 按字段类型读取单个数值 */
|
||||
function readFieldValue(view: DataView, offset: number, type: string, size: number) {
|
||||
switch (type) {
|
||||
case "F":
|
||||
return size === 8 ? view.getFloat64(offset, true) : view.getFloat32(offset, true);
|
||||
case "I":
|
||||
if (size === 1) return view.getInt8(offset);
|
||||
if (size === 2) return view.getInt16(offset, true);
|
||||
if (size === 8) return Number(view.getBigInt64(offset, true));
|
||||
return view.getInt32(offset, true);
|
||||
case "U":
|
||||
if (size === 1) return view.getUint8(offset);
|
||||
if (size === 2) return view.getUint16(offset, true);
|
||||
if (size === 8) return Number(view.getBigUint64(offset, true));
|
||||
return view.getUint32(offset, true);
|
||||
default:
|
||||
return Number.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 x/y/z 三个坐标字段的读取偏移。
|
||||
*
|
||||
* AOS(binary)逐点连续存放,坐标偏移在单点内固定,靠 pointStride 跳到下一个点;
|
||||
* SOA(binary_compressed)按字段分块存放,每个坐标字段各自逐点步进 fieldStride。
|
||||
*/
|
||||
function resolveXyzLayout(fields: PcdField[], isSoa: boolean, points: number) {
|
||||
const offsets: Record<string, number> = {};
|
||||
const fieldStrides: Record<string, number> = {};
|
||||
let cursor = 0;
|
||||
|
||||
fields.forEach((field) => {
|
||||
const fieldBytes = fieldByteSize(field);
|
||||
if (field.name === "x" || field.name === "y" || field.name === "z") {
|
||||
offsets[field.name] = cursor;
|
||||
fieldStrides[field.name] = isSoa ? field.size : 0;
|
||||
}
|
||||
cursor += isSoa ? fieldBytes * points : fieldBytes;
|
||||
});
|
||||
|
||||
return { offsets, fieldStrides, pointStride: isSoa ? 0 : cursor };
|
||||
}
|
||||
|
||||
/** 解析 ascii 文本格式的点坐标 */
|
||||
function parseAsciiPoints(content: string, header: PcdHeader, target: PointTarget) {
|
||||
const xyzIndex = header.fields
|
||||
.map((field, index) => ({ name: field.name, index }))
|
||||
.filter((item) => item.name === "x" || item.name === "y" || item.name === "z");
|
||||
const indexOf = Object.fromEntries(xyzIndex.map((item) => [item.name, item.index]));
|
||||
const lines = content.split(/\r?\n/);
|
||||
let written = 0;
|
||||
let valid = 0;
|
||||
let nextKeep = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const x = Number(parts[indexOf.x]);
|
||||
const y = Number(parts[indexOf.y]);
|
||||
const z = Number(parts[indexOf.z]);
|
||||
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 抽稀按有效点计数,保证取样在点云里均匀分布
|
||||
if (valid >= nextKeep) {
|
||||
target.positions[written * 3] = x;
|
||||
target.positions[written * 3 + 1] = y;
|
||||
target.positions[written * 3 + 2] = z;
|
||||
written += 1;
|
||||
nextKeep += target.interval;
|
||||
}
|
||||
|
||||
valid += 1;
|
||||
|
||||
if (written >= target.capacity) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { written, valid };
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标点缓冲区:容量在解析前按抽稀间隔算好,解析时直接写进 Float32Array,
|
||||
* 省掉"先建一份 JS number 数组再拷贝"的中间开销(大点云上是主要的内存与耗时来源)。
|
||||
*/
|
||||
interface PointTarget {
|
||||
positions: Float32Array;
|
||||
/**
|
||||
* 抽稀间隔:平均每 interval 个有效点保留 1 个。
|
||||
* 用浮点间隔而不是整数倍,保留点数才能精确贴近传入的上限
|
||||
* (整数倍会让 30 万的上限实际只留下 19 万)。
|
||||
*/
|
||||
interval: number;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
/** 按原始点数与显示上限创建写入缓冲区 */
|
||||
function createPointTarget(rawPointCount: number, maxPoints: number): PointTarget {
|
||||
const safeRawCount = Math.max(Math.floor(rawPointCount) || 0, 1);
|
||||
const interval = Math.max(safeRawCount / maxPoints, 1);
|
||||
const capacity = Math.ceil(safeRawCount / interval) + 2;
|
||||
|
||||
return {
|
||||
positions: new Float32Array(capacity * 3),
|
||||
interval,
|
||||
capacity,
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析二进制(含压缩)格式的点坐标 */
|
||||
function parseBinaryPoints(
|
||||
view: DataView,
|
||||
header: PcdHeader,
|
||||
dataStart: number,
|
||||
dataLength: number,
|
||||
target: PointTarget,
|
||||
) {
|
||||
const isSoa = header.dataType === "binary_compressed";
|
||||
const { offsets, fieldStrides, pointStride } = resolveXyzLayout(header.fields, isSoa, header.points);
|
||||
let written = 0;
|
||||
let valid = 0;
|
||||
let nextKeep = 0;
|
||||
|
||||
for (let index = 0; index < header.points; index += 1) {
|
||||
const base = dataStart + index * pointStride;
|
||||
const xOffset = base + offsets.x + index * fieldStrides.x;
|
||||
const yOffset = base + offsets.y + index * fieldStrides.y;
|
||||
const zOffset = base + offsets.z + index * fieldStrides.z;
|
||||
|
||||
// 防止截断文件越界读取
|
||||
if (Math.max(xOffset, yOffset, zOffset) + 4 > dataStart + dataLength) {
|
||||
break;
|
||||
}
|
||||
|
||||
const x = readFieldValue(view, xOffset, "F", 4);
|
||||
const y = readFieldValue(view, yOffset, "F", 4);
|
||||
const z = readFieldValue(view, zOffset, "F", 4);
|
||||
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 抽稀按有效点计数,保证取样在点云里均匀分布
|
||||
if (valid >= nextKeep) {
|
||||
target.positions[written * 3] = x;
|
||||
target.positions[written * 3 + 1] = y;
|
||||
target.positions[written * 3 + 2] = z;
|
||||
written += 1;
|
||||
nextKeep += target.interval;
|
||||
}
|
||||
|
||||
valid += 1;
|
||||
|
||||
if (written >= target.capacity) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { written, valid };
|
||||
}
|
||||
|
||||
/** 根据 x/y/z 扁平数组计算包围盒 */
|
||||
function resolveBounds(coordinates: Float32Array, pointCount: number) {
|
||||
const min: [number, number, number] = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
|
||||
const max: [number, number, number] = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
||||
|
||||
for (let index = 0; index < pointCount; index += 1) {
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
const value = coordinates[index * 3 + axis];
|
||||
if (value < min[axis]) min[axis] = value;
|
||||
if (value > max[axis]) max[axis] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(min[0])) {
|
||||
return { min: [0, 0, 0] as [number, number, number], max: [0, 0, 0] as [number, number, number] };
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算地面高度:从最低处向上找第一个点数足够密集的 z 分层。
|
||||
*
|
||||
* 长廊/隧道点云里地面是面积最大的水平面,用直方图取“第一个密集分层”比直接取
|
||||
* 最低点更稳:既不会被地面以下的离群噪点带偏,也不受墙面点影响。
|
||||
*/
|
||||
function estimateGroundZ(positions: Float32Array, pointCount: number, bounds: ParsedPointCloud["bounds"]) {
|
||||
const span = bounds.max[2] - bounds.min[2];
|
||||
|
||||
if (pointCount < 100 || span < 0.2) {
|
||||
return bounds.min[2];
|
||||
}
|
||||
|
||||
const binCount = Math.min(Math.max(Math.ceil(span / 0.05), 1), 2048);
|
||||
const scale = binCount / span;
|
||||
const bins = new Int32Array(binCount);
|
||||
|
||||
for (let index = 0; index < pointCount; index += 1) {
|
||||
const bin = Math.min(Math.floor((positions[index * 3 + 2] - bounds.min[2]) * scale), binCount - 1);
|
||||
bins[bin] += 1;
|
||||
}
|
||||
|
||||
let peak = 0;
|
||||
for (let index = 0; index < binCount; index += 1) {
|
||||
if (bins[index] > peak) {
|
||||
peak = bins[index];
|
||||
}
|
||||
}
|
||||
|
||||
// 峰值 25% 以上视为密集层,最低的那个密集层就是地面
|
||||
const threshold = peak * 0.25;
|
||||
let floorBin = 0;
|
||||
for (let index = 0; index < binCount; index += 1) {
|
||||
if (bins[index] >= threshold) {
|
||||
floorBin = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const lower = bounds.min[2] + floorBin / scale - 0.1;
|
||||
const upper = bounds.min[2] + (floorBin + 1) / scale + 0.1;
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let index = 0; index < pointCount; index += 1) {
|
||||
const z = positions[index * 3 + 2];
|
||||
if (z >= lower && z <= upper) {
|
||||
sum += z;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count ? sum / count : bounds.min[2] + (floorBin + 0.5) / scale;
|
||||
}
|
||||
|
||||
/** 地面连续平面的三角网格数据 */
|
||||
export interface GroundSurface {
|
||||
/** 三角形顶点坐标,按 x, y, z 排列 */
|
||||
positions: Float32Array;
|
||||
/** 顶点法线,全部朝上 */
|
||||
normals: Float32Array;
|
||||
/** 栅格边长(米) */
|
||||
cellSize: number;
|
||||
/** 生成平面时取用的地面带半高(米):|z - groundZ| ≤ band 的点已由平面表达 */
|
||||
band: number;
|
||||
/** 生成的方格数量 */
|
||||
quadCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用点云里的地面点栅格化出一块连续平面,用来填补点与点之间的空隙。
|
||||
*
|
||||
* 做法:把地面带(z 在 groundZ 附近)内的点投到二维占用栅格上,邻域膨胀一圈补掉
|
||||
* 点云稀疏造成的空洞,再按占用格子生成水平三角面。长廊地面因此是连续平面,
|
||||
* 墙面仍保留点云细节。
|
||||
*/
|
||||
export function buildGroundSurface(parsed: ParsedPointCloud): GroundSurface | null {
|
||||
const { min, max } = parsed.bounds;
|
||||
const spanX = Math.max(max[0] - min[0], 0.5);
|
||||
const spanY = Math.max(max[1] - min[1], 0.5);
|
||||
// 栅格边长随场景尺度自适应,并把总格子数限制在 20 万以内
|
||||
let cellSize = Math.min(Math.max(spanX / 400, 0.06), 0.4);
|
||||
|
||||
while ((spanX / cellSize) * (spanY / cellSize) > 200000) {
|
||||
cellSize *= 1.5;
|
||||
}
|
||||
|
||||
const cols = Math.ceil(spanX / cellSize) + 1;
|
||||
const rows = Math.ceil(spanY / cellSize) + 1;
|
||||
const occupied = new Uint8Array(cols * rows);
|
||||
const band = Math.max(cellSize * 0.5, 0.1);
|
||||
|
||||
for (let index = 0; index < parsed.pointCount; index += 1) {
|
||||
if (Math.abs(parsed.positions[index * 3 + 2] - parsed.groundZ) > band) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const col = Math.floor((parsed.positions[index * 3] - min[0]) / cellSize);
|
||||
const row = Math.floor((parsed.positions[index * 3 + 1] - min[1]) / cellSize);
|
||||
|
||||
if (col < 0 || row < 0 || col >= cols || row >= rows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
occupied[row * cols + col] = 1;
|
||||
}
|
||||
|
||||
// 邻域膨胀一圈,把局部点云稀疏造成的空洞补齐
|
||||
const filled = new Uint8Array(occupied);
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let col = 0; col < cols; col += 1) {
|
||||
if (!occupied[row * cols + col]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let rowOffset = -1; rowOffset <= 1; rowOffset += 1) {
|
||||
for (let colOffset = -1; colOffset <= 1; colOffset += 1) {
|
||||
const targetRow = row + rowOffset;
|
||||
const targetCol = col + colOffset;
|
||||
|
||||
if (targetRow >= 0 && targetRow < rows && targetCol >= 0 && targetCol < cols) {
|
||||
filled[targetRow * cols + targetCol] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quadCount = 0;
|
||||
|
||||
for (let index = 0; index < filled.length; index += 1) {
|
||||
if (filled[index]) {
|
||||
quadCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!quadCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positions = new Float32Array(quadCount * 18);
|
||||
const normals = new Float32Array(quadCount * 18);
|
||||
// 略低于点云地面,让地面点浮在平面之上,避免与平面深度打架
|
||||
const planeZ = parsed.groundZ - 0.02;
|
||||
let cursor = 0;
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let col = 0; col < cols; col += 1) {
|
||||
if (!filled[row * cols + col]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x0 = min[0] + col * cellSize;
|
||||
const y0 = min[1] + row * cellSize;
|
||||
const x1 = x0 + cellSize;
|
||||
const y1 = y0 + cellSize;
|
||||
// 逆时针两个三角形(从 +Z 方向看为正面)
|
||||
const corners = [x0, y0, x1, y0, x1, y1, x0, y0, x1, y1, x0, y1];
|
||||
|
||||
for (let corner = 0; corner < 6; corner += 1) {
|
||||
positions[cursor] = corners[corner * 2];
|
||||
positions[cursor + 1] = corners[corner * 2 + 1];
|
||||
positions[cursor + 2] = planeZ;
|
||||
normals[cursor] = 0;
|
||||
normals[cursor + 1] = 0;
|
||||
normals[cursor + 2] = 1;
|
||||
cursor += 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { positions, normals, cellSize, band, quadCount };
|
||||
}
|
||||
|
||||
export interface ParsePcdOptions {
|
||||
/** 保留的点数上限,超出后按该数量在点云里均匀抽稀,默认 80 万 */
|
||||
maxPoints?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 PCD 点云文件,返回三维渲染所需的扁平坐标数组。
|
||||
*
|
||||
* @param buffer PCD 文件内容
|
||||
* @param options 抽稀参数
|
||||
*/
|
||||
export function parsePcdPointCloud(buffer: ArrayBuffer, options: ParsePcdOptions = {}): ParsedPointCloud {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const header = parseHeader(bytes);
|
||||
|
||||
if (!header.points) {
|
||||
throw new Error("PCD 文件点数量为 0");
|
||||
}
|
||||
|
||||
const maxPoints = options.maxPoints ?? 800000;
|
||||
const target = createPointTarget(header.points, maxPoints);
|
||||
let parsed: { written: number; valid: number };
|
||||
|
||||
if (header.dataType === "ascii") {
|
||||
const content = new TextDecoder("latin1").decode(bytes.subarray(header.dataOffset));
|
||||
parsed = parseAsciiPoints(content, header, target);
|
||||
} else if (header.dataType === "binary") {
|
||||
const view = new DataView(buffer);
|
||||
parsed = parseBinaryPoints(view, header, header.dataOffset, buffer.byteLength - header.dataOffset, target);
|
||||
} else if (header.dataType === "binary_compressed") {
|
||||
if (header.dataOffset + 8 > buffer.byteLength) {
|
||||
throw new Error("PCD 压缩数据区不完整");
|
||||
}
|
||||
|
||||
const headView = new DataView(buffer, header.dataOffset, 8);
|
||||
const compressedSize = headView.getUint32(0, true);
|
||||
const uncompressedSize = headView.getUint32(4, true);
|
||||
const compressed = bytes.subarray(header.dataOffset + 8, header.dataOffset + 8 + compressedSize);
|
||||
|
||||
if (compressed.length < compressedSize) {
|
||||
throw new Error("PCD 压缩数据长度与声明不符");
|
||||
}
|
||||
|
||||
const plain = decompressLzf(compressed, uncompressedSize);
|
||||
const view = new DataView(plain.buffer, plain.byteOffset, plain.byteLength);
|
||||
parsed = parseBinaryPoints(view, header, 0, plain.byteLength, target);
|
||||
} else {
|
||||
throw new Error(`暂不支持的 PCD 数据格式:${header.dataType}`);
|
||||
}
|
||||
|
||||
if (!parsed.written) {
|
||||
throw new Error("PCD 文件中未解析到有效点");
|
||||
}
|
||||
|
||||
const finalPointCount = parsed.written;
|
||||
const finalPositions = target.positions.subarray(0, finalPointCount * 3);
|
||||
const bounds = resolveBounds(finalPositions, finalPointCount);
|
||||
|
||||
return {
|
||||
positions: finalPositions,
|
||||
pointCount: finalPointCount,
|
||||
rawPointCount: parsed.valid,
|
||||
bounds,
|
||||
groundZ: estimateGroundZ(finalPositions, finalPointCount, bounds),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import axios from "axios";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { getToken, getTokenName } from "@/utils/auth";
|
||||
import router from "@/router";
|
||||
@@ -58,8 +58,9 @@ service.interceptors.response.use(
|
||||
window.$message.error(error.response.data.msg, {
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
handleNoLogin();
|
||||
}
|
||||
} else {
|
||||
handleNoLogin();
|
||||
}
|
||||
@@ -70,21 +71,16 @@ service.interceptors.response.use(
|
||||
},
|
||||
);
|
||||
|
||||
// 响应拦截器已解包 response.data,对外暴露的请求方法保持 Promise<T> 语义
|
||||
const request = <T = unknown>(config: AxiosRequestConfig): Promise<T> => {
|
||||
return service.request<any, T>(config);
|
||||
};
|
||||
export default service;
|
||||
|
||||
export default request;
|
||||
|
||||
function showApiMessage(res: { msg?: string }) {
|
||||
function showApiMessage(res) {
|
||||
window.$message.error(res.msg || "error", {
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// 弹窗确认防抖
|
||||
let noLoginAlert: unknown = null;
|
||||
let noLoginAlert = null;
|
||||
|
||||
function handleNoLogin() {
|
||||
if (noLoginAlert == null) {
|
||||
@@ -1,114 +0,0 @@
|
||||
export interface ParsedRosMapYaml {
|
||||
image?: string;
|
||||
resolution: number | null;
|
||||
origin: [number, number, number] | null;
|
||||
}
|
||||
|
||||
function stripInlineComment(value: string) {
|
||||
let quote: string | null = null;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
const previousChar = value[index - 1];
|
||||
|
||||
if ((char === "'" || char === "\"") && previousChar !== "\\") {
|
||||
quote = quote === char ? null : quote || char;
|
||||
}
|
||||
|
||||
if (char === "#" && !quote) {
|
||||
return value.slice(0, index).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function unquoteYamlString(value: string) {
|
||||
const trimmedValue = stripInlineComment(value);
|
||||
|
||||
return trimmedValue.replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
|
||||
function readScalarValue(content: string, key: string) {
|
||||
const match = content.match(new RegExp(`^\\s*${key}\\s*:\\s*(.*?)\\s*$`, "m"));
|
||||
|
||||
return match ? stripInlineComment(match[1]) : "";
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value: string) {
|
||||
const parsedValue = Number(stripInlineComment(value));
|
||||
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
function parseInlineOrigin(content: string) {
|
||||
const originValue = content.match(/^\s*origin\s*:\s*\[([^\]]+)\]\s*(?:#.*)?$/m)?.[1];
|
||||
|
||||
if (!originValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = originValue
|
||||
.split(",")
|
||||
.map((value) => parseFiniteNumber(value.trim()))
|
||||
.slice(0, 3);
|
||||
|
||||
return values.length === 3 && values.every((value) => value !== null)
|
||||
? values as [number, number, number]
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseBlockOrigin(content: string) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const values: number[] = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const originLine = lines[index].match(/^(\s*)origin\s*:\s*(?:#.*)?$/);
|
||||
|
||||
if (!originLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originIndent = originLine[1].length;
|
||||
|
||||
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
|
||||
const line = lines[nextIndex];
|
||||
|
||||
if (!line.trim() || /^\s*#/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineIndent = line.match(/^\s*/)?.[0].length ?? 0;
|
||||
if (lineIndent <= originIndent) {
|
||||
break;
|
||||
}
|
||||
|
||||
const itemValue = line.match(/^\s*-\s*(.*?)\s*$/)?.[1];
|
||||
const parsedValue = itemValue ? parseFiniteNumber(itemValue) : null;
|
||||
|
||||
if (parsedValue === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
values.push(parsedValue);
|
||||
|
||||
if (values.length === 3) {
|
||||
return values as [number, number, number];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseRosMapYaml(content: string): ParsedRosMapYaml {
|
||||
const imageValue = readScalarValue(content, "image");
|
||||
const resolution = parseFiniteNumber(readScalarValue(content, "resolution"));
|
||||
const origin = parseInlineOrigin(content) || parseBlockOrigin(content);
|
||||
|
||||
return {
|
||||
image: imageValue ? unquoteYamlString(imageValue) : undefined,
|
||||
resolution: resolution && resolution > 0 ? resolution : null,
|
||||
origin,
|
||||
};
|
||||
}
|
||||
|
Before Width: | Height: | Size: 353 KiB |
24
src/views/dashboard1/components/btn/index.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineComponent } from "vue";
|
||||
import style from "./index.module.less";
|
||||
export default defineComponent({
|
||||
name: "switchPageBtn",
|
||||
props: {
|
||||
direction: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
text: {
|
||||
required: true
|
||||
},
|
||||
active: Boolean
|
||||
},
|
||||
setup(props) {
|
||||
return () => <div
|
||||
class={[
|
||||
style.btn,
|
||||
props.active ? style.active : style.normal,
|
||||
style[props.direction]
|
||||
]}
|
||||
><div class={[style.text]}>{props.text}</div></div>;
|
||||
}
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { defineComponent } from "vue";
|
||||
import style from "./index.module.less";
|
||||
|
||||
export default defineComponent({
|
||||
name: "switchPageBtn",
|
||||
props: {
|
||||
direction: {
|
||||
type: String as () => "left" | "right",
|
||||
required: true,
|
||||
},
|
||||
text: {
|
||||
required: true,
|
||||
},
|
||||
active: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
return () => (
|
||||
<div
|
||||
class={[
|
||||
style.btn,
|
||||
props.active ? style.active : style.normal,
|
||||
style[props.direction],
|
||||
]}
|
||||
>
|
||||
<div class={[style.text]}>{props.text}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,51 +1,44 @@
|
||||
import { onBeforeUnmount, ref } from "vue";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { generateNumbers } from "@/utils";
|
||||
|
||||
export default function () {
|
||||
const option = ref<EChartsOption>({});
|
||||
|
||||
const option = ref({});
|
||||
function refresh() {
|
||||
option.value = {
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
|
||||
data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
type: "value"
|
||||
},
|
||||
grid: {
|
||||
left: "12%",
|
||||
left: "12%"
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: generateNumbers(1, 300, 7),
|
||||
type: "line",
|
||||
type: "line"
|
||||
},
|
||||
{
|
||||
data: generateNumbers(1, 300, 7),
|
||||
type: "line",
|
||||
type: "line"
|
||||
},
|
||||
{
|
||||
data: generateNumbers(1, 300, 7),
|
||||
type: "line",
|
||||
},
|
||||
],
|
||||
type: "line"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, 3000);
|
||||
|
||||
}, 3e3);
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
option,
|
||||
refresh,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
import { onBeforeUnmount, ref } from "vue";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { generateNumbers } from "@/utils";
|
||||
|
||||
export default function () {
|
||||
const option = ref<EChartsOption>({});
|
||||
|
||||
const option = ref({});
|
||||
function refresh() {
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
trigger: "item"
|
||||
},
|
||||
legend: {
|
||||
top: "5%",
|
||||
left: "center",
|
||||
left: "center"
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -21,46 +18,42 @@ export default function () {
|
||||
radius: ["40%", "70%"],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderColor: "#fff",
|
||||
borderColor: "#fff"
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: "center",
|
||||
position: "center"
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 30,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
fontWeight: "bold"
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{ value: generateNumbers(10, 100), name: "产品一" },
|
||||
{ value: generateNumbers(10, 100), name: "产品二" },
|
||||
{ value: generateNumbers(10, 100), name: "产品三" },
|
||||
{ value: generateNumbers(10, 100), name: "产品四" },
|
||||
{ value: generateNumbers(10, 100), name: "产品五" },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ value: generateNumbers(10, 100), name: "产品五" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, 3000);
|
||||
|
||||
}, 3e3);
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
option,
|
||||
refresh,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { onBeforeUnmount, ref } from "vue";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { generateNumbers } from "@/utils";
|
||||
|
||||
export default function () {
|
||||
const option = ref<EChartsOption>({});
|
||||
|
||||
const option = ref({});
|
||||
function refresh() {
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: "{a} <br/>{b} : {c}%",
|
||||
formatter: "{a} <br/>{b} : {c}%"
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -18,57 +15,53 @@ export default function () {
|
||||
left: "center",
|
||||
width: "100%",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
max: 1e3,
|
||||
minSize: "0%",
|
||||
maxSize: "100%",
|
||||
sort: "descending",
|
||||
gap: 2,
|
||||
label: {
|
||||
show: true,
|
||||
position: "inside",
|
||||
position: "inside"
|
||||
},
|
||||
labelLine: {
|
||||
length: 10,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
type: "solid",
|
||||
},
|
||||
type: "solid"
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: "#fff",
|
||||
borderWidth: 1,
|
||||
borderWidth: 1
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
fontSize: 20,
|
||||
},
|
||||
fontSize: 20
|
||||
}
|
||||
},
|
||||
data: [
|
||||
{ value: generateNumbers(500, 1000), name: "访问量" },
|
||||
{ value: generateNumbers(500, 1000), name: "注册量" },
|
||||
{ value: generateNumbers(500, 1000), name: "浏览量" },
|
||||
{ value: generateNumbers(500, 1e3), name: "访问量" },
|
||||
{ value: generateNumbers(500, 1e3), name: "注册量" },
|
||||
{ value: generateNumbers(500, 1e3), name: "浏览量" },
|
||||
{ value: generateNumbers(200, 500), name: "加入购物车" },
|
||||
{ value: generateNumbers(100, 800), name: "购买" },
|
||||
{ value: generateNumbers(1, 20), name: "退货" },
|
||||
{ value: generateNumbers(1, 20), name: "取消订单" },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ value: generateNumbers(1, 20), name: "取消订单" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, 3000);
|
||||
|
||||
}, 3e3);
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
option,
|
||||
refresh,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
@@ -1,861 +0,0 @@
|
||||
import { onBeforeUnmount, ref, type Ref } from "vue";
|
||||
import type {
|
||||
Color,
|
||||
GridHelper,
|
||||
Group,
|
||||
Material,
|
||||
Mesh,
|
||||
PerspectiveCamera,
|
||||
Points,
|
||||
Scene,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import {
|
||||
buildGroundSurface,
|
||||
parsePcdPointCloud,
|
||||
type GroundSurface,
|
||||
type ParsedPointCloud,
|
||||
} from "@/utils/pcd";
|
||||
|
||||
/** 机器人在三维地图中的位姿(map 坐标系,单位:米/弧度) */
|
||||
export interface RobotPose3D {
|
||||
x: number;
|
||||
y: number;
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
/** 巡检路径节点(map 坐标系,单位:米) */
|
||||
export interface RoutePoint3D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** three.js 模块类型,运行时再按需加载,避免三维依赖拖慢首页首屏 */
|
||||
type ThreeModule = typeof import("three");
|
||||
|
||||
/** 三维模型按真实小车尺寸放大倍数,保证在长廊场景下能看清 */
|
||||
const MODEL_SCALE = 2;
|
||||
|
||||
/**
|
||||
* 点尺寸系数:点世界尺寸 = 场景尺度 / 该值,数值越大点越细。
|
||||
* 方点比同尺寸圆点覆盖面积大 27%,这里已按等效覆盖率折算过。
|
||||
*/
|
||||
const POINT_SIZE_DIVISOR = 900;
|
||||
|
||||
/** 点云原始文件缓存上限,超过则不缓存,换质量档位时改为重新下载 */
|
||||
const RAW_CACHE_LIMIT = 64 * 1024 * 1024;
|
||||
|
||||
/** 默认抽稀上限(点数),页面会按质量档位传入覆盖 */
|
||||
export const DEFAULT_POINT_CAP = 800000;
|
||||
|
||||
/** 高度着色渐变色标(低 → 高) */
|
||||
const heightColorStops: Array<[number, number, number]> = [
|
||||
[0.13, 0.42, 0.78],
|
||||
[0.2, 0.72, 0.86],
|
||||
[0.35, 0.85, 0.55],
|
||||
[0.95, 0.83, 0.35],
|
||||
[0.92, 0.42, 0.36],
|
||||
];
|
||||
|
||||
/** 按归一化高度取渐变色 */
|
||||
function resolveHeightColor(three: ThreeModule, ratio: number, target: Color) {
|
||||
const safeRatio = Math.min(Math.max(ratio, 0), 1);
|
||||
const scaled = safeRatio * (heightColorStops.length - 1);
|
||||
const index = Math.min(Math.floor(scaled), heightColorStops.length - 2);
|
||||
const localRatio = scaled - index;
|
||||
const start = heightColorStops[index];
|
||||
const end = heightColorStops[index + 1];
|
||||
|
||||
return target.setRGB(
|
||||
start[0] + (end[0] - start[0]) * localRatio,
|
||||
start[1] + (end[1] - start[1]) * localRatio,
|
||||
start[2] + (end[2] - start[2]) * localRatio,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 三维点云地图渲染:读取 PCD 点云绘制点云主体,在地面绘制巡检路径,
|
||||
* 并以四轮小车模型叠加机器人实时位姿。
|
||||
*
|
||||
* 坐标直接使用 ROS 的 map 坐标系(x 前 / y 左 / z 上),
|
||||
* 因此把相机 up 设为 +Z 后即可与二维地图保持同一套坐标语义。
|
||||
*/
|
||||
export default function usePointCloud3D(containerRef: Ref<HTMLElement | null>) {
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const info = ref("");
|
||||
const loaded = ref(false);
|
||||
|
||||
let three: ThreeModule | null = null;
|
||||
let renderer: WebGLRenderer | null = null;
|
||||
let scene: Scene | null = null;
|
||||
let camera: PerspectiveCamera | null = null;
|
||||
let controls: OrbitControls | null = null;
|
||||
let gridHelper: GridHelper | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let frameId = 0;
|
||||
let needsRender = false;
|
||||
let mounting = false;
|
||||
let mountToken = 0;
|
||||
|
||||
let pointCloud: Points | null = null;
|
||||
/** 地面连续平面,填补点云空隙 */
|
||||
let floorMesh: Mesh | null = null;
|
||||
let robotGroup: Group | null = null;
|
||||
|
||||
let routeGroup: Group | null = null;
|
||||
let cloudBounds: ParsedPointCloud["bounds"] | null = null;
|
||||
let routeBounds: ParsedPointCloud["bounds"] | null = null;
|
||||
let routePoints: RoutePoint3D[] = [];
|
||||
/** 点云地面高度(长廊地面),由解析阶段按 z 直方图估算 */
|
||||
let groundZ = 0;
|
||||
let robotPose: RobotPose3D | null = null;
|
||||
let loadedUrl = "";
|
||||
/** 当前已加载点云用的抽稀上限,用于判断换质量档位后是否需要重新解析 */
|
||||
let loadedCap = 0;
|
||||
/** 点云原始文件缓存:换质量档位重新解析时不必再次下载,超出上限则不缓存 */
|
||||
let rawCache: ArrayBuffer | null = null;
|
||||
let rawCacheUrl = "";
|
||||
/** 用户是否手动操作过相机,操作后不再自动调整视角 */
|
||||
let cameraTouched = false;
|
||||
/** WASD 键盘漫游状态(仅在地图放大预览时开启),速度随场景尺度自适应 */
|
||||
const pressedKeys = new Set<string>();
|
||||
let keyboardEnabled = false;
|
||||
let keyboardSpeed = 5;
|
||||
let lastFrameTime = 0;
|
||||
|
||||
const ownedGeometries: Array<{ dispose: () => void }> = [];
|
||||
const ownedMaterials: Material[] = [];
|
||||
|
||||
function trackResources(geometries: Array<{ dispose: () => void }>, materials: Material[]) {
|
||||
ownedGeometries.push(...geometries);
|
||||
ownedMaterials.push(...materials);
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
needsRender = true;
|
||||
}
|
||||
|
||||
function renderFrame() {
|
||||
frameId = requestAnimationFrame(renderFrame);
|
||||
|
||||
const now = performance.now();
|
||||
const deltaMs = lastFrameTime ? now - lastFrameTime : 16;
|
||||
lastFrameTime = now;
|
||||
|
||||
// 键盘漫游需要逐帧位移,因此先于渲染判断执行
|
||||
applyKeyboardNavigation(deltaMs);
|
||||
|
||||
if (!needsRender || !renderer || !scene || !camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
needsRender = false;
|
||||
controls?.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function isNavigationKey(key: string) {
|
||||
return key === "w" || key === "a" || key === "s" || key === "d";
|
||||
}
|
||||
|
||||
function handleNavigationKeyDown(event: KeyboardEvent) {
|
||||
if (!keyboardEnabled || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
if (!isNavigationKey(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 输入框内不劫持按键,避免影响页面其它表单输入
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tagName = target?.tagName;
|
||||
|
||||
if (tagName === "INPUT" || tagName === "TEXTAREA" || target?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
pressedKeys.add(key);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function handleNavigationKeyUp(event: KeyboardEvent) {
|
||||
pressedKeys.delete(event.key.toLowerCase());
|
||||
}
|
||||
|
||||
function clearPressedKeys() {
|
||||
pressedKeys.clear();
|
||||
}
|
||||
|
||||
/** 开启/关闭 WASD 键盘漫游,仅在三维地图放大预览时开启 */
|
||||
function setKeyboardNavigation(enabled: boolean) {
|
||||
if (enabled === keyboardEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
keyboardEnabled = enabled;
|
||||
pressedKeys.clear();
|
||||
|
||||
if (enabled) {
|
||||
window.addEventListener("keydown", handleNavigationKeyDown);
|
||||
window.addEventListener("keyup", handleNavigationKeyUp);
|
||||
window.addEventListener("blur", clearPressedKeys);
|
||||
} else {
|
||||
window.removeEventListener("keydown", handleNavigationKeyDown);
|
||||
window.removeEventListener("keyup", handleNavigationKeyUp);
|
||||
window.removeEventListener("blur", clearPressedKeys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按帧位移相机:W/S 沿相机水平朝向前后移动,A/D 沿水平右方向平移,
|
||||
* 位移同时作用在相机与轨道中心上,移动过程中保持当前观察姿态。
|
||||
*/
|
||||
function applyKeyboardNavigation(deltaMs: number) {
|
||||
if (!pressedKeys.size || !three || !camera || !controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const forward = new three.Vector3();
|
||||
camera.getWorldDirection(forward);
|
||||
// 只在水平面内移动,避免视角俯仰把相机带进地面或天花板
|
||||
forward.z = 0;
|
||||
|
||||
if (forward.lengthSq() < 1e-6) {
|
||||
return;
|
||||
}
|
||||
|
||||
const right = new three.Vector3().crossVectors(forward.normalize(), camera.up).normalize();
|
||||
const step = keyboardSpeed * (Math.min(deltaMs, 100) / 1000);
|
||||
const offset = new three.Vector3();
|
||||
|
||||
if (pressedKeys.has("w")) offset.addScaledVector(forward, step);
|
||||
if (pressedKeys.has("s")) offset.addScaledVector(forward, -step);
|
||||
if (pressedKeys.has("d")) offset.addScaledVector(right, step);
|
||||
if (pressedKeys.has("a")) offset.addScaledVector(right, -step);
|
||||
|
||||
if (offset.lengthSq() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
cameraTouched = true;
|
||||
camera.position.add(offset);
|
||||
controls.target.add(offset);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function resizeRenderer() {
|
||||
const container = containerRef.value;
|
||||
|
||||
if (!container || !renderer || !camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = Math.max(container.clientWidth, 1);
|
||||
const height = Math.max(container.clientHeight, 1);
|
||||
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** 销毁点云主体与地面网格,释放显存 */
|
||||
function clearPointCloud() {
|
||||
if (pointCloud && scene) {
|
||||
scene.remove(pointCloud);
|
||||
pointCloud.geometry.dispose();
|
||||
(pointCloud.material as Material).dispose();
|
||||
pointCloud = null;
|
||||
}
|
||||
|
||||
if (floorMesh && scene) {
|
||||
scene.remove(floorMesh);
|
||||
floorMesh.geometry.dispose();
|
||||
(floorMesh.material as Material).dispose();
|
||||
floorMesh = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把地面栅格数据转成 three.js 网格。
|
||||
*
|
||||
* 长廊地面是最完整的一片水平面,与其靠放大点尺寸去"糊"住空隙(近处会变成大色块),
|
||||
* 不如按占用栅格铺一张地面网格;对应的地面点云会被剔除,不再重复渲染。
|
||||
*/
|
||||
function buildFloorMesh(threeModule: ThreeModule, surface: GroundSurface) {
|
||||
const geometry = new threeModule.BufferGeometry();
|
||||
geometry.setAttribute("position", new threeModule.BufferAttribute(surface.positions, 3));
|
||||
geometry.setAttribute("normal", new threeModule.BufferAttribute(surface.normals, 3));
|
||||
|
||||
return new threeModule.Mesh(
|
||||
geometry,
|
||||
new threeModule.MeshLambertMaterial({ color: 0x1c3b52 }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 清理巡检路径对象 */
|
||||
function clearRoute() {
|
||||
if (!routeGroup || !scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
scene.remove(routeGroup);
|
||||
routeGroup.traverse((child) => {
|
||||
const mesh = child as Mesh;
|
||||
mesh.geometry?.dispose?.();
|
||||
const material = mesh.material as Material | undefined;
|
||||
material?.dispose?.();
|
||||
});
|
||||
routeGroup = null;
|
||||
routeBounds = null;
|
||||
}
|
||||
|
||||
/** 创建四轮小车模型(车身 + 4 个车轮 + 顶部雷达 + 地面光圈) */
|
||||
function createRobotMarker() {
|
||||
if (!three || !scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = new three.Group();
|
||||
const bodyLength = 0.62 * MODEL_SCALE;
|
||||
const bodyWidth = 0.42 * MODEL_SCALE;
|
||||
const bodyHeight = 0.18 * MODEL_SCALE;
|
||||
const wheelRadius = 0.1 * MODEL_SCALE;
|
||||
const wheelWidth = 0.06 * MODEL_SCALE;
|
||||
|
||||
const chassisGeometry = new three.BoxGeometry(bodyLength, bodyWidth, bodyHeight);
|
||||
const chassisMaterial = new three.MeshLambertMaterial({ color: 0xffb26e });
|
||||
const chassis = new three.Mesh(chassisGeometry, chassisMaterial);
|
||||
chassis.position.z = wheelRadius + bodyHeight / 2;
|
||||
group.add(chassis);
|
||||
|
||||
// 顶部设备舱:小幅收窄并前移,直观区分车头
|
||||
const cabinGeometry = new three.BoxGeometry(bodyLength * 0.42, bodyWidth * 0.72, bodyHeight * 0.75);
|
||||
const cabinMaterial = new three.MeshLambertMaterial({ color: 0x2f5d7c });
|
||||
const cabin = new three.Mesh(cabinGeometry, cabinMaterial);
|
||||
cabin.position.set(bodyLength * 0.16, 0, wheelRadius + bodyHeight * 1.35);
|
||||
group.add(cabin);
|
||||
|
||||
// 顶部雷达:圆柱轴向默认沿 Y,绕 X 轴转 90° 后立起来
|
||||
const radarGeometry = new three.CylinderGeometry(0.09 * MODEL_SCALE, 0.09 * MODEL_SCALE, 0.08 * MODEL_SCALE, 20);
|
||||
const radarMaterial = new three.MeshLambertMaterial({ color: 0x66d7ff });
|
||||
const radar = new three.Mesh(radarGeometry, radarMaterial);
|
||||
radar.rotation.x = Math.PI / 2;
|
||||
radar.position.set(bodyLength * 0.16, 0, wheelRadius + bodyHeight * 1.35 + bodyHeight * 0.42);
|
||||
group.add(radar);
|
||||
|
||||
// 四个车轮:圆柱轴向默认沿 Y,正好是车轮的滚动轴
|
||||
const wheelGeometry = new three.CylinderGeometry(wheelRadius, wheelRadius, wheelWidth, 18);
|
||||
const wheelMaterial = new three.MeshLambertMaterial({ color: 0x1b2733 });
|
||||
const wheelX = bodyLength * 0.32;
|
||||
const wheelY = bodyWidth / 2 + wheelWidth / 2 - 0.01 * MODEL_SCALE;
|
||||
|
||||
const wheelPositions: Array<[number, number]> = [
|
||||
[wheelX, wheelY],
|
||||
[wheelX, -wheelY],
|
||||
[-wheelX, wheelY],
|
||||
[-wheelX, -wheelY],
|
||||
];
|
||||
|
||||
for (const [x, y] of wheelPositions) {
|
||||
const wheel = new three.Mesh(wheelGeometry, wheelMaterial);
|
||||
wheel.position.set(x, y, wheelRadius);
|
||||
group.add(wheel);
|
||||
}
|
||||
|
||||
// 车头朝向箭头,方便从远处判断朝向
|
||||
const arrow = new three.ArrowHelper(
|
||||
new three.Vector3(1, 0, 0),
|
||||
new three.Vector3(bodyLength * 0.5, 0, wheelRadius + bodyHeight * 0.5),
|
||||
0.85 * MODEL_SCALE,
|
||||
0xffd479,
|
||||
0.22 * MODEL_SCALE,
|
||||
0.14 * MODEL_SCALE,
|
||||
);
|
||||
group.add(arrow);
|
||||
|
||||
// 地面光圈:长廊地图下车体很小,用光圈保证远距离也能定位小车
|
||||
const glowGeometry = new three.CircleGeometry(0.75 * MODEL_SCALE, 32);
|
||||
const glowMaterial = new three.MeshBasicMaterial({
|
||||
color: 0x66d7ff,
|
||||
transparent: true,
|
||||
opacity: 0.22,
|
||||
depthWrite: false,
|
||||
});
|
||||
const glow = new three.Mesh(glowGeometry, glowMaterial);
|
||||
glow.position.z = 0.04;
|
||||
glow.renderOrder = 1;
|
||||
group.add(glow);
|
||||
|
||||
trackResources(
|
||||
[chassisGeometry, cabinGeometry, radarGeometry, wheelGeometry, glowGeometry],
|
||||
[chassisMaterial, cabinMaterial, radarMaterial, wheelMaterial, glowMaterial],
|
||||
);
|
||||
|
||||
robotGroup = group;
|
||||
scene.add(group);
|
||||
updateRobotMarker();
|
||||
}
|
||||
|
||||
function updateRobotMarker() {
|
||||
if (!robotGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!robotPose) {
|
||||
robotGroup.visible = false;
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
robotGroup.visible = true;
|
||||
robotGroup.position.set(robotPose.x, robotPose.y, groundZ);
|
||||
// 模型内部正前方为 +X,随 yaw 绕 Z 轴旋转即得到实际朝向
|
||||
robotGroup.rotation.z = robotPose.yaw;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** 相机取景范围:点云与巡检路径的并集,保证路径一定在视野内 */
|
||||
function resolveFrameBounds() {
|
||||
if (!cloudBounds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!routeBounds) {
|
||||
return cloudBounds;
|
||||
}
|
||||
|
||||
return {
|
||||
min: [
|
||||
Math.min(cloudBounds.min[0], routeBounds.min[0]),
|
||||
Math.min(cloudBounds.min[1], routeBounds.min[1]),
|
||||
Math.min(cloudBounds.min[2], routeBounds.min[2]),
|
||||
] as [number, number, number],
|
||||
max: [
|
||||
Math.max(cloudBounds.max[0], routeBounds.max[0]),
|
||||
Math.max(cloudBounds.max[1], routeBounds.max[1]),
|
||||
Math.max(cloudBounds.max[2], routeBounds.max[2]),
|
||||
] as [number, number, number],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认视角:巡检场景是单向长廊,沿走廊方向侧视最贴合宽扁的地图面板。
|
||||
* 相机在走廊侧向按视口宽高比算出刚好装下整条走廊的距离,再抬高一个仰角。
|
||||
*/
|
||||
function frameCamera() {
|
||||
if (!three || !camera || !controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = resolveFrameBounds();
|
||||
|
||||
if (!bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { min, max } = bounds;
|
||||
const sizeX = Math.max(max[0] - min[0], 0.5);
|
||||
const sizeY = Math.max(max[1] - min[1], 0.5);
|
||||
const sizeZ = Math.max(max[2] - min[2], 0.5);
|
||||
// 巡检路径优先作为取景中心,没配置路径时回到点云中心
|
||||
const target = routeBounds
|
||||
? new three.Vector3(
|
||||
(routeBounds.min[0] + routeBounds.max[0]) / 2,
|
||||
(routeBounds.min[1] + routeBounds.max[1]) / 2,
|
||||
(routeBounds.min[2] + routeBounds.max[2]) / 2,
|
||||
)
|
||||
: new three.Vector3(
|
||||
(min[0] + max[0]) / 2,
|
||||
(min[1] + max[1]) / 2,
|
||||
(min[2] + max[2]) / 2,
|
||||
);
|
||||
const alongX = sizeX >= sizeY;
|
||||
const length = alongX ? sizeX : sizeY;
|
||||
const aspect = camera.aspect || 16 / 9;
|
||||
const halfVFov = (camera.fov * Math.PI) / 360;
|
||||
const halfHFov = Math.atan(Math.tan(halfVFov) * aspect);
|
||||
const distance = Math.max(
|
||||
(length / 2) / Math.tan(halfHFov),
|
||||
(sizeZ / 2) / Math.tan(halfVFov),
|
||||
Math.min(sizeX, sizeY) * 1.2,
|
||||
3,
|
||||
) * 1.05;
|
||||
const elevation = 0.3;
|
||||
const horizontal = distance * Math.cos(elevation);
|
||||
const vertical = distance * Math.sin(elevation);
|
||||
const offset = alongX
|
||||
? new three.Vector3(-length * 0.06, -horizontal, vertical)
|
||||
: new three.Vector3(-horizontal, -length * 0.06, vertical);
|
||||
|
||||
controls.target.copy(target);
|
||||
camera.position.copy(target).add(offset);
|
||||
camera.near = Math.max(distance / 5000, 0.05);
|
||||
camera.far = distance * 20;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** 惰性初始化 three.js 场景(首次切到三维视图时调用) */
|
||||
async function mount() {
|
||||
const container = containerRef.value;
|
||||
|
||||
if (!container || renderer || mounting) {
|
||||
return;
|
||||
}
|
||||
|
||||
mounting = true;
|
||||
const token = ++mountToken;
|
||||
|
||||
try {
|
||||
const [threeModule, orbitModule] = await Promise.all([
|
||||
import("three"),
|
||||
import("three/examples/jsm/controls/OrbitControls.js"),
|
||||
]);
|
||||
|
||||
// 加载期间视图被销毁或容器已被移除时放弃初始化
|
||||
if (token !== mountToken || !containerRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
three = threeModule;
|
||||
renderer = new threeModule.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
scene = new threeModule.Scene();
|
||||
camera = new threeModule.PerspectiveCamera(55, 1, 0.1, 5000);
|
||||
// Z 轴朝上,直接使用 ROS 坐标系
|
||||
camera.up.set(0, 0, 1);
|
||||
|
||||
controls = new orbitModule.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.addEventListener("change", requestRender);
|
||||
controls.addEventListener("start", () => {
|
||||
cameraTouched = true;
|
||||
});
|
||||
|
||||
scene.add(new threeModule.AmbientLight(0xffffff, 1.6));
|
||||
const light = new threeModule.DirectionalLight(0xffffff, 1.1);
|
||||
light.position.set(1, -1.6, 2.4);
|
||||
scene.add(light);
|
||||
|
||||
gridHelper = new threeModule.GridHelper(60, 60, 0x2f4a63, 0x1d3145);
|
||||
// GridHelper 默认在 XZ 平面,绕 X 轴旋转 90° 后铺在 XY 平面
|
||||
gridHelper.rotation.x = Math.PI / 2;
|
||||
scene.add(gridHelper);
|
||||
|
||||
createRobotMarker();
|
||||
if (routePoints.length) {
|
||||
buildRoute();
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(() => resizeRenderer());
|
||||
resizeObserver.observe(container);
|
||||
resizeRenderer();
|
||||
renderFrame();
|
||||
} catch (mountError) {
|
||||
console.error(mountError);
|
||||
error.value = "三维视图初始化失败,请确认浏览器支持 WebGL";
|
||||
dispose();
|
||||
} finally {
|
||||
mounting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 销毁 three.js 场景与事件监听 */
|
||||
function dispose() {
|
||||
mountToken += 1;
|
||||
setKeyboardNavigation(false);
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
needsRender = false;
|
||||
lastFrameTime = 0;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
controls?.removeEventListener("change", requestRender);
|
||||
controls?.dispose();
|
||||
controls = null;
|
||||
|
||||
clearPointCloud();
|
||||
clearRoute();
|
||||
gridHelper = null;
|
||||
|
||||
ownedGeometries.splice(0).forEach((geometry) => geometry.dispose());
|
||||
ownedMaterials.splice(0).forEach((material) => material.dispose());
|
||||
|
||||
if (robotGroup && scene) {
|
||||
scene.remove(robotGroup);
|
||||
}
|
||||
robotGroup = null;
|
||||
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
scene = null;
|
||||
camera = null;
|
||||
three = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取并渲染 PCD 点云文件。
|
||||
*
|
||||
* @param url 点云文件地址
|
||||
* @param maxPoints 抽稀上限(点数),决定渲染压力
|
||||
*/
|
||||
async function loadPointCloud(url: string, maxPoints = DEFAULT_POINT_CAP) {
|
||||
// 三维视图未初始化时不做任何请求,交由切换到三维视图时再加载
|
||||
if (!three || !renderer || !scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
clearPointCloud();
|
||||
clearRoute();
|
||||
cloudBounds = null;
|
||||
groundZ = 0;
|
||||
loaded.value = false;
|
||||
loadedUrl = "";
|
||||
loadedCap = 0;
|
||||
rawCache = null;
|
||||
rawCacheUrl = "";
|
||||
info.value = "";
|
||||
// 未配置点云由页面提示,不额外报错
|
||||
error.value = "";
|
||||
updateRobotMarker();
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// 同一文件且档位未变才跳过,换档位需要重新解析
|
||||
if (loadedUrl === url && pointCloud && loadedCap === maxPoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
let buffer: ArrayBuffer;
|
||||
|
||||
if (rawCacheUrl === url && rawCache) {
|
||||
buffer = rawCache;
|
||||
} else {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("点云文件访问失败");
|
||||
}
|
||||
|
||||
buffer = await response.arrayBuffer();
|
||||
// 缓存原始文件,换档位重新解析时不必再下载一次
|
||||
rawCache = buffer.byteLength <= RAW_CACHE_LIMIT ? buffer : null;
|
||||
rawCacheUrl = url;
|
||||
}
|
||||
|
||||
const parsed = parsePcdPointCloud(buffer, { maxPoints });
|
||||
const threeModule = three;
|
||||
|
||||
if (!threeModule || !renderer || !scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPointCloud();
|
||||
|
||||
// 先算地面平面:地面带内的点改由平面表达,下面构建点云时直接剔除,避免重复渲染
|
||||
const surface = buildGroundSurface(parsed);
|
||||
const groundBand = surface ? surface.band : 0;
|
||||
const height = Math.max(parsed.bounds.max[2] - parsed.groundZ, 0.001);
|
||||
const positions = new Float32Array(parsed.pointCount * 3);
|
||||
const colors = new Float32Array(parsed.pointCount * 3);
|
||||
const color = new threeModule.Color();
|
||||
let keptCount = 0;
|
||||
|
||||
for (let index = 0; index < parsed.pointCount; index += 1) {
|
||||
const x = parsed.positions[index * 3];
|
||||
const y = parsed.positions[index * 3 + 1];
|
||||
const z = parsed.positions[index * 3 + 2];
|
||||
|
||||
if (surface && Math.abs(z - parsed.groundZ) <= groundBand) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ratio = (z - parsed.groundZ) / height;
|
||||
resolveHeightColor(threeModule, ratio, color);
|
||||
positions[keptCount * 3] = x;
|
||||
positions[keptCount * 3 + 1] = y;
|
||||
positions[keptCount * 3 + 2] = z;
|
||||
colors[keptCount * 3] = color.r;
|
||||
colors[keptCount * 3 + 1] = color.g;
|
||||
colors[keptCount * 3 + 2] = color.b;
|
||||
keptCount += 1;
|
||||
}
|
||||
|
||||
const geometry = new threeModule.BufferGeometry();
|
||||
geometry.setAttribute(
|
||||
"position",
|
||||
new threeModule.BufferAttribute(positions.subarray(0, keptCount * 3), 3),
|
||||
);
|
||||
geometry.setAttribute(
|
||||
"color",
|
||||
new threeModule.BufferAttribute(colors.subarray(0, keptCount * 3), 3),
|
||||
);
|
||||
|
||||
const extent = Math.max(
|
||||
parsed.bounds.max[0] - parsed.bounds.min[0],
|
||||
parsed.bounds.max[1] - parsed.bounds.min[1],
|
||||
height,
|
||||
);
|
||||
// 方块点:不挂点精灵贴图,按默认方点渲染
|
||||
const material = new threeModule.PointsMaterial({
|
||||
size: Math.min(Math.max(extent / POINT_SIZE_DIVISOR, 0.02), 0.18),
|
||||
sizeAttenuation: true,
|
||||
vertexColors: true,
|
||||
});
|
||||
|
||||
pointCloud = new threeModule.Points(geometry, material);
|
||||
scene.add(pointCloud);
|
||||
|
||||
// 地面单独铺一块连续平面,对应的地面点已在上面剔除
|
||||
if (surface) {
|
||||
floorMesh = buildFloorMesh(threeModule, surface);
|
||||
scene.add(floorMesh);
|
||||
}
|
||||
|
||||
cloudBounds = parsed.bounds;
|
||||
groundZ = parsed.groundZ;
|
||||
loadedUrl = url;
|
||||
loadedCap = maxPoints;
|
||||
loaded.value = true;
|
||||
const keptText = `${keptCount.toLocaleString("en-US")} 点 + 地面平面`;
|
||||
|
||||
info.value = parsed.pointCount === parsed.rawPointCount
|
||||
? keptText
|
||||
: `${keptText}(原始 ${parsed.rawPointCount.toLocaleString("en-US")} 点,已抽稀)`;
|
||||
|
||||
// 参考网格铺到地面平面之下,只在地面平面之外的区域可见,避免与平面打架
|
||||
if (gridHelper) {
|
||||
gridHelper.position.z = groundZ - 0.08;
|
||||
const gridScale = Math.max(extent / 60, 1) * 1.2;
|
||||
gridHelper.scale.set(gridScale, gridScale, 1);
|
||||
}
|
||||
|
||||
// 键盘漫游速度随场景尺度自适应,长廊下车速更快
|
||||
keyboardSpeed = Math.min(Math.max(extent / 12, 1.5), 15);
|
||||
|
||||
updateRobotMarker();
|
||||
buildRoute();
|
||||
|
||||
if (!cameraTouched) {
|
||||
frameCamera();
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.error(loadError);
|
||||
loaded.value = false;
|
||||
loadedUrl = "";
|
||||
error.value = loadError instanceof Error ? loadError.message : "点云加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 在地面绘制巡检路径(只画路线本身,不标注途经点/业务点) */
|
||||
function buildRoute() {
|
||||
if (!three || !scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRoute();
|
||||
|
||||
if (routePoints.length < 2) {
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const group = new three.Group();
|
||||
const z = groundZ + 0.06;
|
||||
const extent = cloudBounds
|
||||
? Math.max(cloudBounds.max[0] - cloudBounds.min[0], cloudBounds.max[1] - cloudBounds.min[1])
|
||||
: 20;
|
||||
const tubeRadius = Math.min(Math.max(extent / 400, 0.06), 0.3);
|
||||
|
||||
const curvePoints: Vector3[] = [];
|
||||
for (const point of routePoints) {
|
||||
curvePoints.push(new three.Vector3(point.x, point.y, z));
|
||||
}
|
||||
|
||||
// 只绘制路径本身,不画途经点/业务点
|
||||
const curve = new three.CatmullRomCurve3(curvePoints, false, "catmullrom", 0.02);
|
||||
const tubeGeometry = new three.TubeGeometry(curve, Math.max(routePoints.length * 12, 64), tubeRadius, 10, false);
|
||||
const tubeMaterial = new three.MeshBasicMaterial({ color: 0x66d7ff });
|
||||
group.add(new three.Mesh(tubeGeometry, tubeMaterial));
|
||||
|
||||
routeGroup = group;
|
||||
routeBounds = {
|
||||
min: [
|
||||
Math.min(...routePoints.map((point) => point.x)),
|
||||
Math.min(...routePoints.map((point) => point.y)),
|
||||
z,
|
||||
],
|
||||
max: [
|
||||
Math.max(...routePoints.map((point) => point.x)),
|
||||
Math.max(...routePoints.map((point) => point.y)),
|
||||
z,
|
||||
],
|
||||
};
|
||||
|
||||
scene.add(group);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** 更新巡检路径数据(来自后端巡检任务节点),路径到达后如未手动操作过相机则重新取景 */
|
||||
function setRoute(points: RoutePoint3D[]) {
|
||||
routePoints = points;
|
||||
buildRoute();
|
||||
|
||||
if (!cameraTouched && cloudBounds) {
|
||||
frameCamera();
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新机器人实时位姿标记 */
|
||||
function setRobotPose(pose: RobotPose3D | null) {
|
||||
robotPose = pose;
|
||||
updateRobotMarker();
|
||||
}
|
||||
|
||||
/** 重置相机到默认(沿长廊侧视)视角 */
|
||||
function resetView() {
|
||||
cameraTouched = false;
|
||||
frameCamera();
|
||||
}
|
||||
|
||||
onBeforeUnmount(dispose);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
info,
|
||||
loaded,
|
||||
mount,
|
||||
dispose,
|
||||
loadPointCloud,
|
||||
setRoute,
|
||||
setRobotPose,
|
||||
setKeyboardNavigation,
|
||||
resetView,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { onBeforeUnmount, ref } from "vue";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { generateNumbers } from "@/utils";
|
||||
|
||||
export default function () {
|
||||
const option = ref<EChartsOption>({});
|
||||
|
||||
const option = ref({});
|
||||
function refresh() {
|
||||
option.value = {
|
||||
radar: {
|
||||
@@ -14,9 +11,9 @@ export default function () {
|
||||
{ name: "品质", max: 200 },
|
||||
{ name: "客户支持", max: 200 },
|
||||
{ name: "可持续性", max: 200 },
|
||||
{ name: "效果", max: 200 },
|
||||
{ name: "效果", max: 200 }
|
||||
],
|
||||
radius: 100,
|
||||
radius: 100
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -24,26 +21,22 @@ export default function () {
|
||||
data: [
|
||||
{
|
||||
value: generateNumbers(1, 200, 6),
|
||||
areaStyle: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
areaStyle: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, 3000);
|
||||
|
||||
}, 3e3);
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
option,
|
||||
refresh,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
@@ -1,43 +1,40 @@
|
||||
import { onBeforeUnmount, ref } from "vue";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { generateNumbers } from "@/utils";
|
||||
|
||||
export default function () {
|
||||
const option = ref<EChartsOption>({});
|
||||
|
||||
const option = ref({});
|
||||
function refresh() {
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: {
|
||||
type: "shadow",
|
||||
},
|
||||
type: "shadow"
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
top: "5%",
|
||||
itemStyle: {
|
||||
borderColor: "#fff",
|
||||
borderWidth: 2,
|
||||
},
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: "10%",
|
||||
bottom: "8%",
|
||||
containLabel: true,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: "value",
|
||||
},
|
||||
type: "value"
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: "category",
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
data: ["一月", "二月", "三月", "四月", "五月", "六月", "七月"],
|
||||
show: false
|
||||
},
|
||||
data: ["一月", "二月", "三月", "四月", "五月", "六月", "七月"]
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
@@ -45,53 +42,49 @@ export default function () {
|
||||
type: "bar",
|
||||
label: {
|
||||
show: true,
|
||||
position: "inside",
|
||||
position: "inside"
|
||||
},
|
||||
emphasis: {
|
||||
focus: "series",
|
||||
focus: "series"
|
||||
},
|
||||
data: generateNumbers(1, 200, 7),
|
||||
data: generateNumbers(1, 200, 7)
|
||||
},
|
||||
{
|
||||
name: "购买",
|
||||
type: "bar",
|
||||
stack: "Total",
|
||||
label: {
|
||||
show: true,
|
||||
show: true
|
||||
},
|
||||
emphasis: {
|
||||
focus: "series",
|
||||
focus: "series"
|
||||
},
|
||||
data: generateNumbers(1, 300, 7),
|
||||
data: generateNumbers(1, 300, 7)
|
||||
},
|
||||
{
|
||||
name: "退货",
|
||||
type: "bar",
|
||||
stack: "Total",
|
||||
label: {
|
||||
show: true,
|
||||
show: true
|
||||
},
|
||||
emphasis: {
|
||||
focus: "series",
|
||||
focus: "series"
|
||||
},
|
||||
data: generateNumbers(-50, -10, 7),
|
||||
},
|
||||
],
|
||||
data: generateNumbers(-50, -10, 7)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, 3000);
|
||||
|
||||
}, 3e3);
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
option,
|
||||
refresh,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { onBeforeUnmount, ref } from "vue";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { generateNumbers } from "@/utils";
|
||||
|
||||
export default function () {
|
||||
const option = ref<EChartsOption>({});
|
||||
|
||||
const option = ref({});
|
||||
function refresh() {
|
||||
option.value = {
|
||||
xAxis: {},
|
||||
@@ -19,8 +16,8 @@ export default function () {
|
||||
generateNumbers(1, 10, 2),
|
||||
generateNumbers(1, 10, 2),
|
||||
[8.07, 6.95],
|
||||
[13.0, 7.58],
|
||||
[14.0, 7.66],
|
||||
[13, 7.58],
|
||||
[14, 7.66],
|
||||
[13.4, 6.81],
|
||||
generateNumbers(1, 10, 2),
|
||||
generateNumbers(1, 10, 2),
|
||||
@@ -34,26 +31,22 @@ export default function () {
|
||||
generateNumbers(1, 10, 2),
|
||||
generateNumbers(1, 10, 2),
|
||||
generateNumbers(1, 10, 2),
|
||||
[5.02, 5.68],
|
||||
],
|
||||
type: "scatter",
|
||||
},
|
||||
[5.02, 5.68]
|
||||
],
|
||||
type: "scatter"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, 3000);
|
||||
|
||||
}, 3e3);
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
option,
|
||||
refresh,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
@@ -1,54 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import backgroundImage from "./assets/循环背景.png";
|
||||
import bottomNavCenter from "@/assets/底部.png";
|
||||
import bottomNavEquipment from "@/assets/底部-1.png";
|
||||
import bottomNavVideo from "@/assets/底部-2.png";
|
||||
import bottomNavAlarm from "@/assets/底部-3.png";
|
||||
import bottomNavPatrol from "@/assets/底部-4.png";
|
||||
import bottomNavReport from "@/assets/底部-5.png";
|
||||
|
||||
interface DashboardTab {
|
||||
id: number;
|
||||
text: string;
|
||||
path: string;
|
||||
// 热区在底部导航图上的定位(百分比,对应 底部.png 内菜单项位置)
|
||||
left: string;
|
||||
width: string;
|
||||
}
|
||||
|
||||
import SwitchPageBtn from "./components/btn/index.jsx";
|
||||
import backgroundVideo from "./assets/循环背景动画.mp4";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 底部导航项:left/width 按底部导航图(2715x140)中各菜单的实际像素区间实测换算
|
||||
// 对应像素:监管中心 327-717、设备管理 717-1037、视频中心 1041-1337、
|
||||
// 告警管理 1344-1669、巡检记录 1676-1989、统计分析 1998-2407
|
||||
const tabs: DashboardTab[] = [
|
||||
{ id: 1, text: "监管中心", path: "/dashboard/integrated-center", left: "12.0%", width: "14.4%" },
|
||||
{ id: 2, text: "设备管理", path: "/dashboard/equipment-management", left: "26.4%", width: "11.8%" },
|
||||
{ id: 3, text: "视频中心", path: "/dashboard/video-center", left: "38.3%", width: "10.9%" },
|
||||
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management", left: "49.5%", width: "12.0%" },
|
||||
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan", left: "61.7%", width: "11.6%" },
|
||||
{ id: 6, text: "统计分析", path: "/dashboard/data-report", left: "73.6%", width: "15.0%" },
|
||||
];
|
||||
|
||||
const activeTabId = computed(() => Number(route.meta.tabId) || 1);
|
||||
|
||||
// 各菜单对应的底部导航图(图片本身已带当前菜单的高亮效果)
|
||||
const bottomNavImages: Record<number, string> = {
|
||||
1: bottomNavCenter, // 监管中心
|
||||
2: bottomNavEquipment, // 设备管理
|
||||
3: bottomNavVideo, // 视频中心
|
||||
4: bottomNavAlarm, // 告警管理
|
||||
5: bottomNavPatrol, // 巡检记录
|
||||
6: bottomNavReport, // 统计分析
|
||||
const tabs = {
|
||||
left: [
|
||||
{ id: 1, text: "监管中心", path: "/dashboard/integrated-center" },
|
||||
{ id: 2, text: "设备管理", path: "/dashboard/equipment-management" },
|
||||
{ id: 3, text: "视频中心", path: "/dashboard/video-center" }
|
||||
],
|
||||
right: [
|
||||
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management" },
|
||||
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan" },
|
||||
{ id: 6, text: "统计分析", path: "/dashboard/data-report" }
|
||||
]
|
||||
};
|
||||
|
||||
// 当前页面高亮的底部导航图
|
||||
const bottomNavImage = computed(() => bottomNavImages[activeTabId.value] ?? bottomNavCenter);
|
||||
|
||||
function switchPage(tab: DashboardTab) {
|
||||
const activeTabId = computed(() => Number(route.meta.tabId) || 1);
|
||||
function switchPage(tab) {
|
||||
if (route.path !== tab.path) {
|
||||
router.push(tab.path);
|
||||
}
|
||||
@@ -57,29 +27,43 @@ function switchPage(tab: DashboardTab) {
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 背景使用视频首帧截图,避免播放动画 -->
|
||||
<img class="background" :src="backgroundImage" alt="" />
|
||||
<video
|
||||
autoplay
|
||||
class="background"
|
||||
loop
|
||||
muted
|
||||
preload="auto"
|
||||
:src="backgroundVideo"
|
||||
></video>
|
||||
<div class="main">
|
||||
<div class="btn-group">
|
||||
<div class="left">
|
||||
<SwitchPageBtn
|
||||
v-for="tab in tabs.left"
|
||||
:key="tab.id"
|
||||
:active="activeTabId === tab.id"
|
||||
:text="tab.text"
|
||||
direction="left"
|
||||
@click="switchPage(tab)"
|
||||
/>
|
||||
</div>
|
||||
<div class="right">
|
||||
<SwitchPageBtn
|
||||
v-for="tab in tabs.right"
|
||||
:key="tab.id"
|
||||
:active="activeTabId === tab.id"
|
||||
:text="tab.text"
|
||||
direction="right"
|
||||
@click="switchPage(tab)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-stage">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部导航:使用整张导航图,菜单项用透明热区覆盖 -->
|
||||
<nav class="bottom-nav" :style="{ backgroundImage: `url(${bottomNavImage})` }">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['nav-hit', { active: activeTabId === tab.id }]"
|
||||
:style="{ left: tab.left, width: tab.width }"
|
||||
type="button"
|
||||
:aria-label="tab.text"
|
||||
@click="switchPage(tab)"
|
||||
>
|
||||
<span class="nav-hit-label">{{ tab.text }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,105 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { NImage } from "naive-ui";
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import {
|
||||
getAlarmPage,
|
||||
type RosInspectionRecordLineImage,
|
||||
getAlarmPage
|
||||
} from "@/api/alarm";
|
||||
import { BASE_URL } from "@/utils/request";
|
||||
import { getRecentAlertPhoto } from "../shared/alertPhotos";
|
||||
|
||||
interface AlarmRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
robot: string;
|
||||
photo: string;
|
||||
status: RecognitionStatusText;
|
||||
time: string;
|
||||
position: string;
|
||||
mileage: string;
|
||||
result: string;
|
||||
warning: boolean;
|
||||
}
|
||||
|
||||
type RecognitionStatusText = "待执行" | "执行中" | "执行成功" | "执行失败" | "未知状态";
|
||||
type WarningFilterValue = "" | "true" | "false";
|
||||
|
||||
interface AlarmPageResult {
|
||||
total?: number;
|
||||
data?: RosInspectionRecordLineImage[];
|
||||
}
|
||||
|
||||
interface AlarmPageResponse {
|
||||
data?: AlarmPageResult;
|
||||
}
|
||||
|
||||
const recognizeTypeMap: Record<number, string> = {
|
||||
const recognizeTypeMap = {
|
||||
100: "局部高温",
|
||||
1: "电缆表面破损",
|
||||
2: "桥架断裂",
|
||||
3: "隧道积水",
|
||||
4: "墙体渗漏",
|
||||
4: "墙体裂缝"
|
||||
};
|
||||
|
||||
const recognitionStatusMap: Record<number, RecognitionStatusText> = {
|
||||
const recognitionStatusMap = {
|
||||
0: "待执行",
|
||||
1: "执行中",
|
||||
2: "执行成功",
|
||||
3: "执行失败",
|
||||
3: "执行失败"
|
||||
};
|
||||
|
||||
const alarmNo = ref("");
|
||||
const recognizeType = ref("");
|
||||
const warningFilter = ref<WarningFilterValue>("true");
|
||||
const warningFilter = ref("true");
|
||||
const startTime = ref("");
|
||||
const endTime = ref("");
|
||||
const alarms = ref<AlarmRecord[]>([]);
|
||||
const alarms = ref([]);
|
||||
const totalAlarms = ref(0);
|
||||
const listLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const pageSize = 10;
|
||||
const currentPage = ref(1);
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(totalAlarms.value / pageSize)),
|
||||
const totalPages = computed(
|
||||
() => Math.max(1, Math.ceil(totalAlarms.value / pageSize))
|
||||
);
|
||||
|
||||
const pagedAlarms = computed(() => alarms.value);
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = [];
|
||||
const pages = [];
|
||||
const start = Math.max(1, currentPage.value - 2);
|
||||
const end = Math.min(totalPages.value, start + 4);
|
||||
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
pages.push(page);
|
||||
}
|
||||
|
||||
return pages;
|
||||
});
|
||||
|
||||
function getAlarmStatusClass(status: AlarmRecord["status"]) {
|
||||
const classMap: Record<AlarmRecord["status"], string> = {
|
||||
function getAlarmStatusClass(status) {
|
||||
const classMap = {
|
||||
待执行: "alarm-status-pending",
|
||||
执行中: "alarm-status-handling",
|
||||
执行成功: "alarm-status-finished",
|
||||
执行失败: "alarm-status-false",
|
||||
未知状态: "alarm-status-false",
|
||||
未知状态: "alarm-status-false"
|
||||
};
|
||||
|
||||
return classMap[status];
|
||||
}
|
||||
|
||||
function setPage(page: number) {
|
||||
function setPage(page) {
|
||||
currentPage.value = Math.min(Math.max(page, 1), totalPages.value);
|
||||
fetchAlarmList();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1;
|
||||
fetchAlarmList();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
alarmNo.value = "";
|
||||
recognizeType.value = "";
|
||||
@@ -108,28 +68,23 @@ function handleClear() {
|
||||
endTime.value = "";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
// 请求告警分页列表并更新表格数据
|
||||
async function fetchAlarmList() {
|
||||
listLoading.value = true;
|
||||
listError.value = "";
|
||||
|
||||
try {
|
||||
const response = (await getAlarmPage({
|
||||
const response = await getAlarmPage({
|
||||
page: currentPage.value,
|
||||
limit: pageSize,
|
||||
orderList: [{ property: 'createTime', asc: false }],
|
||||
query: {
|
||||
recognizeType: normalizeRecognizeType(recognizeType.value),
|
||||
warning: normalizeWarningFilter(warningFilter.value),
|
||||
startTime: normalizeDateTime(startTime.value),
|
||||
endTime: normalizeDateTime(endTime.value),
|
||||
id: alarmNo.value.trim() || undefined,
|
||||
},
|
||||
})) as AlarmPageResponse;
|
||||
id: alarmNo.value.trim() || void 0
|
||||
}
|
||||
});
|
||||
const result = response.data;
|
||||
const rows = result?.data ?? [];
|
||||
|
||||
alarms.value = rows.map((record, index) => normalizeAlarmRecord(record, index));
|
||||
totalAlarms.value = result?.total ?? rows.length;
|
||||
} catch (error) {
|
||||
@@ -141,11 +96,8 @@ async function fetchAlarmList() {
|
||||
listLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 将后端告警记录转换为页面表格展示结构
|
||||
function normalizeAlarmRecord(record: RosInspectionRecordLineImage, index: number): AlarmRecord {
|
||||
function normalizeAlarmRecord(record, index) {
|
||||
const type = getRecognizeTypeText(record.recognizeType);
|
||||
|
||||
return {
|
||||
id: record.id || record.recordActionId || record.recordId || `AL-${index + 1}`,
|
||||
type,
|
||||
@@ -154,66 +106,46 @@ function normalizeAlarmRecord(record: RosInspectionRecordLineImage, index: numbe
|
||||
status: getRecognitionStatusText(record.recognitionStatus),
|
||||
time: record.captureTime || record.createTime || "--",
|
||||
position: formatPosition(record),
|
||||
mileage: record.mileageLabel || "--",
|
||||
result: record.warningValue || record.recognitionResult || record.failReason || "--",
|
||||
warning: Boolean(record.warning),
|
||||
warning: Boolean(record.warning)
|
||||
};
|
||||
}
|
||||
|
||||
function getCarName(record: RosInspectionRecordLineImage) {
|
||||
function getCarName(record) {
|
||||
return record.car?.name || record.car?.deviceNo || record.car?.deviceKey || record.carId || "未知设备";
|
||||
}
|
||||
|
||||
function getRecognizeTypeText(type?: number) {
|
||||
function getRecognizeTypeText(type) {
|
||||
return type ? recognizeTypeMap[type] ?? "未知告警" : "未知告警";
|
||||
}
|
||||
|
||||
function getRecognitionStatusText(status?: number) {
|
||||
function getRecognitionStatusText(status) {
|
||||
return typeof status === "number" ? recognitionStatusMap[status] ?? "未知状态" : "未知状态";
|
||||
}
|
||||
|
||||
// 相对文件地址通过接口代理访问,避免开发环境直接请求后端静态路径失败
|
||||
function buildFileAccessUrl(fileUrl: string) {
|
||||
if (
|
||||
!fileUrl ||
|
||||
/^(https?:)?\/\//i.test(fileUrl) ||
|
||||
fileUrl.startsWith("blob:") ||
|
||||
fileUrl.startsWith("data:")
|
||||
) {
|
||||
function buildFileAccessUrl(fileUrl) {
|
||||
if (!fileUrl || /^(https?:)?\/\//i.test(fileUrl) || fileUrl.startsWith("blob:") || fileUrl.startsWith("data:")) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
if (fileUrl === BASE_URL || fileUrl.startsWith(`${BASE_URL}/`)) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
return `${BASE_URL}${fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`}`;
|
||||
}
|
||||
|
||||
function normalizeDateTime(value: string) {
|
||||
return value ? value.replace("T", " ") : undefined;
|
||||
function normalizeDateTime(value) {
|
||||
return value ? value.replace("T", " ") : void 0;
|
||||
}
|
||||
|
||||
function normalizeRecognizeType(value: string) {
|
||||
return value ? Number(value) : undefined;
|
||||
function normalizeRecognizeType(value) {
|
||||
return value ? Number(value) : void 0;
|
||||
}
|
||||
|
||||
function normalizeWarningFilter(value: WarningFilterValue) {
|
||||
function normalizeWarningFilter(value) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
return void 0;
|
||||
}
|
||||
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
function formatPosition(record: RosInspectionRecordLineImage) {
|
||||
function formatPosition(record) {
|
||||
if (typeof record.poseX !== "number" || typeof record.poseY !== "number") {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return `X:${record.poseX.toFixed(2)} Y:${record.poseY.toFixed(2)}`;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlarmList();
|
||||
});
|
||||
@@ -231,7 +163,7 @@ onMounted(() => {
|
||||
<option value="1">电缆表面破损</option>
|
||||
<option value="2">桥架断裂</option>
|
||||
<option value="3">隧道积水</option>
|
||||
<option value="4">墙体渗漏</option>
|
||||
<option value="4">墙体裂缝</option>
|
||||
</select>
|
||||
<select v-model="warningFilter" class="toolbar-select">
|
||||
<option value="">全部预警状态</option>
|
||||
@@ -257,7 +189,6 @@ onMounted(() => {
|
||||
/>
|
||||
<button class="page-action-btn primary" type="button" @click="handleSearch">搜索</button>
|
||||
<button class="page-action-btn ghost" type="button" @click="handleClear">清空</button>
|
||||
<button class="page-action-btn ghost" type="button" @click="fetchAlarmList">刷新</button>
|
||||
</div>
|
||||
<div class="toolbar-right">共 {{ totalAlarms }} 条告警记录</div>
|
||||
</div>
|
||||
@@ -273,20 +204,19 @@ onMounted(() => {
|
||||
<th>案件状态</th>
|
||||
<th>告警时间</th>
|
||||
<th>位置</th>
|
||||
<th>里程点</th>
|
||||
<th>识别结果</th>
|
||||
<th>是否预警</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="listLoading">
|
||||
<td class="table-empty" colspan="10">近期告警加载中...</td>
|
||||
<td class="table-empty" colspan="9">近期告警加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="listError">
|
||||
<td class="table-empty" colspan="10">{{ listError }}</td>
|
||||
<td class="table-empty" colspan="9">{{ listError }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="!pagedAlarms.length">
|
||||
<td class="table-empty" colspan="10">暂无告警记录</td>
|
||||
<td class="table-empty" colspan="9">暂无告警记录</td>
|
||||
</tr>
|
||||
<tr v-for="alarm in pagedAlarms" v-else :key="alarm.id">
|
||||
<td>{{ alarm.id }}</td>
|
||||
@@ -308,9 +238,12 @@ onMounted(() => {
|
||||
</td>
|
||||
<td>{{ alarm.time }}</td>
|
||||
<td>{{ alarm.position }}</td>
|
||||
<td>{{ alarm.mileage }}</td>
|
||||
<td>{{ alarm.result }}</td>
|
||||
<td>{{ alarm.warning ? "是" : "否" }}</td>
|
||||
<td>
|
||||
<NTag :type="alarm.warning ? 'error' : 'success'" size="small">
|
||||
{{ alarm.warning ? "是" : "否" }}
|
||||
</NTag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -343,6 +276,19 @@ onMounted(() => {
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<label class="pagination-jumper">
|
||||
<span>跳至</span>
|
||||
<select
|
||||
class="pagination-select"
|
||||
:disabled="listLoading"
|
||||
:value="currentPage"
|
||||
@change="setPage(Number($event.target.value))"
|
||||
>
|
||||
<option v-for="page in totalPages" :key="page" :value="page">
|
||||
第 {{ page }} 页
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import type { EChartsOption } from "echarts";
|
||||
import Chart from "@/components/chart/index.vue";
|
||||
|
||||
// 按需注册 DataReportPage 使用的图表类型和组件
|
||||
import { use } from "echarts/core";
|
||||
import { LineChart, PieChart, RadarChart } from "echarts/charts";
|
||||
import {
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
} from "echarts/components";
|
||||
use([LineChart, PieChart, RadarChart]);
|
||||
use([TooltipComponent, GridComponent, LegendComponent]);
|
||||
|
||||
type AlarmStatus = "待研判" | "待处理" | "处置完成" | "误判";
|
||||
type ProjectName = "西江隧道" | "青云隧道" | "南山隧道";
|
||||
type DistributionRange = "近一周" | "近一个月" | "近三个月";
|
||||
|
||||
interface AlarmReportRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
robot: string;
|
||||
project: ProjectName;
|
||||
status: AlarmStatus;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface TrendRecord {
|
||||
date: string;
|
||||
project: ProjectName;
|
||||
total: number;
|
||||
closed: number;
|
||||
}
|
||||
|
||||
const distributionRange = ref<DistributionRange>("近一周");
|
||||
|
||||
const reportData: AlarmReportRecord[] = [
|
||||
const distributionRange = ref("近一周");
|
||||
const reportData = [
|
||||
{ id: "AL-001", type: "线缆破损", robot: "巡检机器人 R1", project: "西江隧道", status: "待研判", time: "2026-06-23 10:24:18" },
|
||||
{ id: "AL-002", type: "局部高温", robot: "巡检机器人 Q1", project: "青云隧道", status: "待处理", time: "2026-06-23 10:18:42" },
|
||||
{ id: "AL-003", type: "墙体渗漏", robot: "巡检机器人 N3", project: "南山隧道", status: "处置完成", time: "2026-06-23 09:56:11" },
|
||||
{ id: "AL-003", type: "墙体裂缝", robot: "巡检机器人 N3", project: "南山隧道", status: "处置完成", time: "2026-06-23 09:56:11" },
|
||||
{ id: "AL-004", type: "水渍渗漏", robot: "巡检机器人 R2", project: "西江隧道", status: "误判", time: "2026-06-23 09:20:36" },
|
||||
{ id: "AL-005", type: "应急门开启异常", robot: "巡检机器人 N1", project: "南山隧道", status: "待处理", time: "2026-06-23 08:42:57" },
|
||||
{ id: "AL-006", type: "局部高温", robot: "巡检机器人 N3", project: "南山隧道", status: "处置完成", time: "2026-06-22 19:12:06" },
|
||||
{ id: "AL-007", type: "线缆破损", robot: "巡检机器人 R1", project: "西江隧道", status: "处置完成", time: "2026-06-22 17:46:28" },
|
||||
{ id: "AL-008", type: "墙体渗漏", robot: "巡检机器人 Q2", project: "青云隧道", status: "待研判", time: "2026-06-22 16:08:40" },
|
||||
{ id: "AL-008", type: "墙体裂缝", robot: "巡检机器人 Q2", project: "青云隧道", status: "待研判", time: "2026-06-22 16:08:40" },
|
||||
{ id: "AL-009", type: "水渍渗漏", robot: "巡检机器人 R4", project: "西江隧道", status: "待处理", time: "2026-06-22 14:21:19" },
|
||||
{ id: "AL-010", type: "局部高温", robot: "巡检机器人 N1", project: "南山隧道", status: "误判", time: "2026-06-22 11:03:50" },
|
||||
{ id: "AL-011", type: "线缆破损", robot: "巡检机器人 R3", project: "西江隧道", status: "处置完成", time: "2026-06-16 13:14:09" },
|
||||
{ id: "AL-012", type: "局部高温", robot: "巡检机器人 Q1", project: "青云隧道", status: "待处理", time: "2026-06-15 16:42:33" },
|
||||
{ id: "AL-013", type: "墙体渗漏", robot: "巡检机器人 N2", project: "南山隧道", status: "处置完成", time: "2026-06-12 09:25:50" },
|
||||
{ id: "AL-013", type: "墙体裂缝", robot: "巡检机器人 N2", project: "南山隧道", status: "处置完成", time: "2026-06-12 09:25:50" },
|
||||
{ id: "AL-014", type: "水渍渗漏", robot: "巡检机器人 R2", project: "西江隧道", status: "误判", time: "2026-06-08 20:14:27" },
|
||||
{ id: "AL-015", type: "应急门开启异常", robot: "巡检机器人 Q3", project: "青云隧道", status: "待研判", time: "2026-05-28 11:38:44" },
|
||||
{ id: "AL-016", type: "局部高温", robot: "巡检机器人 N4", project: "南山隧道", status: "处置完成", time: "2026-05-19 15:07:18" },
|
||||
{ id: "AL-017", type: "线缆破损", robot: "巡检机器人 R1", project: "西江隧道", status: "待处理", time: "2026-05-06 08:11:52" },
|
||||
{ id: "AL-018", type: "墙体渗漏", robot: "巡检机器人 Q2", project: "青云隧道", status: "误判", time: "2026-04-23 14:54:06" },
|
||||
{ id: "AL-019", type: "水渍渗漏", robot: "巡检机器人 N1", project: "南山隧道", status: "处置完成", time: "2026-04-12 10:03:16" },
|
||||
{ id: "AL-018", type: "墙体裂缝", robot: "巡检机器人 Q2", project: "青云隧道", status: "误判", time: "2026-04-23 14:54:06" },
|
||||
{ id: "AL-019", type: "水渍渗漏", robot: "巡检机器人 N1", project: "南山隧道", status: "处置完成", time: "2026-04-12 10:03:16" }
|
||||
];
|
||||
|
||||
const trendData: TrendRecord[] = [
|
||||
const trendData = [
|
||||
{ date: "06-09", project: "西江隧道", total: 1, closed: 1 },
|
||||
{ date: "06-09", project: "青云隧道", total: 1, closed: 0 },
|
||||
{ date: "06-09", project: "南山隧道", total: 1, closed: 1 },
|
||||
@@ -103,53 +68,48 @@ const trendData: TrendRecord[] = [
|
||||
{ date: "06-22", project: "南山隧道", total: 4, closed: 3 },
|
||||
{ date: "06-23", project: "西江隧道", total: 4, closed: 3 },
|
||||
{ date: "06-23", project: "青云隧道", total: 3, closed: 2 },
|
||||
{ date: "06-23", project: "南山隧道", total: 4, closed: 3 },
|
||||
{ date: "06-23", project: "南山隧道", total: 4, closed: 3 }
|
||||
];
|
||||
|
||||
const trendSeries = computed(() => {
|
||||
const grouped = trendData.reduce<Record<string, { total: number; closed: number }>>(
|
||||
const grouped = trendData.reduce(
|
||||
(accumulator, item) => {
|
||||
if (!accumulator[item.date]) {
|
||||
accumulator[item.date] = { total: 0, closed: 0 };
|
||||
}
|
||||
|
||||
accumulator[item.date].total += item.total;
|
||||
accumulator[item.date].closed += item.closed;
|
||||
return accumulator;
|
||||
},
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
return Object.entries(grouped).map(([date, value]) => ({
|
||||
date,
|
||||
total: value.total,
|
||||
closed: value.closed,
|
||||
closed: value.closed
|
||||
}));
|
||||
});
|
||||
|
||||
const trendOption = computed<EChartsOption>(() => {
|
||||
const trendOption = computed(() => {
|
||||
const dates = trendSeries.value.map((item) => item.date);
|
||||
const totalValues = trendSeries.value.map((item) => item.total);
|
||||
const closedValues = trendSeries.value.map((item) => item.closed);
|
||||
|
||||
return {
|
||||
grid: {
|
||||
left: 24,
|
||||
right: 16,
|
||||
top: 24,
|
||||
bottom: 28,
|
||||
containLabel: true,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "rgba(6, 20, 42, 0.92)",
|
||||
borderColor: "rgba(96, 208, 255, 0.24)",
|
||||
textStyle: {
|
||||
color: "#eefcff",
|
||||
},
|
||||
color: "#eefcff"
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
show: false
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
@@ -157,35 +117,35 @@ const trendOption = computed<EChartsOption>(() => {
|
||||
data: dates,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "rgba(96, 188, 242, 0.2)",
|
||||
},
|
||||
color: "rgba(96, 188, 242, 0.2)"
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: "rgba(181, 230, 249, 0.74)",
|
||||
fontSize: 11,
|
||||
fontSize: 11
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
splitNumber: 5,
|
||||
axisLine: {
|
||||
show: false,
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "rgba(181, 230, 249, 0.62)",
|
||||
fontSize: 11,
|
||||
fontSize: 11
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: "rgba(97, 189, 242, 0.12)",
|
||||
},
|
||||
},
|
||||
color: "rgba(97, 189, 242, 0.12)"
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -197,16 +157,16 @@ const trendOption = computed<EChartsOption>(() => {
|
||||
data: totalValues,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: "#74ecff",
|
||||
color: "#74ecff"
|
||||
},
|
||||
itemStyle: {
|
||||
color: "#74ecff",
|
||||
borderColor: "#081f3e",
|
||||
borderWidth: 2,
|
||||
borderWidth: 2
|
||||
},
|
||||
areaStyle: {
|
||||
color: "rgba(116, 236, 255, 0.12)",
|
||||
},
|
||||
color: "rgba(116, 236, 255, 0.12)"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "闭环告警",
|
||||
@@ -217,48 +177,44 @@ const trendOption = computed<EChartsOption>(() => {
|
||||
data: closedValues,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: "#62e0a8",
|
||||
color: "#62e0a8"
|
||||
},
|
||||
itemStyle: {
|
||||
color: "#62e0a8",
|
||||
borderColor: "#081f3e",
|
||||
borderWidth: 2,
|
||||
borderWidth: 2
|
||||
},
|
||||
areaStyle: {
|
||||
color: "rgba(98, 224, 168, 0.08)",
|
||||
},
|
||||
},
|
||||
],
|
||||
color: "rgba(98, 224, 168, 0.08)"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const typeStats = computed(() => {
|
||||
const statsMap = new Map<string, number>();
|
||||
const statsMap = /* @__PURE__ */ new Map();
|
||||
reportData.forEach((item) => {
|
||||
statsMap.set(item.type, (statsMap.get(item.type) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return Array.from(statsMap.entries()).map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
value
|
||||
}));
|
||||
});
|
||||
|
||||
const typeRadarOption = computed<EChartsOption>(() => {
|
||||
const typeRadarOption = computed(() => {
|
||||
const maxValue = Math.max(...typeStats.value.map((item) => item.value), 1);
|
||||
const indicators = typeStats.value.map((item) => ({
|
||||
name: item.label,
|
||||
max: maxValue,
|
||||
max: maxValue
|
||||
}));
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "rgba(6, 20, 42, 0.92)",
|
||||
borderColor: "rgba(96, 208, 255, 0.24)",
|
||||
textStyle: {
|
||||
color: "#eefcff",
|
||||
},
|
||||
color: "#eefcff"
|
||||
}
|
||||
},
|
||||
radar: {
|
||||
radius: "68%",
|
||||
@@ -267,23 +223,23 @@ const typeRadarOption = computed<EChartsOption>(() => {
|
||||
splitNumber: 4,
|
||||
axisName: {
|
||||
color: "#dff7ff",
|
||||
fontSize: 12,
|
||||
fontSize: 12
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "rgba(97, 189, 242, 0.22)",
|
||||
},
|
||||
color: "rgba(97, 189, 242, 0.22)"
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: "rgba(97, 189, 242, 0.16)",
|
||||
},
|
||||
color: "rgba(97, 189, 242, 0.16)"
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: ["rgba(16, 52, 94, 0.10)", "rgba(16, 52, 94, 0.05)"],
|
||||
},
|
||||
},
|
||||
color: ["rgba(16, 52, 94, 0.10)", "rgba(16, 52, 94, 0.05)"]
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -293,50 +249,47 @@ const typeRadarOption = computed<EChartsOption>(() => {
|
||||
value: typeStats.value.map((item) => item.value),
|
||||
name: "告警类型分布",
|
||||
areaStyle: {
|
||||
color: "rgba(73, 207, 255, 0.20)",
|
||||
color: "rgba(73, 207, 255, 0.20)"
|
||||
},
|
||||
lineStyle: {
|
||||
color: "#61dcff",
|
||||
width: 2.5,
|
||||
width: 2.5
|
||||
},
|
||||
itemStyle: {
|
||||
color: "#8ff3ff",
|
||||
borderColor: "#07203e",
|
||||
borderWidth: 2,
|
||||
borderWidth: 2
|
||||
},
|
||||
symbolSize: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
symbolSize: 8
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const statusStats = computed(() => {
|
||||
const order: AlarmStatus[] = ["待研判", "待处理", "处置完成", "误判"];
|
||||
const order = ["待研判", "待处理", "处置完成", "误判"];
|
||||
const total = reportData.length || 1;
|
||||
|
||||
return order.map((status) => {
|
||||
const value = reportData.filter((item) => item.status === status).length;
|
||||
return {
|
||||
label: status,
|
||||
value,
|
||||
percent: `${Math.round((value / total) * 100)}%`,
|
||||
percent: `${Math.round(value / total * 100)}%`
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const statusPieOption = computed<EChartsOption>(() => ({
|
||||
const statusPieOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "rgba(6, 20, 42, 0.92)",
|
||||
borderColor: "rgba(96, 208, 255, 0.24)",
|
||||
textStyle: {
|
||||
color: "#eefcff",
|
||||
},
|
||||
color: "#eefcff"
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -346,63 +299,50 @@ const statusPieOption = computed<EChartsOption>(() => ({
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderColor: "#081f3e",
|
||||
borderWidth: 3,
|
||||
borderWidth: 3
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
color: "#dff7ff",
|
||||
formatter: "{b}\n{d}%",
|
||||
fontSize: 12,
|
||||
fontSize: 12
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: "rgba(181, 230, 249, 0.52)",
|
||||
},
|
||||
color: "rgba(181, 230, 249, 0.52)"
|
||||
}
|
||||
},
|
||||
data: statusStats.value.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
itemStyle: {
|
||||
color:
|
||||
item.label === "待研判"
|
||||
? "#ffd66e"
|
||||
: item.label === "待处理"
|
||||
? "#ff8b78"
|
||||
: item.label === "处置完成"
|
||||
? "#62e0a8"
|
||||
: "#8ea7ff",
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
color: item.label === "待研判" ? "#ffd66e" : item.label === "待处理" ? "#ff8b78" : item.label === "处置完成" ? "#62e0a8" : "#8ea7ff"
|
||||
}
|
||||
}))
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
const projectDistribution = computed(() => {
|
||||
const now = new Date("2026-06-23T23:59:59");
|
||||
const rangeDaysMap: Record<DistributionRange, number> = {
|
||||
const now = /* @__PURE__ */ new Date("2026-06-23T23:59:59");
|
||||
const rangeDaysMap = {
|
||||
近一周: 7,
|
||||
近一个月: 30,
|
||||
近三个月: 90,
|
||||
近三个月: 90
|
||||
};
|
||||
const threshold = new Date(
|
||||
now.getTime() - rangeDaysMap[distributionRange.value] * 24 * 60 * 60 * 1000,
|
||||
now.getTime() - rangeDaysMap[distributionRange.value] * 24 * 60 * 60 * 1e3
|
||||
);
|
||||
const data = reportData.filter((item) => new Date(item.time.replace(" ", "T")) >= threshold);
|
||||
const statsMap = new Map<string, number>();
|
||||
|
||||
const statsMap = /* @__PURE__ */ new Map();
|
||||
data.forEach((item) => {
|
||||
statsMap.set(item.project, (statsMap.get(item.project) ?? 0) + 1);
|
||||
});
|
||||
|
||||
const maxValue = Math.max(...statsMap.values(), 1);
|
||||
|
||||
return Array.from(statsMap.entries())
|
||||
.map(([label, value]) => ({
|
||||
return Array.from(statsMap.entries()).map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
width: `${(value / maxValue) * 100}%`,
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
width: `${value / maxValue * 100}%`
|
||||
})).sort((a, b) => b.value - a.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -479,7 +419,7 @@ const projectDistribution = computed(() => {
|
||||
:key="range"
|
||||
:class="['report-filter-tab', { active: distributionRange === range }]"
|
||||
type="button"
|
||||
@click="distributionRange = range as DistributionRange"
|
||||
@click="distributionRange = range"
|
||||
>
|
||||
{{ range }}
|
||||
</button>
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
interface PatrolAlarmRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
reportTime: string;
|
||||
}
|
||||
|
||||
interface PatrolRecord {
|
||||
id: string;
|
||||
robot: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
alarms: PatrolAlarmRecord[];
|
||||
}
|
||||
|
||||
const patrolRecords: PatrolRecord[] = [
|
||||
const patrolRecords = [
|
||||
{
|
||||
id: "PR-20260623-001",
|
||||
robot: "巡检机器人 R1",
|
||||
@@ -23,8 +8,8 @@ const patrolRecords: PatrolRecord[] = [
|
||||
endTime: "2026-06-23 09:02:15",
|
||||
alarms: [
|
||||
{ id: "AL-001", type: "线缆破损", reportTime: "2026-06-23 08:36:12" },
|
||||
{ id: "AL-002", type: "局部高温", reportTime: "2026-06-23 08:42:09" },
|
||||
],
|
||||
{ id: "AL-002", type: "局部高温", reportTime: "2026-06-23 08:42:09" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "PR-20260623-002",
|
||||
@@ -32,8 +17,8 @@ const patrolRecords: PatrolRecord[] = [
|
||||
startTime: "2026-06-23 09:20:08",
|
||||
endTime: "2026-06-23 10:01:46",
|
||||
alarms: [
|
||||
{ id: "AL-005", type: "应急门开启异常", reportTime: "2026-06-23 09:48:57" },
|
||||
],
|
||||
{ id: "AL-005", type: "应急门开启异常", reportTime: "2026-06-23 09:48:57" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "PR-20260623-003",
|
||||
@@ -41,28 +26,25 @@ const patrolRecords: PatrolRecord[] = [
|
||||
startTime: "2026-06-23 10:08:34",
|
||||
endTime: "2026-06-23 11:12:25",
|
||||
alarms: [
|
||||
{ id: "AL-003", type: "墙体渗漏", reportTime: "2026-06-23 10:36:41" },
|
||||
{ id: "AL-003", type: "墙体裂缝", reportTime: "2026-06-23 10:36:41" },
|
||||
{ id: "AL-006", type: "水渍渗漏", reportTime: "2026-06-23 10:54:18" },
|
||||
{ id: "AL-007", type: "局部高温", reportTime: "2026-06-23 11:02:33" },
|
||||
],
|
||||
{ id: "AL-007", type: "局部高温", reportTime: "2026-06-23 11:02:33" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "PR-20260623-004",
|
||||
robot: "巡检机器人 R2",
|
||||
startTime: "2026-06-23 13:15:17",
|
||||
endTime: "2026-06-23 14:05:52",
|
||||
alarms: [],
|
||||
},
|
||||
alarms: []
|
||||
}
|
||||
];
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const activePatrolRecord = ref<PatrolRecord | null>(null);
|
||||
|
||||
function openAlarmDialog(record: PatrolRecord) {
|
||||
const activePatrolRecord = ref(null);
|
||||
function openAlarmDialog(record) {
|
||||
activePatrolRecord.value = record;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
activePatrolRecord.value = null;
|
||||
|
||||
@@ -1,75 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { getRosCarList } from "@/api/rosCar";
|
||||
|
||||
type GridMode = 1 | 4 | 9;
|
||||
type CameraType = "可见光" | "红外";
|
||||
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
|
||||
|
||||
interface RosCarRecord {
|
||||
id?: string;
|
||||
deviceNo?: string;
|
||||
name?: string;
|
||||
batteryLevel?: number;
|
||||
status?: number;
|
||||
projectName?: string;
|
||||
cameraWebrtcUrl?: string;
|
||||
cameraNo?: string;
|
||||
cameraWebrtcUrl2?: string;
|
||||
cameraNo2?: string;
|
||||
}
|
||||
|
||||
interface RosCarListResult {
|
||||
total?: number;
|
||||
data?: RosCarRecord[];
|
||||
}
|
||||
|
||||
interface RosCarListResponse {
|
||||
data?: RosCarListResult;
|
||||
}
|
||||
|
||||
interface CameraItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CameraType;
|
||||
status: DeviceStatus;
|
||||
url: string;
|
||||
cameraNo?: string;
|
||||
}
|
||||
|
||||
interface DeviceNode {
|
||||
id: string;
|
||||
deviceNo?: string;
|
||||
name: string;
|
||||
project: string;
|
||||
status: DeviceStatus;
|
||||
battery: string;
|
||||
cameras: CameraItem[];
|
||||
}
|
||||
|
||||
interface ProjectCameraItem extends CameraItem {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
project: string;
|
||||
}
|
||||
|
||||
interface SlotStreamState {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const defaultProject = "视频中心";
|
||||
const gridMode = ref<GridMode>(4);
|
||||
const gridMode = ref(4);
|
||||
const activeSlotIndex = ref(0);
|
||||
const videoSlots = ref<Array<ProjectCameraItem | null>>([]);
|
||||
const slotVideoRefs = ref<Array<HTMLVideoElement | null>>([]);
|
||||
const slotStreamStates = ref<SlotStreamState[]>([]);
|
||||
const videoGridBoardRef = ref<HTMLElement | null>(null);
|
||||
const videoGridBoardStyle = ref<Record<string, string>>({});
|
||||
const slotPeerConnections = new Map<number, RTCPeerConnection>();
|
||||
const slotRequestIds = new Map<number, number>();
|
||||
let videoGridResizeObserver: ResizeObserver | null = null;
|
||||
const devices = ref<DeviceNode[]>([]);
|
||||
const videoSlots = ref([]);
|
||||
const slotVideoRefs = ref([]);
|
||||
const slotStreamStates = ref([]);
|
||||
const videoGridBoardRef = ref(null);
|
||||
const videoGridBoardStyle = ref({});
|
||||
const slotPeerConnections = /* @__PURE__ */ new Map();
|
||||
const slotRequestIds = /* @__PURE__ */ new Map();
|
||||
let videoGridResizeObserver = null;
|
||||
const devices = ref([]);
|
||||
const listLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const playbackDialogVisible = ref(false);
|
||||
@@ -77,141 +20,107 @@ const playbackStartAt = ref("2026-06-23T14:20");
|
||||
const playbackEndAt = ref("2026-06-23T14:50");
|
||||
const playbackProgress = ref(0);
|
||||
const playbackPaused = ref(true);
|
||||
const playbackPlayerRef = ref<HTMLVideoElement | null>(null);
|
||||
|
||||
const slotCountMap: Record<GridMode, number> = {
|
||||
const playbackPlayerRef = ref(null);
|
||||
const slotCountMap = {
|
||||
1: 1,
|
||||
4: 4,
|
||||
9: 9,
|
||||
9: 9
|
||||
};
|
||||
|
||||
const currentProjectName = computed(() => devices.value[0]?.project || defaultProject);
|
||||
|
||||
const playableCameras = computed<ProjectCameraItem[]>(() =>
|
||||
devices.value.flatMap((device) =>
|
||||
device.cameras.map((camera) => ({
|
||||
const playableCameras = computed(
|
||||
() => devices.value.flatMap(
|
||||
(device) => device.cameras.map((camera) => ({
|
||||
...camera,
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
project: device.project,
|
||||
})),
|
||||
),
|
||||
project: device.project
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const cameraTotal = computed(() => playableCameras.value.length);
|
||||
|
||||
const visibleSlots = computed(() => {
|
||||
const targetCount = slotCountMap[gridMode.value];
|
||||
|
||||
return Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => videoSlots.value[index] ?? null,
|
||||
(_, index) => videoSlots.value[index] ?? null
|
||||
);
|
||||
});
|
||||
|
||||
const activeCamera = computed(() => visibleSlots.value[activeSlotIndex.value] ?? null);
|
||||
|
||||
const activeSlotLabel = computed(() => `画面 ${activeSlotIndex.value + 1}`);
|
||||
|
||||
const playbackRangeLabel = computed(() => {
|
||||
const startLabel = playbackStartAt.value.replace("T", " ");
|
||||
const endLabel = playbackEndAt.value.replace("T", " ");
|
||||
return `${startLabel} - ${endLabel}`;
|
||||
});
|
||||
|
||||
// 根据视频墙实际可用宽高计算宫格尺寸,保证每个画面都是 16:9 且不会溢出卡片
|
||||
function updateVideoGridSize() {
|
||||
const board = videoGridBoardRef.value;
|
||||
|
||||
if (!board) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = Math.sqrt(gridMode.value);
|
||||
const rows = columns;
|
||||
const gap = 8;
|
||||
const availableWidth = board.clientWidth - gap * (columns - 1);
|
||||
const availableHeight = board.clientHeight - gap * (rows - 1);
|
||||
const cellWidthByContainerWidth = availableWidth / columns;
|
||||
const cellWidthByContainerHeight = (availableHeight / rows) * (16 / 9);
|
||||
const cellWidthByContainerHeight = availableHeight / rows * (16 / 9);
|
||||
const cellWidth = Math.max(0, Math.min(cellWidthByContainerWidth, cellWidthByContainerHeight));
|
||||
const cellHeight = cellWidth * (9 / 16);
|
||||
|
||||
videoGridBoardStyle.value = {
|
||||
gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`,
|
||||
gridAutoRows: `${cellHeight}px`,
|
||||
gridAutoRows: `${cellHeight}px`
|
||||
};
|
||||
}
|
||||
|
||||
// 根据当前宫格数量补齐画面槽位,切换布局时保留已播放的视频
|
||||
function ensureVideoSlotCount() {
|
||||
const targetCount = slotCountMap[gridMode.value];
|
||||
const previousCount = videoSlots.value.length;
|
||||
|
||||
for (let index = targetCount; index < previousCount; index += 1) {
|
||||
stopSlotStream(index);
|
||||
}
|
||||
|
||||
videoSlots.value = Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => videoSlots.value[index] ?? null,
|
||||
(_, index) => videoSlots.value[index] ?? null
|
||||
);
|
||||
slotVideoRefs.value = Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => slotVideoRefs.value[index] ?? null,
|
||||
(_, index) => slotVideoRefs.value[index] ?? null
|
||||
);
|
||||
slotStreamStates.value = Array.from(
|
||||
{ length: targetCount },
|
||||
(_, index) => slotStreamStates.value[index] ?? { loading: false, error: "" },
|
||||
(_, index) => slotStreamStates.value[index] ?? { loading: false, error: "" }
|
||||
);
|
||||
|
||||
if (activeSlotIndex.value >= targetCount) {
|
||||
activeSlotIndex.value = targetCount - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 将接口的小车状态码转换为页面展示状态
|
||||
function normalizeDeviceStatus(status?: number): DeviceStatus {
|
||||
const statusMap: Record<number, DeviceStatus> = {
|
||||
function normalizeDeviceStatus(status) {
|
||||
const statusMap = {
|
||||
0: "空闲",
|
||||
1: "巡检",
|
||||
2: "离线",
|
||||
3: "充电中",
|
||||
3: "充电中"
|
||||
};
|
||||
|
||||
return statusMap[status ?? 2] ?? "离线";
|
||||
}
|
||||
|
||||
// 根据小车视频地址生成可播放的相机节点
|
||||
function buildCameraItem(
|
||||
car: RosCarRecord,
|
||||
deviceId: string,
|
||||
type: CameraType,
|
||||
url?: string,
|
||||
cameraNo?: string,
|
||||
): CameraItem | null {
|
||||
function buildCameraItem(car, deviceId, type, url, cameraNo) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${deviceId}-${type}`,
|
||||
name: `${type}相机`,
|
||||
type,
|
||||
status: normalizeDeviceStatus(car.status),
|
||||
url,
|
||||
cameraNo,
|
||||
cameraNo
|
||||
};
|
||||
}
|
||||
|
||||
// 将设备列表接口的小车记录转换为左侧机器人树节点
|
||||
function normalizeDeviceNode(car: RosCarRecord, index: number): DeviceNode {
|
||||
function normalizeDeviceNode(car, index) {
|
||||
const deviceId = car.id ?? `ROBOT-${index + 1}`;
|
||||
const cameras = [
|
||||
buildCameraItem(car, deviceId, "可见光", car.cameraWebrtcUrl, car.cameraNo),
|
||||
buildCameraItem(car, deviceId, "红外", car.cameraWebrtcUrl2, car.cameraNo2),
|
||||
].filter((camera): camera is CameraItem => Boolean(camera));
|
||||
|
||||
buildCameraItem(car, deviceId, "红外", car.cameraWebrtcUrl2, car.cameraNo2)
|
||||
].filter((camera) => Boolean(camera));
|
||||
return {
|
||||
id: deviceId,
|
||||
deviceNo: car.deviceNo,
|
||||
@@ -219,23 +128,19 @@ function normalizeDeviceNode(car: RosCarRecord, index: number): DeviceNode {
|
||||
project: car.projectName || defaultProject,
|
||||
status: normalizeDeviceStatus(car.status),
|
||||
battery: typeof car.batteryLevel === "number" ? `${car.batteryLevel}%` : "--",
|
||||
cameras,
|
||||
cameras
|
||||
};
|
||||
}
|
||||
|
||||
// 请求设备列表接口,加载左侧机器人及其可见光/红外相机
|
||||
async function fetchDeviceList() {
|
||||
listLoading.value = true;
|
||||
listError.value = "";
|
||||
|
||||
try {
|
||||
const response = (await getRosCarList({
|
||||
const response = await getRosCarList({
|
||||
page: 1,
|
||||
limit: 999,
|
||||
query: {},
|
||||
})) as RosCarListResponse;
|
||||
query: {}
|
||||
});
|
||||
const rows = response.data?.data ?? [];
|
||||
|
||||
devices.value = rows.map((car, index) => normalizeDeviceNode(car, index));
|
||||
} catch (error) {
|
||||
listError.value = "设备列表加载失败";
|
||||
@@ -244,146 +149,112 @@ async function fetchDeviceList() {
|
||||
listLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选中右侧视频墙中的目标画面
|
||||
function selectVideoSlot(index: number) {
|
||||
function selectVideoSlot(index) {
|
||||
activeSlotIndex.value = index;
|
||||
}
|
||||
|
||||
// 停止全部画面的 WebRTC 拉流,用于页面卸载时释放资源
|
||||
function stopAllSlotStreams() {
|
||||
videoSlots.value.forEach((_, index) => stopSlotStream(index));
|
||||
}
|
||||
|
||||
// 停止指定画面的 WebRTC 拉流并释放播放器资源
|
||||
function stopSlotStream(index: number, clearState = true) {
|
||||
function stopSlotStream(index, clearState = true) {
|
||||
slotRequestIds.set(index, (slotRequestIds.get(index) ?? 0) + 1);
|
||||
|
||||
if (clearState) {
|
||||
slotStreamStates.value[index] = { loading: false, error: "" };
|
||||
}
|
||||
|
||||
const peerConnection = slotPeerConnections.get(index);
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
slotPeerConnections.delete(index);
|
||||
}
|
||||
|
||||
const video = slotVideoRefs.value[index];
|
||||
if (video) {
|
||||
video.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
|
||||
function buildWebrtcApiUrl(streamUrl: string) {
|
||||
function buildWebrtcApiUrl(streamUrl) {
|
||||
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
|
||||
const url = new URL(normalizedUrl);
|
||||
|
||||
return `${url.origin}${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
// 等待本地 ICE 候选收集完成,确保发给流媒体服务的 offer 信息完整
|
||||
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) {
|
||||
function waitForIceGatheringComplete(peerConnection) {
|
||||
if (peerConnection.iceGatheringState === "complete") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
return new Promise((resolve) => {
|
||||
const handleIceGatheringStateChange = () => {
|
||||
if (peerConnection.iceGatheringState === "complete") {
|
||||
peerConnection.removeEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.addEventListener("icegatheringstatechange", handleIceGatheringStateChange);
|
||||
});
|
||||
}
|
||||
|
||||
// 收集视频墙每个画面对应的 video 元素,供 WebRTC ontrack 写入媒体流
|
||||
function setSlotVideoRef(element: Element | { $el?: Element } | null, index: number) {
|
||||
function setSlotVideoRef(element, index) {
|
||||
const target = element instanceof Element ? element : element?.$el;
|
||||
slotVideoRefs.value[index] = target instanceof HTMLVideoElement ? target : null;
|
||||
}
|
||||
|
||||
// 通过 WebRTC offer/answer 将指定相机拉流到选中的画面槽位
|
||||
async function playSlotStream(index: number, camera: ProjectCameraItem) {
|
||||
async function playSlotStream(index, camera) {
|
||||
stopSlotStream(index);
|
||||
|
||||
if (!window.RTCPeerConnection) {
|
||||
slotStreamStates.value[index] = { loading: false, error: "当前浏览器不支持 WebRTC 播放" };
|
||||
return;
|
||||
}
|
||||
|
||||
const video = slotVideoRefs.value[index];
|
||||
if (!video) {
|
||||
slotStreamStates.value[index] = { loading: false, error: "播放器未准备好" };
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = slotRequestIds.get(index) ?? 0;
|
||||
const peerConnection = new RTCPeerConnection();
|
||||
slotPeerConnections.set(index, peerConnection);
|
||||
slotStreamStates.value[index] = { loading: true, error: "" };
|
||||
|
||||
peerConnection.addTransceiver("video", { direction: "recvonly" });
|
||||
peerConnection.addTransceiver("audio", { direction: "recvonly" });
|
||||
peerConnection.ontrack = (event) => {
|
||||
if (requestId !== slotRequestIds.get(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.srcObject = event.streams[0];
|
||||
};
|
||||
|
||||
try {
|
||||
const offer = await peerConnection.createOffer();
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(peerConnection);
|
||||
|
||||
const response = await fetch(buildWebrtcApiUrl(camera.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/sdp",
|
||||
"Content-Type": "application/sdp"
|
||||
},
|
||||
body: peerConnection.localDescription?.sdp ?? offer.sdp,
|
||||
body: peerConnection.localDescription?.sdp ?? offer.sdp
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`拉流失败:${response.status}`);
|
||||
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 };
|
||||
|
||||
const responseData = JSON.parse(responseText);
|
||||
if (responseData.code && responseData.code !== 0) {
|
||||
throw new Error(responseData.msg || "视频流拉取失败");
|
||||
}
|
||||
|
||||
answer = responseData.sdp || responseText;
|
||||
} catch (error) {
|
||||
if (responseText.trim().startsWith("{")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId !== slotRequestIds.get(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await peerConnection.setRemoteDescription({
|
||||
type: "answer",
|
||||
sdp: answer,
|
||||
sdp: answer
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId === slotRequestIds.get(index)) {
|
||||
slotStreamStates.value[index] = {
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : "视频流拉取失败",
|
||||
error: error instanceof Error ? error.message : "视频流拉取失败"
|
||||
};
|
||||
stopSlotStream(index, false);
|
||||
}
|
||||
@@ -391,135 +262,98 @@ async function playSlotStream(index: number, camera: ProjectCameraItem) {
|
||||
if (requestId === slotRequestIds.get(index)) {
|
||||
slotStreamStates.value[index] = {
|
||||
...slotStreamStates.value[index],
|
||||
loading: false,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭指定画面并释放对应 WebRTC 拉流资源
|
||||
function closeVideoSlot(index: number) {
|
||||
function closeVideoSlot(index) {
|
||||
stopSlotStream(index);
|
||||
videoSlots.value[index] = null;
|
||||
|
||||
if (activeSlotIndex.value === index) {
|
||||
activeSlotIndex.value = index;
|
||||
}
|
||||
}
|
||||
|
||||
// 将左侧相机播放到当前选中的右侧画面
|
||||
async function playCamera(camera: ProjectCameraItem) {
|
||||
async function playCamera(camera) {
|
||||
const targetIndex = activeSlotIndex.value;
|
||||
videoSlots.value[targetIndex] = camera;
|
||||
|
||||
await nextTick();
|
||||
await playSlotStream(targetIndex, camera);
|
||||
}
|
||||
|
||||
// 切换视频墙宫格布局
|
||||
async function setGridMode(mode: GridMode) {
|
||||
async function setGridMode(mode) {
|
||||
gridMode.value = mode;
|
||||
await nextTick();
|
||||
updateVideoGridSize();
|
||||
}
|
||||
|
||||
// 同步录像回放播放器进度和暂停状态
|
||||
function syncPlaybackProgress() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
if (!video) {
|
||||
playbackProgress.value = 0;
|
||||
playbackPaused.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
playbackProgress.value = duration > 0 ? (video.currentTime / duration) * 100 : 0;
|
||||
playbackProgress.value = duration > 0 ? video.currentTime / duration * 100 : 0;
|
||||
playbackPaused.value = video.paused;
|
||||
}
|
||||
|
||||
// 从头开始播放当前回放视频
|
||||
function startPlayback() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.currentTime = 0;
|
||||
void video.play();
|
||||
playbackPaused.value = false;
|
||||
syncPlaybackProgress();
|
||||
}
|
||||
|
||||
// 打开当前画面的录像回放弹窗
|
||||
async function openPlaybackDialog() {
|
||||
if (!activeCamera.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
playbackDialogVisible.value = true;
|
||||
playbackProgress.value = 0;
|
||||
playbackPaused.value = true;
|
||||
|
||||
await nextTick();
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
// 关闭录像回放弹窗并暂停播放器
|
||||
function closePlaybackDialog() {
|
||||
playbackPlayerRef.value?.pause();
|
||||
playbackDialogVisible.value = false;
|
||||
playbackPaused.value = true;
|
||||
}
|
||||
|
||||
// 切换录像回放的播放和暂停状态
|
||||
function togglePlaybackPause() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.paused) {
|
||||
void video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
syncPlaybackProgress();
|
||||
}
|
||||
|
||||
// 按秒数快进或快退录像回放
|
||||
function seekPlayback(offsetSeconds: number) {
|
||||
function seekPlayback(offsetSeconds) {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
const targetTime = Math.max(0, Math.min(duration, video.currentTime + offsetSeconds));
|
||||
video.currentTime = targetTime;
|
||||
syncPlaybackProgress();
|
||||
}
|
||||
|
||||
// 根据拖动条位置更新录像回放播放进度
|
||||
function updatePlaybackProgress() {
|
||||
const video = playbackPlayerRef.value;
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
if (duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.currentTime = (playbackProgress.value / 100) * duration;
|
||||
video.currentTime = playbackProgress.value / 100 * duration;
|
||||
}
|
||||
|
||||
watch(gridMode, async () => {
|
||||
ensureVideoSlotCount();
|
||||
await nextTick();
|
||||
@@ -528,7 +362,6 @@ watch(gridMode, async () => {
|
||||
onMounted(() => {
|
||||
void fetchDeviceList();
|
||||
updateVideoGridSize();
|
||||
|
||||
if (videoGridBoardRef.value) {
|
||||
videoGridResizeObserver = new ResizeObserver(updateVideoGridSize);
|
||||
videoGridResizeObserver.observe(videoGridBoardRef.value);
|
||||
@@ -618,7 +451,7 @@ onBeforeUnmount(() => {
|
||||
:key="mode"
|
||||
:class="['grid-switch-btn', { active: gridMode === mode }]"
|
||||
type="button"
|
||||
@click="setGridMode(mode as GridMode)"
|
||||
@click="setGridMode(mode)"
|
||||
>
|
||||
{{ mode }} 宫格
|
||||
</button>
|
||||
|
||||
@@ -25,9 +25,7 @@
|
||||
"poseY": 6.789,
|
||||
"poseYaw": 1.57,
|
||||
"status": 1,
|
||||
"signal": "-58dBm",
|
||||
"dockStatus": "自动回充待机中",
|
||||
"dockActive": false
|
||||
"signal": "-58dBm"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -44,8 +42,6 @@
|
||||
| `data.poseYaw` | 否 | 当前朝向 |
|
||||
| `data.status` | 否 | 小车状态:`0` 空闲,`1` 巡检,`2` 离线,`3` 充电中 |
|
||||
| `data.signal` | 否 | 小车信号 |
|
||||
| `data.dockStatus` | 否 | 自动回充状态文本,取自 `/dock_status` |
|
||||
| `data.dockActive` | 否 | 回充或脱离充电桩是否进行中,取自 `/auto_docking_active` 与 `/auto_undocking_active` |
|
||||
|
||||
说明:
|
||||
|
||||
@@ -81,9 +77,7 @@
|
||||
"poseY": 6.789,
|
||||
"poseYaw": 1.57,
|
||||
"status": 1,
|
||||
"signal": "-58dBm",
|
||||
"dockStatus": "自动回充待机中",
|
||||
"dockActive": false
|
||||
"signal": "-58dBm"
|
||||
},
|
||||
"timestamp": 1782702000010
|
||||
}
|
||||
@@ -92,12 +86,10 @@
|
||||
顶层字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| `type` | 固定为 `car_status` |
|
||||
| `data.deviceKey` | 小车设备标识 |
|
||||
| `data.liftHeight` | 升降柱高度,单位 mm |
|
||||
| `data.dockStatus` | 自动回充状态文本;用于前端回显当前回充阶段 |
|
||||
| `data.dockActive` | 回充或脱离充电桩是否进行中;为 `true` 时前端禁用「自动回充」「充电脱离」按钮 |
|
||||
| `timestamp` | 后端推送时间戳,毫秒 |
|
||||
|
||||
|
||||
|
||||
@@ -165,74 +165,6 @@ uint16 height
|
||||
| `3` | 停止 | `0` |
|
||||
| `4` | 运行到指定高度 | 目标高度,单位 mm |
|
||||
|
||||
## 充电点与回充配置
|
||||
|
||||
ROS 包参考:`charging_dock_interfaces/srv/SetDockPose`、`charging_dock_interfaces/srv/SetDockConfig`
|
||||
|
||||
消息类型:`dock_config`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "dock_config",
|
||||
"requestId": "dock-1",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001",
|
||||
"dockX": 1.235,
|
||||
"dockY": -0.42,
|
||||
"dockYaw": 0.0,
|
||||
"preDockX": 1.8,
|
||||
"preDockY": -0.42,
|
||||
"preDockYaw": 0.0,
|
||||
"autoRechargeEnabled": true,
|
||||
"lowBatteryThreshold": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `dockX` / `dockY` / `dockYaw` | 充电点(对接点)位姿,`map` 坐标系,单位米 / 弧度 |
|
||||
| `preDockX` / `preDockY` / `preDockYaw` | 预对接点位姿,同上 |
|
||||
| `autoRechargeEnabled` | 是否开启低电量自动回充 |
|
||||
| `lowBatteryThreshold` | 自动回充触发的电量阈值(%) |
|
||||
|
||||
说明:
|
||||
|
||||
- 三组字段互相独立:ROS 端只对**同时提供完整三元组**的点位调用 `/charging_dock/set_dock_pose` 或 `/charging_dock/set_pre_dock_pose`,缺任一字段则跳过;`autoRechargeEnabled` 与 `lowBatteryThreshold` 任一缺失时跳过 `/charging_dock/set_config`。
|
||||
- 该消息由后端 `POST /admin/rosCar/updateDockConfig` 保存成功后下发,浏览器端不直接发送。
|
||||
- 小车离线时后端只落库、不下发,需要重新保存一次才能同步到车端。
|
||||
- 服务不可用时 ROS 端只记录告警,不回执失败。
|
||||
|
||||
## 回充与脱离充电桩
|
||||
|
||||
消息类型:`dock_start`(触发自动回充)、`dock_undock`(脱离充电桩)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "dock_undock",
|
||||
"requestId": "dock-undock-1",
|
||||
"data": {
|
||||
"deviceKey": "ROBOT-001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `deviceKey` | 控制目标机器人设备标识 |
|
||||
|
||||
说明:
|
||||
|
||||
- 两条消息都由后端接口下发,浏览器端不直接发送:回充 `POST /admin/rosCar/startCharging`,脱离 `POST /admin/rosCar/undockCharging`,参数都是 `carId`。
|
||||
- 车端分别调用 `/charging_dock/start` 与 `/charging_dock/undock`(均为 `std_srvs/Trigger`)。
|
||||
- `accepted=true` 只代表车端已受理,机械动作在车端异步执行;执行期间 `dockActive` 为 `true`,进度由 `dockStatus` 文本回显(`/auto_docking_active`、`/auto_undocking_active` 合并上报)。
|
||||
- 脱离动作:车端切到横移模式后向左侧退出约 `undock_distance`(默认 0.30 m)离开充电面板,完成后恢复阿克曼模式。
|
||||
- 充电完成自动脱离:车端电量升到 `undock_battery_threshold`(默认 95%),且本次到桩时电量低于(阈值 - `undock_battery_start_margin`,默认 3%)时,自动触发一次脱离。
|
||||
|
||||
## 机器人端收到的消息
|
||||
|
||||
设备端连接:
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
export type AlertPhotoScene = "cable" | "heat" | "crack";
|
||||
|
||||
export function createAlertPhoto(scene: AlertPhotoScene, accent: string) {
|
||||
const sceneMarkup =
|
||||
scene === "cable"
|
||||
? `
|
||||
function createAlertPhoto(scene, accent) {
|
||||
const sceneMarkup = scene === "cable" ? `
|
||||
<path d="M64 112 C140 90, 222 88, 304 104 S470 132, 556 110" stroke="${accent}" stroke-width="6" fill="none" stroke-linecap="round" opacity="0.9" />
|
||||
<path d="M314 106 L340 92 L366 120" stroke="#ffe9a6" stroke-width="5" fill="none" stroke-linecap="round" />
|
||||
<circle cx="340" cy="106" r="32" fill="rgba(255,102,82,0.18)" stroke="#ff8e79" stroke-width="2" stroke-dasharray="6 6" />
|
||||
`
|
||||
: scene === "heat"
|
||||
? `
|
||||
` : scene === "heat" ? `
|
||||
<ellipse cx="340" cy="122" rx="84" ry="46" fill="rgba(255,122,68,0.26)" />
|
||||
<ellipse cx="344" cy="122" rx="58" ry="30" fill="rgba(255,193,82,0.28)" />
|
||||
<path d="M320 146 C330 126, 334 114, 332 96 C344 112, 350 126, 348 148" fill="none" stroke="#ffd36a" stroke-width="4" stroke-linecap="round" />
|
||||
<path d="M358 142 C366 126, 372 114, 370 100 C382 112, 388 126, 384 144" fill="none" stroke="#ff8a5b" stroke-width="4" stroke-linecap="round" />
|
||||
`
|
||||
: `
|
||||
` : `
|
||||
<path d="M206 88 L234 130 L226 166 L268 210 L254 248 L294 282" stroke="#d8ecff" stroke-width="4" fill="none" stroke-linecap="round" />
|
||||
<path d="M248 152 L280 136 L306 164" stroke="#d8ecff" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<path d="M266 212 L302 196 L328 220" stroke="#d8ecff" stroke-width="3" fill="none" stroke-linecap="round" />
|
||||
<ellipse cx="258" cy="178" rx="86" ry="118" fill="rgba(132,196,255,0.08)" stroke="${accent}" stroke-width="2" stroke-dasharray="5 7" />
|
||||
`;
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
|
||||
<defs>
|
||||
@@ -61,21 +53,22 @@ export function createAlertPhoto(scene: AlertPhotoScene, accent: string) {
|
||||
<rect x="20" y="20" width="600" height="320" rx="16" fill="none" stroke="rgba(110,224,255,0.12)" stroke-width="2" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export const recentAlertPhotoSources = {
|
||||
const recentAlertPhotoSources = {
|
||||
"线缆破损": createAlertPhoto("cable", "#ff8f79"),
|
||||
"电缆表面破损": createAlertPhoto("cable", "#ff8f79"),
|
||||
"桥架断裂": createAlertPhoto("crack", "#89d8ff"),
|
||||
"隧道积水": createAlertPhoto("heat", "#ffc86c"),
|
||||
"局部高温": createAlertPhoto("heat", "#ffc86c"),
|
||||
"墙体渗漏": createAlertPhoto("crack", "#89d8ff"),
|
||||
} as const;
|
||||
|
||||
const fallbackAlertPhoto = recentAlertPhotoSources["墙体渗漏"];
|
||||
|
||||
export function getRecentAlertPhoto(type: string) {
|
||||
return recentAlertPhotoSources[type as keyof typeof recentAlertPhotoSources] ?? fallbackAlertPhoto;
|
||||
"墙体裂缝": createAlertPhoto("crack", "#89d8ff")
|
||||
};
|
||||
const fallbackAlertPhoto = recentAlertPhotoSources["墙体裂缝"];
|
||||
function getRecentAlertPhoto(type) {
|
||||
return recentAlertPhotoSources[type] ?? fallbackAlertPhoto;
|
||||
}
|
||||
export {
|
||||
createAlertPhoto,
|
||||
getRecentAlertPhoto,
|
||||
recentAlertPhotoSources
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import SwitchPageBtn from "@/views/dashboard1/components/btn";
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
20
src/views/dashboard3/components/chart/index.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineComponent } from "vue";
|
||||
import "./index.less";
|
||||
const longIndex = [0, 6, 12];
|
||||
export default defineComponent({
|
||||
name: "Chart",
|
||||
props: {},
|
||||
setup(props) {
|
||||
return () => <div class="chart">
|
||||
<ul class="scale__ul">{Array.from({ length: 13 }).map((_, index) => {
|
||||
const long = longIndex.includes(index);
|
||||
return <li
|
||||
class={[`scale__li`, { "scale__li--long": long }, { "scale__li--active": index > 5 }]}
|
||||
style={{ transform: `rotate(-${15 * index}deg) translate(${long ? 59 : 65}px,-50%)` }}
|
||||
key={index}
|
||||
/>;
|
||||
})}</ul>
|
||||
<div class="chart__name">xx</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import {defineComponent} from "vue";
|
||||
import "./index.less"
|
||||
|
||||
const longIndex = [0, 6, 12];
|
||||
export default defineComponent({
|
||||
name: "Chart",
|
||||
props: {},
|
||||
setup(props) {
|
||||
return () => (
|
||||
<div class='chart'>
|
||||
<ul class='scale__ul'>
|
||||
{
|
||||
Array.from({length: 13}).map((_, index) => {
|
||||
const long = longIndex.includes(index);
|
||||
return <li
|
||||
class={[`scale__li`, {'scale__li--long': long}, {'scale__li--active': index > 5}]}
|
||||
style={{transform: `rotate(-${15 * index}deg) translate(${long ? 59 : 65}px,-50%)`}}
|
||||
key={index}></li>
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
<div class='chart__name'>xx</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const animationSvgRef = ref();
|
||||
|
||||
const config = {
|
||||
count: 16,
|
||||
initR: 2,
|
||||
gap: 0.2,
|
||||
dur: 8,
|
||||
startY: 266.5,
|
||||
startY: 266.5
|
||||
};
|
||||
|
||||
function generateNumber() {
|
||||
return (Math.random() * 10).toFixed(1) + "Mb/s";
|
||||
}
|
||||
|
||||
const polylineComputed = computed(() => {
|
||||
const arr = [];
|
||||
let startY = config.startY;
|
||||
@@ -22,28 +18,25 @@ const polylineComputed = computed(() => {
|
||||
let startX = 465;
|
||||
let endX = 1820;
|
||||
const points = `${startX},${startY} 846,${startY} 1280,634 ${endX},634`;
|
||||
|
||||
const textY = startY - 29.5;
|
||||
const text1 = { x: startX + 137, y: textY, value: `${generateNumber()}` };
|
||||
const text2 = {
|
||||
x: startX + 137 + 147,
|
||||
y: textY,
|
||||
value: `${generateNumber()}`,
|
||||
value: `${generateNumber()}`
|
||||
};
|
||||
const text3 = {};
|
||||
|
||||
const value = {
|
||||
points,
|
||||
text1,
|
||||
text2,
|
||||
text3,
|
||||
text3
|
||||
};
|
||||
arr.push(value);
|
||||
startY += 180;
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
const circleComputed = computed(() => {
|
||||
const circles = [];
|
||||
let startY = config.startY;
|
||||
@@ -60,7 +53,7 @@ const circleComputed = computed(() => {
|
||||
r: r.toFixed(1),
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endX
|
||||
};
|
||||
circles.push(circle);
|
||||
endX = 1820;
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { encryptPasswordPayload } from '@/utils/passwordEncrypt'
|
||||
import { useUserStore } from "@/store/modules/user.js";
|
||||
import { encryptPasswordPayload } from "@/utils/passwordEncrypt.js";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const form = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
password: ""
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const redirectPath = computed(() => {
|
||||
const redirect = route.query.redirect;
|
||||
if (
|
||||
typeof redirect === "string" &&
|
||||
redirect.startsWith("/") &&
|
||||
!redirect.startsWith("//")
|
||||
) {
|
||||
if (typeof redirect === "string" && redirect.startsWith("/") && !redirect.startsWith("//")) {
|
||||
return redirect;
|
||||
}
|
||||
return "/dashboard/integrated-center";
|
||||
});
|
||||
|
||||
// 校验登录表单,避免空账号或空密码提交到后端。
|
||||
function validateLoginForm() {
|
||||
if (!form.username.trim()) {
|
||||
errorMessage.value = "请输入账号";
|
||||
@@ -39,23 +30,18 @@ function validateLoginForm() {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 提交登录信息,成功后回到登录前访问的页面。
|
||||
async function handleLogin() {
|
||||
if (!validateLoginForm() || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const encryptedForm = await encryptPasswordPayload(form, ['password'])
|
||||
await userStore.login(encryptedForm)
|
||||
const encryptedForm = await encryptPasswordPayload(form, ["password"]);
|
||||
await userStore.login(encryptedForm);
|
||||
router.replace(redirectPath.value);
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : "登录失败,请检查账号或密码";
|
||||
errorMessage.value = error instanceof Error ? error.message : "登录失败\,请检查账号或密码";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
8
src/vite-env.d.ts
vendored
@@ -1,8 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vue/jsx" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/api/rosCar.ts"]
|
||||
}
|
||||
41
vite.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||
import { resolve } from "path";
|
||||
export default ({ command }) => {
|
||||
const isBuild = command === "build";
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx()
|
||||
],
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: false
|
||||
// 关闭 Vue2 中的 options选项API
|
||||
},
|
||||
base: "./",
|
||||
// 使用相对路径
|
||||
resolve: {
|
||||
alias: [{ find: "@", replacement: resolve(__dirname, "src") }]
|
||||
},
|
||||
server: {
|
||||
port: 6003,
|
||||
host: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: `http://192.168.3.110:16003/`,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, "")
|
||||
},
|
||||
"/ws": {
|
||||
target: `ws://192.168.3.110:16003/`,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
oxc: {
|
||||
//清除全局的console.log和debug
|
||||
drop: isBuild ? ["console", "debugger"] : []
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import { resolve } from 'path'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { defineConfig, type ConfigEnv } from 'vite'
|
||||
|
||||
// import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ command }: ConfigEnv) => {
|
||||
const isBuild = command === 'build'
|
||||
return ({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
Components({
|
||||
resolvers: [NaiveUiResolver()]
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: false // 关闭 Vue2 中的 options选项API
|
||||
},
|
||||
base: './', // 使用相对路径
|
||||
resolve: {
|
||||
alias: [{ find: '@', replacement: resolve(__dirname, 'src') }]
|
||||
|
||||
},
|
||||
server: {
|
||||
port: 6003,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://192.168.3.110:16003/`,
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(/^\/api/, '')
|
||||
},
|
||||
'/ws': {
|
||||
target: `ws://192.168.3.110:16003/`,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
// Vite 8 原生方式:使用 Oxc Minifier 的 drop 选项
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
minify: {
|
||||
compress: {
|
||||
dropConsole: isBuild,
|
||||
dropDebugger: isBuild,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||