style(dashboard1): 优化大屏样式与地图交互
- 引入优设标题黑/圆字体并替换顶部标题为整图 - 统一页面边距与卡片背景为半透明渐变模糊 - 底部导航改用整图背景配透明热区 - 新增地图放大预览与机器人光圈扩散动画 - 视频监控改为白光/红外双路独立拉流
BIN
src/assets/emptyMap.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
src/assets/font/YouSheBiaoTiYuan.otf
Normal file
BIN
src/assets/font/YouSheBiaoTiYuan.ttf
Normal file
8
src/assets/font/YousheBiaoTiHei.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@font-face {
|
||||
/*给字体命名*/
|
||||
font-family: 'YouSheBiaoTiHei';
|
||||
/*引入字体文件*/
|
||||
src: url('./YouSheBiaoTiHei.ttf');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
BIN
src/assets/font/YousheBiaoTiHei.ttf
Normal file
8
src/assets/font/YousheBiaoTiYuan.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@font-face {
|
||||
/*给字体命名*/
|
||||
font-family: 'YouSheBiaoTiYuan';
|
||||
/*引入字体文件*/
|
||||
src: url('./YouSheBiaoTiYuan.ttf');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
BIN
src/assets/robot-marker-8x.png
Normal file
|
After Width: | Height: | Size: 722 KiB |
BIN
src/assets/底部-1.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
src/assets/底部-2.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
src/assets/底部-3.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
src/assets/底部-4.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
src/assets/底部-5.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
src/assets/底部.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
src/assets/管廊巡检机器人综合管理平台.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
@@ -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;
|
||||
|
||||
BIN
src/views/dashboard1/assets/循环背景.png
Normal file
|
After Width: | Height: | Size: 353 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,12 +31,12 @@ export default defineConfig(({ command }: ConfigEnv) => {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://127.0.0.1:16003/`,
|
||||
target: `http://192.168.3.233:16003/`,
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(/^\/api/, '')
|
||||
},
|
||||
'/ws': {
|
||||
target: `ws://127.0.0.1:16003/`,
|
||||
target: `ws://192.168.3.233:16003/`,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
|
||||