react-admin:通过路由生成菜单

386 阅读6分钟

react-admin是一个开箱即用的中大型后台管理系统,不仅只有前端解决方案,更是提供了基于nestjs的后端解决方案。

前端项目地址

后端项目地址

路由

路由分为静态路由和动态路由,目前项目使用的都是静态路由,等后端开发完成之后会加入动态路由,根据当前登录的用户分配不同的路由,其实就是路由权限-页面级权限。

路由--->菜单

目前的路由是写在前端项目里并根据模块来划分的。路径位于: src/router。这边文章主要是写通过路由来生成侧边栏

扩展路由类型

react-router的路由对象不满足现有业务,需要扩展路由类型,使得在生成菜单时更方便。

// src/types/custom-types.d.ts
export interface IRouteObjectMeta {
  auth?: boolean; // 是否需要权限
  title?: string; // 国际化的key
  menu?: boolean; // 是否是菜单
  icon?: string; // 系统icon是使用iconify来管理的,所以这里是iconify的icon值。如:carbon:information 或是使用本地icon ra-icon:esc,本地icon查看项目icon文档。
  order?: number; // 菜单排序 值越小越排在前面
  hidden: false, // 是否隐藏 是否在侧边栏展示
  openMode?: 'iframe' | 'newBrowserTab' | 'router'; // 菜单打开方式 iframe-使用iframe嵌入系统 newBrowserTab-使用新的浏览器标签打开 router-使用路由打开
  type?: 菜单类型,// 目前没有使用到,为了后面扩展先写上,比如菜单不止在侧边栏展示,还有快捷导航,或是首页导航
}

export type IRouteObject = RouteObject & {
  meta?: IRouteObjectMeta;
  children?: (RouteObject & IRouteObjectMeta)[]; // 重写children
};

构造路由对象并排序

在编写好模块的路由后,通过vite的import.meta.glob的来解析模块,并返回一个路由数组

// src/router/utils/index.ts
import { IRouteObject } from '@/types/custom-types';

export function getRoutes() {
  const routes: IRouteObject[] = [];

  // * 导入所有route
  const metaRoutes: Record<string, any> = import.meta.glob('../modules/*.tsx', {
    eager: true,
  });
  Object.keys(metaRoutes).forEach((item) => {
    Object.keys(metaRoutes[item]).forEach((key: any) => {
      routes.push(...metaRoutes[item][key]);
    });
  });
  // 根据order排序,升序排列,值越小越在前面
  routes.sort((a, b) => (a.meta?.order || 0) - (b.meta?.order || 0));
  return routes;
}

将解析后的路由数组组装成最后的路由数组

这里需要注意每个父路由下都会有一个特殊的路由,用于默认跳转

  • 如果用户直接在浏览器输入一个父路由路径,此时页面不会有任何展示
  • 嵌套二三级路由下如果父路由没有element并且没有默认路由或是父路由没有使用outlet,这些情况下子路由都不会有任何展示

基于以上两种情况,我们需要为父路由添加一个默认跳转,并且这个跳转包含outlet,所以封装了一个RedirectRouteView组件

// src/router/RedirectRouteView.tsx
/**
 * 此组件用于父路由默认跳转
 */
import { Navigate, Outlet } from 'react-router-dom';

type RedirectRouteViewProps = {
  to: string;
};
const RedirectRouteView = ({ to }: RedirectRouteViewProps) => {
  return (
    <>
      <Navigate to={to} replace />
      <Outlet />
    </>
  );
};

export default RedirectRouteView;

<RedirectRouteView />组件使用

import { lazy } from 'react';

import LazyLoadComponent from '@/components/LazyLoadComponent';

import RedirectRouteView from '../RedirectRouteView';

import type { IRouteObject } from '@/types/custom-types';

