|
|
@@ -1,5 +1,6 @@
|
|
|
<template>
|
|
|
<div class="dock-wrapper"
|
|
|
+ v-if="items && items.length"
|
|
|
ref="dockWrapper"
|
|
|
:class="[
|
|
|
autoHide ? 'is-auto-hide' : 'is-always-show',
|
|
|
@@ -8,31 +9,30 @@
|
|
|
]"
|
|
|
:style="dockStyles"
|
|
|
@mouseleave="handleDockLeave">
|
|
|
-
|
|
|
- <div class="nav-arrow left-arrow" :class="{ 'is-disabled': !canScrollLeft, 'is-active': canScrollLeft }"
|
|
|
- @click="scrollList(-1)">
|
|
|
+
|
|
|
+ <div class="nav-arrow left-arrow"
|
|
|
+ :class="{ 'is-disabled': !canScrollLeft, 'is-active': canScrollLeft }"
|
|
|
+ @click="scrollList(-1)">
|
|
|
<img v-if="canScrollLeft" src="@/assets/main/main-right.png" class="arrow-img left-facing" />
|
|
|
<img v-else src="@/assets/main/main-left.png" class="arrow-img" />
|
|
|
</div>
|
|
|
|
|
|
<div class="dock-list-container" ref="listContainer" @scroll="checkScrollState">
|
|
|
<div class="dock-list">
|
|
|
- <div v-for="(item, index) in dockItems" :key="index" class="dock-item"
|
|
|
- :class="{
|
|
|
- 'is-active': activeIndex === index,
|
|
|
+ <div v-for="(item, index) in items" :key="index" class="dock-item"
|
|
|
+ :class="{
|
|
|
+ 'is-active': activeIndex === index,
|
|
|
[`theme-${item.theme}`]: item.theme,
|
|
|
'is-breathing': waveAnimation
|
|
|
}"
|
|
|
:style="waveAnimation ? { animationDelay: `${index * 0.15}s` } : {}"
|
|
|
@click="handleSelect(index, item)"
|
|
|
@mouseenter="hoverIndex = index"
|
|
|
- @mouseleave="hoverIndex = null"
|
|
|
- >
|
|
|
+ @mouseleave="hoverIndex = null">
|
|
|
<div class="item-icon">
|
|
|
- <img v-if="item.imgUrl"
|
|
|
- :src="(activeIndex === index || hoverIndex === index) && item.activeImgUrl ? item.activeImgUrl : item.imgUrl"
|
|
|
- class="custom-icon" />
|
|
|
-
|
|
|
+ <img v-if="item.imgUrl"
|
|
|
+ :src="(activeIndex === index || hoverIndex === index) && item.activeImgUrl ? item.activeImgUrl : item.imgUrl"
|
|
|
+ class="custom-icon" />
|
|
|
<i v-else :class="item.iconClass"></i>
|
|
|
</div>
|
|
|
<div class="item-label">{{ item.label }}</div>
|
|
|
@@ -40,8 +40,9 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div class="nav-arrow right-arrow" :class="{ 'is-disabled': !canScrollRight, 'is-active': canScrollRight }"
|
|
|
- @click="scrollList(1)">
|
|
|
+ <div class="nav-arrow right-arrow"
|
|
|
+ :class="{ 'is-disabled': !canScrollRight, 'is-active': canScrollRight }"
|
|
|
+ @click="scrollList(1)">
|
|
|
<img v-if="canScrollRight" src="@/assets/main/main-right.png" class="arrow-img" />
|
|
|
<img v-else src="@/assets/main/main-left.png" class="arrow-img left-facing" />
|
|
|
</div>
|
|
|
@@ -57,17 +58,17 @@ export default {
|
|
|
type: Boolean,
|
|
|
default: true
|
|
|
},
|
|
|
- // 距离屏幕底部的偏移量 (单位 px,正数代表往上抬高)
|
|
|
+ // 距离屏幕底部的偏移量 (单位 px, 正数代表往上抬高)
|
|
|
bottomOffset: {
|
|
|
type: Number,
|
|
|
default: 0
|
|
|
},
|
|
|
- // 允许外部传入的自定义容器样式 (如背景色、宽度等)
|
|
|
+ // 允许外部传入的自定义容器样式
|
|
|
customStyle: {
|
|
|
type: Object,
|
|
|
default: () => ({})
|
|
|
},
|
|
|
- // 允许外部传入自定义 class (支持字符串、数组、对象)
|
|
|
+ // 允许外部传入自定义 class
|
|
|
customClass: {
|
|
|
type: [String, Array, Object],
|
|
|
default: ''
|
|
|
@@ -76,6 +77,11 @@ export default {
|
|
|
waveAnimation: {
|
|
|
type: Boolean,
|
|
|
default: false
|
|
|
+ },
|
|
|
+ // 菜单数据 (一般来自 src/config/menus.js)
|
|
|
+ items: {
|
|
|
+ type: Array,
|
|
|
+ default: () => []
|
|
|
}
|
|
|
},
|
|
|
data() {
|
|
|
@@ -85,80 +91,22 @@ export default {
|
|
|
dockExpanded: false,
|
|
|
canScrollLeft: false,
|
|
|
canScrollRight: true,
|
|
|
- dockItems: [
|
|
|
- {
|
|
|
- label: '首页',
|
|
|
- imgUrl: require('@/assets/main/dock-home.png'),
|
|
|
- activeImgUrl: require('@/assets/main/dock-home-hover.png'),
|
|
|
- route: '/home',
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '状态监控',
|
|
|
- imgUrl: require('@/assets/main/dock-surve.png'),
|
|
|
- activeImgUrl: require('@/assets/main/dock-surve-hover.png'),
|
|
|
- route: '/surve',
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '勤务管理',
|
|
|
- imgUrl: require('@/assets/main/dock-security.png'),
|
|
|
- activeImgUrl: require('@/assets/main/dock-security-hover.png'),
|
|
|
- route: '/security',
|
|
|
- theme: 'gold',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '干线协调',
|
|
|
- imgUrl: require('@/assets/main/dock-coor.png'),
|
|
|
- activeImgUrl: require('@/assets/main/dock-coor-hover.png'),
|
|
|
- route: '/trunk',
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '数据分析',
|
|
|
- imgUrl: require('@/assets/main/dock-watch.png'),
|
|
|
- activeImgUrl: require('@/assets/main/dock-watch-hover.png'),
|
|
|
- route: '/watch',
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '系统设置',
|
|
|
- imgUrl: require('@/assets/main/dock-setting.png'),
|
|
|
- activeImgUrl: require('@/assets/main/dock-setting-hover.png'),
|
|
|
- route: '/setting',
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '测试1',
|
|
|
- imgUrl: require('@/assets/main/dock-home.png'),
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '测试2',
|
|
|
- imgUrl: require('@/assets/main/dock-surve.png'),
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- {
|
|
|
- label: '测试3',
|
|
|
- imgUrl: require('@/assets/main/dock-security.png'),
|
|
|
- theme: 'blue',
|
|
|
- },
|
|
|
- ]
|
|
|
};
|
|
|
},
|
|
|
computed: {
|
|
|
- // 动态计算 CSS 变量和合并自定义样式
|
|
|
dockStyles() {
|
|
|
- // 统一只传一个基础 bottom 偏移量,动画交由 CSS transform 处理
|
|
|
return {
|
|
|
'--dock-bottom': `${this.bottomOffset}px`,
|
|
|
- ...this.customStyle
|
|
|
+ ...this.customStyle
|
|
|
};
|
|
|
}
|
|
|
},
|
|
|
watch: {
|
|
|
$route() {
|
|
|
this.updateActiveIndexByRoute();
|
|
|
+ },
|
|
|
+ items() {
|
|
|
+ this.$nextTick(this.checkScrollState);
|
|
|
}
|
|
|
},
|
|
|
created() {
|
|
|
@@ -179,11 +127,10 @@ export default {
|
|
|
},
|
|
|
methods: {
|
|
|
updateActiveIndexByRoute() {
|
|
|
- const currentPath = this.$route?.path || '';
|
|
|
- const matchIndex = this.dockItems.findIndex(item => {
|
|
|
+ const currentPath = this.$route?.path || '';
|
|
|
+ const matchIndex = this.items.findIndex(item => {
|
|
|
return item.route && currentPath.startsWith(item.route);
|
|
|
});
|
|
|
-
|
|
|
if (matchIndex !== -1) {
|
|
|
this.activeIndex = matchIndex;
|
|
|
}
|
|
|
@@ -214,12 +161,12 @@ export default {
|
|
|
const el = this.$refs.dockWrapper;
|
|
|
if (!el) return;
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
- // 发光线区域:居中,宽度与 CSS clamp(150px, 20vw, 250px) 一致
|
|
|
+ // 发光线区域: 居中, 宽度与 CSS clamp(150px, 20vw, 250px) 一致
|
|
|
const lineWidth = Math.min(250, Math.max(150, window.innerWidth * 0.2));
|
|
|
const centerX = window.innerWidth / 2;
|
|
|
const left = centerX - lineWidth / 2;
|
|
|
const right = centerX + lineWidth / 2;
|
|
|
- // 垂直:dock 可见顶部往上延伸 30px(与 ::after 热区一致)
|
|
|
+ // 垂直: dock 可见顶部往上延伸 30px (与 ::after 热区一致)
|
|
|
if (e.clientX >= left && e.clientX <= right && e.clientY >= rect.top - 30) {
|
|
|
this.dockExpanded = true;
|
|
|
}
|
|
|
@@ -232,14 +179,36 @@ export default {
|
|
|
if (direction === 1 && !this.canScrollRight) return;
|
|
|
|
|
|
const container = this.$refs.listContainer;
|
|
|
- const scrollAmount = 130;
|
|
|
+ if (!container) return;
|
|
|
|
|
|
- if (container) {
|
|
|
- container.scrollBy({
|
|
|
- left: direction * scrollAmount,
|
|
|
- behavior: 'smooth'
|
|
|
- });
|
|
|
+ const items = container.querySelectorAll('.dock-item');
|
|
|
+ if (!items.length) return;
|
|
|
+
|
|
|
+ // 每个 item 在 container scroll 坐标系中的 left 边界
|
|
|
+ const containerRect = container.getBoundingClientRect();
|
|
|
+ const currentScroll = container.scrollLeft;
|
|
|
+ const itemLefts = Array.from(items).map(el =>
|
|
|
+ el.getBoundingClientRect().left - containerRect.left + currentScroll
|
|
|
+ );
|
|
|
+
|
|
|
+ const EPS = 2; // 浮点容差
|
|
|
+ let target = null;
|
|
|
+ if (direction === 1) {
|
|
|
+ // 下一个 left > currentScroll 的 item
|
|
|
+ target = itemLefts.find(left => left > currentScroll + EPS);
|
|
|
+ if (target == null) target = container.scrollWidth - container.clientWidth;
|
|
|
+ } else {
|
|
|
+ // 上一个 left < currentScroll 的 item (倒序找第一个)
|
|
|
+ for (let i = itemLefts.length - 1; i >= 0; i--) {
|
|
|
+ if (itemLefts[i] < currentScroll - EPS) {
|
|
|
+ target = itemLefts[i];
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (target == null) target = 0;
|
|
|
}
|
|
|
+
|
|
|
+ container.scrollTo({ left: target, behavior: 'smooth' });
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
@@ -254,16 +223,18 @@ export default {
|
|
|
justify-content: center;
|
|
|
width: 100%;
|
|
|
height: 160px;
|
|
|
- position: fixed;
|
|
|
+ position: fixed;
|
|
|
left: 0;
|
|
|
bottom: var(--dock-bottom, 0px);
|
|
|
- z-index: 9999;
|
|
|
+ /* z-index 必须 > 地图图例 (.map-legend z:100) 但 < SmartDialog 起始 (z:2000),
|
|
|
+ 否则要么被图例盖, 要么反过来盖住弹窗 */
|
|
|
+ z-index: 500;
|
|
|
transition: transform 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
|
background: linear-gradient(to top, rgba(0, 20, 30, 0.8) 0%, rgba(0, 20, 30, 0.01) 100%);
|
|
|
pointer-events: auto !important;
|
|
|
}
|
|
|
|
|
|
-/* === 状态 A:自动隐藏 === */
|
|
|
+/* === 状态 A: 自动隐藏 === */
|
|
|
.dock-wrapper.is-auto-hide {
|
|
|
transform: translateY(calc(100% - clamp(15px, 4vh, 20px)));
|
|
|
pointer-events: none;
|
|
|
@@ -273,7 +244,7 @@ export default {
|
|
|
pointer-events: auto;
|
|
|
}
|
|
|
|
|
|
-/* 中间触发热区:只覆盖发光线范围,pointer-events 始终开启 */
|
|
|
+/* 中间触发热区: 只覆盖发光线范围, pointer-events 始终开启 */
|
|
|
.dock-wrapper.is-auto-hide::after {
|
|
|
content: '';
|
|
|
position: absolute;
|
|
|
@@ -293,9 +264,8 @@ export default {
|
|
|
top: 0;
|
|
|
left: 50%;
|
|
|
transform: translateX(-50%);
|
|
|
- /* 宽度和厚度也做一点自适应,小屏自动变短点 */
|
|
|
width: clamp(150px, 20vw, 250px);
|
|
|
- height: clamp(3px, 0.5vh, 5px);
|
|
|
+ height: clamp(3px, 0.5vh, 5px);
|
|
|
background: rgba(0, 229, 255, 0.6);
|
|
|
box-shadow: 0 0 10px rgba(0, 229, 255, 0.8);
|
|
|
border-radius: 4px;
|
|
|
@@ -307,7 +277,7 @@ export default {
|
|
|
opacity: 0;
|
|
|
}
|
|
|
|
|
|
-/* === 状态 B:常驻显示 === */
|
|
|
+/* === 状态 B: 常驻显示 === */
|
|
|
.dock-wrapper.is-always-show {
|
|
|
transform: translateY(0);
|
|
|
}
|
|
|
@@ -325,6 +295,9 @@ export default {
|
|
|
white-space: nowrap;
|
|
|
-ms-overflow-style: none;
|
|
|
scrollbar-width: none;
|
|
|
+ /* 滑动 / 滚轮拖动时按 item 边界自动吸附, 配合左右箭头 scrollTo 双保险定位 */
|
|
|
+ scroll-snap-type: x mandatory;
|
|
|
+ scroll-padding-left: 0;
|
|
|
}
|
|
|
|
|
|
.dock-list-container::-webkit-scrollbar {
|
|
|
@@ -358,30 +331,30 @@ export default {
|
|
|
}
|
|
|
|
|
|
.arrow-img {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- object-fit: contain;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
}
|
|
|
|
|
|
.left-facing {
|
|
|
- transform: rotate(180deg);
|
|
|
+ transform: rotate(180deg);
|
|
|
}
|
|
|
|
|
|
.nav-arrow.is-active {
|
|
|
- cursor: pointer;
|
|
|
+ cursor: pointer;
|
|
|
}
|
|
|
.nav-arrow.is-active:hover .arrow-img {
|
|
|
- transform: scale(1.1);
|
|
|
- filter: drop-shadow(0 0 10px rgba(0, 229, 255, 0.8));
|
|
|
+ transform: scale(1.1);
|
|
|
+ filter: drop-shadow(0 0 10px rgba(0, 229, 255, 0.8));
|
|
|
}
|
|
|
|
|
|
.nav-arrow.left-arrow.is-active:hover .arrow-img.left-facing {
|
|
|
- transform: rotate(180deg) scale(1.1);
|
|
|
+ transform: rotate(180deg) scale(1.1);
|
|
|
}
|
|
|
|
|
|
.nav-arrow.is-disabled {
|
|
|
- cursor: not-allowed;
|
|
|
- opacity: 0.6;
|
|
|
+ cursor: not-allowed;
|
|
|
+ opacity: 0.6;
|
|
|
}
|
|
|
|
|
|
/* ================= 单个导航项 ================= */
|
|
|
@@ -396,6 +369,8 @@ export default {
|
|
|
width: 100px;
|
|
|
height: 90px;
|
|
|
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
|
+ /* 配合 .dock-list-container 的 scroll-snap-type: 每个 item 左边界为吸附点 */
|
|
|
+ scroll-snap-align: start;
|
|
|
}
|
|
|
|
|
|
.item-icon {
|
|
|
@@ -403,7 +378,7 @@ export default {
|
|
|
color: #a0cfff;
|
|
|
z-index: 3;
|
|
|
margin-bottom: 5px;
|
|
|
- transition: all 0.3s;
|
|
|
+ transition: transform 0.3s ease, filter 0.3s ease;
|
|
|
}
|
|
|
|
|
|
.item-label {
|
|
|
@@ -414,7 +389,7 @@ export default {
|
|
|
letter-spacing: 1px;
|
|
|
}
|
|
|
|
|
|
-/* ================= 交互状态:悬浮与选中 ================= */
|
|
|
+/* ================= 交互状态: 悬浮与选中 ================= */
|
|
|
.dock-item:hover {
|
|
|
transform: translateY(-15px) scale(1.15);
|
|
|
}
|
|
|
@@ -432,12 +407,6 @@ export default {
|
|
|
text-shadow: 0 0 8px #00e5ff;
|
|
|
}
|
|
|
|
|
|
-.dock-item:hover .top-solid,
|
|
|
-.dock-item.is-active .top-solid {
|
|
|
- background: linear-gradient(135deg, rgba(0, 229, 255, 0.9), rgba(0, 115, 255, 0.9));
|
|
|
- box-shadow: 0 0 20px rgba(0, 229, 255, 0.6);
|
|
|
-}
|
|
|
-
|
|
|
.dock-item.theme-gold.is-active .item-icon {
|
|
|
color: #ffd700;
|
|
|
text-shadow: 0 0 15px #ffaa00;
|
|
|
@@ -448,12 +417,6 @@ export default {
|
|
|
text-shadow: 0 0 8px #ffaa00;
|
|
|
}
|
|
|
|
|
|
-.item-icon {
|
|
|
- z-index: 3;
|
|
|
- margin-bottom: 5px;
|
|
|
- transition: transform 0.3s ease, filter 0.3s ease;
|
|
|
-}
|
|
|
-
|
|
|
.custom-icon {
|
|
|
width: 88px;
|
|
|
height: 64px;
|
|
|
@@ -471,19 +434,17 @@ export default {
|
|
|
transform: translateY(0) scale(1);
|
|
|
}
|
|
|
50% {
|
|
|
- transform: translateY(-8px) scale(1.08); /* 呼吸时轻微上浮和放大 */
|
|
|
+ transform: translateY(-8px) scale(1.08);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.dock-item.is-breathing {
|
|
|
- /* 动画周期设为 2.5s,无限循环,平滑过渡 */
|
|
|
animation: breathing-wave 2.5s infinite ease-in-out;
|
|
|
}
|
|
|
|
|
|
-/* 覆盖动画冲突:当鼠标悬浮或该项处于激活状态时,停止呼吸动画,采用原有的高亮放大效果 */
|
|
|
.dock-item:hover,
|
|
|
.dock-item.is-active {
|
|
|
- animation: none !important; /* 强制停止动画 */
|
|
|
+ animation: none !important;
|
|
|
transform: translateY(-15px) scale(1.15) !important;
|
|
|
}
|
|
|
-</style>
|
|
|
+</style>
|