Talk is cheap. Show me your code

【路由&菜单&权限】一体化设计

【路由&菜单&权限】一体化设计

在中后台项目中,路由、导航菜单、路由权限是最基本的问题。我在工作中见到了各种各样的解决方案, 但都不太理想。在我看来,路由、菜单、权限这三者是强耦合关系,应该放在一起解决。

一、设计思路

路由和菜单在结构上本就是统一的,最多在路由配置上加一个 hiddenInMenu 字段,就能直接过滤过菜单配置。

一个路由下面可能有多个权限,比如一个列表页通常会包含:【查看列表、删除条目、编辑条目、查看详情】这些功能权限。然后在权限设计时遵循这两个原则:

  • 如果配置了多条权限,只要有一个生效,就能访问该路由

  • 如果子路由可访问,则上级路由也可以访问

基于以上分析,可以设计出最终的配置类型:

/** 统一维护路由的唯一键 */
export enum ROUTE_KEYS {
  // ...
}

/** 功能权限Key */
export enum PERMISSIONS {
  // ...
}

export interface IRoute {
  /** 路由配置的唯一键 */
  key: ROUTE_KEYS;
  /** 页面组件的路径,如 @/pages/login */
  component: string;
  /** 页面路径 */
  path: string;
  /** 子路由 */
  routes?: IRoute[];
}

export interface MenuItem extends IRoute {
  /** 菜单名称 */
  name: string;
  /** 是否在菜单中隐藏该页面 */
  hidden?: boolean;
  /** 子菜单 */
  routes?: MenuItem[];
  /** 路由权限,只要包含其中一个权限就能访问该路由,不配置则不做限制 */
  permissions?: PERMISSIONS[];
}

export const SOURCE_MENUS: MenuItem[] = [
 {
    key: ROUTE_KEYS.FIRST_PAGE,
    path: '/first',
    name: '首页
    routes: [
      {
        key: ROUTE_KEYS.SECOND_PAGE,
        name: '二级页面',
        path: '/first/second',
        permissions: [
          PERMISSIONS.FOO,
          PERMISSIONS.BAR,
        ],
      },
    ],
  },
]

二、生成可访问路由

应用加载后,应该第一时间获取用户信息和权限,然后结合全量路由配置 SOURCE_MENUS 和用户权限 permissions(假设接口返回的用户权限是一个 PERMISSIONS[] 数组)过滤出可访问路由 allowedMenus。为了便于快速查询,还可以创建一个字典项 allowedMenuRecords

import { cloneDeep, isArray } from 'lodash-es';

export type RouteRecords = Record<ROUTE_KEYS, MenuItem>;

/**
 * 递归地过滤单个路由项及其子路由项
 * @param item 要过滤的路由项
 * @param permisMap 用户权限映射
 * @returns 如果有权限,则返回路由项;否则返回 undefined
 */
export function loopFilterMenu(
  item: MenuItem,
  permisMap: Record<string, true>,
): RouteItem | undefined {
  // 检查当前路由项是否有权限
  if (!item?.permissions || item.permissions.some((perm) => permisMap[perm])) {
    // 如果有权限或没有指定权限,则处理子路由项
    if (item.routes) {
      item.routes = item.routes.filter((child) =>
        loopFilterMenu(child, permisMap),
      );
      // 如果没有子路由项被允许,则当前路由项也不应被包含在结果中
      if (!item.routes.length) return undefined;
    }
    return item;
  }
  return undefined;
}

/**
 * 根据用户权限过滤出可访问的路由
 * @param routes 所有菜单
 * @param permissions 用户权限
 */
export function getAllowedMenus(
  permissions: PERMISSIONS[],
  routes: MenuItem[] = cloneDeep(SOURCE_MENUS),
): RouteItem[] {
  if (!isArray(routes)) return [];
  if (!isArray(permissions) || !permissions.length) {
    return routes;
  }
  // 将权限转为Map用于快速查询
  const permisMap: Record<string, true> = {};
  for (let i = permissions.length - 1; i >= 0; i--) {
    permisMap[permissions[i]] = true;
  }

  // 过滤所有路由项
  return routes.filter((route) => loopFilterMenu(route, permisMap));
}

/** 将路由数组按key维护为map */
export function buildRouteRecords(arr: MenuItem[], res = {} as RouteRecords) {
  if (isArray(!arr)) return;
  for (let i = arr.length - 1; i >= 0; i--) {
    const item = arr[i];
    res[item.key] = item;
    if (item.routes?.length) {
      buildRouteRecords(item.routes, res);
    }
  }
  return res;
}

// 获取到用户权限userPermissions后生成可访问的路由
const allowedMenus = getAllowedMenus(userPermissions);
// 基于key创建可访问路由map
const allowedMenuRecords = buildRouteRecords(allowedMenus);

三、一级菜单

根据上面生成的 allowedMenus 可以很简单的完成一级路由:

function buildMenus(list?: RouteItem[]) {
  return list
    ?.filter((x) => !x.hidden)
    .map((x) => ({
      label: x.name,
      key: x.key,
      path: x.path,
      // 如果不需要展示次级菜单,可以移除 children
      children: buildMenus(x.routes),
    }));
}

