Compare commits

11 Commits

Author SHA1 Message Date
0bf52a5d55 fix: $modal 改用 useDialog 修复下发巡检确认弹窗报错 2026-08-18 17:41:57 +08:00
e8d37df18e refactor: 移除 API 接口中冗余的 [key: string]: unknown 索引签名
- user.ts: 移除 UserProfile、UserInfoVO 的索引签名
- rosCar.ts: 移除 8 个接口的索引签名(RosLift、RosCar、RosInspectionRecord 等)
- 遵循"用到就补字段声明"原则,保留完整类型检查
2026-08-12 14:33:45 +08:00
61e105def2 perf: echarts 按需导入,分散注册到具体使用页面
- 移除 main.ts 中 echarts 全量导入和集中注册
- chart/index.vue 仅导入 init,改用 ReturnType 推断类型
- DataReportPage.vue 按需注册 LineChart/PieChart/RadarChart 及组件
- CanvasRenderer 保持全局注册,所有图表共用
- 主 chunk 减小 55%(1,279 kB → 572 kB),echarts 模块随页面懒加载
2026-08-12 14:20:25 +08:00
fd753291fe refactor: vite.config.ts 类型修复与 Vite 8 迁移适配
- 修复 vite.config.ts 中 command 隐式 any 类型错误
- 修复 proxy 中 path 参数隐式 any 类型错误
- 将无效的 oxc.drop 配置迁移为 Vite 8 原生方式(build.rolldownOptions.output.minify.compress)
- 引入 defineConfig + ConfigEnv 提供完整类型推导
- JS 文件迁移为 TypeScript(user.ts, routerGuard.ts 等)
- 更新 API 层类型定义
2026-08-12 14:13:45 +08:00
35e98b9ab1 feat(map): 地图右下角新增全局缩略图预览并支持点击拖动联动 2026-08-12 11:20:46 +08:00
0915529af0 feat(map): 滚轮缩放以光标为焦点并调整保存文案 2026-08-12 11:15:25 +08:00
03d319ebf6 feat(map): 优化地图缩放逻辑及点位渲染
- 将设备管理地图最大缩放比例提升至15,支持更大范围缩放
- 修改地图缩放增量为按倍率缩放,提升缩放体验平滑度
- 新增根据地图宽高比计算默认缩放,消除宽扁地图显示过小问题
- 自动缩放时聚焦地图原点,提升用户定位便利性
- 调整地图点位渲染样式,统一圆圈大小,简化视觉效果,只用描边色区分
- 移除点位标签背景框绘制,减少视觉干扰
- 鼠标滚轮缩放时以光标为中心,缩放过程视野自动平移保持焦点
- 集成中心页面地图缩放支持按倍率调整和平滑过渡
- 优化保存巡检任务时进度条逻辑,取消模拟递增,调用完成立即完成进度
- 增强地图缩放动画过渡时长,从0.18秒提升至0.45秒
- 修复集成中心页面真实点位显示优化,起点终点显示标签,途经点隐藏标签
2026-08-12 11:11:07 +08:00
d29075e9b4 登录失效跳转修复 2026-08-12 10:47:18 +08:00
10097f3003 Merge branch 'master' of https://git.haizhiyustc.com/robot/robot_manager 2026-08-08 00:52:52 +08:00
c352a29b4e 文案修改 2026-08-08 00:52:42 +08:00
7c40ae9309 修复地图yaml解析 2026-07-21 17:51:29 +08:00
63 changed files with 5597 additions and 2176 deletions

View File

@@ -1,3 +1,3 @@
{ {
"recommendations": ["Vue.volar"] "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
} }

20
components.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
/* 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']
}
}

View File

@@ -8,6 +8,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

2304
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vue-tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write ." "format": "prettier --write ."
}, },
@@ -15,17 +15,22 @@
"echarts": "^5.4.3", "echarts": "^5.4.3",
"echarts-liquidfill": "^3.1.0", "echarts-liquidfill": "^3.1.0",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"naive-ui": "^2.44.1",
"ol": "^7.4.0", "ol": "^7.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"vue": "^3.2.47", "vue": "^3.5.39",
"vue-router": "^4.6.4" "vue-router": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.1.0", "@vicons/fluent": "^0.12.0",
"@vitejs/plugin-vue-jsx": "^1.3.3", "@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue-jsx": "^5.1.6",
"@vue/compiler-sfc": "^3.5.39",
"less": "^4.1.3", "less": "^4.1.3",
"vite": "^4.2.0" "naive-ui": "^2.44.1",
"typescript": "^5.9.3",
"unplugin-vue-components": "^28.7.0",
"vite": "8.1.4",
"vue-tsc": "^3.3.9"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
public/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 B

View File

@@ -1,32 +1,40 @@
<script setup> <script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const DESIGN_WIDTH = 1920; const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080; const DESIGN_HEIGHT = 1080;
const viewportWidth = ref(0); const viewportWidth = ref(0);
const viewportHeight = ref(0); const viewportHeight = ref(0);
const scale = ref(1); const scale = ref(1);
function updateScreenAdapter() { function updateScreenAdapter() {
viewportWidth.value = window.innerWidth; viewportWidth.value = window.innerWidth;
viewportHeight.value = window.innerHeight; viewportHeight.value = window.innerHeight;
const widthScale = viewportWidth.value / DESIGN_WIDTH; const widthScale = viewportWidth.value / DESIGN_WIDTH;
const heightScale = viewportHeight.value / DESIGN_HEIGHT; const heightScale = viewportHeight.value / DESIGN_HEIGHT;
scale.value = Math.min(widthScale, heightScale); scale.value = Math.min(widthScale, heightScale);
} }
const adapterStyle = computed(() => { const adapterStyle = computed(() => {
const scaledWidth = DESIGN_WIDTH * scale.value; const scaledWidth = DESIGN_WIDTH * scale.value;
const scaledHeight = DESIGN_HEIGHT * scale.value; const scaledHeight = DESIGN_HEIGHT * scale.value;
return { return {
width: `${DESIGN_WIDTH}px`, width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`, height: `${DESIGN_HEIGHT}px`,
left: `${(viewportWidth.value - scaledWidth) / 2}px`, left: `${(viewportWidth.value - scaledWidth) / 2}px`,
top: `${(viewportHeight.value - scaledHeight) / 2}px`, top: `${(viewportHeight.value - scaledHeight) / 2}px`,
transform: `scale(${scale.value})` transform: `scale(${scale.value})`,
}; };
}); });
onMounted(() => { onMounted(() => {
updateScreenAdapter(); updateScreenAdapter();
window.addEventListener("resize", updateScreenAdapter); window.addEventListener("resize", updateScreenAdapter);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("resize", updateScreenAdapter); window.removeEventListener("resize", updateScreenAdapter);
}); });
@@ -35,7 +43,18 @@ onBeforeUnmount(() => {
<template> <template>
<div class="screen-adapter"> <div class="screen-adapter">
<div class="screen-adapter__inner" :style="adapterStyle"> <div class="screen-adapter__inner" :style="adapterStyle">
<RouterView /> <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>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,18 +0,0 @@
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
};

94
src/api/alarm.ts Normal file
View File

@@ -0,0 +1,94 @@
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;
/** 识别状态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,
});
}

View File

@@ -1,61 +0,0 @@
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
};

295
src/api/rosCar.ts Normal file
View File

@@ -0,0 +1,295 @@
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;
/** 升降柱高度 */
liftHeight?: number;
/** 升降柱状态 */
lift?: RosLift;
/** 创建时间 */
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;
}) {
return request<ApiResult<null>>({
url: "/admin/rosCar/updateMapUrls",
data,
});
}

View File

@@ -1,52 +0,0 @@
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 Normal file
View File

@@ -0,0 +1,105 @@
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
})
}

View File

@@ -1,8 +0,0 @@
@font-face {
/*给字体命名*/
font-family: 'YouSheBiaoTiHei';
/*引入字体文件*/
src: url('./YouSheBiaoTiHei.ttf');
font-weight: normal;
font-style: normal;
}

Binary file not shown.

View File

@@ -0,0 +1,21 @@
<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>

View File

