Browse Source

BottomDock 新增椭圆旋转模式;Main 页面切换为椭圆模式

  1. BottomDock 组件新增 ellipse 模式
  - 新增 props:mode(linear/ellipse)、autoRotate、autoRotateSpeed、radiusX、radiusY
  - ellipse 模式下菜单项沿椭圆轨道排列,通过 getEllipseStyle 计算每项的位置、缩放、透明度和层级,前方项目高亮发光
  - 左右箭头在 ellipse 模式下触发 rotateMenu 旋转,始终可点击
  - 支持鼠标/触摸拖拽旋转:拖拽时实时跟随,松开后根据滑动距离判断是切换下一个、上一个还是吸附回正
  - 支持自动旋转(resumeAutoRotate/pauseAutoRotate),鼠标悬停时暂停
  - 点击某项时通过 rotateTo 将其旋转至正前方
  - 注释掉了测试1/2/3 菜单项
  - 所有 img 添加 draggable="false" 防止浏览器默认拖拽
  - dockItems 数据格式压缩为单行

  2. Main.vue 调整
  - BottomDock 切换为 mode="ellipse",开启 autoRotate
  - 背景闪烁点数量从 28 增加到 50,点半径范围从 1.2~3.0 扩大到 1.2~4.0
画安 4 weeks ago
parent
commit
0e9e8815ce
2 changed files with 246 additions and 111 deletions
  1. 243 108
      src/components/ui/BottomDock.vue
  2. 3 3
      src/views/Main.vue

+ 243 - 108
src/components/ui/BottomDock.vue

@@ -9,16 +9,32 @@
          :style="dockStyles"
          @mouseleave="handleDockLeave">
          
-        <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 class="nav-arrow left-arrow" 
+            :class="{ 'is-disabled': mode === 'linear' && !canScrollLeft, 'is-active': mode === 'ellipse' || canScrollLeft }"
+            @click="mode === 'ellipse' ? rotateMenu(-1) : scrollList(-1)"
+            @mouseenter="pauseAutoRotate"
+            @mouseleave="resumeAutoRotate">
+            <img v-if="mode === 'ellipse' || canScrollLeft" src="@/assets/main/main-right.png" class="arrow-img left-facing" draggable="false" />
+            <img v-else src="@/assets/main/main-left.png" class="arrow-img" draggable="false" />
         </div>
 
-        <div class="dock-list-container" ref="listContainer" @scroll="checkScrollState">
-            <div class="dock-list">
+        <div class="dock-list-container" 
+             :class="{ 'is-ellipse-mode': mode === 'ellipse' }"
+             ref="listContainer" 
+             @scroll="checkScrollState"
+             @mousedown.prevent="handleDragStart"
+             @touchstart.passive="handleDragStart"
+             @mouseenter="pauseAutoRotate"
+             @mouseleave="resumeAutoRotate">
+             
+            <div class="dock-list" :class="{ 'is-ellipse-mode': mode === 'ellipse' }">
                 <div v-for="(item, index) in dockItems" :key="index" class="dock-item"
-                    :class="{ 'is-active': activeIndex === index, [`theme-${item.theme}`]: item.theme}"
+                    :class="{ 
+                        'is-active': activeIndex === index, 
+                        [`theme-${item.theme}`]: item.theme,
+                        'is-front': mode === 'ellipse' && frontIndex === index 
+                    }"
+                    :style="mode === 'ellipse' ? getEllipseStyle(index) : {}"
                     @click="handleSelect(index, item)"
                     @mouseenter="hoverIndex = index"
                     @mouseleave="hoverIndex = null"
@@ -26,8 +42,7 @@
                     <div class="item-icon">
                         <img v-if="item.imgUrl" 
                         :src="(activeIndex === index || hoverIndex === index) && item.activeImgUrl ? item.activeImgUrl : item.imgUrl" 
-                        class="custom-icon" />
-
+                        class="custom-icon" draggable="false" />
                         <i v-else :class="item.iconClass"></i>
                     </div>
                     <div class="item-label">{{ item.label }}</div>
@@ -35,10 +50,13 @@
             </div>
         </div>
 
-        <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 class="nav-arrow right-arrow" 
+            :class="{ 'is-disabled': mode === 'linear' && !canScrollRight, 'is-active': mode === 'ellipse' || canScrollRight }"
+            @click="mode === 'ellipse' ? rotateMenu(1) : scrollList(1)"
+            @mouseenter="pauseAutoRotate"
+            @mouseleave="resumeAutoRotate">
+            <img v-if="mode === 'ellipse' || canScrollRight" src="@/assets/main/main-right.png" class="arrow-img" draggable="false" />
+            <img v-else src="@/assets/main/main-left.png" class="arrow-img left-facing" draggable="false" />
         </div>
     </div>
 </template>