const items = buildMenus(allowedMenus);

然后在项目中直接获取当前路由Key来设置当前高亮的菜单

不同的技术栈获取当前路由的方式不太一样,这里以 Umi 4.x 的项目为例:

import { useSelectedRoutes } from 'umi';
import { isArray } from 'lodash-es';

/**
 * 获取当前的路由配置,默认只返回末级,可传入level获取指定层级
 */
export function useCurrentRoute(level?: number) {
  const selectedRoutes = useSelectedRoutes();
  const currentRoute = useMemo=(() => {
    const index = level ?? selectedRoutes.length - 1;
    const route = isArray(selectedRoutes) ? selectedRoutes[index] : null;
    return route?.route || null;
  }, [level, selectedRoutes]);
  
  return {
    /** 当前路由配置 */
    currentRoute,
  };
}

四、次级菜单

如果二级、三级菜单和一级菜单没在同一个组件内,可以封装一个高阶组件 CommonSubLayout,获取当前路由信息,然后从 allowedMenuRecords 中查到当前路由的子路由

import React, { useMemo } from 'react';
import { useSelectedRoutes } from 'umi';

/** 获取子路由的最小路由层级 */
export const MIN_ROUTE_LEVEL = 2;

function CommonSubLayout({
  children,
  /** 路由层级,不小于2 */
  level = 3,
}) {
  const selectedRoutes = useSelectedRoutes();
  
  // 根据项目的状态管理方案获取 allowedMenuRecords
  const allowedMenuRecords = {};
  
  /**
   * 根据路由层级返回对应的可访问菜单
   * @param lv 层级,不小于2
   */
  const getSubRoutes = useCallback(
    (lv: number = MIN_ROUTE_LEVEL) => {
      let xlevel = Number(lv) || 0;
      // 确保level不小于2
      xlevel = xlevel <= MIN_ROUTE_LEVEL ? MIN_ROUTE_LEVEL : xlevel;
      if (!allowedMenuRecords || selectedRoutes?.length < xlevel) {
        return undefined;
      }
      // 获取一级菜单
      const parentMenu = selectedRoutes[xlevel - 1]?.route as ISelectedRoute;
      // 获取当前菜单下有可访问的二级路由
      return allowedMenuRecords[parentMenu?.key]?.routes;
    },
    [allowedMenuRecords, selectedRoutes],
  );
  
  const lv = useMemo(() => {
    // 确保level不小于2
    const propLv = Number(level) || 0;
    return propLv <= MIN_ROUTE_LEVEL ? MIN_ROUTE_LEVEL : propLv;
  }, [level]);
  
  /** 当前路由层级下的子路由 */
  const subRoutes = useMemo(() => getSubRoutes(lv), [getSubRoutes, lv]);
  const defaultSubRoutePath = subRoutes?.[0]?.path;
  
  /** 基于子路由生成菜单,会过滤掉hidden */
  const items = useMemo(() => {
    return subRoutes?.length
      ? subRoutes
          .filter((x) => !x.hidden)
          .map((x) => {
            // ...
          })
      : undefined;
  }, [subRoutes]);
  
  // 当路由指向当前文件,则重定向到子路由
  if (selectedRoutes.length < lv + 1) {
    return defaultSubRoutePath ? (
      // 重定向
      <Navigate to={defaultSubRoutePath} />
    ) : (
      // 路由无权限
      <ErrorResult errorCode="403" />
    );
  }
  
    // 为子组件注入 items
  return React.isValidElement(children) ? (
    React.cloneElement(children, { items })
  ) : (
    <></>
  );
}

export default React.memo(CommonSubLayout);

如此一来,我们就基本实现【路由 + 菜单 + 权限 】的一体化配置, 只需要维护一个全量路由配置 SOURCE_MENUS 就能通过上述方案自动生成可访问的菜单。

五、路由拦截

虽然上面的内容可以很好的过滤出可访问的菜单,但还不能处理【直接通过URL访问无权限路由】的场景。所以最后还需要做一个全局路由权限检查。

前文已经实现了 useCurrentRouteallowedMenuRecords,所以权限校验的逻辑就很简单:

const { currentRoute } = useCurrentRoute();

// 根据项目的状态管理方案获取 allowedMenuRecords
const allowedMenuRecords = {};

/** 是否可访问当前页面 */
const isAllowed: boolean = useMemo(() => {
  if (!currentRoute) return false;
  // 如果没有配置权限,则不做拦截
  if (!currentRoute.permissions?.length) return true;
  // 校验当前路由key是否可访问
  const key = currentRoute.key;
  return Boolean(key && allowedMenuRecords?.[key]);
}, [allowedMenuRecords, currentRoute]);

只需要根据项目情况将这部分逻辑放到合适的地方就行,一般来说可以放到全局路由守卫,或者项目根节点。在每次路由切换的时候都做一次校验即可。

posted @ 2024-07-25 14:21  Wise.Wrong  阅读(13)  评论(0编辑  收藏  举报