@@ -1,45 +1,42 @@
<script setup> <script lang="ts" setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import * as echarts from "echarts"; import { init } from "echarts/core";
let chart = null; import type { EChartsOption } from "echarts";
let resizeObserver = null;
const chartRef = ref(); let chart: ReturnType<typeof init> | null = null;
const chartRef = ref<HTMLElement | undefined>();
const props = defineProps({ const props = defineProps({
option: { option: {
type: Object, type: Object as () => EChartsOption | {},
required: true required: true,
} },
}); });
function applyOption(option) {
function applyOption(option?: EChartsOption | Record<string, never>) {
if (!chart || !option) { if (!chart || !option) {
return; return;
} }
chart.clear(); chart.clear();
chart.setOption(option); chart.setOption(option);
} }
onMounted(() => { onMounted(() => {
chart = echarts.init(chartRef.value, "t-theme"); chart = init(chartRef.value, "t-theme");
applyOption(props.option); applyOption(props.option);
resizeObserver = new ResizeObserver(() => {
chart?.resize();
});
resizeObserver.observe(chartRef.value);
nextTick(() => {
chart?.resize();
});
}); });
watch( watch(
() => props.option, () => props.option,
(value) => { (value) => {
applyOption(value); applyOption(value);
}, },
{ deep: true } { deep: true },
); );
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (chart) { if (chart) {
chart.dispose(); chart.dispose();
chart = null; chart = null;

View File

@@ -8,8 +8,8 @@ const color = [
colorStops: [ colorStops: [
{ offset: 0, color: "#94c7fd" }, { offset: 0, color: "#94c7fd" },
{ offset: 0.5, color: "#3b99fb" }, { offset: 0.5, color: "#3b99fb" },
{ offset: 1, color: "#004ca0" } { offset: 1, color: "#004ca0" },
] ],
}, },
{ {
type: "linear", type: "linear",
@@ -20,8 +20,8 @@ const color = [
colorStops: [ colorStops: [
{ offset: 0, color: "#b070c0" }, { offset: 0, color: "#b070c0" },
{ offset: 0.5, color: "#b070c0" }, { offset: 0.5, color: "#b070c0" },
{ offset: 1, color: "#5d235d" } { offset: 1, color: "#5d235d" },
] ],
}, },
{ {
type: "linear", type: "linear",
@@ -32,8 +32,8 @@ const color = [
colorStops: [ colorStops: [
{ offset: 0, color: "#7eedd6" }, { offset: 0, color: "#7eedd6" },
{ offset: 0.5, color: "#3be8bb" }, { offset: 0.5, color: "#3be8bb" },
{ offset: 1, color: "#00b38a" } { offset: 1, color: "#00b38a" },
] ],
}, },
{ {
type: "linear", type: "linear",
@@ -44,8 +44,8 @@ const color = [
colorStops: [ colorStops: [
{ offset: 0, color: "#fff1f0" }, { offset: 0, color: "#fff1f0" },
{ offset: 0.5, color: "#ff9494" }, { offset: 0.5, color: "#ff9494" },
{ offset: 1, color: "#ff4d4f" } { offset: 1, color: "#ff4d4f" },
] ],
}, },
{ {
type: "linear", type: "linear",
@@ -56,8 +56,8 @@ const color = [
colorStops: [ colorStops: [
{ offset: 0, color: "#fffbe6" }, { offset: 0, color: "#fffbe6" },
{ offset: 0.5, color: "#ffe58f" }, { offset: 0.5, color: "#ffe58f" },
{ offset: 1, color: "#ffbb96" } { offset: 1, color: "#ffbb96" },
] ],
}, },
{ {
type: "linear", type: "linear",
@@ -68,8 +68,8 @@ const color = [
colorStops: [ colorStops: [
{ offset: 0, color: "#ff3333" }, { offset: 0, color: "#ff3333" },
{ offset: 0.5, color: "#ff0000" }, { offset: 0.5, color: "#ff0000" },
{ offset: 1, color: "#cc0000" } { offset: 1, color: "#cc0000" },
] ],
}, },
{ {
type: "linear", type: "linear",
@@ -80,24 +80,25 @@ const color = [
colorStops: [ colorStops: [
{ offset: 0, color: "#ffe6f0" }, { offset: 0, color: "#ffe6f0" },
{ offset: 0.5, color: "#ffb3d9" }, { offset: 0.5, color: "#ffb3d9" },
{ offset: 1, color: "#ff85c0" } { offset: 1, color: "#ff85c0" },
] ],
} },
]; ];
export default { export default {
color, color,
textStyle: { textStyle: {
fontSize: 12, fontSize: 12,
color: "#fff" color: "#fff",
}, },
legend: { legend: {
textStyle: { textStyle: {
color: "#fff" color: "#fff",
} },
}, },
tooltip: { tooltip: {
textStyle: { textStyle: {
align: "left" align: "left",
} },
} },
}; };

View File

@@ -1,30 +1,35 @@
import { Map } from "ol";
import VectorSource from "ol/source/Vector"; import VectorSource from "ol/source/Vector";
import { GeoJSON } from "ol/format"; import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
import { Fill, Stroke, Style } from "ol/style"; import { Fill, Stroke, Style } from "ol/style";
export default function (mapInstance) { import { FeatureLike } from "ol/Feature";
import { MultiPolygon } from "ol/geom";
export default function (mapInstance: Map) {
const vectorSource = new VectorSource({ const vectorSource = new VectorSource({
url: "https://geo.datav.aliyun.com/areas_v3/bound/330000.json", url: "https://geo.datav.aliyun.com/areas_v3/bound/330000.json",
format: new GeoJSON() format: new GeoJSON(),
}); });
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
const layer = new VectorLayer({ const layer = new VectorLayer({
zIndex: -1, zIndex: -1,
source: vectorSource, source: vectorSource,
style: (feature) => { style: (feature: FeatureLike) => {
const geometry = feature.getGeometry().clone(); const geometry = (feature.getGeometry() as MultiPolygon).clone();
geometry.translate(0, -0.04 * i); geometry.translate(0, -0.04 * i);
return new Style({ return new Style({
fill: new Fill({ fill: new Fill({
color: "rgba(176, 166, 132,0.6)" color: "rgba(176, 166, 132,0.6)",
}), }),
stroke: new Stroke({ stroke: new Stroke({
color: "rgba(176, 166, 132,0.6)", color: "rgba(176, 166, 132,0.6)",
width: 1 width: 1,
}), }),
geometry geometry,
}); });
} },
}); });
mapInstance.addLayer(layer); mapInstance.addLayer(layer);
} }

View File

@@ -7,28 +7,33 @@ import { Fill, Stroke, Style, Text } from "ol/style";
import { getBottomLeft, getCenter, getHeight, getWidth } from "ol/extent"; import { getBottomLeft, getCenter, getHeight, getWidth } from "ol/extent";
import materialJpg from "@/assets/map-material.jpg"; import materialJpg from "@/assets/map-material.jpg";
import { toContext } from "ol/render"; import { toContext } from "ol/render";
import { MultiPolygon } from "ol/geom";
import { defaults } from "ol/interaction"; import { defaults } from "ol/interaction";
function useMap(target) {
export default function useMap(target: string) {
const mapInstance = new Map({ const mapInstance = new Map({
view: new View({ view: new View({
projection: "EPSG:4326", projection: "EPSG:4326",
zoom: 12, zoom: 12,
center: [0, 0] center: [0, 0],
}), }),
controls: [], controls: [],
interactions: defaults({ interactions: defaults({
dragPan: false, dragPan: false,
mouseWheelZoom: false mouseWheelZoom: false,
}) }),
}); });
const vectorSource = new VectorSource({ const vectorSource = new VectorSource({
url: "https://geo.datav.aliyun.com/areas_v3/bound/330000_full.json", url: "https://geo.datav.aliyun.com/areas_v3/bound/330000_full.json",
format: new GeoJSON() format: new GeoJSON(),
}); });
const customStyle = new Style({ const customStyle = new Style({
renderer: function(pixelCoordinates, state) { renderer: function (pixelCoordinates, state) {
const context = state.context; const context = state.context;
const multiPolygon = state.geometry.clone(); const multiPolygon = state.geometry.clone() as MultiPolygon;
// @ts-ignore
multiPolygon.setCoordinates(pixelCoordinates); multiPolygon.setCoordinates(pixelCoordinates);
const extent = multiPolygon.getExtent(); const extent = multiPolygon.getExtent();
const width = getWidth(extent); const width = getWidth(extent);
@@ -39,14 +44,14 @@ function useMap(target) {
} }
context.save(); context.save();
const renderContext = toContext(context, { const renderContext = toContext(context, {
pixelRatio: 1 pixelRatio: 1,
}); });
renderContext.setFillStrokeStyle( renderContext.setFillStrokeStyle(
new Fill(), new Fill(),
new Stroke({ new Stroke({
color: "rgba(255,255,255,0.8)", color: "rgba(255,255,255,0.8)",
width: 3 width: 3,
}) }),
); );
renderContext.drawMultiPolygon(multiPolygon); renderContext.drawMultiPolygon(multiPolygon);
context.clip(); context.clip();
@@ -55,8 +60,9 @@ function useMap(target) {
const bottom = bottomLeft[1]; const bottom = bottomLeft[1];
context.drawImage(flag, left, bottom, width, height); context.drawImage(flag, left, bottom, width, height);
context.restore(); context.restore();
} },
}); });
const layer = new VectorLayer({ const layer = new VectorLayer({
source: vectorSource, source: vectorSource,
style: (feature) => { style: (feature) => {
@@ -66,35 +72,40 @@ function useMap(target) {
text: new Text({ text: new Text({
text: feature.get("name"), text: feature.get("name"),
fill: new Fill({ fill: new Fill({
color: "#fff" color: "#fff",
}), }),
scale: 1.5 scale: 1.5,
}),
zIndex: 1,
}), }),
zIndex: 1
})
]; ];
} },
}); });
vectorSource.on("addfeature", function(event) {
vectorSource.on("addfeature", function (event) {
const feature = event.feature; const feature = event.feature;
const img = new Image(); const img = new Image();
img.src = materialJpg; img.src = materialJpg;
img.onload = function() { img.onload = function () {
feature?.set("material", img); feature?.set("material", img);
}; };
}); });
vectorSource.on("featuresloadend", (event) => { vectorSource.on("featuresloadend", (event) => {
const view = mapInstance.getView(); const view = mapInstance.getView();
const extent = event.target.getExtent(); const extent = event.target.getExtent();
view.fit(extent, { padding: [0, 30, 0, 0] }); view.fit(extent, { padding: [0, 30, 0, 0] });
view.setCenter(getCenter(extent)); view.setCenter(getCenter(extent));
}); });
mapInstance.addLayer(layer); mapInstance.addLayer(layer);
onMounted(() => { onMounted(() => {
mapInstance.setTarget(target); mapInstance.setTarget(target);
}); });
return { return {
mapInstance mapInstance,
}; };
} }
export default useMap;

View File

@@ -2,19 +2,23 @@ import { createApp } from "vue";
import "./style.less"; import "./style.less";
import App from "./App.vue"; import App from "./App.vue";
import Chart from "./components/chart/index.vue"; import Chart from "./components/chart/index.vue";
import * as echarts from "echarts"; import { registerTheme } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import echartsConfig from "./config/echarts.config"; import echartsConfig from "./config/echarts.config";
import naive, { createDiscreteApi } from "naive-ui";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import router from "./router"; import router from "./router";
import '../src/assets/font/YouSheBiaoTiHei.css'; import "./router/routerGuard";
echarts.registerTheme("t-theme", echartsConfig);
const { message, dialog } = createDiscreteApi(["message", "dialog"]); // CanvasRenderer 注册到全局,所有图表都需要
window.$message = message; import { use } from "echarts/core";
window.$modal = dialog; use([CanvasRenderer]);
registerTheme("t-theme", echartsConfig);
const app = createApp(App); const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
app.use(router); app.use(router);
app.use(naive);
app.component("TChart", Chart); app.component("TChart", Chart);
app.mount("#app"); app.mount("#app");

View File

@@ -1,13 +1,16 @@
import { getToken } from "@/utils/auth"; import { getToken } from "@/utils/auth";
import { import {
createRouter, createRouter,
createWebHashHistory createWebHashHistory,
type RouteRecordRaw,
} from "vue-router"; } from "vue-router";
const dashboardDefaultPath = "/dashboard/integrated-center"; const dashboardDefaultPath = "/dashboard/integrated-center";
const routes = [
const routes: RouteRecordRaw[] = [
{ {
path: "/", path: "/",
redirect: () => getToken() ? dashboardDefaultPath : "/login" redirect: () => (getToken() ? dashboardDefaultPath : "/login"),
}, },
{ {
path: "/dashboard", path: "/dashboard",
@@ -17,67 +20,61 @@ const routes = [
{ {
path: "integrated-center", path: "integrated-center",
name: "IntegratedCenter", name: "IntegratedCenter",
component: () => import("@/views/dashboard1/pages/IntegratedCenterPage.vue"), component: () =>
meta: { title: "监管中心", tabId: 1 } import("@/views/dashboard1/pages/IntegratedCenterPage.vue"),
meta: { title: "监管中心", tabId: 1, },
}, },
{ {
path: "equipment-management", path: "equipment-management",
name: "EquipmentManagement", name: "EquipmentManagement",
component: () => import("@/views/dashboard1/pages/EquipmentManagementPage.vue"), component: () =>
meta: { title: "设备管理", tabId: 2 } import("@/views/dashboard1/pages/EquipmentManagementPage.vue"),
meta: { title: "设备管理", tabId: 2, },
}, },
{ {
path: "video-center", path: "video-center",
name: "VideoCenter", name: "VideoCenter",
component: () => import("@/views/dashboard1/pages/VideoCenterPage.vue"), component: () => import("@/views/dashboard1/pages/VideoCenterPage.vue"),
meta: { title: "视频中心", tabId: 3 } meta: { title: "视频中心", tabId: 3, },
}, },
{ {
path: "alarm-management", path: "alarm-management",
name: "AlarmManagement", name: "AlarmManagement",
component: () => import("@/views/dashboard1/pages/AlarmManagementPage.vue"), component: () =>
meta: { title: "告警管理", tabId: 4 } import("@/views/dashboard1/pages/AlarmManagementPage.vue"),
meta: { title: "告警管理", tabId: 4, },
}, },
{ {
path: "patrol-plan", path: "patrol-plan",
name: "PatrolPlan", name: "PatrolPlan",
component: () => import("@/views/dashboard1/pages/PatrolPlanPage.vue"), component: () => import("@/views/dashboard1/pages/PatrolPlanPage.vue"),
meta: { title: "巡检记录", tabId: 5 } meta: { title: "巡检记录", tabId: 5, },
}, },
{ {
path: "data-report", path: "data-report",
name: "DataReport", name: "DataReport",
component: () => import("@/views/dashboard1/pages/DataReportPage.vue"), component: () => import("@/views/dashboard1/pages/DataReportPage.vue"),
meta: { title: "统计分析", tabId: 6 } meta: { title: "统计分析", tabId: 6, },
} },
] ],
}, },
{ {
path: "/login", path: "/login",
name: "Login", name: "Login",
component: () => import("@/views/login/index.vue"), component: () => import("@/views/login/index.vue"),
meta: { title: "登录" } meta: { title: "登录" },
}, },
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
redirect: dashboardDefaultPath redirect: dashboardDefaultPath,
} },
]; ];
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), 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() { export function resetRouter() {
// 当前项目暂无动态 addRoute 路由,保留该方法兼容登出流程。 // 当前项目暂无动态 addRoute 路由,保留该方法兼容登出流程。
} }

60
src/router/routerGuard.ts Normal file
View File

@@ -0,0 +1,60 @@
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()
})

View File

@@ -1,5 +0,0 @@
import { createPinia } from 'pinia'
export function setupStore(app) {
app.use(createPinia())
}

6
src/store/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createPinia } from 'pinia'
import type { App } from 'vue'
export function setupStore(app: App) {
app.use(createPinia())
}

View File

@@ -1,21 +1,31 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { useDark } from '@vueuse/core'
const sidebarStatusKey = 'sidebar_status' 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', { export const useAppStore = defineStore('app', {
state() { state: (): AppState => ({
return {
sidebarStatus: localStorage.getItem(sidebarStatusKey) ? localStorage.getItem(sidebarStatusKey) === '1' : true, sidebarStatus: localStorage.getItem(sidebarStatusKey) ? localStorage.getItem(sidebarStatusKey) === '1' : true,
device: 'desktop', device: 'desktop',
config: {}, config: {},
reloadFlag: true, reloadFlag: true,
isDark: !useDark(), isDark: !window.matchMedia('(prefers-color-scheme: dark)').matches,
} }),
},
getters: { getters: {
sidebar() { sidebar(): boolean {
return this.sidebarStatus return this.sidebarStatus
} }
}, },
@@ -28,11 +38,11 @@ export const useAppStore = defineStore('app', {
localStorage.setItem(sidebarStatusKey, '0') localStorage.setItem(sidebarStatusKey, '0')
} }
}, },
closeSideBar(withoutAnimation) { closeSideBar(withoutAnimation: boolean) {
localStorage.setItem(sidebarStatusKey, '0') localStorage.setItem(sidebarStatusKey, '0')
this.sidebarStatus = false this.sidebarStatus = false
}, },
toggleDevice(device) { toggleDevice(device: string) {
this.device = device this.device = device
}, },
async reloadPage() { async reloadPage() {

View File

@@ -1,112 +0,0 @@
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();
},
},
});

73
src/store/modules/user.ts Normal file
View File

@@ -0,0 +1,73 @@
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();
},
},
});

48
src/types/api.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* 后端统一返回结构,对应后端 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 Normal file
View File

@@ -0,0 +1,20 @@
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"];

View File