// 嵌套菜单
const nestedMenuRouter: IRouteObject[] = [
  {
    path: '/nestedMenu',
    meta: {
      title: 'nestedMenu',
      icon: 'ant-design:menu-outlined',
      auth: true,
      menu: true,
      hidden: false,
      openMode: 'router',
      order: 8,
    },

    children: [
     // 注意这里,index: true就是父路由的默认跳转,然后他的element就是我们封装的RedirectRouteView组件
      {
        index: true,
        element: <RedirectRouteView to="/nestedMenu/menu1" />,
      },
      {
        path: 'menu1',
        element: (
          <LazyLoadComponent
            Component={lazy(() => import('@/pages/nestedMenu/menu1'))}
          />
        ),
        meta: {
          auth: true,
          menu: true,
          hidden: false,
          openMode: 'router',
          title: 'nestedMenu1',
          icon: 'ant-design:menu-outlined',
        },
      },
      {
        path: 'menu2',
        meta: {
          auth: true,
          menu: true,
          hidden: false,
          openMode: 'router',
          title: 'nestedMenu2',
          icon: 'ri:menu-fold-4-line',
        },

        children: [
          {
            index: true,
            element: <RedirectRouteView to="/nestedMenu/menu2/menu2-1" />,
          },
          {
            path: 'menu2-1',
            index: true,
            element: (
              <LazyLoadComponent
                Component={lazy(
                  () => import('@/pages/nestedMenu/menu2/menu2-1'),
                )}
              />
            ),
            meta: {
              auth: true,
              menu: true,
              hidden: false,
              openMode: 'router',
              title: 'nestedMenu2-1',
              icon: 'ant-design:menu-outlined',
            },
          },
        ],
      },
      {
        path: 'menu3',
        meta: {
          auth: true,
          menu: true,
          hidden: false,
          openMode: 'router',
          title: 'nestedMenu3',
          icon: 'ri:menu-unfold-4-line',
        },
        children: [
          {
            index: true,
            element: <RedirectRouteView to="/nestedMenu/menu3/menu3-1" />,
          },
          {
            path: 'menu3-1',
            index: true,
            element: (
              <LazyLoadComponent
                Component={lazy(
                  () => import('@/pages/nestedMenu/menu3/menu3-1'),
                )}
              />
            ),
            meta: {
              auth: true,
              menu: true,
              hidden: false,
              openMode: 'router',
              title: 'nestedMenu3-1',
              icon: 'ant-design:menu-outlined',
            },
          },
          {
            path: 'menu3-2',
            meta: {
              auth: true,
              menu: true,
              hidden: false,
              openMode: 'router',
              title: 'nestedMenu3-2',
              icon: 'ri:menu-unfold-4-line',
            },
            children: [
              {
                index: true,
                element: (
                  <RedirectRouteView to="/nestedMenu/menu3/menu3-2/menu3-2-1" />
                ),
              },
              {
                path: 'menu3-2-1',
                element: (
                  <LazyLoadComponent
                    Component={lazy(
                      () =>
                        import('@/pages/nestedMenu/menu3/menu3-2/menu3-2-1'),
                    )}
                  />
                ),
                meta: {
                  auth: true,
                  menu: true,
                  hidden: false,
                  openMode: 'router',
                  title: '菜单3-2-1',
                  icon: 'ant-design:menu-outlined',
                },
              },
            ],
          },
        ],
      },
    ],
  },
];

export default nestedMenuRouter;

最终生成的routes

// src/router/routes.tsx
import { lazy } from 'react';
import { Navigate } from 'react-router-dom';

import Error404 from '@/components/Error/404';
import LazyLoadComponent from '@/components/LazyLoadComponent';

import { getRoutes } from './utils';

import Layout from '@/layouts/index';
import { IRouteObject } from '@/types/custom-types';

const routeArray = getRoutes();

const routes: IRouteObject[] = [
  {
    path: '/login',
    element: (
      <LazyLoadComponent Component={lazy(() => import('@/pages/login'))} />
    ),
    meta: {
      menu: false,
    },
  },
  {
    path: '/',
    element: <Layout />,
    meta: {
      menu: false,
    },
    children: [
      {
        index: true,
        element: <Navigate to="/overview/workspace" replace />,
        meta: {
          menu: false,
        },
      },
      ...routeArray,
      {
        path: '*',
        element: <Error404 />,
        meta: {
          menu: false,
        },
      },
    ],
  },
];

export default routes;

侧边栏

侧边栏主要是通过递归遍历路由来过滤生成的

// src/layouts/hooks/useMenu.tsx
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';

import { useShallow } from 'zustand/react/shallow';

import Icon from '@/components/RaIcon';

import type { IRouteObject, MenuItem } from '@/types/custom-types';

import routes from '@/router/routes';
import useGlobalStore from '@/store';
import useMenuStore from '@/store/menu';
import { dfs } from '@/utils';

