【Next】5. 全局权限管理

137 阅读4分钟

以下笔记来源:编程导航

需求

  1. 能够灵活配置每个页面所需要的用户权限,由全局权限管理系统自动校验和拦截路由,而不需要在每个页面中编写权限校验代码,提高开发效率。(路由权限)
  2. 还要能够根据权限控制导航菜单的显隐,只有具有权限的菜单,才对用户可见。(菜单显隐)

实现思路

  1. 在路由配置文件, 定义某个路由的访问权限。由于 Next.js 项目是约定式路由,只有我们自定义的菜单配置文件,可以在菜单配置文件中定义权限。
  2. 每次访问页面时,根据用户要访问页面的路由权限信息,判断用户是否有对应的访问权限,并进行相应的拦截处理。这是一个全局逻辑,可以在项目根布局 app/layout.tsx 中添加。
  3. 导航栏展示菜单时,可以过滤掉登录用户没有权限的菜单项,从而实现根据权限控制导航菜单的显隐。

具体实现:

1. 新增 forbidden 页面

src/app/forbidden.tsx

import { Button, Result } from "antd";

/**
 * 无权限页面
 * @constructor
 */
const Forbidden = () => {
  return (
    <Result
      title="403"
      status="403"
      subTitle="对不起,你无权访问该页面"
      extra={
        <Button type="primary" href={"/"}>
          返回首页
        </Button>
      }
    />
  );
};

export default Forbidden;

2. 定义权限常量和默认用户

src/access/accessEnum.ts

/**
 * 权限枚举
 */
const Access_Enum = {
  NOT_LOGIN: "notLogin",
  USER: "user",
  ADMIN: "admin"
};
export default Access_Enum;

src/ constants/user.ts

// 默认用户
import Access_Enum from "@/access/accessEnum";

const DEFAULT_USER: API.LoginUserVO = {
    userName: "未登录",
    userProfile: "暂无简介",
    userAvatar: "/assets/notLoginUser.png",
    userRole: Access_Enum.NOT_LOGIN,
};

export default DEFAULT_USER;

3. 菜单配置和筛选

为需要权限的菜单增加配置项,并根据路径查找菜单。

config/menu.tsx

import { MenuDataItem } from "@ant-design/pro-layout";
import { CrownOutlined } from "@ant-design/icons";
import Access_Enum from "@/access/accessEnum";

// 菜单列表
export const menus = [
  {
    path: "/",
    name: "主页",
  },
  {
    path: "/banks",
    name: "题库",
  },
  {
    path: "/questions",
    name: "题目",
  },
  {
    path: "/admin",
    name: "管理",
    icon: <CrownOutlined />,
    access: Access_Enum.ADMIN,
    children: [
      {
        path: "/admin/user",
        name: "用户管理",
        access: Access_Enum.ADMIN,
      },
      {
        path: "/admin/bank",
        name: "题库管理",
        access: Access_Enum.ADMIN,
      },
      {
        path: "/admin/question",
        name: "题目管理",
        access: Access_Enum.ADMIN,
      },
    ],
  },
] as MenuDataItem[];

/**
 * 根据路径查找菜单
 */
export const findMenuItemByPath = (
  menus: MenuDataItem[],
  path: string,
): MenuDataItem | null => {
  for (let menu of menus) {
    if (menu.path === path) {
      return menu;
    }
    if (menu.children) {
      const matchedMenuItem = findMenuItemByPath(menu.children, path);
      if (matchedMenuItem) {
        return matchedMenuItem;
      }
    }
  }
  return null;
};

/**
 * 根据路径查找所有菜单
 */
export const findAllMenuItemByPath = (path: string): MenuDataItem | null => {
  return findMenuItemByPath(menus, path);
};

4. 编写通用权限校验方法

因为菜单组件中要判断权限、权限拦截也要用到权限判断功能,所以抽离成公共模块。

src/access/checkAccess.ts

import Access_Enum from "@/access/accessEnum";

/**
 * 检查权限(判断当前登录用户是否具有某个权限)
 * @param loginUser 当前登录用户
 * @param needAccess 需要检查的权限
 * @return boolean 有无权限
 */