@@ -1,10 +1,10 @@
const TokenKey = "token"; const TokenKey = "token";
const TOKEN_NAME_KEY = "tokenName"; const TOKEN_NAME_KEY = "tokenName";
let token = null; let token: string | null = null;
let tokenName = null; let tokenName: string | null = null;
export function getToken() { export function getToken(): string | null {
const storedToken = token || window.localStorage.getItem(TokenKey); const storedToken = token || window.localStorage.getItem(TokenKey);
if (!storedToken || storedToken === "null" || storedToken === "undefined") { if (!storedToken || storedToken === "null" || storedToken === "undefined") {
token = null; token = null;
@@ -15,21 +15,21 @@ export function getToken() {
return token; return token;
} }
export function setToken(tokenValue, tokenNameValue) { export function setToken(tokenValue: string, tokenNameValue?: string): void {
token = tokenValue; token = tokenValue;
tokenName = tokenNameValue || "Authorization"; tokenName = tokenNameValue || "Authorization";
window.localStorage.setItem(TokenKey, token); window.localStorage.setItem(TokenKey, token);
window.localStorage.setItem(TOKEN_NAME_KEY, tokenName); window.localStorage.setItem(TOKEN_NAME_KEY, tokenName);
} }
export function removeToken() { export function removeToken(): void {
token = null; token = null;
tokenName = null; tokenName = null;
window.localStorage.removeItem(TokenKey); window.localStorage.removeItem(TokenKey);
window.localStorage.removeItem(TOKEN_NAME_KEY); window.localStorage.removeItem(TOKEN_NAME_KEY);
} }
export function getTokenName() { export function getTokenName(): string | null {
tokenName = tokenName || window.localStorage.getItem(TOKEN_NAME_KEY); tokenName = tokenName || window.localStorage.getItem(TOKEN_NAME_KEY);
return tokenName; return tokenName;
} }

View File

@@ -1,4 +1,10 @@
function generateNumbers(min, max, count) { 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[] {
if (!count) { if (!count) {
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
@@ -9,6 +15,5 @@ function generateNumbers(min, max, count) {
} }
return numbers; return numbers;
} }
export {
generateNumbers export { generateNumbers };
};

View File

@@ -1,12 +1,12 @@
import { JSEncrypt } from 'jsencrypt' import { JSEncrypt } from 'jsencrypt'
import { getPublicKey } from '@/api/user' import { getPublicKey } from '@/api/user'
export async function getPasswordPublicKey() { export async function getPasswordPublicKey(): Promise<string> {
const res = await getPublicKey() const res = await getPublicKey()
return res.data return res.data
} }
export function encryptPassword(plainText, publicKey) { export function encryptPassword(plainText: string, publicKey: string): string {
const encryptor = new JSEncrypt() const encryptor = new JSEncrypt()
encryptor.setPublicKey(formatPublicKey(publicKey)) encryptor.setPublicKey(formatPublicKey(publicKey))
const encrypted = encryptor.encrypt(plainText) const encrypted = encryptor.encrypt(plainText)
@@ -16,16 +16,19 @@ export function encryptPassword(plainText, publicKey) {
return encrypted return encrypted
} }
export async function encryptPasswordPayload(payload, fields) { export async function encryptPasswordPayload<T extends Record<string, unknown>>(
payload: T,
fields: Array<keyof T>,
): Promise<T> {
const publicKey = await getPasswordPublicKey() const publicKey = await getPasswordPublicKey()
const nextPayload = { ...payload } const nextPayload = { ...payload }
for (const field of fields) { for (const field of fields) {
nextPayload[field] = encryptPassword(payload[field] || '', publicKey) nextPayload[field] = encryptPassword(String(payload[field] || ''), publicKey) as T[keyof T]
} }
return nextPayload return nextPayload
} }
function formatPublicKey(publicKey) { function formatPublicKey(publicKey: string): string {
if (!publicKey) { if (!publicKey) {
return '' return ''
} }

View File

@@ -1,4 +1,4 @@
import axios from "axios"; import axios, { type AxiosRequestConfig } from "axios";
import { useUserStore } from "@/store/modules/user"; import { useUserStore } from "@/store/modules/user";
import { getToken, getTokenName } from "@/utils/auth"; import { getToken, getTokenName } from "@/utils/auth";
import router from "@/router"; import router from "@/router";
@@ -58,9 +58,8 @@ service.interceptors.response.use(
window.$message.error(error.response.data.msg, { window.$message.error(error.response.data.msg, {
duration: 5 * 1000, duration: 5 * 1000,
}); });
} else {
handleNoLogin();
} }
handleNoLogin();
} else { } else {
handleNoLogin(); handleNoLogin();
} }
@@ -71,16 +70,21 @@ service.interceptors.response.use(
}, },
); );
export default service; // 响应拦截器已解包 response.data对外暴露的请求方法保持 Promise<T> 语义
const request = <T = unknown>(config: AxiosRequestConfig): Promise<T> => {
return service.request<any, T>(config);
};
function showApiMessage(res) { export default request;
function showApiMessage(res: { msg?: string }) {
window.$message.error(res.msg || "error", { window.$message.error(res.msg || "error", {
duration: 5 * 1000, duration: 5 * 1000,
}); });
} }
// 弹窗确认防抖 // 弹窗确认防抖
let noLoginAlert = null; let noLoginAlert: unknown = null;
function handleNoLogin() { function handleNoLogin() {
if (noLoginAlert == null) { if (noLoginAlert == null) {

114
src/utils/rosMapYaml.ts Normal file
View File

@@ -0,0 +1,114 @@
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,
};
}

View File

@@ -1,24 +0,0 @@
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>;
}
});

View File

@@ -0,0 +1,29 @@
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>
);
},
});

View File

@@ -1,44 +1,51 @@
import { onBeforeUnmount, ref } from "vue"; import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils"; import { generateNumbers } from "@/utils";
export default function () { export default function () {
const option = ref({}); const option = ref<EChartsOption>({});
function refresh() { function refresh() {
option.value = { option.value = {
xAxis: { xAxis: {
type: "category", type: "category",
data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
}, },
yAxis: { yAxis: {
type: "value" type: "value",
}, },
grid: { grid: {
left: "12%" left: "12%",
}, },
series: [ series: [
{ {
data: generateNumbers(1, 300, 7), data: generateNumbers(1, 300, 7),
type: "line" type: "line",
}, },
{ {
data: generateNumbers(1, 300, 7), data: generateNumbers(1, 300, 7),
type: "line" type: "line",
}, },
{ {
data: generateNumbers(1, 300, 7), data: generateNumbers(1, 300, 7),
type: "line" type: "line",
} },
] ],
}; };
} }
refresh(); refresh();
const timer = setInterval(() => { const timer = setInterval(() => {
refresh(); refresh();
}, 3e3); }, 3000);
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer); clearInterval(timer);
}); });
return { return {
option, option,
refresh refresh,
}; };
} }

View File

@@ -1,15 +1,18 @@
import { onBeforeUnmount, ref } from "vue"; import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils"; import { generateNumbers } from "@/utils";
export default function () { export default function () {
const option = ref({}); const option = ref<EChartsOption>({});
function refresh() { function refresh() {
option.value = { option.value = {
tooltip: { tooltip: {
trigger: "item" trigger: "item",
}, },
legend: { legend: {
top: "5%", top: "5%",
left: "center" left: "center",
}, },
series: [ series: [
{ {
@@ -18,42 +21,46 @@ export default function () {
radius: ["40%", "70%"], radius: ["40%", "70%"],
avoidLabelOverlap: false, avoidLabelOverlap: false,
itemStyle: { itemStyle: {
borderColor: "#fff" borderColor: "#fff",
}, },
label: { label: {
show: false, show: false,
position: "center" position: "center",
}, },
emphasis: { emphasis: {
label: { label: {
show: true, show: true,
fontSize: 30, fontSize: 30,
fontWeight: "bold" fontWeight: "bold",
} },
}, },
labelLine: { labelLine: {
show: false show: false,
}, },
data: [ 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: "产品三" },
{ value: generateNumbers(10, 100), name: "产品四" }, { value: generateNumbers(10, 100), name: "产品四" },
{ value: generateNumbers(10, 100), name: "产品五" } { value: generateNumbers(10, 100), name: "产品五" },
] ],
} },
] ],
}; };
} }
refresh(); refresh();
const timer = setInterval(() => { const timer = setInterval(() => {
refresh(); refresh();
}, 3e3); }, 3000);
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer); clearInterval(timer);
}); });
return { return {
option, option,
refresh refresh,
}; };
} }

View File

@@ -1,12 +1,15 @@
import { onBeforeUnmount, ref } from "vue"; import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils"; import { generateNumbers } from "@/utils";
export default function () { export default function () {
const option = ref({}); const option = ref<EChartsOption>({});
function refresh() { function refresh() {
option.value = { option.value = {
tooltip: { tooltip: {
trigger: "item", trigger: "item",
formatter: "{a} <br/>{b} : {c}%" formatter: "{a} <br/>{b} : {c}%",
}, },
series: [ series: [
{ {
@@ -15,53 +18,57 @@ export default function () {
left: "center", left: "center",
width: "100%", width: "100%",
min: 0, min: 0,
max: 1e3, max: 1000,
minSize: "0%", minSize: "0%",
maxSize: "100%", maxSize: "100%",
sort: "descending", sort: "descending",
gap: 2, gap: 2,
label: { label: {
show: true, show: true,
position: "inside" position: "inside",
}, },
labelLine: { labelLine: {
length: 10, length: 10,
lineStyle: { lineStyle: {
width: 1, width: 1,
type: "solid" type: "solid",
} },
}, },
itemStyle: { itemStyle: {
borderColor: "#fff", borderColor: "#fff",
borderWidth: 1 borderWidth: 1,
}, },
emphasis: { emphasis: {
label: { label: {
fontSize: 20 fontSize: 20,
} },
}, },
data: [ data: [
{ value: generateNumbers(500, 1e3), name: "访问量" }, { value: generateNumbers(500, 1000), name: "访问量" },
{ value: generateNumbers(500, 1e3), name: "注册量" }, { value: generateNumbers(500, 1000), name: "注册量" },
{ value: generateNumbers(500, 1e3), name: "浏览量" }, { value: generateNumbers(500, 1000), name: "浏览量" },
{ value: generateNumbers(200, 500), name: "加入购物车" }, { value: generateNumbers(200, 500), name: "加入购物车" },
{ value: generateNumbers(100, 800), name: "购买" }, { value: generateNumbers(100, 800), name: "购买" },
{ value: generateNumbers(1, 20), name: "退货" }, { value: generateNumbers(1, 20), name: "退货" },
{ value: generateNumbers(1, 20), name: "取消订单" } { value: generateNumbers(1, 20), name: "取消订单" },
] ],
} },
] ],
}; };
} }
refresh(); refresh();
const timer = setInterval(() => { const timer = setInterval(() => {
refresh(); refresh();
}, 3e3); }, 3000);
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer); clearInterval(timer);
}); });
return { return {
option, option,
refresh refresh,
}; };
} }

View File

@@ -1,7 +1,10 @@
import { onBeforeUnmount, ref } from "vue"; import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils"; import { generateNumbers } from "@/utils";
export default function () { export default function () {
const option = ref({}); const option = ref<EChartsOption>({});
function refresh() { function refresh() {
option.value = { option.value = {
radar: { radar: {
@@ -11,9 +14,9 @@ export default function () {
{ name: "品质", max: 200 }, { name: "品质", max: 200 },
{ name: "客户支持", max: 200 }, { name: "客户支持", max: 200 },
{ name: "可持续性", max: 200 }, { name: "可持续性", max: 200 },
{ name: "效果", max: 200 } { name: "效果", max: 200 },
], ],
radius: 100 radius: 100,
}, },
series: [ series: [
{ {
@@ -21,22 +24,26 @@ export default function () {
data: [ data: [
{ {
value: generateNumbers(1, 200, 6), value: generateNumbers(1, 200, 6),
areaStyle: {} areaStyle: {},
} },
] ],
} },
] ],
}; };
} }
refresh(); refresh();
const timer = setInterval(() => { const timer = setInterval(() => {
refresh(); refresh();
}, 3e3); }, 3000);
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer); clearInterval(timer);
}); });
return { return {
option, option,
refresh refresh,
}; };
} }

View File

