style(dashboard1): 优化大屏样式与地图交互

- 引入优设标题黑/圆字体并替换顶部标题为整图
- 统一页面边距与卡片背景为半透明渐变模糊
- 底部导航改用整图背景配透明热区
- 新增地图放大预览与机器人光圈扩散动画
- 视频监控改为白光/红外双路独立拉流
This commit is contained in:
2026-09-21 16:00:54 +08:00
parent fe56a8ba6d
commit 37c67ce25b
20 changed files with 650 additions and 323 deletions

BIN
src/assets/emptyMap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

BIN
src/assets/底部-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
src/assets/底部-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
src/assets/底部-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
src/assets/底部-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
src/assets/底部-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
src/assets/底部.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,3 +1,20 @@
// 大屏设计稿字体:优设标题黑 / 优设标题圆
@font-face {
font-family: "YouSheBiaoTiHei";
src: url("./assets/font/YousheBiaoTiHei.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "YouSheBiaoTiYuan";
src: url("./assets/font/YouSheBiaoTiYuan.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -28,20 +28,26 @@
auto,
auto;
// 顶部标题:使用导出的标题整图(1167x81,按设计稿高度 45px 等比展示)
&:before {
content: "管廊巡检机器人综合管理平台";
color: #fff;
letter-spacing: 12px;
font-size: 36px;
line-height: 70px;
width: 100%;
font-weight: bold;
content: "";
position: absolute;
text-align: center;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: 560px;
height: 39px;
z-index: 10;
background-image: url("../../assets/管廊巡检机器人综合管理平台.png");
background-repeat: no-repeat;
background-position: center;
background-size: 100% 100%;
pointer-events: none;
}
}
.background {
display: block;
position: relative;
z-index: -1;
width: 100%;
@@ -60,9 +66,10 @@
height: 100%;
}
// 页面内容区边距与大屏(监管中心)保持一致:上 87 / 左右 24 / 下 60(避开底部导航)
.page-shell {
height: 100%;
padding: 7.8% 34px 7%;
padding: 87px 24px 60px;
box-sizing: border-box;
}
@@ -105,26 +112,23 @@
box-sizing: border-box;
}
.management-page {
padding-top: 8.5%;
padding-left: 42px;
padding-right: 42px;
}
.alarm-page,
.plan-page,
.report-page {
padding-left: 42px;
padding-right: 42px;
// 各页面统一沿用 .page-shell 的边距,不再单独覆盖
// 视频中心 / 统计分析页:内容区左右再留 14px,与大屏内容区保持一致
.video-center-layout,
.report-main-grid {
margin: 0 14px;
}
// 面板背景与大屏内容区统一:半透明渐变 + 背景模糊
.page-card {
min-height: 0;
padding: 22px 24px;
background:
linear-gradient(180deg, rgba(8, 29, 60, 0.78) 0%, rgba(5, 18, 39, 0.62) 100%);
border: 1px solid rgba(86, 198, 255, 0.16);
box-shadow: inset 0 0 24px rgba(46, 144, 224, 0.08);
background: linear-gradient(0deg,
rgba(0, 83, 132, 0.3) 0%,
rgba(0, 83, 132, 0) 97.3%);
border: 1px solid rgba(86, 205, 255, 0.12);
box-shadow: 0 0 18px 0 rgba(0, 10, 24, 0.32);
backdrop-filter: blur(4px);
}
@@ -141,13 +145,12 @@
display: flex;
flex-direction: column;
padding: 24px 28px 22px;
background:
linear-gradient(180deg, rgba(10, 38, 74, 0.88) 0%, rgba(5, 18, 39, 0.8) 100%);
border: 1px solid rgba(93, 208, 255, 0.2);
box-shadow:
inset 0 0 32px rgba(42, 142, 221, 0.08),
0 0 0 1px rgba(17, 75, 122, 0.35),
0 18px 42px rgba(3, 12, 24, 0.3);
background: linear-gradient(0deg,
rgba(0, 83, 132, 0.3) 0%,
rgba(0, 83, 132, 0) 97.3%);
border: 1px solid rgba(86, 205, 255, 0.12);
box-shadow: 0 0 18px 0 rgba(0, 10, 24, 0.32);
backdrop-filter: blur(4px);
overflow: hidden;
}
@@ -636,11 +639,7 @@
grid-template-columns: 1.2fr 0.8fr;
}
.video-center-shell {
padding-top: 7.4%;
padding-left: 42px;
padding-right: 42px;
}
// 视频中心同样沿用统一的页面边距
.video-center-layout {
height: 100%;
@@ -1164,10 +1163,7 @@
white-space: nowrap;
}
.report-dashboard-page {
padding-left: 42px;
padding-right: 42px;
}
// 统计分析页同样沿用统一的页面边距
.report-dashboard {
height: 100%;
@@ -1511,50 +1507,51 @@
font-weight: 700;
}
.btn-group {
position: absolute;
top: 5%;
display: flex;
justify-content: center;
left: 50%;
gap: 100%;
transform: translateX(-50%);
.left {
display: flex;
justify-content: space-between;
gap: 30px;
}
.right {
display: flex;
justify-content: space-between;
gap: 30px;
}
}
// 监管中心布局:顶部通栏地图 + 底部四列等高面板(对应设计稿坐标)
.center {
height: 100%;
display: flex;
box-sizing: border-box;
// 底部留白,避免面板被底部导航遮挡
padding: 87px 24px 60px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
// 第一行地图区 356,第二行面板区占满剩余高度,四张卡片自动等高
grid-template-rows: 356px minmax(0, 1fr);
column-gap: 16px;
row-gap: 11px;
.middle {
width: 50%;
height: 100%;
grid-column: 1 / -1;
grid-row: 1;
width: 100%;
// 地图面板:标题样式与其他面板保持一致(复用 card- 前缀样式)
.nav-map-panel {
margin: calc(8.5% + 40px) auto 0;
width: 92%;
height: 74%;
margin: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 18px 22px 16px;
background: linear-gradient(
180deg,
rgba(7, 23, 48, 0.76) 0%,
rgba(4, 16, 34, 0.58) 100%
);
border: 1px solid rgba(95, 204, 255, 0.18);
box-shadow: inset 0 0 24px rgba(45, 144, 224, 0.08);
}
// 地图卡标题背景与设备列表等卡片完全一致:
// 标题底图按单卡宽度(456px,即底部各卡实际宽度)铺贴,避免通栏拉伸变形
.card-map {
background-size:
456px auto,
456px auto,
100%,
456px auto;
background-position:
top left,
top left,
20px 40px,
top left;
}
.card-map .title {
flex: none;
margin-bottom: 10px;
}
.nav-map-header {
@@ -1582,15 +1579,116 @@
position: relative;
flex: 1;
overflow: hidden;
background:
radial-gradient(circle at 50% 58%, rgba(46, 163, 224, 0.12), transparent 34%),
linear-gradient(180deg, rgba(6, 18, 31, 0.96) 0%, rgba(3, 10, 22, 0.98) 100%);
// 内容区左右(及底部)留出与其它卡片一致的内缩距离
margin: 0 14px 12px;
// 内容区背景:半透明渐变 + 背景模糊(对应设计稿 fill)
background: linear-gradient(0deg,
rgba(0, 83, 132, 0.3) 0%,
rgba(0, 83, 132, 0) 97.3%);
backdrop-filter: blur(4px);
border: 1px solid rgba(82, 191, 255, 0.16);
transition: inset 0.2s ease;
box-shadow:
inset 0 0 38px rgba(24, 92, 140, 0.18),
inset 0 -46px 72px rgba(0, 0, 0, 0.34);
}
// 预览遮罩:压暗背景并轻微模糊
.map-preview-mask {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(2, 8, 18, 0.72);
backdrop-filter: blur(3px);
animation: mapPreviewMaskIn 0.2s ease-out;
}
@keyframes mapPreviewMaskIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// 地图放大预览:全屏展示地图
.nav-map-stage.preview-mode {
position: fixed;
inset: 0;
z-index: 90;
box-sizing: border-box;
margin: 0;
padding-top: 0;
background: rgba(3, 12, 24, 0.99);
border: none;
box-shadow: none;
animation: mapPreviewIn 0.22s ease-out;
}
@keyframes mapPreviewIn {
from {
opacity: 0;
transform: scale(0.985);
}
to {
opacity: 1;
transform: scale(1);
}
}
.map-preview-btn {
position: absolute;
z-index: 6;
right: 14px;
top: 10px;
width: 30px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(86, 205, 255, 0.22);
background: rgba(9, 43, 82, 0.78);
color: rgba(222, 249, 255, 0.92);
cursor: pointer;
backdrop-filter: blur(1px);
transition: border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
&:hover {
border-color: rgba(122, 228, 255, 0.5);
color: #eefcff;
box-shadow: 0 0 12px rgba(77, 190, 255, 0.22);
}
}
.map-preview-icon {
width: 16px;
height: 16px;
}
.map-preview-close {
position: absolute;
z-index: 7;
right: 14px;
top: 10px;
width: 30px;
height: 26px;
border: 1px solid rgba(86, 205, 255, 0.24);
background: rgba(9, 43, 82, 0.8);
color: rgba(226, 250, 255, 0.92);
font-size: 14px;
line-height: 1;
cursor: pointer;
&:hover {
border-color: rgba(255, 126, 126, 0.5);
color: #ffd6d6;
}
}
.nav-map-stage::before {
content: "";
position: absolute;
@@ -1625,7 +1723,7 @@
}
.real-map-svg {
padding: 34px 56px 62px;
// padding: 34px 56px 62px;
box-sizing: border-box;
cursor: grab;
user-select: none;
@@ -1726,13 +1824,16 @@
stroke: #76efff;
stroke-width: 1.6;
stroke-dasharray: 5 4;
// 去掉路线外层的半透明发光底
filter: none;
// vector-effect: non-scaling-stroke;
}
.checkpoint circle {
fill: rgba(5, 18, 42, 0.96);
stroke: #8becff;
stroke-width: 1.5;
// 空心圆点:仅保留青色描边(原深色填充会让圆心发黑)
fill: transparent;
stroke: #00f0ff;
stroke-width: 2px;
filter: drop-shadow(0 0 12px rgba(97, 220, 255, 0.24));
}
@@ -1765,6 +1866,52 @@
transition: transform 3s ease;
}
// 机器人图标:不拦截地图拖拽事件
.robot-icon {
pointer-events: none;
}
// 机器人光圈:由内向外扩散(三圈时长/延迟/亮度各不相同,避免机械感)
.robot-ring {
fill: none;
stroke: #8fe3ff;
stroke-width: 1;
opacity: 0;
// 以圆自身中心为缩放基准
transform-box: fill-box;
transform-origin: center;
pointer-events: none;
animation: robotRingSpread 2.6s cubic-bezier(0.25, 0.62, 0.36, 1) infinite;
}
.robot-ring.ring-2 {
stroke-width: 0.9;
animation-duration: 2.9s;
animation-delay: 0.8s;
}
.robot-ring.ring-3 {
stroke-width: 0.8;
animation-duration: 3.3s;
animation-delay: 1.7s;
}
@keyframes robotRingSpread {
0% {
transform: scale(0.4);
opacity: 0.5;
}
55% {
opacity: 0.22;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.real-map-svg .checkpoint text {
font-size: 7px;
}
@@ -1808,16 +1955,20 @@
.map-scale-controls {
position: absolute;
z-index: 4;
right: 14px;
top: 34px;
// 右移让位给右上角的预览/关闭按钮
right: 52px;
top: 10px;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 7px;
background: rgba(4, 18, 38, 0.68);
border: 1px solid rgba(86, 205, 255, 0.14);
box-shadow: 0 0 16px rgba(0, 10, 24, 0.28);
backdrop-filter: blur(2px);
// 设计稿标签样式:半透明渐变底 + 细边框 + 外发光 + 轻微模糊
background: linear-gradient(90deg,
rgba(0, 50, 135, 0.3) 0%,
rgba(0, 53, 118, 0.2) 100%);
border: 1px solid rgba(86, 205, 255, 0.12);
box-shadow: 0 0 18px 0 rgba(0, 10, 24, 0.32);
backdrop-filter: blur(1px);
}
.map-scale-controls button {
@@ -1852,10 +2003,13 @@
.nav-map-overlay.bottom-right {
max-width: calc(100% - 28px);
padding: 6px 10px;
background: linear-gradient(90deg, rgba(3, 13, 30, 0.76), rgba(7, 28, 54, 0.5));
// 设计稿标签样式:半透明渐变底 + 细边框 + 外发光 + 轻微模糊
background: linear-gradient(90deg,
rgba(0, 50, 135, 0.3) 0%,
rgba(0, 53, 118, 0.2) 100%);
border: 1px solid rgba(86, 205, 255, 0.12);
backdrop-filter: blur(2px);
box-shadow: 0 0 18px rgba(0, 10, 24, 0.32);
box-shadow: 0 0 18px 0 rgba(0, 10, 24, 0.32);
backdrop-filter: blur(1px);
}
.nav-map-footer {
@@ -1888,44 +2042,57 @@
[class^="card-"] {
width: 100%;
position: relative;
// 背景层顺序(前者在上):01.webp → 01.png → 卡片底纹 → 标题1_静态背景
// 标题1_静态背景.png 放最底层,避免压住其它底图与标题文字
background-image: url("./assets/01.webp"), url("./assets/01.png"),
url("./assets/标题1_静态背景.png"), url("./assets/卡片背景.png");
url("./assets/卡片背景.png"), url("./assets/标题1_静态背景.png");
background-repeat: no-repeat;
background-position: top,
top,
top,
20px 40px;
background-size: 100%;
20px 40px,
top;
background-size: 100%, 100%, 100%, 100%;
.title {
font-weight: bold;
background-image: -webkit-linear-gradient(top,
rgb(157, 195, 234),
#fff,
rgb(96, 162, 225));
user-select: none;
// 设计稿卡片标题样式(渐变文字)
font-family: YouSheBiaoTiHei, "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 26px;
font-style: normal;
font-weight: 400;
line-height: 26px;
// 用 background-image 声明,避免 background 简写把裁剪方式重置成色块
background-image: linear-gradient(180deg, #fff 27.19%, #63c1ff 100%);
background-repeat: no-repeat;
background-size: 100% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
line-height: 30px;
margin-left: 80px;
color: #fff;
font-size: 18px;
// 兜底:渐变裁剪未生效时文字仍是可见的浅色,不会被底图遮没
color: #eaf6ff;
position: relative;
z-index: 1;
text-align: left;
user-select: none;
margin-left: 60px;
}
}
.device-card {
.device-panel {
margin: 4px 24px 0;
padding: 12px 14px 16px;
height: calc(100% - 56px);
// 标题与内容之间留出明显间距,同时保持底部不溢出
margin: 16px 24px 0;
// 内容区四周内边距 15px
padding: 15px;
box-sizing: border-box;
height: calc(100% - 64px);
display: flex;
flex-direction: column;
gap: 12px;
background: linear-gradient(
180deg,
rgba(7, 27, 61, 0.76) 0%,
rgba(3, 18, 41, 0.56) 100%
);
// 内容区背景:半透明渐变 + 背景模糊(对应设计稿 fill)
background: linear-gradient(0deg,
rgba(0, 83, 132, 0.3) 0%,
rgba(0, 83, 132, 0) 97.3%);
backdrop-filter: blur(4px);
border: 1px solid rgba(80, 197, 255, 0.18);
box-shadow: inset 0 0 20px rgba(65, 179, 255, 0.08);
}
@@ -2175,7 +2342,7 @@
gap: 12px;
.title {
margin-left: 80px;
margin-left: 60px;
flex: 0 0 auto;
}
}
@@ -2183,12 +2350,20 @@
.alert-card {
.alert-panel {
margin: 8px 24px 0;
// 内容区四周内边距 10px
padding: 10px;
box-sizing: border-box;
height: calc(100% - 56px);
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
// 内容区背景:半透明渐变 + 背景模糊(对应设计稿 fill)
background: linear-gradient(0deg,
rgba(0, 83, 132, 0.3) 0%,
rgba(0, 83, 132, 0) 97.3%);
backdrop-filter: blur(4px);
}
.alert-tabs {
@@ -2356,14 +2531,19 @@
.video-card {
> .card-header .title {
margin-left: 80px;
margin-left: 60px;
}
.video-panel {
margin: 12px 4px 0 20px;
height: calc(100% - 56px);
margin: 12px 20px;
display: flex;
flex-direction: column;
gap: 10px;
// 内容区背景:半透明渐变 + 背景模糊(对应设计稿 fill)
background: linear-gradient(0deg,
rgba(0, 83, 132, 0.3) 0%,
rgba(0, 83, 132, 0) 97.3%);
backdrop-filter: blur(4px);
}
.camera-switch {
@@ -2371,6 +2551,8 @@
justify-content: flex-end;
gap: 8px;
align-items: center;
color: rgba(192, 235, 250, 0.78);
font-size: 12px;
}
.header-switch {
@@ -2401,6 +2583,7 @@
}
}
// 两个摄像头画面均按 16:9 展示
.video-screen {
position: relative;
width: 100%;
@@ -2482,16 +2665,32 @@
.control-card {
> .card-header .title {
margin-left: 80px;
margin-left: 60px;
}
.control-panel {
height: calc(100% - 56px);
margin: 10px 4px 0 20px;
margin: 8px 14px 10px;
// 内容区四周内边距(左右 14px)
padding: 0px 14px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
gap: 8px;
overflow-y: auto;
// 内容区背景:半透明渐变 + 背景模糊(对应设计稿 fill)
background: linear-gradient(0deg,
rgba(0, 83, 132, 0.3) 0%,
rgba(0, 83, 132, 0) 97.3%);
backdrop-filter: blur(4px);
// 不显示滚动条(内容超出时仍可滚轮滚动)
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
width: 0;
display: none;
}
}
.control-status {
@@ -2735,7 +2934,7 @@
.control-top-row > .vehicle-section,
.control-top-row > .gimbal-section {
padding: 10px 10px 8px;
padding: 6px 8px 4px;
background: linear-gradient(
180deg,
rgba(8, 31, 62, 0.78) 0%,
@@ -2795,10 +2994,10 @@
.vehicle-pad {
width: 208px;
height: 208px;
margin: -10px auto -36px;
margin: -6px auto -32px;
position: relative;
left: -22px;
transform: scale(0.78);
transform: scale(0.7);
transform-origin: center top;
display: grid;
grid-template-columns: 1fr 72px 1fr;
@@ -3007,7 +3206,7 @@
}
.section-title {
margin-bottom: 8px;
margin-bottom: 6px;
color: #d9e7f1;
font-size: 11px;
font-weight: 700;
@@ -3033,9 +3232,9 @@
}
.gimbal-wheel {
margin: -10px auto -36px;
margin: -6px auto -32px;
left: -22px;
transform: scale(0.78);
transform: scale(0.7);
}
.gimbal-preset-panel {
@@ -3098,7 +3297,7 @@
.lift-control-panel {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
padding-top: 0;
}
@@ -3115,7 +3314,7 @@
}
.lift-input-shell {
height: 36px;
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
@@ -3150,7 +3349,7 @@
.lift-action-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
gap: 8px;
}
.lift-submit-wide {
@@ -3158,7 +3357,7 @@
}
.lift-submit-btn {
height: 34px;
height: 32px;
border: 1px solid rgba(91, 208, 255, 0.26);
background: linear-gradient(
90deg,
@@ -3193,7 +3392,7 @@
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
padding: 6px 8px;
background: linear-gradient(
180deg,
rgba(10, 36, 72, 0.78) 0%,
@@ -3216,36 +3415,38 @@
}
// 底部两列容器:各占 Grid 一列,内部再分两列,卡片高度随行高自动对齐
.left {
padding-left: 30px;
grid-row: 2;
grid-column: 1;
}
.right {
padding-right: 30px;
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 11%;
padding-bottom: 4%;
box-sizing: border-box;
grid-row: 2;
grid-column: 2;
.card-1 {
top: 0;
flex: none;
margin-top: -100px;
.card-1,
.card-2 {
margin-top: 0;
}
.card-2 {
top: 0;
flex: 1;
min-height: 0;
}
}
.left,
.right {
width: 25%;
height: 100%;
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
> div {
min-width: 0;
min-height: 0;
}
}
@keyframes screenScan {
@@ -3310,27 +3511,49 @@
}
}
// 左右列卡片高度均由 Grid 行高拉伸对齐,不再单独固定
.left {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: 11%;
padding-bottom: 8%;
box-sizing: border-box;
.card-1 {
top: 0;
height: 400px;
flex: none;
margin-top: -100px;
}
.card-1,
.card-2 {
top: 0;
height: 500px;
flex: none;
height: auto;
}
}
}
}
// 底部导航:dashboard 直接子级(与 .main 平级),整图背景 + 透明热区按钮
.bottom-nav {
position: absolute;
left: 50%;
bottom: 5px;
transform: translateX(-50%);
width: 1357px;
height: 70px;
z-index: 20;
background-repeat: no-repeat;
background-size: 100% 100%;
// 纯透明热区:仅提供点击区域,不叠加任何半透明占位样式
// top/height 按导航图中菜单的实际高度(像素 y 25-104 / 图高 140)换算
.nav-hit {
position: absolute;
top: 13px;
height: 40px;
border: 0;
padding: 0;
background: transparent;
color: transparent;
cursor: pointer;
// 屏幕阅读器可见的菜单文字(视觉上由背景图提供)
.nav-hit-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
}
}

View File

@@ -1,33 +1,53 @@
<script lang="ts" setup>
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import SwitchPageBtn from "./components/btn";
import backgroundVideo from "./assets/循环背景动画.mp4";
import backgroundImage from "./assets/循环背景.png";
import bottomNavCenter from "@/assets/底部.png";
import bottomNavEquipment from "@/assets/底部-1.png";
import bottomNavVideo from "@/assets/底部-2.png";
import bottomNavAlarm from "@/assets/底部-3.png";
import bottomNavPatrol from "@/assets/底部-4.png";
import bottomNavReport from "@/assets/底部-5.png";
interface DashboardTab {
id: number;
text: string;
path: string;
// 热区在底部导航图上的定位(百分比,对应 底部.png 内菜单项位置)
left: string;
width: string;
}
const route = useRoute();
const router = useRouter();
const tabs: Record<"left" | "right", DashboardTab[]> = {
left: [
{ id: 1, text: "监管中心", path: "/dashboard/integrated-center" },
{ id: 2, text: "设备管理", path: "/dashboard/equipment-management" },
{ id: 3, text: "视频中心", path: "/dashboard/video-center" },
],
right: [
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management" },
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan" },
{ id: 6, text: "统计分析", path: "/dashboard/data-report" },
],
};
// 底部导航项:left/width 按底部导航图(2715x140)中各菜单的实际像素区间实测换算
// 对应像素:监管中心 327-717、设备管理 717-1037、视频中心 1041-1337、
// 告警管理 1344-1669、巡检记录 1676-1989、统计分析 1998-2407
const tabs: DashboardTab[] = [
{ id: 1, text: "监管中心", path: "/dashboard/integrated-center", left: "12.0%", width: "14.4%" },
{ id: 2, text: "设备管理", path: "/dashboard/equipment-management", left: "26.4%", width: "11.8%" },
{ id: 3, text: "视频中心", path: "/dashboard/video-center", left: "38.3%", width: "10.9%" },
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management", left: "49.5%", width: "12.0%" },
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan", left: "61.7%", width: "11.6%" },
{ id: 6, text: "统计分析", path: "/dashboard/data-report", left: "73.6%", width: "15.0%" },
];
const activeTabId = computed(() => Number(route.meta.tabId) || 1);
// 各菜单对应的底部导航图(图片本身已带当前菜单的高亮效果)
const bottomNavImages: Record<number, string> = {
1: bottomNavCenter, // 监管中心
2: bottomNavEquipment, // 设备管理
3: bottomNavVideo, // 视频中心
4: bottomNavAlarm, // 告警管理
5: bottomNavPatrol, // 巡检记录
6: bottomNavReport, // 统计分析
};
// 当前页面高亮的底部导航图
const bottomNavImage = computed(() => bottomNavImages[activeTabId.value] ?? bottomNavCenter);
function switchPage(tab: DashboardTab) {
if (route.path !== tab.path) {
router.push(tab.path);
@@ -37,43 +57,29 @@ function switchPage(tab: DashboardTab) {
<template>
<div class="dashboard">
<video
autoplay
class="background"
loop
muted
preload="auto"
:src="backgroundVideo"
></video>
<!-- 背景使用视频首帧截图,避免播放动画 -->
<img class="background" :src="backgroundImage" alt="" />
<div class="main">
<div class="btn-group">
<div class="left">
<SwitchPageBtn
v-for="tab in tabs.left"
:key="tab.id"
:active="activeTabId === tab.id"
:text="tab.text"
direction="left"
@click="switchPage(tab)"
/>
</div>
<div class="right">
<SwitchPageBtn
v-for="tab in tabs.right"
:key="tab.id"
:active="activeTabId === tab.id"
:text="tab.text"
direction="right"
@click="switchPage(tab)"
/>
</div>
</div>
<div class="content-stage">
<RouterView v-slot="{ Component, route }">
<component :is="Component" :key="route.fullPath" />
</RouterView>
</div>
</div>
<!-- 底部导航:使用整张导航图,菜单项用透明热区覆盖 -->
<nav class="bottom-nav" :style="{ backgroundImage: `url(${bottomNavImage})` }">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['nav-hit', { active: activeTabId === tab.id }]"
:style="{ left: tab.left, width: tab.width }"
type="button"
:aria-label="tab.text"
@click="switchPage(tab)"
>
<span class="nav-hit-label">{{ tab.text }}</span>
</button>
</nav>
</div>
</template>

View File

@@ -1,6 +1,15 @@
<script lang="ts" setup>
import { NImage } from "naive-ui";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
type Ref,
} from "vue";
import {
getRecentAlarmList,
type RosInspectionRecordLineImage,
@@ -20,10 +29,17 @@ import { getToken } from "@/utils/auth";
import { parseRosMapYaml } from "@/utils/rosMapYaml";
import { BASE_URL } from "@/utils/request";
import { getRecentAlertPhoto } from "../shared/alertPhotos";
// 地图上的机器人标记图标(设计稿 巡检机器人_03,8 倍图,放大时不易模糊)
import robotIcon from "@/assets/robot-marker-8x.png";
// 未配置地图时展示的默认隧道示意图
import emptyMapImage from "@/assets/emptyMap.png";
const defaultProject = "西江隧道";
type DriveMode = "阿克曼" | "旋转" | "横移";
// 视频监控的摄像头通道:白光镜头 / 红外镜头
type CameraChannelKey = "white" | "infrared";
const cameraChannelKeys: CameraChannelKey[] = ["white", "infrared"];
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
type ControlMessageType = "cmd_vel" | "ptz_control" | "lift_control";
type PtzAction = "up" | "down" | "left" | "right" | "stop";
@@ -517,7 +533,6 @@ const alertLoading = ref(false);
const alertError = ref("");
const alertTabs = ["局部高温", "电缆表面破损", "桥架断裂", "隧道积水", "墙体渗漏"] as const;
const activeAlertTab = ref<(typeof alertTabs)[number]>("局部高温");
const activeCameraMode = ref<"white" | "infrared">("white");
const activeDeviceId = ref("");
const driveModeTabs: DriveMode[] = ["阿克曼", "旋转", "横移"];
const activeDriveMode = ref<DriveMode>("阿克曼");
@@ -529,14 +544,33 @@ const selectedPresetPoint = ref("预置点1");
const controlMode = ref<"人工接管" | "自主巡检">("自主巡检");
const maxLiftHeight = 800;
const targetLiftHeight = ref("1200");
const monitorVideoRef = ref<HTMLVideoElement | null>(null);
const navMapSvgRef = ref<SVGSVGElement | null>(null);
const streamLoading = ref(false);
const streamError = ref("");
const controlWsStatus = ref("未连接");
const controlError = ref("");
let streamPeerConnection: RTCPeerConnection | null = null;
let streamRequestId = 0;
// 视频监控同时展示白光/红外两路摄像头,各自持有独立的播放器与拉流状态
const whiteVideoRef = ref<HTMLVideoElement | null>(null);
const infraredVideoRef = ref<HTMLVideoElement | null>(null);
const cameraVideoRefs: Record<CameraChannelKey, Ref<HTMLVideoElement | null>> = {
white: whiteVideoRef,
infrared: infraredVideoRef,
};
const cameraLoading = reactive<Record<CameraChannelKey, boolean>>({
white: false,
infrared: false,
});
const cameraError = reactive<Record<CameraChannelKey, string>>({
white: "",
infrared: "",
});
// WebRTC 连接对象不参与响应式代理,避免 Proxy 包裹导致原生方法调用异常
const cameraStreams: Record<
CameraChannelKey,
{ requestId: number; peerConnection: RTCPeerConnection | null }
> = {
white: { requestId: 0, peerConnection: null },
infrared: { requestId: 0, peerConnection: null },
};
let controlSocket: WebSocket | null = null;
let controlRequestSeed = 0;
let controlReconnectTimer: number | null = null;
@@ -624,10 +658,11 @@ const currentDevices = computed(() => currentProject.value?.devices ?? []);
const currentRobot = computed(
() => currentDevices.value.find((device) => device.id === activeDeviceId.value) ?? currentDevices.value[0] ?? emptyDevice,
);
const currentMonitorVideo = computed(() => activeCameraMode.value === "white"
? currentRobot.value.whiteLightUrl
: currentRobot.value.infraredUrl,
);
// 白光/红外两路摄像头的当前播放地址
const cameraUrls = computed(() => ({
white: currentRobot.value.whiteLightUrl,
infrared: currentRobot.value.infraredUrl,
}));
const currentScenario = computed(() => navScenario);
const hasRobotPose = computed(
() => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number",
@@ -682,13 +717,10 @@ const robotMapPoint = computed(() => {
currentRobot.value.poseYaw ?? 0,
);
});
const robotMarkerScale = computed(() => {
if (!rosMap.value) {
return 1;
}
return 1 / mapScale.value;
});
// 机器人图标尺寸:宽度固定为地图点位直径的两倍(点位半径 3 → 直径 6 → 图标宽 12),
// 与点位共用同一坐标系,缩放时与点保持固定比例
const robotIconWidth = computed(() => (rosMap.value ? 12 : 28));
const robotIconHeight = computed(() => robotIconWidth.value * (30 / 55));
const mapContentTransform = computed(() => {
if (!rosMap.value) {
return "";
@@ -1194,21 +1226,29 @@ function sendControlMessage(type: ControlMessageType, data: Record<string, strin
socket.addEventListener("open", sendPayload, { once: true });
}
// 停止当前 WebRTC 拉流并释放播放器资源
function stopMonitorStream() {
streamRequestId += 1;
streamLoading.value = false;
// 停止指定摄像头的 WebRTC 拉流并释放播放器资源
function stopCameraStream(key: CameraChannelKey) {
const stream = cameraStreams[key];
stream.requestId += 1;
cameraLoading[key] = false;
if (streamPeerConnection) {
streamPeerConnection.close();
streamPeerConnection = null;
if (stream.peerConnection) {
stream.peerConnection.close();
stream.peerConnection = null;
}
if (monitorVideoRef.value) {
monitorVideoRef.value.srcObject = null;
const video = cameraVideoRefs[key].value;
if (video) {
video.srcObject = null;
}
}
// 停止全部摄像头拉流
function stopAllCameraStreams() {
cameraChannelKeys.forEach((key) => stopCameraStream(key));
}
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
function buildWebrtcApiUrl(streamUrl: string) {
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
@@ -1235,33 +1275,36 @@ function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) {
});
}
// 通过 WebRTC offer/answer 拉取当前机器人视频流
async function playMonitorStream() {
stopMonitorStream();
streamError.value = "";
// 通过 WebRTC offer/answer 拉取指定摄像头的视频流
async function playCameraStream(key: CameraChannelKey) {
stopCameraStream(key);
cameraError[key] = "";
if (!currentMonitorVideo.value) {
const streamUrl = cameraUrls.value[key];
if (!streamUrl) {
return;
}
if (!window.RTCPeerConnection) {
streamError.value = "当前浏览器不支持 WebRTC 播放";
cameraError[key] = "当前浏览器不支持 WebRTC 播放";
return;
}
const requestId = streamRequestId;
const stream = cameraStreams[key];
const requestId = stream.requestId;
const peerConnection = new RTCPeerConnection();
streamPeerConnection = peerConnection;
streamLoading.value = true;
stream.peerConnection = peerConnection;
cameraLoading[key] = true;
peerConnection.addTransceiver("video", { direction: "recvonly" });
peerConnection.addTransceiver("audio", { direction: "recvonly" });
peerConnection.ontrack = (event) => {
if (requestId !== streamRequestId || !monitorVideoRef.value) {
if (requestId !== stream.requestId || !cameraVideoRefs[key].value) {
return;
}
monitorVideoRef.value.srcObject = event.streams[0];
cameraVideoRefs[key].value.srcObject = event.streams[0];
};
try {
@@ -1269,7 +1312,7 @@ async function playMonitorStream() {
await peerConnection.setLocalDescription(offer);
await waitForIceGatheringComplete(peerConnection);
const response = await fetch(buildWebrtcApiUrl(currentMonitorVideo.value), {
const response = await fetch(buildWebrtcApiUrl(streamUrl), {
method: "POST",
headers: {
"Content-Type": "application/sdp",
@@ -1298,7 +1341,7 @@ async function playMonitorStream() {
}
}
if (requestId !== streamRequestId) {
if (requestId !== stream.requestId) {
return;
}
@@ -1307,38 +1350,59 @@ async function playMonitorStream() {
sdp: answer,
});
} catch (error) {
if (requestId === streamRequestId) {
streamError.value = error instanceof Error ? error.message : "视频流拉取失败";
stopMonitorStream();
if (requestId === stream.requestId) {
cameraError[key] = error instanceof Error ? error.message : "视频流拉取失败";
stopCameraStream(key);
}
} finally {
if (requestId === streamRequestId) {
streamLoading.value = false;
if (requestId === stream.requestId) {
cameraLoading[key] = false;
}
}
}
// 地图放大预览:把地图舞台切换为全屏浮层
const mapPreviewVisible = ref(false);
function openMapPreview() {
mapPreviewVisible.value = true;
}
function closeMapPreview() {
mapPreviewVisible.value = false;
}
// Esc 关闭预览
function handleMapPreviewKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && mapPreviewVisible.value) {
closeMapPreview();
}
}
onMounted(() => {
fetchDeviceList();
fetchRecentAlerts();
connectControlSocket();
// startInspectionPolling();
window.addEventListener("blur", stopContinuousControl);
window.addEventListener("keydown", handleMapPreviewKeydown);
});
onBeforeUnmount(() => {
stopMonitorStream();
stopAllCameraStreams();
stopContinuousControl();
// stopInspectionPolling();
closeControlSocket();
window.removeEventListener("blur", stopContinuousControl);
window.removeEventListener("keydown", handleMapPreviewKeydown);
});
// 切换机器人或视频地址变化时,两路摄像头各自重新拉流
watch(
currentMonitorVideo,
cameraUrls,
() => {
nextTick(() => {
playMonitorStream();
cameraChannelKeys.forEach((key) => playCameraStream(key));
});
},
);
@@ -1365,10 +1429,6 @@ function switchAlertTab(tab: (typeof alertTabs)[number]) {
fetchRecentAlerts();
}
function switchCameraMode(mode: "white" | "infrared") {
activeCameraMode.value = mode;
}
// 点击设备列表中的机器人后,同步切换控制对象和监控视频源
function selectDevice(device: DeviceItem) {
activeDeviceId.value = device.id;
@@ -1654,8 +1714,23 @@ function getStatusClass(status: DeviceItem["status"]) {
</div>
<div class="middle">
<div class="nav-map-panel">
<div class="nav-map-stage">
<!-- card- 前缀复用与其他面板一致的卡片标题样式 -->
<div class="card-map nav-map-panel">
<div class="title">管廊实时地图</div>
<!-- 预览遮罩:点击空白处也可关闭 -->
<div v-if="mapPreviewVisible" class="map-preview-mask" @click="closeMapPreview"></div>
<div :class="['nav-map-stage', { 'preview-mode': mapPreviewVisible }]">
<!-- 地图放大预览:点击后地图舞台切换为全屏浮层 -->
<button v-if="!mapPreviewVisible" class="map-preview-btn" type="button" title="预览"
aria-label="预览" @click.stop="openMapPreview">
<svg class="map-preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button v-else class="map-preview-close" type="button" @click.stop="closeMapPreview">
✕
</button>
<svg v-if="rosMap" ref="navMapSvgRef" :class="['nav-map-svg', 'real-map-svg', { dragging: mapDragState.dragging }]" :viewBox="rosMap.viewBox" preserveAspectRatio="xMidYMid meet"
@wheel="handleMapWheel" @pointerdown="startMapDrag" @pointermove="moveMapDrag" @pointerup="stopMapDrag"
@pointerleave="stopMapDrag" @pointercancel="stopMapDrag">
@@ -1667,11 +1742,17 @@ function getStatusClass(status: DeviceItem["status"]) {
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 路线遮罩:在巡检点位置挖空,避免路线伸进点位圆内 -->
<mask id="routePointMask" maskUnits="userSpaceOnUse" x="0" y="0" :width="rosMap.width"
:height="rosMap.height">
<rect x="0" y="0" :width="rosMap.width" :height="rosMap.height" fill="#fff" />
<circle v-for="(point, index) in realCheckPoints" :key="`route-mask-${index}`" :cx="point.x"
:cy="point.y" r="5" fill="#000" />
</mask>
</defs>
<g class="real-map-content" :transform="mapContentTransform">
<image class="ros-map-image" :href="rosMap.imageUrl" x="0" y="0" :width="rosMap.width" :height="rosMap.height" />
<path v-if="realRoutePath" class="route-glow" :d="realRoutePath" />
<path v-if="realRoutePath" class="route-line" :d="realRoutePath" />
<path v-if="realRoutePath" class="route-line" :d="realRoutePath" mask="url(#routePointMask)" />
<g class="checkpoint-layer">
<g v-for="(point, index) in realCheckPoints" :key="index" class="checkpoint"
:transform="`translate(${point.x}, ${point.y})`">
@@ -1680,50 +1761,35 @@ function getStatusClass(status: DeviceItem["status"]) {
</g>
</g>
<g v-if="robotMapPoint" class="robot-layer real-robot-layer"
:transform="`translate(${robotMapPoint.x}, ${robotMapPoint.y}) rotate(${robotMapPoint.angle}) scale(${robotMarkerScale})`">
<circle class="robot-shadow" r="6.5" />
<circle class="robot-body" r="5.8" />
<path class="robot-head" d="M -1.6 -5.4 L 4.8 0 L -1.6 5.4 Z" />
:transform="`translate(${robotMapPoint.x}, ${robotMapPoint.y}) rotate(${robotMapPoint.angle})`">
<!-- 机器人光圈:由内向外扩散的三层圆环(基准半径约车位宽的 0.75 倍) -->
<circle class="robot-ring ring-1" :r="robotIconWidth * 0.75" />
<circle class="robot-ring ring-2" :r="robotIconWidth * 0.75" />
<circle class="robot-ring ring-3" :r="robotIconWidth * 0.75" />
<!-- 机器人标记:图标尺寸随地图分辨率等比,并随地图一起缩放 -->
<image class="robot-icon" :href="robotIcon" :x="-robotIconWidth / 2" :y="-robotIconHeight / 2"
:width="robotIconWidth" :height="robotIconHeight" />
</g>
</g>
</svg>
<svg v-else class="nav-map-svg" viewBox="0 0 760 420" preserveAspectRatio="xMidYMid meet">
<g class="grid-layer">
<line v-for="line in 7" :key="`h-${line}`" :x1="40" :y1="line * 52" :x2="720" :y2="line * 52" />
<line v-for="line in 12" :key="`v-${line}`" :x1="line * 56" :y1="40" :x2="line * 56" :y2="380" />
</g>
<path class="corridor-lane" :d="currentScenario.lanePath" />
<path class="corridor-edge" :d="currentScenario.upperEdgePath" />
<path class="corridor-edge" :d="currentScenario.lowerEdgePath" />
<g class="wall-layer wall-upper">
<circle v-for="(point, index) in currentScenario.upperWallPoints" :key="`upper-wall-${index}`"
:cx="point.x" :cy="point.y" :r="index % 4 === 0 ? 3.4 : 2.4" />
</g>
<g class="wall-layer wall-lower">
<circle v-for="(point, index) in currentScenario.lowerWallPoints" :key="`lower-wall-${index}`"
:cx="point.x" :cy="point.y" :r="index % 5 === 0 ? 3.2 : 2.3" />
</g>
<path class="route-glow" :d="currentScenario.routePath" />
<path class="route-line" :d="currentScenario.routePath" />
<g class="checkpoint-layer">
<g v-for="point in currentScenario.checkPoints" :key="point.label" class="checkpoint"
:transform="`translate(${point.x}, ${point.y})`">
<circle r="7"></circle>
<text x="12" y="4">{{ point.label }}</text>
</g>
</g>
<g class="robot-layer">
<circle class="robot-shadow" r="18" cx="358" cy="258" />
<circle class="robot-body" r="10" cx="358" cy="258" />
<path class="robot-head" d="M 355 249 L 366 258 L 355 267 Z" />
<!-- 未配置地图:使用默认隧道示意图,机器人叠加在图中车辆位置 -->
<svg v-else class="nav-map-svg" viewBox="0 0 1398 467" preserveAspectRatio="xMidYMid meet">
<image class="empty-map-image" :href="emptyMapImage" x="0" y="0" width="1398" height="467" />
<g class="robot-layer" transform="translate(1216, 266)">
<!-- 机器人光圈:由内向外扩散的三层圆环 -->
<circle class="robot-ring ring-1" r="21" />
<circle class="robot-ring ring-2" r="21" />
<circle class="robot-ring ring-3" r="21" />
<!-- 机器人标记使用设计稿图标(28x15) -->
<image class="robot-icon" :href="robotIcon" x="-14" y="-8" width="28" height="15" />
</g>
</svg>
<div v-if="mapLoading" class="map-state-tip">地图加载中...</div>
<div v-else-if="mapError" class="map-state-tip">{{ mapError }}</div>
<div v-else-if="routeLoading" class="map-state-tip">路线加载中...</div>
<div v-else-if="routeError" class="map-state-tip">{{ routeError }}</div>
<div class="nav-map-overlay top-left">ROS 实时地图</div>
<div class="nav-map-overlay top-right">真实坐标路径跟踪</div>
<!-- <div class="nav-map-overlay top-left">ROS 实时地图</div>
<div class="nav-map-overlay top-right">真实坐标路径跟踪</div> -->
<div v-if="rosMap" class="map-scale-controls">
<button type="button" @click="changeMapScale(-0.05)">-</button>
<span>{{ Math.round(mapScale * 100) }}%</span>
@@ -1745,32 +1811,31 @@ function getStatusClass(status: DeviceItem["status"]) {
<div class="card-1 video-card">
<div class="card-header">
<div class="title">视频监控</div>
<div class="camera-switch header-switch">
<button :class="['camera-btn', { active: activeCameraMode === 'white' }]" type="button"
@click="switchCameraMode('white')">
白光
</button>
<button :class="['camera-btn', { active: activeCameraMode === 'infrared' }]" type="button"
@click="switchCameraMode('infrared')">
红外
</button>
</div>
</div>
<div class="video-panel">
<!-- 白光镜头:16:9 -->
<div class="video-screen">
<!-- <img src="/image.png" alt="" class="monitor-video"> -->
<video v-if="currentMonitorVideo" ref="monitorVideoRef" :key="currentMonitorVideo"
:class="['monitor-video', { infrared: activeCameraMode === 'infrared' }]" autoplay muted
playsinline></video>
<video v-if="cameraUrls.white" ref="whiteVideoRef" :key="cameraUrls.white"
class="monitor-video" autoplay muted playsinline></video>
<div class="scan-line"></div>
<div class="video-overlay top-left">实时视频流</div>
<div class="video-overlay top-right">
{{ activeCameraMode === "white" ? "白光镜头" : "红外镜头" }}
</div>
<div class="video-overlay top-right">白光镜头</div>
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
<div v-if="streamLoading" class="video-empty">视频流连接中...</div>
<div v-else-if="streamError" class="video-empty">{{ streamError }}</div>
<div v-else-if="!currentMonitorVideo" class="video-empty">当前机器人暂无视频地址</div>
<div v-if="cameraLoading.white" class="video-empty">视频流连接中...</div>
<div v-else-if="cameraError.white" class="video-empty">{{ cameraError.white }}</div>
<div v-else-if="!cameraUrls.white" class="video-empty">当前机器人暂无白光视频地址</div>
</div>
<!-- 红外镜头:16:9 -->
<div class="video-screen">
<video v-if="cameraUrls.infrared" ref="infraredVideoRef" :key="cameraUrls.infrared"
class="monitor-video infrared" autoplay muted playsinline></video>
<div class="scan-line"></div>
<div class="video-overlay top-left">实时视频流</div>
<div class="video-overlay top-right">红外镜头</div>
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
<div v-if="cameraLoading.infrared" class="video-empty">视频流连接中...</div>
<div v-else-if="cameraError.infrared" class="video-empty">{{ cameraError.infrared }}</div>
<div v-else-if="!cameraUrls.infrared" class="video-empty">当前机器人暂无红外视频地址</div>
</div>
</div>
</div>