const checkAccess = (
  loginUser: API.LoginUserVO,
  needAccess = Access_Enum.NOT_LOGIN,
) => {
  // 获取当前用户具有的权限 如果没有登录 默认没有权限
  const loginUserAccess = loginUser?.userRole ?? Access_Enum.NOT_LOGIN;
  // 如果当前不需要权限
  if (needAccess === Access_Enum.NOT_LOGIN) {
    return true;
  }
  // 如果需要权限为普通用户
  if (needAccess === Access_Enum.USER) {
    if (loginUserAccess === Access_Enum.NOT_LOGIN) {
      return false;
    }
  }
  // 如果需要权限为管理员
  if (needAccess === Access_Enum.ADMIN) {
    if (loginUserAccess !== Access_Enum.ADMIN) {
      return false;
    }
  }
  return true;
};

export default checkAccess;

5. 新增权限校验布局

src/access/AccessLayout.tsx

import React from "react";
import {useSelector} from "react-redux";
import {RootState} from "@/stores";
import {usePathname} from "next/navigation";
import {findAllMenuItemByPath} from "../../config/menu";
import Access_Enum from "@/access/accessEnum";
import checkAccess from "@/access/checkAccess";
import Forbidden from "@/app/forbidden";

/**
 * 统一权限校验拦截器
 * @param children
 * @constructor
 */
const AccessLayout: React.FC<Readonly<{ children: React.ReactNode }>> = ({children}) => {
    const pathname = usePathname()
    // 当前登录用户
    const loginUser = useSelector((state: RootState) => state.loginUser);
    // 根据路径获取当前菜单
    const menu = findAllMenuItemByPath(pathname) || {};
    // 需要的权限
    const needAccess = menu?.access ?? Access_Enum.NOT_LOGIN;
    // 判断是否拥有权限
    const canAccess = checkAccess(loginUser, needAccess);
    if (!canAccess) {
        return <Forbidden />
    }
    return <>{children}</>
};

export default AccessLayout;

6. 包裹(增强)基础布局

src/app/layout.tsx

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="zh">
      <body>
        <AntdRegistry>
          <Provider store={store}>
            <InitLayout>
              <BasicLayout>
                <AccessLayout>{children}</AccessLayout>
              </BasicLayout>
            </InitLayout>
          </Provider>
        </AntdRegistry>
      </body>
    </html>
  );
}

7. 控制菜单显隐

src/access/menuAccess.ts

import { menus } from "../../config/menu";
import checkAccess from "@/access/checkAccess";

const getAccessibleMenus = (loginUser: API.LoginUserVO, menuItems = menus) => {
  return menuItems.filter((item) => {
    if (!checkAccess(loginUser, item.access)) {
      return false;
    }
    if (item.children) {
      item.children = getAccessibleMenus(loginUser, item.children);
    }
    return true;
  });
};

export default getAccessibleMenus;

然后就可以在基础布局页面的菜单渲染使用上进行使用:

src/layouts/BasicLayout/index.tsx

// 定义菜单
menuDataRender={() => {
  return getAccessibleMenus(loginUser, menus);
}}

8. 404 页面

未和路由菜单匹配的路径,404 未找到。

src/app/not-found.tsx (约定式)

import {Button, Result} from "antd";

/**
 * 未找到页面
 * @constructor
 */
const NotFound = () => {
    return (
        <Result
            title="404"
            status="404"
            subTitle="抱歉,你访问的页面不存在"
            extra={
                <Button type="primary" href={"/"}>
                    返回首页
                </Button>
            }
        />
    );
};

export default NotFound;

其他(高阶组件权限校验)

还有其他实现权限校验的方法,比如使用高阶组件(HOC)在客户端进行权限校验,这种方法会更灵活。如下:

// components/withAuth.js
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useSelector } from 'react-redux'; // 或者使用其他全局状态管理库

export default function withAuth(Component) {
  return function AuthenticatedComponent(props) {
    const router = useRouter();
    const isAuthenticated = useSelector((state) => state.auth.isAuthenticated); // 获取用户登录状态

    useEffect(() => {
      if (!isAuthenticated) {
        // 如果未登录,重定向到登录页面
        router.push('/login');
      }
    }, [isAuthenticated]);

    // 如果未登录,不渲染组件
    if (!isAuthenticated) {
      return null;
    }

    // 如果已登录,渲染组件
    return <Component {...props} />;
  };
}

使用这个 HOC 包裹需要进行权限校验的页面:

// pages/protected.js
import withAuth from '@/components/withAuth';

function ProtectedPage() {
  return <div>This is a protected page.</div>;
}

export default withAuth(ProtectedPage);