@@ -1,40 +1,43 @@
import { onBeforeUnmount, ref } from "vue"; import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils"; import { generateNumbers } from "@/utils";
export default function () { export default function () {
const option = ref({}); const option = ref<EChartsOption>({});
function refresh() { function refresh() {
option.value = { option.value = {
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
axisPointer: { axisPointer: {
type: "shadow" type: "shadow",
} },
}, },
legend: { legend: {
top: "5%", top: "5%",
itemStyle: { itemStyle: {
borderColor: "#fff", borderColor: "#fff",
borderWidth: 2 borderWidth: 2,
} },
}, },
grid: { grid: {
left: "10%", left: "10%",
bottom: "8%", bottom: "8%",
containLabel: true containLabel: true,
}, },
xAxis: [ xAxis: [
{ {
type: "value" type: "value",
} },
], ],
yAxis: [ yAxis: [
{ {
type: "category", type: "category",
axisTick: { axisTick: {
show: false show: false,
},
data: ["一月", "二月", "三月", "四月", "五月", "六月", "七月"],
}, },
data: ["一月", "二月", "三月", "四月", "五月", "六月", "七月"]
}
], ],
series: [ series: [
{ {
@@ -42,49 +45,53 @@ export default function () {
type: "bar", type: "bar",
label: { label: {
show: true, show: true,
position: "inside" position: "inside",
}, },
emphasis: { emphasis: {
focus: "series" focus: "series",
}, },
data: generateNumbers(1, 200, 7) data: generateNumbers(1, 200, 7),
}, },
{ {
name: "购买", name: "购买",
type: "bar", type: "bar",
stack: "Total", stack: "Total",
label: { label: {
show: true show: true,
}, },
emphasis: { emphasis: {
focus: "series" focus: "series",
}, },
data: generateNumbers(1, 300, 7) data: generateNumbers(1, 300, 7),
}, },
{ {
name: "退货", name: "退货",
type: "bar", type: "bar",
stack: "Total", stack: "Total",
label: { label: {
show: true show: true,
}, },
emphasis: { emphasis: {
focus: "series" focus: "series",
}, },
data: generateNumbers(-50, -10, 7) data: generateNumbers(-50, -10, 7),
} },
] ],
}; };
} }
refresh(); refresh();
const timer = setInterval(() => { const timer = setInterval(() => {
refresh(); refresh();
}, 3e3); }, 3000);
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer); clearInterval(timer);
}); });
return { return {
option, option,
refresh refresh,
}; };
} }

View File

@@ -1,7 +1,10 @@
import { onBeforeUnmount, ref } from "vue"; import { onBeforeUnmount, ref } from "vue";
import { EChartsOption } from "echarts";
import { generateNumbers } from "@/utils"; import { generateNumbers } from "@/utils";
export default function () { export default function () {
const option = ref({}); const option = ref<EChartsOption>({});
function refresh() { function refresh() {
option.value = { option.value = {
xAxis: {}, xAxis: {},
@@ -16,8 +19,8 @@ export default function () {
generateNumbers(1, 10, 2), generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2), generateNumbers(1, 10, 2),
[8.07, 6.95], [8.07, 6.95],
[13, 7.58], [13.0, 7.58],
[14, 7.66], [14.0, 7.66],
[13.4, 6.81], [13.4, 6.81],
generateNumbers(1, 10, 2), generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2), generateNumbers(1, 10, 2),
@@ -31,22 +34,26 @@ export default function () {
generateNumbers(1, 10, 2), generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2), generateNumbers(1, 10, 2),
generateNumbers(1, 10, 2), generateNumbers(1, 10, 2),
[5.02, 5.68] [5.02, 5.68],
],
type: "scatter",
},
], ],
type: "scatter"
}
]
}; };
} }
refresh(); refresh();
const timer = setInterval(() => { const timer = setInterval(() => {
refresh(); refresh();
}, 3e3); }, 3000);
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer); clearInterval(timer);
}); });
return { return {
option, option,
refresh refresh,
}; };
} }

View File

