Sfoglia il codice sorgente

feat: support smooth auto-scroll to active menu item (#6102)

Vben 1 settimana fa
parent
commit
045bc4e5ee

+ 23 - 7
packages/@core/ui-kit/menu-ui/src/components/menu.vue

@@ -31,6 +31,7 @@ import {
   createSubMenuContext,
   useMenuStyle,
 } from '../hooks';
+import { useMenuScroll } from '../hooks/use-menu-scroll';
 import { flattedChildren } from '../utils';
 import SubMenu from './sub-menu.vue';
 
@@ -44,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
   mode: 'vertical',
   rounded: true,
   theme: 'dark',
+  scrollToActive: false,
 });
 
 const emit = defineEmits<{
@@ -206,15 +208,19 @@ function handleResize() {
   isFirstTimeRender = false;
 }
 
-function getActivePaths() {
-  const activeItem = activePath.value && items.value[activePath.value];
+const enableScroll = computed(
+  () => props.scrollToActive && props.mode === 'vertical' && !props.collapse,
+);
 
-  if (!activeItem || props.mode === 'horizontal' || props.collapse) {
-    return [];
-  }
+const { scrollToActiveItem } = useMenuScroll(activePath, {
+  enable: enableScroll,
+  delay: 320,
+});
 
-  return activeItem.parentPaths;
-}
+// 监听 activePath 变化,自动滚动到激活项
+watch(activePath, () => {
+  scrollToActiveItem();
+});
 
 // 默认展开菜单
 function initMenu() {
@@ -318,6 +324,16 @@ function removeSubMenu(subMenu: MenuItemRegistered) {
 function removeMenuItem(item: MenuItemRegistered) {
   Reflect.deleteProperty(items.value, item.path);
 }
+
+function getActivePaths() {
+  const activeItem = activePath.value && items.value[activePath.value];
+
+  if (!activeItem || props.mode === 'horizontal' || props.collapse) {
+    return [];
+  }
+
+  return activeItem.parentPaths;
+}
 </script>
 <template>
   <ul

+ 46 - 0
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts

@@ -0,0 +1,46 @@
+import type { Ref } from 'vue';
+
+import { watch } from 'vue';
+
+import { useDebounceFn } from '@vueuse/core';
+
+interface UseMenuScrollOptions {
+  delay?: number;
+  enable?: boolean | Ref<boolean>;
+}
+
+export function useMenuScroll(
+  activePath: Ref<string | undefined>,
+  options: UseMenuScrollOptions = {},
+) {
+  const { enable = true, delay = 320 } = options;
+
+  function scrollToActiveItem() {
+    const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
+    if (!isEnabled) return;
+
+    const activeElement = document.querySelector(
+      `aside li[role=menuitem].is-active`,
+    );
+    if (activeElement) {
+      activeElement.scrollIntoView({
+        behavior: 'smooth',
+        block: 'center',
+        inline: 'center',
+      });
+    }
+  }
+
+  const debouncedScroll = useDebounceFn(scrollToActiveItem, delay);
+
+  watch(activePath, () => {
+    const isEnabled = typeof enable === 'boolean' ? enable : enable.value;
+    if (!isEnabled) return;
+
+    debouncedScroll();
+  });
+
+  return {
+    scrollToActiveItem,
+  };
+}

+ 0 - 6
packages/@core/ui-kit/menu-ui/src/menu.vue

@@ -18,15 +18,9 @@ defineOptions({
 
 const props = withDefaults(defineProps<Props>(), {
   collapse: false,
-  // theme: 'dark',
 });
 
 const forward = useForwardProps(props);
-
-// const emit = defineEmits<{
-//   'update:openKeys': [key: Key[]];
-//   'update:selectedKeys': [key: Key[]];
-// }>();
 </script>
 
 <template>

+ 6 - 0
packages/@core/ui-kit/menu-ui/src/types.ts

@@ -42,6 +42,12 @@ interface MenuProps {
    */
   rounded?: boolean;
 
+  /**
+   * @zh_CN 是否自动滚动到激活的菜单项
+   * @default false
+   */
+  scrollToActive?: boolean;
+
   /**
    * @zh_CN 菜单主题
    * @default dark

+ 1 - 1
packages/effects/access/src/accessible.ts

@@ -66,7 +66,7 @@ async function generateAccessible(
   }
 
   // 生成菜单
-  const accessibleMenus = await generateMenus(accessibleRoutes, options.router);
+  const accessibleMenus = generateMenus(accessibleRoutes, options.router);
 
   return { accessibleMenus, accessibleRoutes };
 }

+ 1 - 0
packages/effects/layouts/src/basic/menu/menu.vue

@@ -37,6 +37,7 @@ function handleMenuOpen(key: string, path: string[]) {
     :menus="menus"
     :mode="mode"
     :rounded="rounded"
+    scroll-to-active
     :theme="theme"
     @open="handleMenuOpen"
     @select="handleMenuSelect"

+ 40 - 24
packages/effects/layouts/src/basic/menu/use-navigation.ts

@@ -6,41 +6,57 @@ import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';
 
 function useNavigation() {
   const router = useRouter();
-  const routes = router.getRoutes();
-
   const routeMetaMap = new Map<string, RouteRecordNormalized>();
 
-  routes.forEach((route) => {
-    routeMetaMap.set(route.path, route);
+  // 初始化路由映射
+  const initRouteMetaMap = () => {
+    const routes = router.getRoutes();
+    routes.forEach((route) => {
+      routeMetaMap.set(route.path, route);
+    });
+  };
+
+  initRouteMetaMap();
+
+  // 监听路由变化
+  router.afterEach(() => {
+    initRouteMetaMap();
   });
 
-  const navigation = async (path: string) => {
-    const route = routeMetaMap.get(path);
-    const { openInNewWindow = false, query = {} } = route?.meta ?? {};
+  // 检查是否应该在新窗口打开
+  const shouldOpenInNewWindow = (path: string): boolean => {
     if (isHttpUrl(path)) {
-      openWindow(path, { target: '_blank' });
-    } else if (openInNewWindow) {
-      openRouteInNewWindow(path);
-    } else {
-      await router.push({
-        path,
-        query,
-      });
+      return true;
     }
+    const route = routeMetaMap.get(path);
+    return route?.meta?.openInNewWindow ?? false;
   };
 
-  const willOpenedByWindow = (path: string) => {
-    const route = routeMetaMap.get(path);
-    const { openInNewWindow = false } = route?.meta ?? {};
-    if (isHttpUrl(path)) {
-      return true;
-    } else if (openInNewWindow) {
-      return true;
-    } else {
-      return false;
+  const navigation = async (path: string) => {
+    try {
+      const route = routeMetaMap.get(path);
+      const { openInNewWindow = false, query = {} } = route?.meta ?? {};
+
+      if (isHttpUrl(path)) {
+        openWindow(path, { target: '_blank' });
+      } else if (openInNewWindow) {
+        openRouteInNewWindow(path);
+      } else {
+        await router.push({
+          path,
+          query,
+        });
+      }
+    } catch (error) {
+      console.error('Navigation failed:', error);
+      throw error;
     }
   };
 
+  const willOpenedByWindow = (path: string) => {
+    return shouldOpenInNewWindow(path);
+  };
+
   return { navigation, willOpenedByWindow };
 }
 

+ 6 - 9
packages/utils/src/helpers/__tests__/generate-menus.test.ts

@@ -69,7 +69,7 @@ describe('generateMenus', () => {
       },
     ];
 
-    const menus = await generateMenus(mockRoutes, mockRouter as any);
+    const menus = generateMenus(mockRoutes, mockRouter as any);
     expect(menus).toEqual(expectedMenus);
   });
 
@@ -82,7 +82,7 @@ describe('generateMenus', () => {
       },
     ] as RouteRecordRaw[];
 
-    const menus = await generateMenus(mockRoutesWithMeta, mockRouter as any);
+    const menus = generateMenus(mockRoutesWithMeta, mockRouter as any);
     expect(menus).toEqual([
       {
         badge: undefined,
@@ -109,7 +109,7 @@ describe('generateMenus', () => {
       },
     ] as RouteRecordRaw[];
 
-    const menus = await generateMenus(mockRoutesWithParams, mockRouter as any);
+    const menus = generateMenus(mockRoutesWithParams, mockRouter as any);
     expect(menus).toEqual([
       {
         badge: undefined,
@@ -141,10 +141,7 @@ describe('generateMenus', () => {
       },
     ] as RouteRecordRaw[];
 
-    const menus = await generateMenus(
-      mockRoutesWithRedirect,
-      mockRouter as any,
-    );
+    const menus = generateMenus(mockRoutesWithRedirect, mockRouter as any);
     expect(menus).toEqual([
       // Assuming your generateMenus function excludes redirect routes from the menu
       {
@@ -195,7 +192,7 @@ describe('generateMenus', () => {
   });
 
   it('should generate menu list with correct order', async () => {
-    const menus = await generateMenus(routes, router);
+    const menus = generateMenus(routes, router);
     const expectedMenus = [
       {
         badge: undefined,
@@ -230,7 +227,7 @@ describe('generateMenus', () => {
 
   it('should handle empty routes', async () => {
     const emptyRoutes: any[] = [];
-    const menus = await generateMenus(emptyRoutes, router);
+    const menus = generateMenus(emptyRoutes, router);
     expect(menus).toEqual([]);
   });
 });

+ 32 - 23
packages/utils/src/helpers/generate-menus.ts

@@ -1,30 +1,38 @@
 import type { Router, RouteRecordRaw } from 'vue-router';
 
-import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
+import type {
+  ExRouteRecordRaw,
+  MenuRecordRaw,
+  RouteMeta,
+} from '@vben-core/typings';
 
 import { filterTree, mapTree } from '@vben-core/shared/utils';
 
 /**
  * 根据 routes 生成菜单列表
- * @param routes
+ * @param routes - 路由配置列表
+ * @param router - Vue Router 实例
+ * @returns 生成的菜单列表
  */
-async function generateMenus(
+function generateMenus(
   routes: RouteRecordRaw[],
   router: Router,
-): Promise<MenuRecordRaw[]> {
+): MenuRecordRaw[] {
   // 将路由列表转换为一个以 name 为键的对象映射
-  // 获取所有router最终的path及name
   const finalRoutesMap: { [key: string]: string } = Object.fromEntries(
     router.getRoutes().map(({ name, path }) => [name, path]),
   );
 
   let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => {
-    // 路由表的路径写法有多种,这里从router获取到最终的path并赋值
-    const path = finalRoutesMap[route.name as string] ?? route.path;
+    // 获取最终的路由路径
+    const path = finalRoutesMap[route.name as string] ?? route.path ?? '';
 
-    // 转换为菜单结构
-    // const path = matchRoute?.path ?? route.path;
-    const { meta, name: routeName, redirect, children } = route;
+    const {
+      meta = {} as RouteMeta,
+      name: routeName,
+      redirect,
+      children = [],
+    } = route;
     const {
       activeIcon,
       badge,
@@ -35,24 +43,27 @@ async function generateMenus(
       link,
       order,
       title = '',
-    } = meta || {};
+    } = meta;
 
+    // 确保菜单名称不为空
     const name = (title || routeName || '') as string;
 
-    // 隐藏子菜单
+    // 处理子菜单
     const resultChildren = hideChildrenInMenu
       ? []
       : (children as MenuRecordRaw[]);
 
-    // 将菜单的所有父级和父级菜单记录到菜单项内
-    if (resultChildren && resultChildren.length > 0) {
+    // 设置子菜单的父子关系
+    if (resultChildren.length > 0) {
       resultChildren.forEach((child) => {
-        child.parents = [...(route.parents || []), path];
+        child.parents = [...(route.parents ?? []), path];
         child.parent = path;
       });
     }
-    // 隐藏子菜单
+
+    // 确定最终路径
     const resultPath = hideChildrenInMenu ? redirect || path : link || path;
+
     return {
       activeIcon,
       badge,
@@ -63,19 +74,17 @@ async function generateMenus(
       order,
       parent: route.parent,
       parents: route.parents,
-      path: resultPath as string,
-      show: !route?.meta?.hideInMenu,
-      children: resultChildren || [],
+      path: resultPath,
+      show: !meta.hideInMenu,
+      children: resultChildren,
     };
   });
 
   // 对菜单进行排序,避免order=0时被替换成999的问题
   menus = menus.sort((a, b) => (a?.order ?? 999) - (b?.order ?? 999));
 
-  const finalMenus = filterTree(menus, (menu) => {
-    return !!menu.show;
-  });
-  return finalMenus;
+  // 过滤掉隐藏的菜单项
+  return filterTree(menus, (menu) => !!menu.show);
 }
 
 export { generateMenus };