@@ -47,26 +65,16 @@
 export default {
     name: 'BottomDock',
     props: {
-        // 是否自动隐藏 (默认 true: 悬浮升起; false: 常驻显示)
-        autoHide: {
-            type: Boolean,
-            default: true
-        },
-        // 距离屏幕底部的偏移量 (单位 px,正数代表往上抬高)
-        bottomOffset: {
-            type: Number,
-            default: 0
-        },
-        // 允许外部传入的自定义容器样式 (如背景色、宽度等)
-        customStyle: {
-            type: Object,
-            default: () => ({})
-        },
-        // 允许外部传入自定义 class (支持字符串、数组、对象)
-        customClass: {
-            type: [String, Array, Object],
-            default: ''
-        }
+        autoHide: { type: Boolean, default: true },
+        bottomOffset: { type: Number, default: 0 },
+        customStyle: { type: Object, default: () => ({}) },
+        customClass: { type: [String, Array, Object], default: '' },
+        
+        mode: { type: String, default: 'linear' },         
+        autoRotate: { type: Boolean, default: true },      
+        autoRotateSpeed: { type: Number, default: 2500 },  
+        radiusX: { type: Number, default: 380 },           
+        radiusY: { type: Number, default: 60 },            
     },
     data() {
         return {
@@ -75,71 +83,34 @@ export default {
             dockExpanded: false,
             canScrollLeft: false,
             canScrollRight: true,
+            
+            ellipseRotation: Math.PI / 2, 
+            frontIndex: 0,
+            
+            // --- 拖拽交互状态 ---
+            isDragging: false,
+            hasDragged: false,
+            startX: 0,
+            currentX: 0,         // 记录实时拖动位置
+            startRotation: 0,
+            startFrontIndex: 0,  // 记录拖拽开始时的正前方项目
+            rotateTimer: null,
+
             dockItems: [
-                {
-                    label: '首页',
-                    imgUrl: require('@/assets/main/main-home.png'),
-                    activeImgUrl: require('@/assets/main/main-home-hover.png'),
-                    route: '/home',
-                    theme: 'blue',
-                },
-                {
-                    label: '状态监控',
-                    imgUrl: require('@/assets/main/main-surve.png'),
-                    activeImgUrl: require('@/assets/main/main-surve-hover.png'),
-                    route: '/surve',
-                    theme: 'blue',
-                },
-                {
-                    label: '勤务管理',
-                    imgUrl: require('@/assets/main/main-security.png'),
-                    activeImgUrl: require('@/assets/main/main-security-hover.png'),
-                    route: '/security',
-                    theme: 'gold',
-                },
-                {
-                    label: '干线协调',
-                    imgUrl: require('@/assets/main/main-coor.png'),
-                    activeImgUrl: require('@/assets/main/main-coor-hover.png'),
-                    route: '/coor',
-                    theme: 'blue',
-                },
-                {
-                    label: '数据分析',
-                    imgUrl: require('@/assets/main/main-watch.png'),
-                    activeImgUrl: require('@/assets/main/main-watch-hover.png'),
-                    route: '/watch',
-                    theme: 'blue',
-                },
-                {
-                    label: '系统设置',
-                    imgUrl: require('@/assets/main/main-setting.png'),
-                    activeImgUrl: require('@/assets/main/main-setting-hover.png'),
-                    route: '/setting',
-                    theme: 'blue',
-                },
-                {
-                    label: '测试1',
-                    imgUrl: require('@/assets/main/main-home.png'),
-                    theme: 'blue',
-                },
-                {
-                    label: '测试2',
-                    imgUrl: require('@/assets/main/main-surve.png'),
-                    theme: 'blue',
-                },
-                {
-                    label: '测试3',
-                    imgUrl: require('@/assets/main/main-security.png'),
-                    theme: 'blue',
-                },
+                { label: '首页', imgUrl: require('@/assets/main/main-home.png'), activeImgUrl: require('@/assets/main/main-home-hover.png'), route: '/home', theme: 'blue' },
+                { label: '状态监控', imgUrl: require('@/assets/main/main-surve.png'), activeImgUrl: require('@/assets/main/main-surve-hover.png'), route: '/surve', theme: 'blue' },
+                { label: '勤务管理', imgUrl: require('@/assets/main/main-security.png'), activeImgUrl: require('@/assets/main/main-security-hover.png'), route: '/security', theme: 'gold' },
+                { label: '干线协调', imgUrl: require('@/assets/main/main-coor.png'), activeImgUrl: require('@/assets/main/main-coor-hover.png'), route: '/coor', theme: 'blue' },
+                { label: '数据分析', imgUrl: require('@/assets/main/main-watch.png'), activeImgUrl: require('@/assets/main/main-watch-hover.png'), route: '/watch', theme: 'blue' },
+                { label: '系统设置', imgUrl: require('@/assets/main/main-setting.png'), activeImgUrl: require('@/assets/main/main-setting-hover.png'), route: '/setting', theme: 'blue' },
+                // { label: '测试1', imgUrl: require('@/assets/main/main-home.png'), activeImgUrl: require('@/assets/main/main-home-hover.png'), theme: 'blue' },
+                // { label: '测试2', imgUrl: require('@/assets/main/main-surve.png'), activeImgUrl: require('@/assets/main/main-surve-hover.png'), theme: 'blue' },
+                // { label: '测试3', imgUrl: require('@/assets/main/main-security.png'), activeImgUrl: require('@/assets/main/main-security-hover.png'), theme: 'blue' },
             ]
         };
     },
     computed: {
-        // 动态计算 CSS 变量和合并自定义样式
         dockStyles() {
-            // 统一只传一个基础 bottom 偏移量,动画交由 CSS transform 处理
             return {
                 '--dock-bottom': `${this.bottomOffset}px`,
                 ...this.customStyle 
@@ -156,16 +127,29 @@ export default {
     },
     mounted() {
         this.$nextTick(() => {
-            this.checkScrollState();
-            window.addEventListener('resize', this.checkScrollState);
+            if (this.mode === 'linear') {
+                this.checkScrollState();
+                window.addEventListener('resize', this.checkScrollState);
+            } else {
+                this.resumeAutoRotate();
+            }
             if (this.autoHide) {
                 document.addEventListener('mousemove', this.handleGlobalMouseMove);
             }
+            window.addEventListener('mousemove', this.handleDragging);
+            window.addEventListener('mouseup', this.handleDragEnd);
+            window.addEventListener('touchmove', this.handleDragging, { passive: false });
+            window.addEventListener('touchend', this.handleDragEnd);
         });
     },
     beforeDestroy() {
         window.removeEventListener('resize', this.checkScrollState);
         document.removeEventListener('mousemove', this.handleGlobalMouseMove);
+        window.removeEventListener('mousemove', this.handleDragging);
+        window.removeEventListener('mouseup', this.handleDragEnd);
+        window.removeEventListener('touchmove', this.handleDragging);
+        window.removeEventListener('touchend', this.handleDragEnd);
+        this.pauseAutoRotate();
     },
     methods: {
         updateActiveIndexByRoute() {
@@ -176,10 +160,23 @@ export default {
 
             if (matchIndex !== -1) {
                 this.activeIndex = matchIndex;
+                if (this.mode === 'ellipse') {
+                    this.rotateTo(matchIndex);
+                }
             }
         },
         handleSelect(index, item) {
-            if (this.activeIndex === index) return;
+            if (this.hasDragged) {
+                this.hasDragged = false;
+                return;
+            }
+
+            if (this.mode === 'ellipse') {
+                this.rotateTo(index);
+            } else if (this.activeIndex === index) {
+                return;
+            }
+
             this.activeIndex = index;
 
             if (item.route) {
@@ -189,10 +186,10 @@ export default {
                     }
                 });
             }
-
             this.$emit('change', item);
         },
         checkScrollState() {
+            if (this.mode !== 'linear') return;
             const container = this.$refs.listContainer;
             if (!container) return;
 
@@ -204,12 +201,10 @@ export default {
             const el = this.$refs.dockWrapper;
             if (!el) return;
             const rect = el.getBoundingClientRect();
-            // 发光线区域:居中,宽度与 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 热区一致)
             if (e.clientX >= left && e.clientX <= right && e.clientY >= rect.top - 30) {
                 this.dockExpanded = true;
             }
@@ -230,13 +225,125 @@ export default {
                     behavior: 'smooth'
                 });
             }
+        },
+        getEllipseStyle(index) {
+            const total = this.dockItems.length;
+            const baseAngle = (index / total) * Math.PI * 2;
+            const finalAngle = baseAngle + this.ellipseRotation;
+
+            const x = Math.cos(finalAngle) * this.radiusX;
+            const y = Math.sin(finalAngle) * this.radiusY;
+
+            const sinVal = Math.sin(finalAngle);
+            const normalizedDepth = (sinVal + 1) / 2; 
+
+            let scale = 0.5 + (normalizedDepth * 0.7);
+            if (this.hoverIndex === index) scale *= 1.2;
+
+            const opacity = 0.3 + (normalizedDepth * 0.7);
+            const zIndex = Math.round(normalizedDepth * 100);
+
+            return {
+                position: 'absolute',
+                left: '50%',
+                top: '50%',
+                transform: `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${scale})`,
+                opacity: opacity,
+                zIndex: zIndex,
+                transition: this.isDragging ? 'none' : 'transform 0.6s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.6s'
+            };
+        },
+        rotateMenu(direction) {
+            const total = this.dockItems.length;
+            const stepAngle = (Math.PI * 2) / total;
+            this.ellipseRotation -= direction * stepAngle;
+            this.updateFrontIndex();
+        },
+        rotateTo(index) {
+            const total = this.dockItems.length;
+            let diff = this.frontIndex - index;
+            if (diff > total / 2) diff -= total;
+            if (diff < -total / 2) diff += total;
+
+            const stepAngle = (Math.PI * 2) / total;
+            this.ellipseRotation += diff * stepAngle;
+            this.frontIndex = index;
+        },
+        updateFrontIndex() {
+            const total = this.dockItems.length;
+            const stepAngle = (Math.PI * 2) / total;
+            const currentRot = ((this.ellipseRotation % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
+            let targetIndex = Math.round((Math.PI / 2 - currentRot) / stepAngle);
+            this.frontIndex = ((targetIndex % total) + total) % total;
+        },
+        
+        // ================= 拖拽逻辑核心修改区 =================
+        handleDragStart(e) {
+            if (this.mode !== 'ellipse') return;
+            this.isDragging = true;
+            this.hasDragged = false;
+            this.pauseAutoRotate();
+            this.startX = e.clientX || (e.touches && e.touches[0].clientX);
+            this.currentX = this.startX;
+            
+            // 记录拖拽前的完美状态
+            this.startRotation = this.ellipseRotation;
+            this.startFrontIndex = this.frontIndex;
+        },
+        handleDragging(e) {
+            if (!this.isDragging) return;
+            this.currentX = e.clientX || (e.touches && e.touches[0].clientX);
+            const diffX = this.currentX - this.startX;
+            if (Math.abs(diffX) > 5) this.hasDragged = true; 
+            
+            // 拖动时,视觉上跟随鼠标
+            this.ellipseRotation = this.startRotation - (diffX / 350);
+            this.updateFrontIndex(); // 让中间的图标实时发光
+        },
+        handleDragEnd() {
+            if (!this.isDragging) return;
+            this.isDragging = false;
+            
+            const diffX = this.currentX - this.startX;
+            const threshold = 40; // 触发切换的距离阈值(滑动超过 40px 就切换)
+
+            // 【关键】无论拖动多远,先把底层状态恢复到起点
+            // 配合 isDragging = false 时的 transition 过渡,这能保证完美的滑动动画
+            this.ellipseRotation = this.startRotation;
+            this.frontIndex = this.startFrontIndex;
+
+            if (diffX < -threshold) {
+                // 向左滑:精准切换到“下一个”
+                this.rotateMenu(1);
+            } else if (diffX > threshold) {
+                // 向右滑:精准切换到“上一个”
+                this.rotateMenu(-1);
+            } else {
+                // 滑动距离不够,原地吸附回正(无操作,因为上面已经还原了 startRotation)
+            }
+
+            this.resumeAutoRotate();
+        },
+        // ======================================================
+
+        resumeAutoRotate() {
+            if (this.mode !== 'ellipse' || !this.autoRotate || this.rotateTimer) return;
+            this.rotateTimer = setInterval(() => {
+                this.rotateMenu(-1);
+            }, this.autoRotateSpeed);
+        },
+        pauseAutoRotate() {
+            if (this.rotateTimer) {
+                clearInterval(this.rotateTimer);
+                this.rotateTimer = null;
+            }
         }
     }
 };
 </script>
 
 <style scoped>
-/* ================= 整体容器布局 ================= */
+/* ================= 以下是你的原版 CSS (原封不动) ================= */
 .dock-wrapper {
     display: flex !important;
     flex-direction: row !important;
@@ -253,7 +360,6 @@ export default {
     pointer-events: auto !important;
 }
 
-/* === 状态 A:自动隐藏 === */
 .dock-wrapper.is-auto-hide {
     transform: translateY(calc(100% - clamp(15px, 4vh, 20px)));
     pointer-events: none;
@@ -263,7 +369,6 @@ export default {
     pointer-events: auto;
 }
 
-/* 中间触发热区:只覆盖发光线范围,pointer-events 始终开启 */
 .dock-wrapper.is-auto-hide::after {
     content: '';
     position: absolute;
@@ -276,14 +381,12 @@ export default {
     pointer-events: auto;
 }
 
-/* 发光的指示线自适应 */
 .dock-wrapper.is-auto-hide::before {
     content: '';
     position: absolute;
     top: 0;
     left: 50%;
     transform: translateX(-50%);
-    /* 宽度和厚度也做一点自适应,小屏自动变短点 */
     width: clamp(150px, 20vw, 250px);
     height: clamp(3px, 0.5vh, 5px); 
     background: rgba(0, 229, 255, 0.6);
@@ -297,7 +400,6 @@ export default {
     opacity: 0;
 }
 
-/* === 状态 B:常驻显示 === */
 .dock-wrapper.is-always-show {
     transform: translateY(0);
 }
@@ -306,7 +408,6 @@ export default {
     display: none;
 }
 
-/* ================= 内部列表与滚动容器 ================= */
 .dock-list-container {
     width: 750px;
     height: 160px;
@@ -333,7 +434,6 @@ export default {
     gap: 30px;
 }
 
-/* ================= 左右控制箭头 ================= */
 .nav-arrow {
     flex-shrink: 0;
     width: 40px;
@@ -374,7 +474,6 @@ export default {
   opacity: 0.6; 
 }
 
-/* ================= 单个导航项 ================= */
 .dock-item {
     flex-shrink: 0;
     position: relative;
@@ -404,7 +503,6 @@ export default {
     letter-spacing: 1px;
 }
 
-/* ================= 交互状态:悬浮与选中 ================= */
 .dock-item:hover {
     transform: translateY(-15px) scale(1.15);
 }
@@ -454,4 +552,41 @@ export default {
 .dock-item.is-active .custom-icon {
     filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.8));
 }
+
+/* ================= 针对 3D 椭圆模式的样式覆盖 ================= */
+.dock-list-container.is-ellipse-mode {
+    overflow: visible !important;
+    cursor: grab;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+}
+.dock-list-container.is-ellipse-mode:active {
+    cursor: grabbing;
+}
+
+.dock-list.is-ellipse-mode {
+    position: relative !important;
+    width: 0 !important;
+    height: 0 !important;
+    min-width: 0 !important;
+    padding-bottom: 0 !important;
+    gap: 0 !important;
+}
+
+.dock-list.is-ellipse-mode .dock-item {
+    position: absolute;
+}
+.dock-list.is-ellipse-mode .dock-item:hover {
+    transform: none; 
+}
+
+.dock-list.is-ellipse-mode .dock-item.is-front .item-label {
+    color: #ffffff;
+    font-weight: bold;
+    text-shadow: 0 0 10px #00e5ff;
+}
+.dock-list.is-ellipse-mode .dock-item.is-front .custom-icon {
+    filter: drop-shadow(0 0 10px rgba(0, 229, 255, 0.8));
+}
 </style>

+ 3 - 3
src/views/Main.vue

@@ -11,7 +11,7 @@
     </template>
 
     <template #main>
-      <BottomDock :auto-hide="false" :custom-class="['dock-style']"></BottomDock>
+      <BottomDock mode="ellipse" :auto-hide="false" :custom-class="['dock-style']" :auto-rotate="true"></BottomDock>
     </template>
 
   </LoginLayout>
@@ -55,14 +55,14 @@ export default {
     },
     // ---------- 闪烁点 ----------
     initDots() {
-      const count = 28;
+      const count = 50;
       const arr = [];
       for (let i = 0; i < count; i++) {
         arr.push({
           id: i,
           x: 18 + Math.random() * 64, // 百分比布局,适配任意屏幕
           y: 22 + Math.random() * 56,
-          r: 1.2 + Math.random() * 1.8,
+          r: 1.2 + Math.random() * 2.8, // 半径
           a: 0.45 + Math.random() * 0.55,
           d: Math.random() * 2.8, // delay
           t: 1.8 + Math.random() * 2.6, // duration