function formatRoutes({
  routes,
  menuItems,
  parentRoute,
  parentMenu,
}: {
  routes: IRouteObject[]; // 路由数组也会是路由嵌套children
  menuItems: MenuItem[]; // 最后返回的菜单,嵌套的时候是对应的children
  parentMenu?: MenuItem; // 父菜单
  parentRoute?: IRouteObject; // 父路由
}) {
  for (let i = 0; i < routes.length; i++) {
    const route = routes[i];
    const meta = route.meta;
    let menuItem: MenuItem | null = null;
    // 过滤不是菜单的路由
    if (meta?.menu) {
      menuItem = {
        title: meta.title as string,
        label: meta.title as string,
        hidden: !!meta.hidden,
        // 如果是菜单用父菜单的key加当前路由的path组成,这样既能是唯一的也是当前菜单的完整路由
        key: parentRoute?.meta?.menu
          ? ((parentMenu?.key + '/' + route.path) as string)
          : (route.path as string),
        icon: (
          <Icon
            icon={meta?.icon || 'ant-design:appstore-outlined'}
            wrapClassName="leading-none mr-[4]"
          />
        ),
        // 加上这个是为了全局搜索和tabs缓存在本地时使用,localstorage不支持组件缓存所以缓存字符串
        ['data-icon']: meta?.icon || 'ant-design:appstore-outlned',
      };
    }
    // 有嵌套路由递归遍历
    if (route.children && route.children.length > 0) {
      menuItem = menuItem || { children: [], key: '', label: '' };
      menuItem.children = [];
      formatRoutes({
        routes: route.children,
        menuItems: menuItem.children,
        parentMenu: menuItem,
        parentRoute: route,
      });
    }
    if (menuItem) {
      menuItems?.push(menuItem);
    }
  }
}
function useMenu() {
  const { t } = useTranslation();
  const appLanguage = useGlobalStore((state) => state.appLanguage);
  // 将菜单数据存入全局store
  const { setFlatMenuItems, setMenuItems } = useMenuStore(
    useShallow((state) => ({
      setMenuItems: state.setMenuItems,
      setFlatMenuItems: state.setFlatMenuItems,
    })),
  );
  // 菜单缓存
  const menuItemsRef = useRef<MenuItem[]>([]);
  // 铺平的菜单,tabs及全局搜索用到
  const flatMenuItemsRef = useRef<MenuItem[]>([]);
  useEffect(() => {
    const menuItems: MenuItem[] = [];
    formatRoutes({
      routes,
      menuItems,
    });
    
    menuItemsRef.current = menuItems[0].children as MenuItem[];
    flatMenuItemsRef.current = dfs(menuItemsRef.current);
  }, [routes]);

  useEffect(() => {
    const filteredMenuItems: MenuItem[] = [];
    function translate(menuItems: MenuItem[], filteredMenuItems: MenuItem[]) {
      for (let i = 0; i < menuItems.length; i++) {
        const item = menuItems[i];
        // label是国际化后的文本
        item.label = t(`menu.${item.title}`);
        const cloneItem = { ...item };
        if (item.children && item.children.length > 0) {
          const cloneItemChildren: MenuItem[] = [];
          translate(item.children, cloneItemChildren);
          cloneItem.children = cloneItemChildren;
        }
        if (!cloneItem.hidden) {
          filteredMenuItems.push(cloneItem);
        }
      }
    }
    translate(menuItemsRef.current, filteredMenuItems);
    setMenuItems(filteredMenuItems);
    const newFlatMenuItems = flatMenuItemsRef.current.map((i) => ({
      ...i,
      label: t(`menu.${i.title}`),
    }));
    setFlatMenuItems(newFlatMenuItems);
  }, [appLanguage]);
}

export default useMenu;
  1. 根据菜单在Sider组件中使用
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';

import { Layout, Menu } from 'antd';
import { useShallow } from 'zustand/react/shallow';

import AppLogo from '@/components/app/AppLogo';

import { getLevelKeys } from '../../utils/utils';

import styles from './index.module.css';

import type { LevelKeysProps } from '../../utils/utils';
import type { MenuProps } from 'antd/lib';

import useMenuStore from '@/store/menu';

const { Sider } = Layout;
const AppSider = () => {
  const navigate = useNavigate();
  const location = useLocation();
  const { collapsed, menuItems } = useMenuStore(
    useShallow((state) => ({
      menuItems: state.menuItems,
      collapsed: state.collapsed,
    })),
  );
  const [openKeys, setOpenKeys] = useState<string[]>([]);
  const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
  // 监听路由变化 设置侧边栏展开选中
  useEffect(() => {
    setSelectedKeys([location.pathname]);
    const keysArr = location.pathname.split('/').filter((i) => i);
    const keys: string[] = [];
    // 根据pathname生成keys
    keysArr.reduce((prev: string, current: string) => {
      const path = prev ? `${prev}/${current}` : `/${current}`;
      keys.push(path);
      return path;
    }, '');
    setOpenKeys(keys);
  }, [location.pathname]);
  const levelKeys = getLevelKeys(menuItems as LevelKeysProps[]);
  // 菜单展开时关闭其他已经展开的菜单
  const onOpenChange: MenuProps['onOpenChange'] = (allOpenKeys) => {
    const currentOpenKey = allOpenKeys.find(
      (key) => openKeys.indexOf(key) === -1,
    );
    // open
    if (currentOpenKey !== undefined) {
      const repeatIndex = allOpenKeys
        .filter((key) => key !== currentOpenKey)
        .findIndex((key) => levelKeys[key] === levelKeys[currentOpenKey]);
      setOpenKeys(
        allOpenKeys
          // remove repeat key
          .filter((_, index) => index !== repeatIndex)
          // remove current level all child
          .filter((key) => levelKeys[key] <= levelKeys[currentOpenKey]),
      );
    } else {
      // close
      setOpenKeys(allOpenKeys);
    }
  };
  const handleItemClick: MenuProps['onClick'] = ({ key }) => {
    navigate(key);
  };

  return (
    <Sider
      className={styles['app-layout-sider']}
      trigger={null}
      collapsible
      collapsed={collapsed}
      collapsedWidth={48}
    >
      <AppLogo
        showTitle={!collapsed}
        style={{
          color: 'var(--ant-color-text)',
          backgroundColor: 'var(--ant-color-bg-container)',
        }}
      />
      <Menu
        mode="inline"
        defaultSelectedKeys={['1']}
        items={menuItems}
        onClick={handleItemClick}
        selectedKeys={selectedKeys}
        openKeys={openKeys}
        onOpenChange={onOpenChange}
        className="overflow-y-auto flex-1"
      />
    </Sider>
  );
};

export default AppSider;