@@ -2,15 +2,6 @@
width: 1920px; width: 1920px;
height: 1080px; height: 1080px;
position: relative; position: relative;
z-index: 0;
overflow: hidden;
&:after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background-image: url("./assets/头部动画.webp"), url("./assets/头部动画.png"), background-image: url("./assets/头部动画.webp"), url("./assets/头部动画.png"),
url("./assets/整体边框_静态图片.png"), url("./assets/底部动画.webp"), url("./assets/整体边框_静态图片.png"), url("./assets/底部动画.webp"),
url("./assets/底部动画.png"), url("./assets/头部动画_左侧点.webp"), url("./assets/底部动画.png"), url("./assets/头部动画_左侧点.webp"),
@@ -36,33 +27,23 @@
auto, auto,
auto, auto,
auto; auto;
}
&:before { &:before {
content: "管廊巡检机器人综合管理平台"; content: "管廊巡检机器人综合管理平台";
color: #FFFFFF; color: #fff;
background: linear-gradient(0deg, #02B3FE 0%, #F9FDFF 100%); letter-spacing: 12px;
background-clip: text; font-size: 36px;
-webkit-background-clip: text; line-height: 70px;
-webkit-text-fill-color: transparent; width: 100%;
font-family: YouSheBiaoTiHei, system-ui; font-weight: bold;
font-size: 42px;
position: absolute; position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
text-align: center; text-align: center;
} }
} }
.background { .background {
position: absolute; position: relative;
inset: 0; z-index: -1;
z-index: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
@@ -70,7 +51,6 @@
.main { .main {
position: absolute; position: absolute;
z-index: 2;
width: 100%; width: 100%;
height: 100%; height: 100%;
top: 0; top: 0;
@@ -276,27 +256,6 @@
overflow: auto; overflow: auto;
border: 1px solid rgba(77, 188, 248, 0.12); border: 1px solid rgba(77, 188, 248, 0.12);
background: rgba(5, 22, 46, 0.46); background: rgba(5, 22, 46, 0.46);
scrollbar-width: thin;
scrollbar-color: rgba(92, 215, 255, 0.48) rgba(5, 22, 46, 0.72);
}
.table-shell::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-shell::-webkit-scrollbar-track {
background: rgba(5, 22, 46, 0.72);
}
.table-shell::-webkit-scrollbar-thumb {
border-radius: 999px;
background: linear-gradient(180deg, rgba(107, 230, 255, 0.78), rgba(27, 121, 202, 0.72));
box-shadow: inset 0 0 6px rgba(178, 245, 255, 0.28);
}
.table-shell::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(146, 242, 255, 0.95), rgba(43, 154, 231, 0.88));
} }
.device-table { .device-table {
@@ -308,8 +267,7 @@
.device-table th, .device-table th,
.device-table td { .device-table td {
padding: 14px 16px; padding: 14px 16px;
text-align: center; text-align: left;
vertical-align: middle;
border-bottom: 1px solid rgba(77, 188, 248, 0.08); border-bottom: 1px solid rgba(77, 188, 248, 0.08);
color: #dff7ff; color: #dff7ff;
font-size: 13px; font-size: 13px;
@@ -423,7 +381,6 @@
.table-actions { .table-actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 8px; gap: 8px;
} }
@@ -486,31 +443,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
.pagination-jumper {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(174, 226, 247, 0.78);
font-size: 12px;
}
.pagination-select {
height: 34px;
min-width: 96px;
padding: 0 28px 0 12px;
border: 1px solid rgba(87, 198, 255, 0.18);
background: rgba(8, 31, 62, 0.72);
color: #dff8ff;
font-size: 12px;
outline: none;
cursor: pointer;
}
.pagination-select:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.crud-dialog-mask { .crud-dialog-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1712,17 +1644,6 @@
drop-shadow(0 0 28px rgba(20, 94, 160, 0.18)); drop-shadow(0 0 28px rgba(20, 94, 160, 0.18));
} }
.meter-grid-fill {
pointer-events: none;
}
.meter-grid-line {
fill: none;
stroke: rgba(139, 236, 255, 0.22);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.map-state-tip { .map-state-tip {
position: absolute; position: absolute;
z-index: 3; z-index: 3;

View File

@@ -1,24 +1,34 @@
<script setup> <script lang="ts" setup>
import { computed } from "vue"; import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import SwitchPageBtn from "./components/btn/index.jsx"; import SwitchPageBtn from "./components/btn";
import backgroundVideo from "./assets/循环背景动画.mp4"; import backgroundVideo from "./assets/循环背景动画.mp4";
interface DashboardTab {
id: number;
text: string;
path: string;
}
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const tabs = {
const tabs: Record<"left" | "right", DashboardTab[]> = {
left: [ left: [
{ id: 1, text: "监管中心", path: "/dashboard/integrated-center" }, { id: 1, text: "监管中心", path: "/dashboard/integrated-center" },
{ id: 2, text: "设备管理", path: "/dashboard/equipment-management" }, { id: 2, text: "设备管理", path: "/dashboard/equipment-management" },
{ id: 3, text: "视频中心", path: "/dashboard/video-center" } { id: 3, text: "视频中心", path: "/dashboard/video-center" },
], ],
right: [ right: [
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management" }, { id: 4, text: "告警管理", path: "/dashboard/alarm-management" },
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan" }, { id: 5, text: "巡检记录", path: "/dashboard/patrol-plan" },
{ id: 6, text: "统计分析", path: "/dashboard/data-report" } { id: 6, text: "统计分析", path: "/dashboard/data-report" },
] ],
}; };
const activeTabId = computed(() => Number(route.meta.tabId) || 1); const activeTabId = computed(() => Number(route.meta.tabId) || 1);
function switchPage(tab) {
function switchPage(tab: DashboardTab) {
if (route.path !== tab.path) { if (route.path !== tab.path) {
router.push(tab.path); router.push(tab.path);
} }

View File

@@ -1,65 +1,104 @@
<script setup> <script lang="ts" setup>
import { NImage } from "naive-ui";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { import {
getAlarmPage getAlarmPage,
type RosInspectionRecordLineImage,
} from "@/api/alarm"; } from "@/api/alarm";
import { BASE_URL } from "@/utils/request"; import { BASE_URL } from "@/utils/request";
import { getRecentAlertPhoto } from "../shared/alertPhotos"; import { getRecentAlertPhoto } from "../shared/alertPhotos";
const recognizeTypeMap = {
interface AlarmRecord {
id: string;
type: string;
robot: string;
photo: string;
status: RecognitionStatusText;
time: string;
position: 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> = {
100: "局部高温", 100: "局部高温",
1: "电缆表面破损", 1: "电缆表面破损",
2: "桥架断裂", 2: "桥架断裂",
3: "隧道积水", 3: "隧道积水",
4: "墙体裂缝" 4: "墙体渗漏",
}; };
const recognitionStatusMap = {
const recognitionStatusMap: Record<number, RecognitionStatusText> = {
0: "待执行", 0: "待执行",
1: "执行中", 1: "执行中",
2: "执行成功", 2: "执行成功",
3: "执行失败" 3: "执行失败",
}; };
const alarmNo = ref(""); const alarmNo = ref("");
const recognizeType = ref(""); const recognizeType = ref("");
const warningFilter = ref("true"); const warningFilter = ref<WarningFilterValue>("true");
const startTime = ref(""); const startTime = ref("");
const endTime = ref(""); const endTime = ref("");
const alarms = ref([]); const alarms = ref<AlarmRecord[]>([]);
const totalAlarms = ref(0); const totalAlarms = ref(0);
const listLoading = ref(false); const listLoading = ref(false);
const listError = ref(""); const listError = ref("");
const pageSize = 10; const pageSize = 10;
const currentPage = ref(1); 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 pagedAlarms = computed(() => alarms.value);
const visiblePages = computed(() => { const visiblePages = computed(() => {
const pages = []; const pages: number[] = [];
const start = Math.max(1, currentPage.value - 2); const start = Math.max(1, currentPage.value - 2);
const end = Math.min(totalPages.value, start + 4); const end = Math.min(totalPages.value, start + 4);
for (let page = start; page <= end; page += 1) { for (let page = start; page <= end; page += 1) {
pages.push(page); pages.push(page);
} }
return pages; return pages;
}); });
function getAlarmStatusClass(status) {
const classMap = { function getAlarmStatusClass(status: AlarmRecord["status"]) {
const classMap: Record<AlarmRecord["status"], string> = {
待执行: "alarm-status-pending", 待执行: "alarm-status-pending",
执行中: "alarm-status-handling", 执行中: "alarm-status-handling",
执行成功: "alarm-status-finished", 执行成功: "alarm-status-finished",
执行失败: "alarm-status-false", 执行失败: "alarm-status-false",
未知状态: "alarm-status-false" 未知状态: "alarm-status-false",
}; };
return classMap[status]; return classMap[status];
} }
function setPage(page) {
function setPage(page: number) {
currentPage.value = Math.min(Math.max(page, 1), totalPages.value); currentPage.value = Math.min(Math.max(page, 1), totalPages.value);
fetchAlarmList(); fetchAlarmList();
} }
function handleSearch() { function handleSearch() {
currentPage.value = 1; currentPage.value = 1;
fetchAlarmList(); fetchAlarmList();
} }
function handleClear() { function handleClear() {
alarmNo.value = ""; alarmNo.value = "";
recognizeType.value = ""; recognizeType.value = "";
@@ -68,23 +107,28 @@ function handleClear() {
endTime.value = ""; endTime.value = "";
handleSearch(); handleSearch();
} }
// 请求告警分页列表并更新表格数据
async function fetchAlarmList() { async function fetchAlarmList() {
listLoading.value = true; listLoading.value = true;
listError.value = ""; listError.value = "";
try { try {
const response = await getAlarmPage({ const response = (await getAlarmPage({
page: currentPage.value, page: currentPage.value,
limit: pageSize, limit: pageSize,
orderList: [{ property: 'createTime', asc: false }],
query: { query: {
recognizeType: normalizeRecognizeType(recognizeType.value), recognizeType: normalizeRecognizeType(recognizeType.value),
warning: normalizeWarningFilter(warningFilter.value), warning: normalizeWarningFilter(warningFilter.value),
startTime: normalizeDateTime(startTime.value), startTime: normalizeDateTime(startTime.value),
endTime: normalizeDateTime(endTime.value), endTime: normalizeDateTime(endTime.value),
id: alarmNo.value.trim() || void 0 id: alarmNo.value.trim() || undefined,
} },
}); })) as AlarmPageResponse;
const result = response.data; const result = response.data;
const rows = result?.data ?? []; const rows = result?.data ?? [];
alarms.value = rows.map((record, index) => normalizeAlarmRecord(record, index)); alarms.value = rows.map((record, index) => normalizeAlarmRecord(record, index));
totalAlarms.value = result?.total ?? rows.length; totalAlarms.value = result?.total ?? rows.length;
} catch (error) { } catch (error) {
@@ -96,8 +140,11 @@ async function fetchAlarmList() {
listLoading.value = false; listLoading.value = false;
} }
} }
function normalizeAlarmRecord(record, index) {
// 将后端告警记录转换为页面表格展示结构
function normalizeAlarmRecord(record: RosInspectionRecordLineImage, index: number): AlarmRecord {
const type = getRecognizeTypeText(record.recognizeType); const type = getRecognizeTypeText(record.recognizeType);
return { return {
id: record.id || record.recordActionId || record.recordId || `AL-${index + 1}`, id: record.id || record.recordActionId || record.recordId || `AL-${index + 1}`,
type, type,
@@ -107,45 +154,64 @@ function normalizeAlarmRecord(record, index) {
time: record.captureTime || record.createTime || "--", time: record.captureTime || record.createTime || "--",
position: formatPosition(record), position: formatPosition(record),
result: record.warningValue || record.recognitionResult || record.failReason || "--", result: record.warningValue || record.recognitionResult || record.failReason || "--",
warning: Boolean(record.warning) warning: Boolean(record.warning),
}; };
} }
function getCarName(record) {
function getCarName(record: RosInspectionRecordLineImage) {
return record.car?.name || record.car?.deviceNo || record.car?.deviceKey || record.carId || "未知设备"; return record.car?.name || record.car?.deviceNo || record.car?.deviceKey || record.carId || "未知设备";
} }
function getRecognizeTypeText(type) {
function getRecognizeTypeText(type?: number) {
return type ? recognizeTypeMap[type] ?? "未知告警" : "未知告警"; return type ? recognizeTypeMap[type] ?? "未知告警" : "未知告警";
} }
function getRecognitionStatusText(status) {
function getRecognitionStatusText(status?: number) {
return typeof status === "number" ? recognitionStatusMap[status] ?? "未知状态" : "未知状态"; return typeof status === "number" ? recognitionStatusMap[status] ?? "未知状态" : "未知状态";
} }
function buildFileAccessUrl(fileUrl) {
if (!fileUrl || /^(https?:)?\/\//i.test(fileUrl) || fileUrl.startsWith("blob:") || fileUrl.startsWith("data:")) { // 相对文件地址通过接口代理访问,避免开发环境直接请求后端静态路径失败
function buildFileAccessUrl(fileUrl: string) {
if (
!fileUrl ||
/^(https?:)?\/\//i.test(fileUrl) ||
fileUrl.startsWith("blob:") ||
fileUrl.startsWith("data:")
) {
return fileUrl; return fileUrl;
} }
if (fileUrl === BASE_URL || fileUrl.startsWith(`${BASE_URL}/`)) { if (fileUrl === BASE_URL || fileUrl.startsWith(`${BASE_URL}/`)) {
return fileUrl; return fileUrl;
} }
return `${BASE_URL}${fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`}`; return `${BASE_URL}${fileUrl.startsWith("/") ? fileUrl : `/${fileUrl}`}`;
} }
function normalizeDateTime(value) {
return value ? value.replace("T", " ") : void 0; function normalizeDateTime(value: string) {
return value ? value.replace("T", " ") : undefined;
} }
function normalizeRecognizeType(value) {
return value ? Number(value) : void 0; function normalizeRecognizeType(value: string) {
return value ? Number(value) : undefined;
} }
function normalizeWarningFilter(value) {
function normalizeWarningFilter(value: WarningFilterValue) {
if (!value) { if (!value) {
return void 0; return undefined;
} }
return value === "true"; return value === "true";
} }
function formatPosition(record) {
function formatPosition(record: RosInspectionRecordLineImage) {
if (typeof record.poseX !== "number" || typeof record.poseY !== "number") { if (typeof record.poseX !== "number" || typeof record.poseY !== "number") {
return "--"; return "--";
} }
return `X:${record.poseX.toFixed(2)} Y:${record.poseY.toFixed(2)}`; return `X:${record.poseX.toFixed(2)} Y:${record.poseY.toFixed(2)}`;
} }
onMounted(() => { onMounted(() => {
fetchAlarmList(); fetchAlarmList();
}); });
@@ -163,7 +229,7 @@ onMounted(() => {
<option value="1">电缆表面破损</option> <option value="1">电缆表面破损</option>
<option value="2">桥架断裂</option> <option value="2">桥架断裂</option>
<option value="3">隧道积水</option> <option value="3">隧道积水</option>
<option value="4">墙体裂缝</option> <option value="4">墙体渗漏</option>
</select> </select>
<select v-model="warningFilter" class="toolbar-select"> <select v-model="warningFilter" class="toolbar-select">
<option value="">全部预警状态</option> <option value="">全部预警状态</option>
@@ -189,6 +255,7 @@ onMounted(() => {
/> />
<button class="page-action-btn primary" type="button" @click="handleSearch">搜索</button> <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="handleClear">清空</button>
<button class="page-action-btn ghost" type="button" @click="fetchAlarmList">刷新</button>
</div> </div>
<div class="toolbar-right"> {{ totalAlarms }} 条告警记录</div> <div class="toolbar-right"> {{ totalAlarms }} 条告警记录</div>
</div> </div>
@@ -239,11 +306,7 @@ onMounted(() => {
<td>{{ alarm.time }}</td> <td>{{ alarm.time }}</td>
<td>{{ alarm.position }}</td> <td>{{ alarm.position }}</td>
<td>{{ alarm.result }}</td> <td>{{ alarm.result }}</td>
<td> <td>{{ alarm.warning ? "是" : "否" }}</td>
<NTag :type="alarm.warning ? 'error' : 'success'" size="small">
{{ alarm.warning ? "是" : "否" }}
</NTag>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -276,19 +339,6 @@ onMounted(() => {
> >
下一页 下一页
</button> </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> </div>
</section> </section>
</div> </div>

View File

@@ -1,29 +1,64 @@
<script setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import type { EChartsOption } from "echarts";
import Chart from "@/components/chart/index.vue"; import Chart from "@/components/chart/index.vue";
const distributionRange = ref("近一周");
const reportData = [ // 按需注册 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[] = [
{ id: "AL-001", type: "线缆破损", robot: "巡检机器人 R1", project: "西江隧道", status: "待研判", time: "2026-06-23 10:24:18" }, { 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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-019", type: "水渍渗漏", robot: "巡检机器人 N1", project: "南山隧道", status: "处置完成", time: "2026-04-12 10:03:16" },
]; ];
const trendData = [
const trendData: TrendRecord[] = [
{ date: "06-09", project: "西江隧道", total: 1, closed: 1 }, { date: "06-09", project: "西江隧道", total: 1, closed: 1 },
{ date: "06-09", project: "青云隧道", total: 1, closed: 0 }, { date: "06-09", project: "青云隧道", total: 1, closed: 0 },
{ date: "06-09", project: "南山隧道", total: 1, closed: 1 }, { date: "06-09", project: "南山隧道", total: 1, closed: 1 },
@@ -68,48 +103,53 @@ const trendData = [
{ date: "06-22", project: "南山隧道", total: 4, closed: 3 }, { date: "06-22", project: "南山隧道", total: 4, closed: 3 },
{ date: "06-23", 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: 3, closed: 2 },
{ date: "06-23", project: "南山隧道", total: 4, closed: 3 } { date: "06-23", project: "南山隧道", total: 4, closed: 3 },
]; ];
const trendSeries = computed(() => { const trendSeries = computed(() => {
const grouped = trendData.reduce( const grouped = trendData.reduce<Record<string, { total: number; closed: number }>>(
(accumulator, item) => { (accumulator, item) => {
if (!accumulator[item.date]) { if (!accumulator[item.date]) {
accumulator[item.date] = { total: 0, closed: 0 }; accumulator[item.date] = { total: 0, closed: 0 };
} }
accumulator[item.date].total += item.total; accumulator[item.date].total += item.total;
accumulator[item.date].closed += item.closed; accumulator[item.date].closed += item.closed;
return accumulator; return accumulator;
}, },
{} {},
); );
return Object.entries(grouped).map(([date, value]) => ({ return Object.entries(grouped).map(([date, value]) => ({
date, date,
total: value.total, total: value.total,
closed: value.closed closed: value.closed,
})); }));
}); });
const trendOption = computed(() => {
const trendOption = computed<EChartsOption>(() => {
const dates = trendSeries.value.map((item) => item.date); const dates = trendSeries.value.map((item) => item.date);
const totalValues = trendSeries.value.map((item) => item.total); const totalValues = trendSeries.value.map((item) => item.total);
const closedValues = trendSeries.value.map((item) => item.closed); const closedValues = trendSeries.value.map((item) => item.closed);
return { return {
grid: { grid: {
left: 24, left: 24,
right: 16, right: 16,
top: 24, top: 24,
bottom: 28, bottom: 28,
containLabel: true containLabel: true,
}, },
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
backgroundColor: "rgba(6, 20, 42, 0.92)", backgroundColor: "rgba(6, 20, 42, 0.92)",
borderColor: "rgba(96, 208, 255, 0.24)", borderColor: "rgba(96, 208, 255, 0.24)",
textStyle: { textStyle: {
color: "#eefcff" color: "#eefcff",
} },
}, },
legend: { legend: {
show: false show: false,
}, },
xAxis: { xAxis: {
type: "category", type: "category",
@@ -117,35 +157,35 @@ const trendOption = computed(() => {
data: dates, data: dates,
axisLine: { axisLine: {
lineStyle: { lineStyle: {
color: "rgba(96, 188, 242, 0.2)" color: "rgba(96, 188, 242, 0.2)",
} },
}, },
axisLabel: { axisLabel: {
color: "rgba(181, 230, 249, 0.74)", color: "rgba(181, 230, 249, 0.74)",
fontSize: 11 fontSize: 11,
}, },
axisTick: { axisTick: {
show: false show: false,
} },
}, },
yAxis: { yAxis: {
type: "value", type: "value",
splitNumber: 5, splitNumber: 5,
axisLine: { axisLine: {
show: false show: false,
}, },
axisTick: { axisTick: {
show: false show: false,
}, },
axisLabel: { axisLabel: {
color: "rgba(181, 230, 249, 0.62)", color: "rgba(181, 230, 249, 0.62)",
fontSize: 11 fontSize: 11,
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: "rgba(97, 189, 242, 0.12)" color: "rgba(97, 189, 242, 0.12)",
} },
} },
}, },
series: [ series: [
{ {
@@ -157,16 +197,16 @@ const trendOption = computed(() => {
data: totalValues, data: totalValues,
lineStyle: { lineStyle: {
width: 3, width: 3,
color: "#74ecff" color: "#74ecff",
}, },
itemStyle: { itemStyle: {
color: "#74ecff", color: "#74ecff",
borderColor: "#081f3e", borderColor: "#081f3e",
borderWidth: 2 borderWidth: 2,
}, },
areaStyle: { areaStyle: {
color: "rgba(116, 236, 255, 0.12)" color: "rgba(116, 236, 255, 0.12)",
} },
}, },
{ {
name: "闭环告警", name: "闭环告警",
@@ -177,44 +217,48 @@ const trendOption = computed(() => {
data: closedValues, data: closedValues,
lineStyle: { lineStyle: {
width: 3, width: 3,
color: "#62e0a8" color: "#62e0a8",
}, },
itemStyle: { itemStyle: {
color: "#62e0a8", color: "#62e0a8",
borderColor: "#081f3e", borderColor: "#081f3e",
borderWidth: 2 borderWidth: 2,
}, },
areaStyle: { areaStyle: {
color: "rgba(98, 224, 168, 0.08)" color: "rgba(98, 224, 168, 0.08)",
} },
} },
] ],
}; };
}); });
const typeStats = computed(() => { const typeStats = computed(() => {
const statsMap = /* @__PURE__ */ new Map(); const statsMap = new Map<string, number>();
reportData.forEach((item) => { reportData.forEach((item) => {
statsMap.set(item.type, (statsMap.get(item.type) ?? 0) + 1); statsMap.set(item.type, (statsMap.get(item.type) ?? 0) + 1);
}); });
return Array.from(statsMap.entries()).map(([label, value]) => ({ return Array.from(statsMap.entries()).map(([label, value]) => ({
label, label,
value value,
})); }));
}); });
const typeRadarOption = computed(() => {
const typeRadarOption = computed<EChartsOption>(() => {
const maxValue = Math.max(...typeStats.value.map((item) => item.value), 1); const maxValue = Math.max(...typeStats.value.map((item) => item.value), 1);
const indicators = typeStats.value.map((item) => ({ const indicators = typeStats.value.map((item) => ({
name: item.label, name: item.label,
max: maxValue max: maxValue,
})); }));
return { return {
tooltip: { tooltip: {
trigger: "item", trigger: "item",
backgroundColor: "rgba(6, 20, 42, 0.92)", backgroundColor: "rgba(6, 20, 42, 0.92)",
borderColor: "rgba(96, 208, 255, 0.24)", borderColor: "rgba(96, 208, 255, 0.24)",
textStyle: { textStyle: {
color: "#eefcff" color: "#eefcff",
} },
}, },
radar: { radar: {
radius: "68%", radius: "68%",
@@ -223,23 +267,23 @@ const typeRadarOption = computed(() => {
splitNumber: 4, splitNumber: 4,
axisName: { axisName: {
color: "#dff7ff", color: "#dff7ff",
fontSize: 12 fontSize: 12,
}, },
axisLine: { axisLine: {
lineStyle: { lineStyle: {
color: "rgba(97, 189, 242, 0.22)" color: "rgba(97, 189, 242, 0.22)",
} },
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
color: "rgba(97, 189, 242, 0.16)" color: "rgba(97, 189, 242, 0.16)",
} },
}, },
splitArea: { splitArea: {
areaStyle: { 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: [ series: [
{ {
@@ -249,47 +293,50 @@ const typeRadarOption = computed(() => {
value: typeStats.value.map((item) => item.value), value: typeStats.value.map((item) => item.value),
name: "告警类型分布", name: "告警类型分布",
areaStyle: { areaStyle: {
color: "rgba(73, 207, 255, 0.20)" color: "rgba(73, 207, 255, 0.20)",
}, },
lineStyle: { lineStyle: {
color: "#61dcff", color: "#61dcff",
width: 2.5 width: 2.5,
}, },
itemStyle: { itemStyle: {
color: "#8ff3ff", color: "#8ff3ff",
borderColor: "#07203e", borderColor: "#07203e",
borderWidth: 2 borderWidth: 2,
}, },
symbolSize: 8 symbolSize: 8,
} },
] ],
} },
] ],
}; };
}); });
const statusStats = computed(() => { const statusStats = computed(() => {
const order = ["待研判", "待处理", "处置完成", "误判"]; const order: AlarmStatus[] = ["待研判", "待处理", "处置完成", "误判"];
const total = reportData.length || 1; const total = reportData.length || 1;
return order.map((status) => { return order.map((status) => {
const value = reportData.filter((item) => item.status === status).length; const value = reportData.filter((item) => item.status === status).length;
return { return {
label: status, label: status,
value, value,
percent: `${Math.round(value / total * 100)}%` percent: `${Math.round((value / total) * 100)}%`,
}; };
}); });
}); });
const statusPieOption = computed(() => ({
const statusPieOption = computed<EChartsOption>(() => ({
tooltip: { tooltip: {
trigger: "item", trigger: "item",
backgroundColor: "rgba(6, 20, 42, 0.92)", backgroundColor: "rgba(6, 20, 42, 0.92)",
borderColor: "rgba(96, 208, 255, 0.24)", borderColor: "rgba(96, 208, 255, 0.24)",
textStyle: { textStyle: {
color: "#eefcff" color: "#eefcff",
} },
}, },
legend: { legend: {
show: false show: false,
}, },
series: [ series: [
{ {
@@ -299,50 +346,63 @@ const statusPieOption = computed(() => ({
avoidLabelOverlap: false, avoidLabelOverlap: false,
itemStyle: { itemStyle: {
borderColor: "#081f3e", borderColor: "#081f3e",
borderWidth: 3 borderWidth: 3,
}, },
label: { label: {
show: true, show: true,
color: "#dff7ff", color: "#dff7ff",
formatter: "{b}\n{d}%", formatter: "{b}\n{d}%",
fontSize: 12 fontSize: 12,
}, },
labelLine: { labelLine: {
lineStyle: { lineStyle: {
color: "rgba(181, 230, 249, 0.52)" color: "rgba(181, 230, 249, 0.52)",
} },
}, },
data: statusStats.value.map((item) => ({ data: statusStats.value.map((item) => ({
name: item.label, name: item.label,
value: item.value, value: item.value,
itemStyle: { 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 projectDistribution = computed(() => {
const now = /* @__PURE__ */ new Date("2026-06-23T23:59:59"); const now = new Date("2026-06-23T23:59:59");
const rangeDaysMap = { const rangeDaysMap: Record<DistributionRange, number> = {
近一周: 7, 近一周: 7,
近一个月: 30, 近一个月: 30,
近三个月: 90 近三个月: 90,
}; };
const threshold = new Date( const threshold = new Date(
now.getTime() - rangeDaysMap[distributionRange.value] * 24 * 60 * 60 * 1e3 now.getTime() - rangeDaysMap[distributionRange.value] * 24 * 60 * 60 * 1000,
); );
const data = reportData.filter((item) => new Date(item.time.replace(" ", "T")) >= threshold); const data = reportData.filter((item) => new Date(item.time.replace(" ", "T")) >= threshold);
const statsMap = /* @__PURE__ */ new Map(); const statsMap = new Map<string, number>();
data.forEach((item) => { data.forEach((item) => {
statsMap.set(item.project, (statsMap.get(item.project) ?? 0) + 1); statsMap.set(item.project, (statsMap.get(item.project) ?? 0) + 1);
}); });
const maxValue = Math.max(...statsMap.values(), 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, label,
value, value,
width: `${value / maxValue * 100}%` width: `${(value / maxValue) * 100}%`,
})).sort((a, b) => b.value - a.value); }))
.sort((a, b) => b.value - a.value);
}); });
</script> </script>
@@ -419,7 +479,7 @@ const projectDistribution = computed(() => {
:key="range" :key="range"
:class="['report-filter-tab', { active: distributionRange === range }]" :class="['report-filter-tab', { active: distributionRange === range }]"
type="button" type="button"
@click="distributionRange = range" @click="distributionRange = range as DistributionRange"
> >
{{ range }} {{ range }}
</button> </button>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,21 @@
<script setup> <script lang="ts" setup>
import { ref } from "vue"; import { ref } from "vue";
const patrolRecords = [
interface PatrolAlarmRecord {
id: string;
type: string;
reportTime: string;
}
interface PatrolRecord {
id: string;
robot: string;
startTime: string;
endTime: string;
alarms: PatrolAlarmRecord[];
}
const patrolRecords: PatrolRecord[] = [
{ {
id: "PR-20260623-001", id: "PR-20260623-001",
robot: "巡检机器人 R1", robot: "巡检机器人 R1",
@@ -8,8 +23,8 @@ const patrolRecords = [
endTime: "2026-06-23 09:02:15", endTime: "2026-06-23 09:02:15",
alarms: [ alarms: [
{ id: "AL-001", type: "线缆破损", reportTime: "2026-06-23 08:36:12" }, { 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", id: "PR-20260623-002",
@@ -17,8 +32,8 @@ const patrolRecords = [
startTime: "2026-06-23 09:20:08", startTime: "2026-06-23 09:20:08",
endTime: "2026-06-23 10:01:46", endTime: "2026-06-23 10:01:46",
alarms: [ 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", id: "PR-20260623-003",
@@ -26,25 +41,28 @@ const patrolRecords = [
startTime: "2026-06-23 10:08:34", startTime: "2026-06-23 10:08:34",
endTime: "2026-06-23 11:12:25", endTime: "2026-06-23 11:12:25",
alarms: [ 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-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", id: "PR-20260623-004",
robot: "巡检机器人 R2", robot: "巡检机器人 R2",
startTime: "2026-06-23 13:15:17", startTime: "2026-06-23 13:15:17",
endTime: "2026-06-23 14:05:52", endTime: "2026-06-23 14:05:52",
alarms: [] alarms: [],
} },
]; ];
const dialogVisible = ref(false); const dialogVisible = ref(false);
const activePatrolRecord = ref(null); const activePatrolRecord = ref<PatrolRecord | null>(null);
function openAlarmDialog(record) {
function openAlarmDialog(record: PatrolRecord) {
activePatrolRecord.value = record; activePatrolRecord.value = record;
dialogVisible.value = true; dialogVisible.value = true;
} }
function closeDialog() { function closeDialog() {
dialogVisible.value = false; dialogVisible.value = false;
activePatrolRecord.value = null; activePatrolRecord.value = null;

View File

@@ -1,18 +1,75 @@
<script setup> <script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { getRosCarList } from "@/api/rosCar"; 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 defaultProject = "视频中心";
const gridMode = ref(4); const gridMode = ref<GridMode>(4);
const activeSlotIndex = ref(0); const activeSlotIndex = ref(0);
const videoSlots = ref([]); const videoSlots = ref<Array<ProjectCameraItem | null>>([]);
const slotVideoRefs = ref([]); const slotVideoRefs = ref<Array<HTMLVideoElement | null>>([]);
const slotStreamStates = ref([]); const slotStreamStates = ref<SlotStreamState[]>([]);
const videoGridBoardRef = ref(null); const videoGridBoardRef = ref<HTMLElement | null>(null);
const videoGridBoardStyle = ref({}); const videoGridBoardStyle = ref<Record<string, string>>({});
const slotPeerConnections = /* @__PURE__ */ new Map(); const slotPeerConnections = new Map<number, RTCPeerConnection>();
const slotRequestIds = /* @__PURE__ */ new Map(); const slotRequestIds = new Map<number, number>();
let videoGridResizeObserver = null; let videoGridResizeObserver: ResizeObserver | null = null;
const devices = ref([]); const devices = ref<DeviceNode[]>([]);
const listLoading = ref(false); const listLoading = ref(false);
const listError = ref(""); const listError = ref("");
const playbackDialogVisible = ref(false); const playbackDialogVisible = ref(false);
@@ -20,107 +77,141 @@ const playbackStartAt = ref("2026-06-23T14:20");
const playbackEndAt = ref("2026-06-23T14:50"); const playbackEndAt = ref("2026-06-23T14:50");
const playbackProgress = ref(0); const playbackProgress = ref(0);
const playbackPaused = ref(true); const playbackPaused = ref(true);
const playbackPlayerRef = ref(null); const playbackPlayerRef = ref<HTMLVideoElement | null>(null);
const slotCountMap = {
const slotCountMap: Record<GridMode, number> = {
1: 1, 1: 1,
4: 4, 4: 4,
9: 9 9: 9,
}; };
const currentProjectName = computed(() => devices.value[0]?.project || defaultProject); const currentProjectName = computed(() => devices.value[0]?.project || defaultProject);
const playableCameras = computed(
() => devices.value.flatMap( const playableCameras = computed<ProjectCameraItem[]>(() =>
(device) => device.cameras.map((camera) => ({ devices.value.flatMap((device) =>
device.cameras.map((camera) => ({
...camera, ...camera,
deviceId: device.id, deviceId: device.id,
deviceName: device.name, deviceName: device.name,
project: device.project project: device.project,
})) })),
) ),
); );
const cameraTotal = computed(() => playableCameras.value.length); const cameraTotal = computed(() => playableCameras.value.length);
const visibleSlots = computed(() => { const visibleSlots = computed(() => {
const targetCount = slotCountMap[gridMode.value]; const targetCount = slotCountMap[gridMode.value];
return Array.from( return Array.from(
{ length: targetCount }, { length: targetCount },
(_, index) => videoSlots.value[index] ?? null (_, index) => videoSlots.value[index] ?? null,
); );
}); });
const activeCamera = computed(() => visibleSlots.value[activeSlotIndex.value] ?? null); const activeCamera = computed(() => visibleSlots.value[activeSlotIndex.value] ?? null);
const activeSlotLabel = computed(() => `画面 ${activeSlotIndex.value + 1}`); const activeSlotLabel = computed(() => `画面 ${activeSlotIndex.value + 1}`);
const playbackRangeLabel = computed(() => { const playbackRangeLabel = computed(() => {
const startLabel = playbackStartAt.value.replace("T", " "); const startLabel = playbackStartAt.value.replace("T", " ");
const endLabel = playbackEndAt.value.replace("T", " "); const endLabel = playbackEndAt.value.replace("T", " ");
return `${startLabel} - ${endLabel}`; return `${startLabel} - ${endLabel}`;
}); });
// 根据视频墙实际可用宽高计算宫格尺寸,保证每个画面都是 16:9 且不会溢出卡片
function updateVideoGridSize() { function updateVideoGridSize() {
const board = videoGridBoardRef.value; const board = videoGridBoardRef.value;
if (!board) { if (!board) {
return; return;
} }
const columns = Math.sqrt(gridMode.value); const columns = Math.sqrt(gridMode.value);
const rows = columns; const rows = columns;
const gap = 8; const gap = 8;
const availableWidth = board.clientWidth - gap * (columns - 1); const availableWidth = board.clientWidth - gap * (columns - 1);
const availableHeight = board.clientHeight - gap * (rows - 1); const availableHeight = board.clientHeight - gap * (rows - 1);
const cellWidthByContainerWidth = availableWidth / columns; 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 cellWidth = Math.max(0, Math.min(cellWidthByContainerWidth, cellWidthByContainerHeight));
const cellHeight = cellWidth * (9 / 16); const cellHeight = cellWidth * (9 / 16);
videoGridBoardStyle.value = { videoGridBoardStyle.value = {
gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`, gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`,
gridAutoRows: `${cellHeight}px` gridAutoRows: `${cellHeight}px`,
}; };
} }
// 根据当前宫格数量补齐画面槽位,切换布局时保留已播放的视频
function ensureVideoSlotCount() { function ensureVideoSlotCount() {
const targetCount = slotCountMap[gridMode.value]; const targetCount = slotCountMap[gridMode.value];
const previousCount = videoSlots.value.length; const previousCount = videoSlots.value.length;
for (let index = targetCount; index < previousCount; index += 1) { for (let index = targetCount; index < previousCount; index += 1) {
stopSlotStream(index); stopSlotStream(index);
} }
videoSlots.value = Array.from( videoSlots.value = Array.from(
{ length: targetCount }, { length: targetCount },
(_, index) => videoSlots.value[index] ?? null (_, index) => videoSlots.value[index] ?? null,
); );
slotVideoRefs.value = Array.from( slotVideoRefs.value = Array.from(
{ length: targetCount }, { length: targetCount },
(_, index) => slotVideoRefs.value[index] ?? null (_, index) => slotVideoRefs.value[index] ?? null,
); );
slotStreamStates.value = Array.from( slotStreamStates.value = Array.from(
{ length: targetCount }, { length: targetCount },
(_, index) => slotStreamStates.value[index] ?? { loading: false, error: "" } (_, index) => slotStreamStates.value[index] ?? { loading: false, error: "" },
); );
if (activeSlotIndex.value >= targetCount) { if (activeSlotIndex.value >= targetCount) {
activeSlotIndex.value = targetCount - 1; activeSlotIndex.value = targetCount - 1;
} }
} }
function normalizeDeviceStatus(status) {
const statusMap = { // 将接口的小车状态码转换为页面展示状态
function normalizeDeviceStatus(status?: number): DeviceStatus {
const statusMap: Record<number, DeviceStatus> = {
0: "空闲", 0: "空闲",
1: "巡检", 1: "巡检",
2: "离线", 2: "离线",
3: "充电中" 3: "充电中",
}; };
return statusMap[status ?? 2] ?? "离线"; return statusMap[status ?? 2] ?? "离线";
} }
function buildCameraItem(car, deviceId, type, url, cameraNo) {
// 根据小车视频地址生成可播放的相机节点
function buildCameraItem(
car: RosCarRecord,
deviceId: string,
type: CameraType,
url?: string,
cameraNo?: string,
): CameraItem | null {
if (!url) { if (!url) {
return null; return null;
} }
return { return {
id: `${deviceId}-${type}`, id: `${deviceId}-${type}`,
name: `${type}相机`, name: `${type}相机`,
type, type,
status: normalizeDeviceStatus(car.status), status: normalizeDeviceStatus(car.status),
url, url,
cameraNo cameraNo,
}; };
} }
function normalizeDeviceNode(car, index) {
// 将设备列表接口的小车记录转换为左侧机器人树节点
function normalizeDeviceNode(car: RosCarRecord, index: number): DeviceNode {
const deviceId = car.id ?? `ROBOT-${index + 1}`; const deviceId = car.id ?? `ROBOT-${index + 1}`;
const cameras = [ const cameras = [
buildCameraItem(car, deviceId, "可见光", car.cameraWebrtcUrl, car.cameraNo), buildCameraItem(car, deviceId, "可见光", car.cameraWebrtcUrl, car.cameraNo),
buildCameraItem(car, deviceId, "红外", car.cameraWebrtcUrl2, car.cameraNo2) buildCameraItem(car, deviceId, "红外", car.cameraWebrtcUrl2, car.cameraNo2),
].filter((camera) => Boolean(camera)); ].filter((camera): camera is CameraItem => Boolean(camera));
return { return {
id: deviceId, id: deviceId,
deviceNo: car.deviceNo, deviceNo: car.deviceNo,
@@ -128,19 +219,23 @@ function normalizeDeviceNode(car, index) {
project: car.projectName || defaultProject, project: car.projectName || defaultProject,
status: normalizeDeviceStatus(car.status), status: normalizeDeviceStatus(car.status),
battery: typeof car.batteryLevel === "number" ? `${car.batteryLevel}%` : "--", battery: typeof car.batteryLevel === "number" ? `${car.batteryLevel}%` : "--",
cameras cameras,
}; };
} }
// 请求设备列表接口,加载左侧机器人及其可见光/红外相机
async function fetchDeviceList() { async function fetchDeviceList() {
listLoading.value = true; listLoading.value = true;
listError.value = ""; listError.value = "";
try { try {
const response = await getRosCarList({ const response = (await getRosCarList({
page: 1, page: 1,
limit: 999, limit: 999,
query: {} query: {},
}); })) as RosCarListResponse;
const rows = response.data?.data ?? []; const rows = response.data?.data ?? [];
devices.value = rows.map((car, index) => normalizeDeviceNode(car, index)); devices.value = rows.map((car, index) => normalizeDeviceNode(car, index));
} catch (error) { } catch (error) {
listError.value = "设备列表加载失败"; listError.value = "设备列表加载失败";
@@ -149,112 +244,146 @@ async function fetchDeviceList() {
listLoading.value = false; listLoading.value = false;
} }
} }
function selectVideoSlot(index) {
// 选中右侧视频墙中的目标画面
function selectVideoSlot(index: number) {
activeSlotIndex.value = index; activeSlotIndex.value = index;
} }
// 停止全部画面的 WebRTC 拉流,用于页面卸载时释放资源
function stopAllSlotStreams() { function stopAllSlotStreams() {
videoSlots.value.forEach((_, index) => stopSlotStream(index)); videoSlots.value.forEach((_, index) => stopSlotStream(index));
} }
function stopSlotStream(index, clearState = true) {
// 停止指定画面的 WebRTC 拉流并释放播放器资源
function stopSlotStream(index: number, clearState = true) {
slotRequestIds.set(index, (slotRequestIds.get(index) ?? 0) + 1); slotRequestIds.set(index, (slotRequestIds.get(index) ?? 0) + 1);
if (clearState) { if (clearState) {
slotStreamStates.value[index] = { loading: false, error: "" }; slotStreamStates.value[index] = { loading: false, error: "" };
} }
const peerConnection = slotPeerConnections.get(index); const peerConnection = slotPeerConnections.get(index);
if (peerConnection) { if (peerConnection) {
peerConnection.close(); peerConnection.close();
slotPeerConnections.delete(index); slotPeerConnections.delete(index);
} }
const video = slotVideoRefs.value[index]; const video = slotVideoRefs.value[index];
if (video) { if (video) {
video.srcObject = null; video.srcObject = null;
} }
} }
function buildWebrtcApiUrl(streamUrl) {
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
function buildWebrtcApiUrl(streamUrl: string) {
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://"); const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
const url = new URL(normalizedUrl); const url = new URL(normalizedUrl);
return `${url.origin}${url.pathname}${url.search}`; return `${url.origin}${url.pathname}${url.search}`;
} }
function waitForIceGatheringComplete(peerConnection) {
// 等待本地 ICE 候选收集完成,确保发给流媒体服务的 offer 信息完整
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) {
if (peerConnection.iceGatheringState === "complete") { if (peerConnection.iceGatheringState === "complete") {
return Promise.resolve(); return Promise.resolve();
} }
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
const handleIceGatheringStateChange = () => { const handleIceGatheringStateChange = () => {
if (peerConnection.iceGatheringState === "complete") { if (peerConnection.iceGatheringState === "complete") {
peerConnection.removeEventListener("icegatheringstatechange", handleIceGatheringStateChange); peerConnection.removeEventListener("icegatheringstatechange", handleIceGatheringStateChange);
resolve(); resolve();
} }
}; };
peerConnection.addEventListener("icegatheringstatechange", handleIceGatheringStateChange); peerConnection.addEventListener("icegatheringstatechange", handleIceGatheringStateChange);
}); });
} }
function setSlotVideoRef(element, index) {
// 收集视频墙每个画面对应的 video 元素,供 WebRTC ontrack 写入媒体流
function setSlotVideoRef(element: Element | { $el?: Element } | null, index: number) {
const target = element instanceof Element ? element : element?.$el; const target = element instanceof Element ? element : element?.$el;
slotVideoRefs.value[index] = target instanceof HTMLVideoElement ? target : null; slotVideoRefs.value[index] = target instanceof HTMLVideoElement ? target : null;
} }
async function playSlotStream(index, camera) {
// 通过 WebRTC offer/answer 将指定相机拉流到选中的画面槽位
async function playSlotStream(index: number, camera: ProjectCameraItem) {
stopSlotStream(index); stopSlotStream(index);
if (!window.RTCPeerConnection) { if (!window.RTCPeerConnection) {
slotStreamStates.value[index] = { loading: false, error: "当前浏览器不支持 WebRTC 播放" }; slotStreamStates.value[index] = { loading: false, error: "当前浏览器不支持 WebRTC 播放" };
return; return;
} }
const video = slotVideoRefs.value[index]; const video = slotVideoRefs.value[index];
if (!video) { if (!video) {
slotStreamStates.value[index] = { loading: false, error: "播放器未准备好" }; slotStreamStates.value[index] = { loading: false, error: "播放器未准备好" };
return; return;
} }
const requestId = slotRequestIds.get(index) ?? 0; const requestId = slotRequestIds.get(index) ?? 0;
const peerConnection = new RTCPeerConnection(); const peerConnection = new RTCPeerConnection();
slotPeerConnections.set(index, peerConnection); slotPeerConnections.set(index, peerConnection);
slotStreamStates.value[index] = { loading: true, error: "" }; slotStreamStates.value[index] = { loading: true, error: "" };
peerConnection.addTransceiver("video", { direction: "recvonly" }); peerConnection.addTransceiver("video", { direction: "recvonly" });
peerConnection.addTransceiver("audio", { direction: "recvonly" }); peerConnection.addTransceiver("audio", { direction: "recvonly" });
peerConnection.ontrack = (event) => { peerConnection.ontrack = (event) => {
if (requestId !== slotRequestIds.get(index)) { if (requestId !== slotRequestIds.get(index)) {
return; return;
} }
video.srcObject = event.streams[0]; video.srcObject = event.streams[0];
}; };
try { try {
const offer = await peerConnection.createOffer(); const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer); await peerConnection.setLocalDescription(offer);
await waitForIceGatheringComplete(peerConnection); await waitForIceGatheringComplete(peerConnection);
const response = await fetch(buildWebrtcApiUrl(camera.url), { const response = await fetch(buildWebrtcApiUrl(camera.url), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/sdp" "Content-Type": "application/sdp",
}, },
body: peerConnection.localDescription?.sdp ?? offer.sdp body: peerConnection.localDescription?.sdp ?? offer.sdp,
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`拉流失败\${response.status}`); throw new Error(`拉流失败:${response.status}`);
} }
const responseText = await response.text(); const responseText = await response.text();
let answer = responseText; let answer = responseText;
try { try {
const responseData = JSON.parse(responseText); const responseData = JSON.parse(responseText) as { code?: number; sdp?: string; msg?: string };
if (responseData.code && responseData.code !== 0) { if (responseData.code && responseData.code !== 0) {
throw new Error(responseData.msg || "视频流拉取失败"); throw new Error(responseData.msg || "视频流拉取失败");
} }
answer = responseData.sdp || responseText; answer = responseData.sdp || responseText;
} catch (error) { } catch (error) {
if (responseText.trim().startsWith("{")) { if (responseText.trim().startsWith("{")) {
throw error; throw error;
} }
} }
if (requestId !== slotRequestIds.get(index)) { if (requestId !== slotRequestIds.get(index)) {
return; return;
} }
await peerConnection.setRemoteDescription({ await peerConnection.setRemoteDescription({
type: "answer", type: "answer",
sdp: answer sdp: answer,
}); });
} catch (error) { } catch (error) {
if (requestId === slotRequestIds.get(index)) { if (requestId === slotRequestIds.get(index)) {
slotStreamStates.value[index] = { slotStreamStates.value[index] = {
loading: false, loading: false,
error: error instanceof Error ? error.message : "视频流拉取失败" error: error instanceof Error ? error.message : "视频流拉取失败",
}; };
stopSlotStream(index, false); stopSlotStream(index, false);
} }
@@ -262,98 +391,135 @@ async function playSlotStream(index, camera) {
if (requestId === slotRequestIds.get(index)) { if (requestId === slotRequestIds.get(index)) {
slotStreamStates.value[index] = { slotStreamStates.value[index] = {
...slotStreamStates.value[index], ...slotStreamStates.value[index],
loading: false loading: false,
}; };
} }
} }
} }
function closeVideoSlot(index) {
// 关闭指定画面并释放对应 WebRTC 拉流资源
function closeVideoSlot(index: number) {
stopSlotStream(index); stopSlotStream(index);
videoSlots.value[index] = null; videoSlots.value[index] = null;
if (activeSlotIndex.value === index) { if (activeSlotIndex.value === index) {
activeSlotIndex.value = index; activeSlotIndex.value = index;
} }
} }
async function playCamera(camera) {
// 将左侧相机播放到当前选中的右侧画面
async function playCamera(camera: ProjectCameraItem) {
const targetIndex = activeSlotIndex.value; const targetIndex = activeSlotIndex.value;
videoSlots.value[targetIndex] = camera; videoSlots.value[targetIndex] = camera;
await nextTick(); await nextTick();
await playSlotStream(targetIndex, camera); await playSlotStream(targetIndex, camera);
} }
async function setGridMode(mode) {
// 切换视频墙宫格布局
async function setGridMode(mode: GridMode) {
gridMode.value = mode; gridMode.value = mode;
await nextTick(); await nextTick();
updateVideoGridSize(); updateVideoGridSize();
} }
// 同步录像回放播放器进度和暂停状态
function syncPlaybackProgress() { function syncPlaybackProgress() {
const video = playbackPlayerRef.value; const video = playbackPlayerRef.value;
if (!video) { if (!video) {
playbackProgress.value = 0; playbackProgress.value = 0;
playbackPaused.value = true; playbackPaused.value = true;
return; return;
} }
const duration = Number.isFinite(video.duration) ? video.duration : 0; 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; playbackPaused.value = video.paused;
} }
// 从头开始播放当前回放视频
function startPlayback() { function startPlayback() {
const video = playbackPlayerRef.value; const video = playbackPlayerRef.value;
if (!video) { if (!video) {
return; return;
} }
video.currentTime = 0; video.currentTime = 0;
void video.play(); void video.play();
playbackPaused.value = false; playbackPaused.value = false;
syncPlaybackProgress(); syncPlaybackProgress();
} }
// 打开当前画面的录像回放弹窗
async function openPlaybackDialog() { async function openPlaybackDialog() {
if (!activeCamera.value) { if (!activeCamera.value) {
return; return;
} }
playbackDialogVisible.value = true; playbackDialogVisible.value = true;
playbackProgress.value = 0; playbackProgress.value = 0;
playbackPaused.value = true; playbackPaused.value = true;
await nextTick(); await nextTick();
startPlayback(); startPlayback();
} }
// 关闭录像回放弹窗并暂停播放器
function closePlaybackDialog() { function closePlaybackDialog() {
playbackPlayerRef.value?.pause(); playbackPlayerRef.value?.pause();
playbackDialogVisible.value = false; playbackDialogVisible.value = false;
playbackPaused.value = true; playbackPaused.value = true;
} }
// 切换录像回放的播放和暂停状态
function togglePlaybackPause() { function togglePlaybackPause() {
const video = playbackPlayerRef.value; const video = playbackPlayerRef.value;
if (!video) { if (!video) {
return; return;
} }
if (video.paused) { if (video.paused) {
void video.play(); void video.play();
} else { } else {
video.pause(); video.pause();
} }
syncPlaybackProgress(); syncPlaybackProgress();
} }
function seekPlayback(offsetSeconds) {
// 按秒数快进或快退录像回放
function seekPlayback(offsetSeconds: number) {
const video = playbackPlayerRef.value; const video = playbackPlayerRef.value;
if (!video) { if (!video) {
return; return;
} }
const duration = Number.isFinite(video.duration) ? video.duration : 0; const duration = Number.isFinite(video.duration) ? video.duration : 0;
const targetTime = Math.max(0, Math.min(duration, video.currentTime + offsetSeconds)); const targetTime = Math.max(0, Math.min(duration, video.currentTime + offsetSeconds));
video.currentTime = targetTime; video.currentTime = targetTime;
syncPlaybackProgress(); syncPlaybackProgress();
} }
// 根据拖动条位置更新录像回放播放进度
function updatePlaybackProgress() { function updatePlaybackProgress() {
const video = playbackPlayerRef.value; const video = playbackPlayerRef.value;
if (!video) { if (!video) {
return; return;
} }
const duration = Number.isFinite(video.duration) ? video.duration : 0; const duration = Number.isFinite(video.duration) ? video.duration : 0;
if (duration <= 0) { if (duration <= 0) {
return; return;
} }
video.currentTime = playbackProgress.value / 100 * duration;
video.currentTime = (playbackProgress.value / 100) * duration;
} }
watch(gridMode, async () => { watch(gridMode, async () => {
ensureVideoSlotCount(); ensureVideoSlotCount();
await nextTick(); await nextTick();
@@ -362,6 +528,7 @@ watch(gridMode, async () => {
onMounted(() => { onMounted(() => {
void fetchDeviceList(); void fetchDeviceList();
updateVideoGridSize(); updateVideoGridSize();
if (videoGridBoardRef.value) { if (videoGridBoardRef.value) {
videoGridResizeObserver = new ResizeObserver(updateVideoGridSize); videoGridResizeObserver = new ResizeObserver(updateVideoGridSize);
videoGridResizeObserver.observe(videoGridBoardRef.value); videoGridResizeObserver.observe(videoGridBoardRef.value);
@@ -451,7 +618,7 @@ onBeforeUnmount(() => {
:key="mode" :key="mode"
:class="['grid-switch-btn', { active: gridMode === mode }]" :class="['grid-switch-btn', { active: gridMode === mode }]"
type="button" type="button"
@click="setGridMode(mode)" @click="setGridMode(mode as GridMode)"
> >
{{ mode }} 宫格 {{ mode }} 宫格
</button> </button>

View File

@@ -1,19 +1,27 @@
function createAlertPhoto(scene, accent) { export type AlertPhotoScene = "cable" | "heat" | "crack";
const sceneMarkup = scene === "cable" ? `
export function createAlertPhoto(scene: AlertPhotoScene, accent: string) {
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="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" /> <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" /> <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="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)" /> <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="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="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="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="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" /> <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" /> <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 = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
<defs> <defs>
@@ -53,22 +61,21 @@ function createAlertPhoto(scene, accent) {
<rect x="20" y="20" width="600" height="320" rx="16" fill="none" stroke="rgba(110,224,255,0.12)" stroke-width="2" /> <rect x="20" y="20" width="600" height="320" rx="16" fill="none" stroke="rgba(110,224,255,0.12)" stroke-width="2" />
</svg> </svg>
`; `;
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
} }
const recentAlertPhotoSources = {
export const recentAlertPhotoSources = {
"线缆破损": createAlertPhoto("cable", "#ff8f79"), "线缆破损": createAlertPhoto("cable", "#ff8f79"),
"电缆表面破损": createAlertPhoto("cable", "#ff8f79"), "电缆表面破损": createAlertPhoto("cable", "#ff8f79"),
"桥架断裂": createAlertPhoto("crack", "#89d8ff"), "桥架断裂": createAlertPhoto("crack", "#89d8ff"),
"隧道积水": createAlertPhoto("heat", "#ffc86c"), "隧道积水": createAlertPhoto("heat", "#ffc86c"),
"局部高温": createAlertPhoto("heat", "#ffc86c"), "局部高温": createAlertPhoto("heat", "#ffc86c"),
"墙体裂缝": createAlertPhoto("crack", "#89d8ff") "墙体渗漏": createAlertPhoto("crack", "#89d8ff"),
}; } as const;
const fallbackAlertPhoto = recentAlertPhotoSources["墙体裂缝"];
function getRecentAlertPhoto(type) { const fallbackAlertPhoto = recentAlertPhotoSources["墙体渗漏"];
return recentAlertPhotoSources[type] ?? fallbackAlertPhoto;
export function getRecentAlertPhoto(type: string) {
return recentAlertPhotoSources[type as keyof typeof recentAlertPhotoSources] ?? fallbackAlertPhoto;
} }
export {
createAlertPhoto,
getRecentAlertPhoto,
recentAlertPhotoSources
};

View File

@@ -1,5 +1,5 @@
<script setup> <script lang="ts" setup>
import SwitchPageBtn from "@/views/dashboard1/components/btn";
</script> </script>
<template> <template>

View File

@@ -1,20 +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}
/>;
})}</ul>
<div class="chart__name">xx</div>
</div>;
}
});

View File

@@ -0,0 +1,26 @@
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>
);
},
});

