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 {
|
:root {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
BIN
src/views/dashboard1/assets/循环背景.png
Normal file
|
After Width: | Height: | Size: 353 KiB |
@@ -28,20 +28,26 @@
|
|||||||
auto,
|
auto,
|
||||||
auto;
|
auto;
|
||||||
|
|
||||||
|
// 顶部标题:使用导出的标题整图(1167x81,按设计稿高度 45px 等比展示)
|
||||||
&:before {
|
&:before {
|
||||||
content: "管廊巡检机器人综合管理平台";
|
content: "";
|
||||||
color: #fff;
|
|
||||||
letter-spacing: 12px;
|
|
||||||
font-size: 36px;
|
|
||||||
line-height: 70px;
|
|
||||||
width: 100%;
|
|
||||||
font-weight: bold;
|
|
||||||
position: absolute;
|
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 {
|
.background {
|
||||||
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -60,9 +66,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 页面内容区边距与大屏(监管中心)保持一致:上 87 / 左右 24 / 下 60(避开底部导航)
|
||||||
.page-shell {
|
.page-shell {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 7.8% 34px 7%;
|
padding: 87px 24px 60px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,26 +112,23 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.management-page {
|
// 各页面统一沿用 .page-shell 的边距,不再单独覆盖
|
||||||
padding-top: 8.5%;
|
|
||||||
padding-left: 42px;
|
// 视频中心 / 统计分析页:内容区左右再留 14px,与大屏内容区保持一致
|
||||||
padding-right: 42px;
|
.video-center-layout,
|
||||||
}
|
.report-main-grid {
|
||||||
|
margin: 0 14px;
|
||||||
.alarm-page,
|
|
||||||
.plan-page,
|
|
||||||
.report-page {
|
|
||||||
padding-left: 42px;
|
|
||||||
padding-right: 42px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 面板背景与大屏内容区统一:半透明渐变 + 背景模糊
|
||||||
.page-card {
|
.page-card {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
background:
|
background: linear-gradient(0deg,
|
||||||
linear-gradient(180deg, rgba(8, 29, 60, 0.78) 0%, rgba(5, 18, 39, 0.62) 100%);
|
rgba(0, 83, 132, 0.3) 0%,
|
||||||
border: 1px solid rgba(86, 198, 255, 0.16);
|
rgba(0, 83, 132, 0) 97.3%);
|
||||||
box-shadow: inset 0 0 24px rgba(46, 144, 224, 0.08);
|
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);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +145,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 24px 28px 22px;
|
padding: 24px 28px 22px;
|
||||||
background:
|
background: linear-gradient(0deg,
|
||||||
linear-gradient(180deg, rgba(10, 38, 74, 0.88) 0%, rgba(5, 18, 39, 0.8) 100%);
|
rgba(0, 83, 132, 0.3) 0%,
|
||||||
border: 1px solid rgba(93, 208, 255, 0.2);
|
rgba(0, 83, 132, 0) 97.3%);
|
||||||
box-shadow:
|
border: 1px solid rgba(86, 205, 255, 0.12);
|
||||||
inset 0 0 32px rgba(42, 142, 221, 0.08),
|
box-shadow: 0 0 18px 0 rgba(0, 10, 24, 0.32);
|
||||||
0 0 0 1px rgba(17, 75, 122, 0.35),
|
backdrop-filter: blur(4px);
|
||||||
0 18px 42px rgba(3, 12, 24, 0.3);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,11 +639,7 @@
|
|||||||
grid-template-columns: 1.2fr 0.8fr;
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-center-shell {
|
// 视频中心同样沿用统一的页面边距
|
||||||
padding-top: 7.4%;
|
|
||||||
padding-left: 42px;
|
|
||||||
padding-right: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-center-layout {
|
.video-center-layout {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1164,10 +1163,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-dashboard-page {
|
// 统计分析页同样沿用统一的页面边距
|
||||||
padding-left: 42px;
|
|
||||||
padding-right: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-dashboard {
|
.report-dashboard {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1511,50 +1507,51 @@
|
|||||||
font-weight: 700;
|
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 {
|
.center {
|
||||||
height: 100%;
|
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 {
|
.middle {
|
||||||
width: 50%;
|
grid-column: 1 / -1;
|
||||||
height: 100%;
|
grid-row: 1;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// 地图面板:标题样式与其他面板保持一致(复用 card- 前缀样式)
|
||||||
.nav-map-panel {
|
.nav-map-panel {
|
||||||
margin: calc(8.5% + 40px) auto 0;
|
margin: 0;
|
||||||
width: 92%;
|
width: 100%;
|
||||||
height: 74%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 18px 22px 16px;
|
}
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
// 地图卡标题背景与设备列表等卡片完全一致:
|
||||||
rgba(7, 23, 48, 0.76) 0%,
|
// 标题底图按单卡宽度(456px,即底部各卡实际宽度)铺贴,避免通栏拉伸变形
|
||||||
rgba(4, 16, 34, 0.58) 100%
|
.card-map {
|
||||||
);
|
background-size:
|
||||||
border: 1px solid rgba(95, 204, 255, 0.18);
|
456px auto,
|
||||||
box-shadow: inset 0 0 24px rgba(45, 144, 224, 0.08);
|
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 {
|
.nav-map-header {
|
||||||
@@ -1582,15 +1579,116 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
// 内容区左右(及底部)留出与其它卡片一致的内缩距离
|
||||||
radial-gradient(circle at 50% 58%, rgba(46, 163, 224, 0.12), transparent 34%),
|
margin: 0 14px 12px;
|
||||||
linear-gradient(180deg, rgba(6, 18, 31, 0.96) 0%, rgba(3, 10, 22, 0.98) 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(82, 191, 255, 0.16);
|
border: 1px solid rgba(82, 191, 255, 0.16);
|
||||||
|
transition: inset 0.2s ease;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 38px rgba(24, 92, 140, 0.18),
|
inset 0 0 38px rgba(24, 92, 140, 0.18),
|
||||||
inset 0 -46px 72px rgba(0, 0, 0, 0.34);
|
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 {
|
.nav-map-stage::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1625,7 +1723,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.real-map-svg {
|
.real-map-svg {
|
||||||
padding: 34px 56px 62px;
|
// padding: 34px 56px 62px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -1726,13 +1824,16 @@
|
|||||||
stroke: #76efff;
|
stroke: #76efff;
|
||||||
stroke-width: 1.6;
|
stroke-width: 1.6;
|
||||||
stroke-dasharray: 5 4;
|
stroke-dasharray: 5 4;
|
||||||
|
// 去掉路线外层的半透明发光底
|
||||||
|
filter: none;
|
||||||
// vector-effect: non-scaling-stroke;
|
// vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkpoint circle {
|
.checkpoint circle {
|
||||||
fill: rgba(5, 18, 42, 0.96);
|
// 空心圆点:仅保留青色描边(原深色填充会让圆心发黑)
|
||||||
stroke: #8becff;
|
fill: transparent;
|
||||||
stroke-width: 1.5;
|
stroke: #00f0ff;
|
||||||
|
stroke-width: 2px;
|
||||||
filter: drop-shadow(0 0 12px rgba(97, 220, 255, 0.24));
|
filter: drop-shadow(0 0 12px rgba(97, 220, 255, 0.24));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1765,6 +1866,52 @@
|
|||||||
transition: transform 3s ease;
|
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 {
|
.real-map-svg .checkpoint text {
|
||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
}
|
}
|
||||||
@@ -1808,16 +1955,20 @@
|
|||||||
.map-scale-controls {
|
.map-scale-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
right: 14px;
|
// 右移让位给右上角的预览/关闭按钮
|
||||||
top: 34px;
|
right: 52px;
|
||||||
|
top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
background: rgba(4, 18, 38, 0.68);
|
// 设计稿标签样式:半透明渐变底 + 细边框 + 外发光 + 轻微模糊
|
||||||
border: 1px solid rgba(86, 205, 255, 0.14);
|
background: linear-gradient(90deg,
|
||||||
box-shadow: 0 0 16px rgba(0, 10, 24, 0.28);
|
rgba(0, 50, 135, 0.3) 0%,
|
||||||
backdrop-filter: blur(2px);
|
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 {
|
.map-scale-controls button {
|
||||||
@@ -1852,10 +2003,13 @@
|
|||||||
.nav-map-overlay.bottom-right {
|
.nav-map-overlay.bottom-right {
|
||||||
max-width: calc(100% - 28px);
|
max-width: calc(100% - 28px);
|
||||||
padding: 6px 10px;
|
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);
|
border: 1px solid rgba(86, 205, 255, 0.12);
|
||||||
backdrop-filter: blur(2px);
|
box-shadow: 0 0 18px 0 rgba(0, 10, 24, 0.32);
|
||||||
box-shadow: 0 0 18px rgba(0, 10, 24, 0.32);
|
backdrop-filter: blur(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-map-footer {
|
.nav-map-footer {
|
||||||
@@ -1888,44 +2042,57 @@
|
|||||||
[class^="card-"] {
|
[class^="card-"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
// 背景层顺序(前者在上):01.webp → 01.png → 卡片底纹 → 标题1_静态背景
|
||||||
|
// 标题1_静态背景.png 放最底层,避免压住其它底图与标题文字
|
||||||
background-image: url("./assets/01.webp"), url("./assets/01.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-repeat: no-repeat;
|
||||||
background-position: top,
|
background-position: top,
|
||||||
top,
|
top,
|
||||||
top,
|
20px 40px,
|
||||||
20px 40px;
|
top;
|
||||||
background-size: 100%;
|
background-size: 100%, 100%, 100%, 100%;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: bold;
|
// 设计稿卡片标题样式(渐变文字)
|
||||||
background-image: -webkit-linear-gradient(top,
|
font-family: YouSheBiaoTiHei, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
rgb(157, 195, 234),
|
font-size: 26px;
|
||||||
#fff,
|
font-style: normal;
|
||||||
rgb(96, 162, 225));
|
font-weight: 400;
|
||||||
user-select: none;
|
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-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
line-height: 30px;
|
// 兜底:渐变裁剪未生效时文字仍是可见的浅色,不会被底图遮没
|
||||||
margin-left: 80px;
|
color: #eaf6ff;
|
||||||
color: #fff;
|
position: relative;
|
||||||
font-size: 18px;
|
z-index: 1;
|
||||||
|
text-align: left;
|
||||||
|
user-select: none;
|
||||||
|
margin-left: 60px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-card {
|
.device-card {
|
||||||
.device-panel {
|
.device-panel {
|
||||||
margin: 4px 24px 0;
|
// 标题与内容之间留出明显间距,同时保持底部不溢出
|
||||||
padding: 12px 14px 16px;
|
margin: 16px 24px 0;
|
||||||
height: calc(100% - 56px);
|
// 内容区四周内边距 15px
|
||||||
|
padding: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: calc(100% - 64px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
background: linear-gradient(
|
// 内容区背景:半透明渐变 + 背景模糊(对应设计稿 fill)
|
||||||
180deg,
|
background: linear-gradient(0deg,
|
||||||
rgba(7, 27, 61, 0.76) 0%,
|
rgba(0, 83, 132, 0.3) 0%,
|
||||||
rgba(3, 18, 41, 0.56) 100%
|
rgba(0, 83, 132, 0) 97.3%);
|
||||||
);
|
backdrop-filter: blur(4px);
|
||||||
border: 1px solid rgba(80, 197, 255, 0.18);
|
border: 1px solid rgba(80, 197, 255, 0.18);
|
||||||
box-shadow: inset 0 0 20px rgba(65, 179, 255, 0.08);
|
box-shadow: inset 0 0 20px rgba(65, 179, 255, 0.08);
|
||||||
}
|
}
|
||||||
@@ -2175,7 +2342,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin-left: 80px;
|
margin-left: 60px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2183,12 +2350,20 @@
|
|||||||
.alert-card {
|
.alert-card {
|
||||||
.alert-panel {
|
.alert-panel {
|
||||||
margin: 8px 24px 0;
|
margin: 8px 24px 0;
|
||||||
|
// 内容区四周内边距 10px
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
height: calc(100% - 56px);
|
height: calc(100% - 56px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
overflow: hidden;
|
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 {
|
.alert-tabs {
|
||||||
@@ -2356,14 +2531,19 @@
|
|||||||
|
|
||||||
.video-card {
|
.video-card {
|
||||||
> .card-header .title {
|
> .card-header .title {
|
||||||
margin-left: 80px;
|
margin-left: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-panel {
|
.video-panel {
|
||||||
margin: 12px 4px 0 20px;
|
margin: 12px 20px;
|
||||||
height: calc(100% - 56px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.camera-switch {
|
||||||
@@ -2371,6 +2551,8 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
color: rgba(192, 235, 250, 0.78);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-switch {
|
.header-switch {
|
||||||
@@ -2401,6 +2583,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 两个摄像头画面均按 16:9 展示
|
||||||
.video-screen {
|
.video-screen {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2482,16 +2665,32 @@
|
|||||||
|
|
||||||
.control-card {
|
.control-card {
|
||||||
> .card-header .title {
|
> .card-header .title {
|
||||||
margin-left: 80px;
|
margin-left: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-panel {
|
.control-panel {
|
||||||
height: calc(100% - 56px);
|
height: calc(100% - 56px);
|
||||||
margin: 10px 4px 0 20px;
|
margin: 8px 14px 10px;
|
||||||
|
// 内容区四周内边距(左右 14px)
|
||||||
|
padding: 0px 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
overflow: hidden;
|
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 {
|
.control-status {
|
||||||
@@ -2735,7 +2934,7 @@
|
|||||||
|
|
||||||
.control-top-row > .vehicle-section,
|
.control-top-row > .vehicle-section,
|
||||||
.control-top-row > .gimbal-section {
|
.control-top-row > .gimbal-section {
|
||||||
padding: 10px 10px 8px;
|
padding: 6px 8px 4px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(8, 31, 62, 0.78) 0%,
|
rgba(8, 31, 62, 0.78) 0%,
|
||||||
@@ -2795,10 +2994,10 @@
|
|||||||
.vehicle-pad {
|
.vehicle-pad {
|
||||||
width: 208px;
|
width: 208px;
|
||||||
height: 208px;
|
height: 208px;
|
||||||
margin: -10px auto -36px;
|
margin: -6px auto -32px;
|
||||||
position: relative;
|
position: relative;
|
||||||
left: -22px;
|
left: -22px;
|
||||||
transform: scale(0.78);
|
transform: scale(0.7);
|
||||||
transform-origin: center top;
|
transform-origin: center top;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 72px 1fr;
|
grid-template-columns: 1fr 72px 1fr;
|
||||||
@@ -3007,7 +3206,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
color: #d9e7f1;
|
color: #d9e7f1;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -3033,9 +3232,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gimbal-wheel {
|
.gimbal-wheel {
|
||||||
margin: -10px auto -36px;
|
margin: -6px auto -32px;
|
||||||
left: -22px;
|
left: -22px;
|
||||||
transform: scale(0.78);
|
transform: scale(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gimbal-preset-panel {
|
.gimbal-preset-panel {
|
||||||
@@ -3098,7 +3297,7 @@
|
|||||||
.lift-control-panel {
|
.lift-control-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3115,7 +3314,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lift-input-shell {
|
.lift-input-shell {
|
||||||
height: 36px;
|
height: 32px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3150,7 +3349,7 @@
|
|||||||
.lift-action-row {
|
.lift-action-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lift-submit-wide {
|
.lift-submit-wide {
|
||||||
@@ -3158,7 +3357,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lift-submit-btn {
|
.lift-submit-btn {
|
||||||
height: 34px;
|
height: 32px;
|
||||||
border: 1px solid rgba(91, 208, 255, 0.26);
|
border: 1px solid rgba(91, 208, 255, 0.26);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
@@ -3193,7 +3392,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px 10px;
|
padding: 6px 8px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(10, 36, 72, 0.78) 0%,
|
rgba(10, 36, 72, 0.78) 0%,
|
||||||
@@ -3216,36 +3415,38 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 底部两列容器:各占 Grid 一列,内部再分两列,卡片高度随行高自动对齐
|
||||||
.left {
|
.left {
|
||||||
padding-left: 30px;
|
grid-row: 2;
|
||||||
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
padding-right: 30px;
|
grid-row: 2;
|
||||||
display: flex;
|
grid-column: 2;
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding-top: 11%;
|
|
||||||
padding-bottom: 4%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.card-1 {
|
.card-1,
|
||||||
top: 0;
|
.card-2 {
|
||||||
flex: none;
|
margin-top: 0;
|
||||||
margin-top: -100px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-2 {
|
.card-2 {
|
||||||
top: 0;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.left,
|
.left,
|
||||||
.right {
|
.right {
|
||||||
width: 25%;
|
min-width: 0;
|
||||||
height: 100%;
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes screenScan {
|
@keyframes screenScan {
|
||||||
@@ -3310,27 +3511,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 左右列卡片高度均由 Grid 行高拉伸对齐,不再单独固定
|
||||||
.left {
|
.left {
|
||||||
display: flex;
|
.card-1,
|
||||||
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-2 {
|
.card-2 {
|
||||||
top: 0;
|
height: auto;
|
||||||
height: 500px;
|
|
||||||
flex: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 底部导航: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>
|
<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";
|
import backgroundImage from "./assets/循环背景.png";
|
||||||
import backgroundVideo from "./assets/循环背景动画.mp4";
|
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 {
|
interface DashboardTab {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
// 热区在底部导航图上的定位(百分比,对应 底部.png 内菜单项位置)
|
||||||
|
left: string;
|
||||||
|
width: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const tabs: Record<"left" | "right", DashboardTab[]> = {
|
// 底部导航项:left/width 按底部导航图(2715x140)中各菜单的实际像素区间实测换算
|
||||||
left: [
|
// 对应像素:监管中心 327-717、设备管理 717-1037、视频中心 1041-1337、
|
||||||
{ id: 1, text: "监管中心", path: "/dashboard/integrated-center" },
|
// 告警管理 1344-1669、巡检记录 1676-1989、统计分析 1998-2407
|
||||||
{ id: 2, text: "设备管理", path: "/dashboard/equipment-management" },
|
const tabs: DashboardTab[] = [
|
||||||
{ id: 3, text: "视频中心", path: "/dashboard/video-center" },
|
{ 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%" },
|
||||||
right: [
|
{ id: 3, text: "视频中心", path: "/dashboard/video-center", left: "38.3%", width: "10.9%" },
|
||||||
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management" },
|
{ id: 4, text: "告警管理", path: "/dashboard/alarm-management", left: "49.5%", width: "12.0%" },
|
||||||
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan" },
|
{ id: 5, text: "巡检记录", path: "/dashboard/patrol-plan", left: "61.7%", width: "11.6%" },
|
||||||
{ id: 6, text: "统计分析", path: "/dashboard/data-report" },
|
{ id: 6, text: "统计分析", path: "/dashboard/data-report", left: "73.6%", width: "15.0%" },
|
||||||
],
|
];
|
||||||
};
|
|
||||||
|
|
||||||
const activeTabId = computed(() => Number(route.meta.tabId) || 1);
|
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) {
|
function switchPage(tab: DashboardTab) {
|
||||||
if (route.path !== tab.path) {
|
if (route.path !== tab.path) {
|
||||||
router.push(tab.path);
|
router.push(tab.path);
|
||||||
@@ -37,43 +57,29 @@ function switchPage(tab: DashboardTab) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<video
|
<!-- 背景使用视频首帧截图,避免播放动画 -->
|
||||||
autoplay
|
<img class="background" :src="backgroundImage" alt="" />
|
||||||
class="background"
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
preload="auto"
|
|
||||||
:src="backgroundVideo"
|
|
||||||
></video>
|
|
||||||
<div class="main">
|
<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">
|
<div class="content-stage">
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
<component :is="Component" :key="route.fullPath" />
|
<component :is="Component" :key="route.fullPath" />
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { NImage } from "naive-ui";
|
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 {
|
import {
|
||||||
getRecentAlarmList,
|
getRecentAlarmList,
|
||||||
type RosInspectionRecordLineImage,
|
type RosInspectionRecordLineImage,
|
||||||
@@ -20,10 +29,17 @@ import { getToken } from "@/utils/auth";
|
|||||||
import { parseRosMapYaml } from "@/utils/rosMapYaml";
|
import { parseRosMapYaml } from "@/utils/rosMapYaml";
|
||||||
import { BASE_URL } from "@/utils/request";
|
import { BASE_URL } from "@/utils/request";
|
||||||
import { getRecentAlertPhoto } from "../shared/alertPhotos";
|
import { getRecentAlertPhoto } from "../shared/alertPhotos";
|
||||||
|
// 地图上的机器人标记图标(设计稿 巡检机器人_03,8 倍图,放大时不易模糊)
|
||||||
|
import robotIcon from "@/assets/robot-marker-8x.png";
|
||||||
|
// 未配置地图时展示的默认隧道示意图
|
||||||
|
import emptyMapImage from "@/assets/emptyMap.png";
|
||||||
|
|
||||||
const defaultProject = "西江隧道";
|
const defaultProject = "西江隧道";
|
||||||
|
|
||||||
type DriveMode = "阿克曼" | "旋转" | "横移";
|
type DriveMode = "阿克曼" | "旋转" | "横移";
|
||||||
|
// 视频监控的摄像头通道:白光镜头 / 红外镜头
|
||||||
|
type CameraChannelKey = "white" | "infrared";
|
||||||
|
const cameraChannelKeys: CameraChannelKey[] = ["white", "infrared"];
|
||||||
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
|
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
|
||||||
type ControlMessageType = "cmd_vel" | "ptz_control" | "lift_control";
|
type ControlMessageType = "cmd_vel" | "ptz_control" | "lift_control";
|
||||||
type PtzAction = "up" | "down" | "left" | "right" | "stop";
|
type PtzAction = "up" | "down" | "left" | "right" | "stop";
|
||||||
@@ -517,7 +533,6 @@ const alertLoading = ref(false);
|
|||||||
const alertError = ref("");
|
const alertError = ref("");
|
||||||
const alertTabs = ["局部高温", "电缆表面破损", "桥架断裂", "隧道积水", "墙体渗漏"] as const;
|
const alertTabs = ["局部高温", "电缆表面破损", "桥架断裂", "隧道积水", "墙体渗漏"] as const;
|
||||||
const activeAlertTab = ref<(typeof alertTabs)[number]>("局部高温");
|
const activeAlertTab = ref<(typeof alertTabs)[number]>("局部高温");
|
||||||
const activeCameraMode = ref<"white" | "infrared">("white");
|
|
||||||
const activeDeviceId = ref("");
|
const activeDeviceId = ref("");
|
||||||
const driveModeTabs: DriveMode[] = ["阿克曼", "旋转", "横移"];
|
const driveModeTabs: DriveMode[] = ["阿克曼", "旋转", "横移"];
|
||||||
const activeDriveMode = ref<DriveMode>("阿克曼");
|
const activeDriveMode = ref<DriveMode>("阿克曼");
|
||||||
@@ -529,14 +544,33 @@ const selectedPresetPoint = ref("预置点1");
|
|||||||
const controlMode = ref<"人工接管" | "自主巡检">("自主巡检");
|
const controlMode = ref<"人工接管" | "自主巡检">("自主巡检");
|
||||||
const maxLiftHeight = 800;
|
const maxLiftHeight = 800;
|
||||||
const targetLiftHeight = ref("1200");
|
const targetLiftHeight = ref("1200");
|
||||||
const monitorVideoRef = ref<HTMLVideoElement | null>(null);
|
|
||||||
const navMapSvgRef = ref<SVGSVGElement | null>(null);
|
const navMapSvgRef = ref<SVGSVGElement | null>(null);
|
||||||
const streamLoading = ref(false);
|
|
||||||
const streamError = ref("");
|
|
||||||
const controlWsStatus = ref("未连接");
|
const controlWsStatus = ref("未连接");
|
||||||
const controlError = 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 controlSocket: WebSocket | null = null;
|
||||||
let controlRequestSeed = 0;
|
let controlRequestSeed = 0;
|
||||||
let controlReconnectTimer: number | null = null;
|
let controlReconnectTimer: number | null = null;
|
||||||
@@ -624,10 +658,11 @@ const currentDevices = computed(() => currentProject.value?.devices ?? []);
|
|||||||
const currentRobot = computed(
|
const currentRobot = computed(
|
||||||
() => currentDevices.value.find((device) => device.id === activeDeviceId.value) ?? currentDevices.value[0] ?? emptyDevice,
|
() => currentDevices.value.find((device) => device.id === activeDeviceId.value) ?? currentDevices.value[0] ?? emptyDevice,
|
||||||
);
|
);
|
||||||
const currentMonitorVideo = computed(() => activeCameraMode.value === "white"
|
// 白光/红外两路摄像头的当前播放地址
|
||||||
? currentRobot.value.whiteLightUrl
|
const cameraUrls = computed(() => ({
|
||||||
: currentRobot.value.infraredUrl,
|
white: currentRobot.value.whiteLightUrl,
|
||||||
);
|
infrared: currentRobot.value.infraredUrl,
|
||||||
|
}));
|
||||||
const currentScenario = computed(() => navScenario);
|
const currentScenario = computed(() => navScenario);
|
||||||
const hasRobotPose = computed(
|
const hasRobotPose = computed(
|
||||||
() => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number",
|
() => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number",
|
||||||
@@ -682,13 +717,10 @@ const robotMapPoint = computed(() => {
|
|||||||
currentRobot.value.poseYaw ?? 0,
|
currentRobot.value.poseYaw ?? 0,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const robotMarkerScale = computed(() => {
|
// 机器人图标尺寸:宽度固定为地图点位直径的两倍(点位半径 3 → 直径 6 → 图标宽 12),
|
||||||
if (!rosMap.value) {
|
// 与点位共用同一坐标系,缩放时与点保持固定比例
|
||||||
return 1;
|
const robotIconWidth = computed(() => (rosMap.value ? 12 : 28));
|
||||||
}
|
const robotIconHeight = computed(() => robotIconWidth.value * (30 / 55));
|
||||||
|
|
||||||
return 1 / mapScale.value;
|
|
||||||
});
|
|
||||||
const mapContentTransform = computed(() => {
|
const mapContentTransform = computed(() => {
|
||||||
if (!rosMap.value) {
|
if (!rosMap.value) {
|
||||||
return "";
|
return "";
|
||||||
@@ -1194,21 +1226,29 @@ function sendControlMessage(type: ControlMessageType, data: Record<string, strin
|
|||||||
socket.addEventListener("open", sendPayload, { once: true });
|
socket.addEventListener("open", sendPayload, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止当前 WebRTC 拉流并释放播放器资源
|
// 停止指定摄像头的 WebRTC 拉流并释放播放器资源
|
||||||
function stopMonitorStream() {
|
function stopCameraStream(key: CameraChannelKey) {
|
||||||
streamRequestId += 1;
|
const stream = cameraStreams[key];
|
||||||
streamLoading.value = false;
|
stream.requestId += 1;
|
||||||
|
cameraLoading[key] = false;
|
||||||
|
|
||||||
if (streamPeerConnection) {
|
if (stream.peerConnection) {
|
||||||
streamPeerConnection.close();
|
stream.peerConnection.close();
|
||||||
streamPeerConnection = null;
|
stream.peerConnection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (monitorVideoRef.value) {
|
const video = cameraVideoRefs[key].value;
|
||||||
monitorVideoRef.value.srcObject = null;
|
|
||||||
|
if (video) {
|
||||||
|
video.srcObject = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止全部摄像头拉流
|
||||||
|
function stopAllCameraStreams() {
|
||||||
|
cameraChannelKeys.forEach((key) => stopCameraStream(key));
|
||||||
|
}
|
||||||
|
|
||||||
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
|
// 将设备返回的 webrtc 播放地址转换为 ZLMediaKit WebRTC 播放接口地址
|
||||||
function buildWebrtcApiUrl(streamUrl: string) {
|
function buildWebrtcApiUrl(streamUrl: string) {
|
||||||
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
|
const normalizedUrl = streamUrl.replace(/^webrtc:\/\//, "https://");
|
||||||
@@ -1235,33 +1275,36 @@ function waitForIceGatheringComplete(peerConnection: RTCPeerConnection) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 WebRTC offer/answer 拉取当前机器人视频流
|
// 通过 WebRTC offer/answer 拉取指定摄像头的视频流
|
||||||
async function playMonitorStream() {
|
async function playCameraStream(key: CameraChannelKey) {
|
||||||
stopMonitorStream();
|
stopCameraStream(key);
|
||||||
streamError.value = "";
|
cameraError[key] = "";
|
||||||
|
|
||||||
if (!currentMonitorVideo.value) {
|
const streamUrl = cameraUrls.value[key];
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.RTCPeerConnection) {
|
if (!window.RTCPeerConnection) {
|
||||||
streamError.value = "当前浏览器不支持 WebRTC 播放";
|
cameraError[key] = "当前浏览器不支持 WebRTC 播放";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = streamRequestId;
|
const stream = cameraStreams[key];
|
||||||
|
const requestId = stream.requestId;
|
||||||
const peerConnection = new RTCPeerConnection();
|
const peerConnection = new RTCPeerConnection();
|
||||||
streamPeerConnection = peerConnection;
|
stream.peerConnection = peerConnection;
|
||||||
streamLoading.value = true;
|
cameraLoading[key] = true;
|
||||||
|
|
||||||
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 !== streamRequestId || !monitorVideoRef.value) {
|
if (requestId !== stream.requestId || !cameraVideoRefs[key].value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
monitorVideoRef.value.srcObject = event.streams[0];
|
cameraVideoRefs[key].value.srcObject = event.streams[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1269,7 +1312,7 @@ async function playMonitorStream() {
|
|||||||
await peerConnection.setLocalDescription(offer);
|
await peerConnection.setLocalDescription(offer);
|
||||||
await waitForIceGatheringComplete(peerConnection);
|
await waitForIceGatheringComplete(peerConnection);
|
||||||
|
|
||||||
const response = await fetch(buildWebrtcApiUrl(currentMonitorVideo.value), {
|
const response = await fetch(buildWebrtcApiUrl(streamUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/sdp",
|
"Content-Type": "application/sdp",
|
||||||
@@ -1298,7 +1341,7 @@ async function playMonitorStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestId !== streamRequestId) {
|
if (requestId !== stream.requestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,38 +1350,59 @@ async function playMonitorStream() {
|
|||||||
sdp: answer,
|
sdp: answer,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId === streamRequestId) {
|
if (requestId === stream.requestId) {
|
||||||
streamError.value = error instanceof Error ? error.message : "视频流拉取失败";
|
cameraError[key] = error instanceof Error ? error.message : "视频流拉取失败";
|
||||||
stopMonitorStream();
|
stopCameraStream(key);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === streamRequestId) {
|
if (requestId === stream.requestId) {
|
||||||
streamLoading.value = false;
|
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(() => {
|
onMounted(() => {
|
||||||
fetchDeviceList();
|
fetchDeviceList();
|
||||||
fetchRecentAlerts();
|
fetchRecentAlerts();
|
||||||
connectControlSocket();
|
connectControlSocket();
|
||||||
// startInspectionPolling();
|
// startInspectionPolling();
|
||||||
window.addEventListener("blur", stopContinuousControl);
|
window.addEventListener("blur", stopContinuousControl);
|
||||||
|
window.addEventListener("keydown", handleMapPreviewKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopMonitorStream();
|
stopAllCameraStreams();
|
||||||
stopContinuousControl();
|
stopContinuousControl();
|
||||||
// stopInspectionPolling();
|
// stopInspectionPolling();
|
||||||
closeControlSocket();
|
closeControlSocket();
|
||||||
window.removeEventListener("blur", stopContinuousControl);
|
window.removeEventListener("blur", stopContinuousControl);
|
||||||
|
window.removeEventListener("keydown", handleMapPreviewKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 切换机器人或视频地址变化时,两路摄像头各自重新拉流
|
||||||
watch(
|
watch(
|
||||||
currentMonitorVideo,
|
cameraUrls,
|
||||||
() => {
|
() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
playMonitorStream();
|
cameraChannelKeys.forEach((key) => playCameraStream(key));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1365,10 +1429,6 @@ function switchAlertTab(tab: (typeof alertTabs)[number]) {
|
|||||||
fetchRecentAlerts();
|
fetchRecentAlerts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchCameraMode(mode: "white" | "infrared") {
|
|
||||||
activeCameraMode.value = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击设备列表中的机器人后,同步切换控制对象和监控视频源
|
// 点击设备列表中的机器人后,同步切换控制对象和监控视频源
|
||||||
function selectDevice(device: DeviceItem) {
|
function selectDevice(device: DeviceItem) {
|
||||||
activeDeviceId.value = device.id;
|
activeDeviceId.value = device.id;
|
||||||
@@ -1654,8 +1714,23 @@ function getStatusClass(status: DeviceItem["status"]) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="middle">
|
<div class="middle">
|
||||||
<div class="nav-map-panel">
|
<!-- card- 前缀复用与其他面板一致的卡片标题样式 -->
|
||||||
<div class="nav-map-stage">
|
<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"
|
<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"
|
@wheel="handleMapWheel" @pointerdown="startMapDrag" @pointermove="moveMapDrag" @pointerup="stopMapDrag"
|
||||||
@pointerleave="stopMapDrag" @pointercancel="stopMapDrag">
|
@pointerleave="stopMapDrag" @pointercancel="stopMapDrag">
|
||||||
@@ -1667,11 +1742,17 @@ function getStatusClass(status: DeviceItem["status"]) {
|
|||||||
<feMergeNode in="SourceGraphic" />
|
<feMergeNode in="SourceGraphic" />
|
||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</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>
|
</defs>
|
||||||
<g class="real-map-content" :transform="mapContentTransform">
|
<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" />
|
<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" mask="url(#routePointMask)" />
|
||||||
<path v-if="realRoutePath" class="route-line" :d="realRoutePath" />
|
|
||||||
<g class="checkpoint-layer">
|
<g class="checkpoint-layer">
|
||||||
<g v-for="(point, index) in realCheckPoints" :key="index" class="checkpoint"
|
<g v-for="(point, index) in realCheckPoints" :key="index" class="checkpoint"
|
||||||
:transform="`translate(${point.x}, ${point.y})`">
|
:transform="`translate(${point.x}, ${point.y})`">
|
||||||
@@ -1680,50 +1761,35 @@ function getStatusClass(status: DeviceItem["status"]) {
|
|||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<g v-if="robotMapPoint" class="robot-layer real-robot-layer"
|
<g v-if="robotMapPoint" class="robot-layer real-robot-layer"
|
||||||
:transform="`translate(${robotMapPoint.x}, ${robotMapPoint.y}) rotate(${robotMapPoint.angle}) scale(${robotMarkerScale})`">
|
:transform="`translate(${robotMapPoint.x}, ${robotMapPoint.y}) rotate(${robotMapPoint.angle})`">
|
||||||
<circle class="robot-shadow" r="6.5" />
|
<!-- 机器人光圈:由内向外扩散的三层圆环(基准半径约车位宽的 0.75 倍) -->
|
||||||
<circle class="robot-body" r="5.8" />
|
<circle class="robot-ring ring-1" :r="robotIconWidth * 0.75" />
|
||||||
<path class="robot-head" d="M -1.6 -5.4 L 4.8 0 L -1.6 5.4 Z" />
|
<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>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else class="nav-map-svg" viewBox="0 0 760 420" preserveAspectRatio="xMidYMid meet">
|
<!-- 未配置地图:使用默认隧道示意图,机器人叠加在图中车辆位置 -->
|
||||||
<g class="grid-layer">
|
<svg v-else class="nav-map-svg" viewBox="0 0 1398 467" preserveAspectRatio="xMidYMid meet">
|
||||||
<line v-for="line in 7" :key="`h-${line}`" :x1="40" :y1="line * 52" :x2="720" :y2="line * 52" />
|
<image class="empty-map-image" :href="emptyMapImage" x="0" y="0" width="1398" height="467" />
|
||||||
<line v-for="line in 12" :key="`v-${line}`" :x1="line * 56" :y1="40" :x2="line * 56" :y2="380" />
|
<g class="robot-layer" transform="translate(1216, 266)">
|
||||||
</g>
|
<!-- 机器人光圈:由内向外扩散的三层圆环 -->
|
||||||
<path class="corridor-lane" :d="currentScenario.lanePath" />
|
<circle class="robot-ring ring-1" r="21" />
|
||||||
<path class="corridor-edge" :d="currentScenario.upperEdgePath" />
|
<circle class="robot-ring ring-2" r="21" />
|
||||||
<path class="corridor-edge" :d="currentScenario.lowerEdgePath" />
|
<circle class="robot-ring ring-3" r="21" />
|
||||||
<g class="wall-layer wall-upper">
|
<!-- 机器人标记使用设计稿图标(28x15) -->
|
||||||
<circle v-for="(point, index) in currentScenario.upperWallPoints" :key="`upper-wall-${index}`"
|
<image class="robot-icon" :href="robotIcon" x="-14" y="-8" width="28" height="15" />
|
||||||
: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" />
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div v-if="mapLoading" class="map-state-tip">地图加载中...</div>
|
<div v-if="mapLoading" class="map-state-tip">地图加载中...</div>
|
||||||
<div v-else-if="mapError" class="map-state-tip">{{ mapError }}</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="routeLoading" class="map-state-tip">路线加载中...</div>
|
||||||
<div v-else-if="routeError" class="map-state-tip">{{ routeError }}</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-left">ROS 实时地图</div>
|
||||||
<div class="nav-map-overlay top-right">真实坐标路径跟踪</div>
|
<div class="nav-map-overlay top-right">真实坐标路径跟踪</div> -->
|
||||||
<div v-if="rosMap" class="map-scale-controls">
|
<div v-if="rosMap" class="map-scale-controls">
|
||||||
<button type="button" @click="changeMapScale(-0.05)">-</button>
|
<button type="button" @click="changeMapScale(-0.05)">-</button>
|
||||||
<span>{{ Math.round(mapScale * 100) }}%</span>
|
<span>{{ Math.round(mapScale * 100) }}%</span>
|
||||||
@@ -1745,32 +1811,31 @@ function getStatusClass(status: DeviceItem["status"]) {
|
|||||||
<div class="card-1 video-card">
|
<div class="card-1 video-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="title">视频监控</div>
|
<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>
|
||||||
<div class="video-panel">
|
<div class="video-panel">
|
||||||
|
<!-- 白光镜头:16:9 -->
|
||||||
<div class="video-screen">
|
<div class="video-screen">
|
||||||
<!-- <img src="/image.png" alt="" class="monitor-video"> -->
|
<video v-if="cameraUrls.white" ref="whiteVideoRef" :key="cameraUrls.white"
|
||||||
<video v-if="currentMonitorVideo" ref="monitorVideoRef" :key="currentMonitorVideo"
|
class="monitor-video" autoplay muted playsinline></video>
|
||||||
:class="['monitor-video', { infrared: activeCameraMode === 'infrared' }]" autoplay muted
|
|
||||||
playsinline></video>
|
|
||||||
<div class="scan-line"></div>
|
<div class="scan-line"></div>
|
||||||
<div class="video-overlay top-left">实时视频流</div>
|
<div class="video-overlay top-left">实时视频流</div>
|
||||||
<div class="video-overlay top-right">
|
<div class="video-overlay top-right">白光镜头</div>
|
||||||
{{ activeCameraMode === "white" ? "白光镜头" : "红外镜头" }}
|
|
||||||
</div>
|
|
||||||
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
|
<div class="video-overlay bottom-left">{{ currentRobot.id }}</div>
|
||||||
<div v-if="streamLoading" class="video-empty">视频流连接中...</div>
|
<div v-if="cameraLoading.white" class="video-empty">视频流连接中...</div>
|
||||||
<div v-else-if="streamError" class="video-empty">{{ streamError }}</div>
|
<div v-else-if="cameraError.white" class="video-empty">{{ cameraError.white }}</div>
|
||||||
<div v-else-if="!currentMonitorVideo" class="video-empty">当前机器人暂无视频地址</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ export default defineConfig(({ command }: ConfigEnv) => {
|
|||||||
host: true,
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: `http://127.0.0.1:16003/`,
|
target: `http://192.168.3.233:16003/`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path: string) => path.replace(/^\/api/, '')
|
rewrite: (path: string) => path.replace(/^\/api/, '')
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: `ws://127.0.0.1:16003/`,
|
target: `ws://192.168.3.233:16003/`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true
|
ws: true
|
||||||
}
|
}
|
||||||
|
|||||||