Jelajahi Sumber

perf: 新增top nav导航

曾坤森 3 minggu lalu
induk
melakukan
a761eb8295

+ 1 - 1
src/components/global-breadcrumb/index.vue

@@ -74,7 +74,7 @@ const goToRoute = (path: string) => {
   :deep(.arco-breadcrumb) {
     display: flex;
     align-items: center;
-    height: 50px;
+    height: 40px;
   }
 
   :deep(.arco-breadcrumb-item) {

+ 1 - 0
src/components/menu/index.vue

@@ -16,6 +16,7 @@ export default defineComponent({
     const router = useRouter();
     const route = useRoute();
     const { menuTree } = useMenuTree();
+    console.log('iiii', menuTree.value);
     const collapsed = computed({
       get() {
         if (appStore.device === 'desktop') return appStore.menuCollapse;

+ 110 - 0
src/components/menu/nav-menu.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <ul class="menu-container">
+      <li
+        v-for="item in menuTree"
+        :class="[
+          'menu-item',
+          {
+            active:
+              item.name === route.matched[0].name ||
+              item.name === hoveredMenuItem?.name,
+          },
+        ]"
+        @mouseenter="handleMouseEnter(item)"
+        @mouseleave="handleMouseLeave"
+      >
+        <span>{{ item.name }}</span>
+      </li>
+    </ul>
+  </div>
+</template>
+<script lang="tsx" setup name="NavMenu">
+import useMenuTree from './use-menu-tree';
+import { defineComponent, ref, h, compile, computed, watch } from 'vue';
+import { useRoute, useRouter, RouteRecordNormalized } from 'vue-router';
+interface NavMenuEmits {
+  (e: 'menuHover', val: RouteRecordNormalized): void;
+}
+interface NavMenuProps {
+  internalHoveredItem: RouteRecordNormalized;
+}
+const route = useRoute();
+
+const { menuTree } = useMenuTree();
+// 定义 emits
+const emit = defineEmits<NavMenuEmits>();
+const props = withDefaults(defineProps<NavMenuProps>(), {
+  internalHoveredItem: () => ({}) as RouteRecordNormalized,
+});
+const hoveredMenuItem = ref<RouteRecordNormalized>({} as RouteRecordNormalized);
+watch(
+  () => props.internalHoveredItem,
+  value => {
+    hoveredMenuItem.value = value;
+  }
+);
+let hoverTimeout: number | null = null;
+
+// 处理鼠标进入事件
+const handleMouseEnter = (item: RouteRecordNormalized) => {
+  // 清除之前的定时器
+  if (hoverTimeout) {
+    clearTimeout(hoverTimeout);
+    hoverTimeout = null;
+  }
+  // 发送事件给父组件,传递当前菜单项
+  emit('menuHover', item);
+};
+
+// 处理鼠标离开事件
+const handleMouseLeave = () => {
+  // 设置延迟,避免快速移动时菜单消失
+  hoverTimeout = setTimeout(() => {
+    // 发送 null 值表示鼠标离开
+    emit('menuHover', {} as RouteRecordNormalized);
+  }, 100);
+};
+</script>
+<style lang="less" scoped>
+.menu-container {
+  display: flex;
+  flex-direction: row;
+  justify-content: start;
+  margin-top: 1px;
+
+  .menu-item {
+    height: 40px;
+    padding-right: 20px;
+    padding-left: 20px;
+    line-height: 40px;
+    list-style: none;
+    border-top-left-radius: 10px;
+    border-top-right-radius: 10px;
+    cursor: pointer;
+
+    span {
+      color: black;
+      font-weight: 600;
+      font-size: 20px;
+      text-decoration: none;
+    }
+  }
+
+  .menu-item:hover {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: #0b2349;
+  }
+
+  .active {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: #0b2349;
+  }
+}
+</style>

+ 132 - 0
src/components/menu/nav-second-menu.vue

@@ -0,0 +1,132 @@
+<template>
+  <ul
+    class="menu-container"
+    @mouseenter="handleSelfMouseEnter"
+    @mouseleave="handleSelfMouseLeave"
+  >
+    <li
+      v-for="item in displayedMenuList"
+      :class="['menu-item', { active: item.name === route.name }]"
+      @click="handleJumpRoute(item)"
+    >
+      <span>{{ item.name }}</span>
+    </li>
+  </ul>
+</template>
+<script lang="tsx" setup name="NavSecondMenu">
+import useMenuTree from './use-menu-tree';
+import { defineComponent, ref, h, compile, computed } from 'vue';
+import {
+  useRoute,
+  useRouter,
+  RouteRecordNormalized,
+  RouteRecordRaw,
+} from 'vue-router';
+interface NavSecondMenuEmits {
+  (e: 'internalHoveredItemFun', val: RouteRecordNormalized): void;
+}
+interface NavSecondMenuProps {
+  hoveredMenuItem: RouteRecordNormalized;
+}
+const route = useRoute();
+const router = useRouter();
+const menuList = computed(() => route.matched[0].children);
+// 接收来自 NavMenu 的事件
+const props = withDefaults(defineProps<NavSecondMenuProps>(), {
+  hoveredMenuItem: () => ({}) as RouteRecordNormalized,
+});
+const emits = defineEmits<NavSecondMenuEmits>();
+// 内部状态管理
+const internalHoveredItem = ref<RouteRecordNormalized>(
+  {} as RouteRecordNormalized
+);
+let mouseInSelf = false;
+let clearTimer: number | null = null;
+
+// 计算显示的菜单列表
+const displayedMenuList = computed(() => {
+  // 优先使用内部锁定的菜单项
+  if (internalHoveredItem.value?.children) {
+    return internalHoveredItem.value.children;
+  }
+
+  // 其次使用外部传入的菜单项
+  if (props.hoveredMenuItem?.children) {
+    return props.hoveredMenuItem.children;
+  }
+
+  // 最后使用默认菜单
+  return menuList.value || [];
+});
+
+// 处理自身鼠标进入事件
+const handleSelfMouseEnter = () => {
+  mouseInSelf = true;
+  // 清除可能存在的清除定时器
+  if (clearTimer) {
+    clearTimeout(clearTimer);
+    clearTimer = null;
+  }
+  // 锁定当前显示的菜单项
+  if (props.hoveredMenuItem) {
+    emits('internalHoveredItemFun', props.hoveredMenuItem);
+    internalHoveredItem.value = props.hoveredMenuItem;
+  }
+};
+
+// 处理自身鼠标离开事件
+const handleSelfMouseLeave = () => {
+  mouseInSelf = false;
+  // 延迟清除内部状态,给人类操作留出时间
+  clearTimer = setTimeout(() => {
+    emits('internalHoveredItemFun', {} as RouteRecordNormalized);
+    internalHoveredItem.value = {} as RouteRecordNormalized;
+  }, 100);
+};
+const handleJumpRoute = (item: RouteRecordRaw) => {
+  console.log('item', item.name);
+  // 使用 name 跳转避免路径解析问题
+  router.push({ name: item.name });
+};
+</script>
+<style lang="less" scoped>
+.menu-container {
+  display: flex;
+  flex-direction: row;
+  justify-content: start;
+  width: 100%;
+  margin: 0;
+  background-color: #0b2349;
+
+  .menu-item {
+    height: 45px;
+    margin-top: 1px;
+    padding: 10px 20px;
+    list-style: none;
+    cursor: pointer;
+
+    span {
+      color: #9ba6b5;
+      font-weight: 600;
+      font-size: 20px;
+      text-decoration: none;
+    }
+  }
+
+  .menu-item:hover {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: #3c4e69;
+  }
+
+  .active {
+    span {
+      color: var(--color-white);
+    }
+
+    background-color: #3c4e69;
+  }
+}
+</style>

+ 219 - 183
src/components/navbar/index.vue

@@ -1,86 +1,94 @@
 <template>
-  <div class="navbar" :style="{ width: currentWidth }">
-    <div class="left-side">
-      <a-button
-        type="text"
-        :style="{
-          padding: '0 7px',
-          height: '25px',
-          lineHeight: '25px',
-          color: 'var(--color-text-3)',
-        }"
-        @click="toggleCollapse"
-      >
-        <icon-menu-unfold v-if="collapsed" />
-        <icon-menu-fold v-else />
-      </a-button>
-    </div>
-    <GlobalBreadcrumb v-show="!topMenu" />
-
-    <div class="center-side">
-      <Menu v-if="topMenu" />
-    </div>
-    <ul class="right-side">
-      <li>
-        <a-tooltip :content="$t('settings.search')">
-          <a-button class="nav-btn" type="outline" :shape="'circle'">
-            <template #icon>
-              <icon-search />
+  <div
+    class="navbar-container"
+    :style="{ width: topMenu ? '100%' : currentWidth }"
+  >
+    <div class="navbar">
+      <div class="left-side">
+        <a-button
+          type="text"
+          :style="{
+            padding: '0 7px',
+            height: '25px',
+            lineHeight: '25px',
+            color: 'var(--color-text-3)',
+          }"
+          @click="toggleCollapse"
+          v-show="!topMenu"
+        >
+          <icon-menu-unfold v-if="collapsed" />
+          <icon-menu-fold v-else />
+        </a-button>
+      </div>
+      <GlobalBreadcrumb v-show="!topMenu" />
+      <div class="center-side">
+        <NavMenu
+          v-if="topMenu"
+          :internalHoveredItem="internalHoveredItem"
+          @menuHover="handleMenuHover"
+        ></NavMenu>
+      </div>
+      <ul class="right-side">
+        <li>
+          <a-tooltip :content="$t('settings.search')">
+            <a-button class="nav-btn" type="outline" :shape="'circle'">
+              <template #icon>
+                <icon-search />
+              </template>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <li>
+          <a-tooltip :content="$t('settings.language')">
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="setDropDownVisible"
+            >
+              <template #icon>
+                <icon-language />
+              </template>
+            </a-button>
+          </a-tooltip>
+          <a-dropdown trigger="click" @select="changeLocale as any">
+            <div ref="triggerBtn" class="trigger-btn"></div>
+            <template #content>
+              <a-doption
+                v-for="item in locales"
+                :key="item.value"
+                :value="item.value"
+              >
+                <template #icon>
+                  <icon-check v-show="item.value === currentLocale" />
+                </template>
+                {{ item.label }}
+              </a-doption>
             </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <li>
-        <a-tooltip :content="$t('settings.language')">
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="setDropDownVisible"
+          </a-dropdown>
+        </li>
+        <li>
+          <a-tooltip
+            :content="
+              theme === 'light'
+                ? $t('settings.navbar.theme.toDark')
+                : $t('settings.navbar.theme.toLight')
+            "
           >
-            <template #icon>
-              <icon-language />
-            </template>
-          </a-button>
-        </a-tooltip>
-        <a-dropdown trigger="click" @select="changeLocale as any">
-          <div ref="triggerBtn" class="trigger-btn"></div>
-          <template #content>
-            <a-doption
-              v-for="item in locales"
-              :key="item.value"
-              :value="item.value"
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="handleToggleTheme"
             >
               <template #icon>
-                <icon-check v-show="item.value === currentLocale" />
+                <icon-moon-fill v-if="theme === 'dark'" />
+                <icon-sun-fill v-else />
               </template>
-              {{ item.label }}
-            </a-doption>
-          </template>
-        </a-dropdown>
-      </li>
-      <li>
-        <a-tooltip
-          :content="
-            theme === 'light'
-              ? $t('settings.navbar.theme.toDark')
-              : $t('settings.navbar.theme.toLight')
-          "
-        >
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="handleToggleTheme"
-          >
-            <template #icon>
-              <icon-moon-fill v-if="theme === 'dark'" />
-              <icon-sun-fill v-else />
-            </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <!-- <li>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <!-- <li>
         <a-tooltip :content="$t('settings.navbar.alerts')">
           <div class="message-box-trigger">
             <a-badge :count="9" dot>
@@ -107,51 +115,51 @@
           </template>
         </a-popover>
       </li> -->
-      <li>
-        <a-tooltip
-          :content="
-            isFullscreen
-              ? $t('settings.navbar.screen.toExit')
-              : $t('settings.navbar.screen.toFull')
-          "
-        >
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="toggleFullScreen"
-          >
-            <template #icon>
-              <icon-fullscreen-exit v-if="isFullscreen" />
-              <icon-fullscreen v-else />
-            </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <li>
-        <a-tooltip :content="$t('settings.title')">
-          <a-button
-            class="nav-btn"
-            type="outline"
-            :shape="'circle'"
-            @click="setVisible"
-          >
-            <template #icon>
-              <icon-settings />
-            </template>
-          </a-button>
-        </a-tooltip>
-      </li>
-      <li>
-        <a-dropdown trigger="click">
-          <a-avatar
-            :size="32"
-            :style="{ marginRight: '8px', cursor: 'pointer' }"
+        <li>
+          <a-tooltip
+            :content="
+              isFullscreen
+                ? $t('settings.navbar.screen.toExit')
+                : $t('settings.navbar.screen.toFull')
+            "
           >
-            <img alt="avatar" :src="avatar" />
-          </a-avatar>
-          <template #content>
-            <!-- <a-doption>
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="toggleFullScreen"
+            >
+              <template #icon>
+                <icon-fullscreen-exit v-if="isFullscreen" />
+                <icon-fullscreen v-else />
+              </template>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <li>
+          <a-tooltip :content="$t('settings.title')">
+            <a-button
+              class="nav-btn"
+              type="outline"
+              :shape="'circle'"
+              @click="setVisible"
+            >
+              <template #icon>
+                <icon-settings />
+              </template>
+            </a-button>
+          </a-tooltip>
+        </li>
+        <li>
+          <a-dropdown trigger="click">
+            <a-avatar
+              :size="32"
+              :style="{ marginRight: '8px', cursor: 'pointer' }"
+            >
+              <img alt="avatar" :src="avatar" />
+            </a-avatar>
+            <template #content>
+              <!-- <a-doption>
               <a-space @click="switchRoles">
                 <icon-tag />
                 <span>
@@ -159,26 +167,33 @@
                 </span>
               </a-space>
             </a-doption> -->
-            <a-doption>
-              <a-space @click="$router.push({ name: 'Setting' })">
-                <icon-settings />
-                <span>
-                  {{ $t('messageBox.userSettings') }}
-                </span>
-              </a-space>
-            </a-doption>
-            <a-doption>
-              <a-space @click="handleLogout">
-                <icon-export />
-                <span>
-                  {{ $t('messageBox.logout') }}
-                </span>
-              </a-space>
-            </a-doption>
-          </template>
-        </a-dropdown>
-      </li>
-    </ul>
+              <a-doption>
+                <a-space @click="$router.push({ name: 'Setting' })">
+                  <icon-settings />
+                  <span>
+                    {{ $t('messageBox.userSettings') }}
+                  </span>
+                </a-space>
+              </a-doption>
+              <a-doption>
+                <a-space @click="handleLogout">
+                  <icon-export />
+                  <span>
+                    {{ $t('messageBox.logout') }}
+                  </span>
+                </a-space>
+              </a-doption>
+            </template>
+          </a-dropdown>
+        </li>
+      </ul>
+    </div>
+    <NavSecondMenu
+      v-if="topMenu"
+      :hoveredMenuItem="currentHoveredMenu"
+      @internalHoveredItemFun="internalHoveredItemFun"
+    ></NavSecondMenu>
+    <!-- <div :style="{ width: '100%', height: '50px' }">dddd</div> -->
   </div>
 </template>
 
@@ -190,9 +205,11 @@ import { useAppStore, useUserStore } from '@/store';
 import { LOCALE_OPTIONS } from '@/locale';
 import useLocale from '@/hooks/locale';
 import useUser from '@/hooks/user';
-import Menu from '@/components/menu/index.vue';
+import NavMenu from '@/components/menu/nav-menu.vue';
+import NavSecondMenu from '@/components/menu/nav-second-menu.vue';
 import MessageBox from '../message-box/index.vue';
 import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
+import { useRoute, useRouter, RouteRecordNormalized } from 'vue-router';
 
 const appStore = useAppStore();
 const userStore = useUserStore();
@@ -269,62 +286,81 @@ const toggleCollapse = () => {
   setCollapse(!collapsed.value);
   // toggleWidth(collapsed.value);
 };
+const currentHoveredMenu = ref<RouteRecordNormalized>(
+  {} as RouteRecordNormalized
+);
+const internalHoveredItem = ref<RouteRecordNormalized>(
+  {} as RouteRecordNormalized
+);
+
+const handleMenuHover = (menuItem: RouteRecordNormalized) => {
+  currentHoveredMenu.value = menuItem;
+};
+const internalHoveredItemFun = (item: RouteRecordNormalized) => {
+  internalHoveredItem.value = item;
+};
 </script>
 
 <style scoped lang="less">
-.navbar {
+.navbar-container {
   display: flex;
-  justify-content: space-between;
-  justify-self: end;
-  height: 100%;
-  background-color: var(--color-bg-2);
-  border-bottom: 1px solid var(--color-border);
-}
-
-.left-side {
-  display: flex;
-  align-items: center;
-  padding-left: 20px;
-}
-
-.center-side {
-  flex: 1;
-}
-
-.right-side {
-  display: flex;
-  padding-right: 20px;
-  list-style: none;
+  flex-direction: column;
+  width: 100%;
 
-  :deep(.locale-select) {
-    border-radius: 20px;
+  .navbar {
+    display: flex;
+    justify-content: space-between;
+    justify-self: end;
+    height: 100%;
+    background-color: var(--color-bg-2);
+    border-bottom: 1px solid var(--color-border);
   }
 
-  li {
+  .left-side {
     display: flex;
     align-items: center;
-    padding: 0 10px;
+    padding-left: 20px;
   }
 
-  a {
-    color: var(--color-text-1);
-    text-decoration: none;
+  .center-side {
+    flex: 1;
   }
 
-  .nav-btn {
-    color: rgb(var(--gray-8));
-    font-size: 16px;
-    border-color: rgb(var(--gray-2));
-  }
+  .right-side {
+    display: flex;
+    padding-right: 20px;
+    list-style: none;
 
-  .trigger-btn,
-  .ref-btn {
-    position: absolute;
-    bottom: 14px;
-  }
+    :deep(.locale-select) {
+      border-radius: 20px;
+    }
+
+    li {
+      display: flex;
+      align-items: center;
+      padding: 0 10px;
+    }
+
+    a {
+      color: var(--color-text-1);
+      text-decoration: none;
+    }
+
+    .nav-btn {
+      color: rgb(var(--gray-8));
+      font-size: 16px;
+      border-color: rgb(var(--gray-2));
+    }
+
+    .trigger-btn,
+    .ref-btn {
+      position: absolute;
+      bottom: 14px;
+    }
 
-  .trigger-btn {
-    margin-left: 14px;
+    .trigger-btn {
+      margin-left: 14px;
+    }
   }
 }
 </style>

+ 1 - 1
src/config/settings.json

@@ -3,7 +3,7 @@
   "colorWeak": false,
   "navbar": true,
   "menu": true,
-  "topMenu": false,
+  "topMenu": true,
   "hideMenu": false,
   "menuCollapse": false,
   "footer": false,

+ 4 - 4
src/layout/default-layout.vue

@@ -82,13 +82,13 @@ const router = useRouter();
 const route = useRoute();
 const permission = usePermission();
 useResponsive(true);
-const navbarHeight = `50px`;
+const navbarHeight = `40px`;
 const navbar = computed(() => appStore.navbar);
 const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
 const hideMenu = computed(() => appStore.hideMenu);
 const footer = computed(() => appStore.footer);
 const menuWidth = computed(() => {
-  return appStore.menuCollapse ? 48 : appStore.menuWidth;
+  return appStore.menuCollapse ? 40 : appStore.menuWidth;
 });
 const collapsed = computed(() => {
   return appStore.menuCollapse;
@@ -125,7 +125,7 @@ onMounted(() => {
 </script>
 
 <style scoped lang="less">
-@nav-size-height: 50px;
+@nav-size-height: 40px;
 @layout-max-width: 1100px;
 
 .layout {
@@ -142,7 +142,7 @@ onMounted(() => {
   justify-content: end;
   width: 100%;
   height: @nav-size-height;
-  background-color: white;
+  background-color: var(--color-bg-2);
 }
 
 .layout-sider {