View File

@@ -1,16 +1,20 @@
<script setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
const animationSvgRef = ref(); const animationSvgRef = ref();
const config = { const config = {
count: 16, count: 16,
initR: 2, initR: 2,
gap: 0.2, gap: 0.2,
dur: 8, dur: 8,
startY: 266.5 startY: 266.5,
}; };
function generateNumber() { function generateNumber() {
return (Math.random() * 10).toFixed(1) + "Mb/s"; return (Math.random() * 10).toFixed(1) + "Mb/s";
} }
const polylineComputed = computed(() => { const polylineComputed = computed(() => {
const arr = []; const arr = [];
let startY = config.startY; let startY = config.startY;
@@ -18,25 +22,28 @@ const polylineComputed = computed(() => {
let startX = 465; let startX = 465;
let endX = 1820; let endX = 1820;
const points = `${startX},${startY} 846,${startY} 1280,634 ${endX},634`; const points = `${startX},${startY} 846,${startY} 1280,634 ${endX},634`;
const textY = startY - 29.5; const textY = startY - 29.5;
const text1 = { x: startX + 137, y: textY, value: `${generateNumber()}` }; const text1 = { x: startX + 137, y: textY, value: `${generateNumber()}` };
const text2 = { const text2 = {
x: startX + 137 + 147, x: startX + 137 + 147,
y: textY, y: textY,
value: `${generateNumber()}` value: `${generateNumber()}`,
}; };
const text3 = {}; const text3 = {};
const value = { const value = {
points, points,
text1, text1,
text2, text2,
text3 text3,
}; };
arr.push(value); arr.push(value);
startY += 180; startY += 180;
} }
return arr; return arr;
}); });
const circleComputed = computed(() => { const circleComputed = computed(() => {
const circles = []; const circles = [];
let startY = config.startY; let startY = config.startY;
@@ -53,7 +60,7 @@ const circleComputed = computed(() => {
r: r.toFixed(1), r: r.toFixed(1),
startX, startX,
startY, startY,
endX endX,
}; };
circles.push(circle); circles.push(circle);
endX = 1820; endX = 1820;

View File

@@ -1,24 +1,33 @@
<script setup> <script lang="ts" setup>
import { computed, reactive, ref } from "vue"; import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useUserStore } from "@/store/modules/user.js"; import { useUserStore } from "@/store/modules/user";
import { encryptPasswordPayload } from "@/utils/passwordEncrypt.js"; import { encryptPasswordPayload } from '@/utils/passwordEncrypt'
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const form = reactive({ const form = reactive({
username: "", username: "",
password: "" password: "",
}); });
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const redirectPath = computed(() => { const redirectPath = computed(() => {
const redirect = route.query.redirect; const redirect = route.query.redirect;
if (typeof redirect === "string" && redirect.startsWith("/") && !redirect.startsWith("//")) { if (
typeof redirect === "string" &&
redirect.startsWith("/") &&
!redirect.startsWith("//")
) {
return redirect; return redirect;
} }
return "/dashboard/integrated-center"; return "/dashboard/integrated-center";
}); });
// 校验登录表单,避免空账号或空密码提交到后端。
function validateLoginForm() { function validateLoginForm() {
if (!form.username.trim()) { if (!form.username.trim()) {
errorMessage.value = "请输入账号"; errorMessage.value = "请输入账号";
@@ -30,18 +39,23 @@ function validateLoginForm() {
} }
return true; return true;
} }
// 提交登录信息,成功后回到登录前访问的页面。
async function handleLogin() { async function handleLogin() {
if (!validateLoginForm() || loading.value) { if (!validateLoginForm() || loading.value) {
return; return;
} }
loading.value = true; loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
const encryptedForm = await encryptPasswordPayload(form, ["password"]); const encryptedForm = await encryptPasswordPayload(form, ['password'])
await userStore.login(encryptedForm); await userStore.login(encryptedForm)
router.replace(redirectPath.value); router.replace(redirectPath.value);
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : "登录失败\,请检查账号或密码"; errorMessage.value =
error instanceof Error ? error.message : "登录失败,请检查账号或密码";
} finally { } finally {
loading.value = false; loading.value = false;
} }

8
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
/// <reference types="vue/jsx" />
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"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"]
}

View File

@@ -1,41 +0,0 @@
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"] : []
}
};
};

59
vite.config.ts Normal file
View File

@@ -0,0 +1,59 @@
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://127.0.0.1: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,
},
},
},
},
},
})
})