React 驱动的房东管理平台:实现基于 RBAC(角色控制)的动态菜单生成与后端路由权限双校验

各位好,今天我们不谈那些虚头巴脑的架构图,也不搞那些看起来很高深其实很尴尬的名词堆砌。今天我们要聊的是那个让无数前端开发者半夜惊醒,也让甲方爸爸拍着桌子喊“我要加功能”的核心技术点——权限控制

特别是针对咱们这个“房东管理平台”的项目,想象一下:一个拥有“管理员”权限的人,正在前台浏览普通租客的房产信息,这画面是不是有点像开着法拉利去送外卖,虽然能开,但总觉得哪里不对劲?

为了防止这种“权限越狱”现象,我们需要一套严密的防守体系。而这套体系的核心,就是大名鼎鼎的 RBAC(基于角色的访问控制)。今天,我们就来手把手地拆解一下,如何用 React 实现基于 RBAC 的动态菜单生成,以及前后端的双重校验。


第一部分:先别急着写路由,先搞清楚“人设”

在代码开始之前,我们必须得先定义好数据模型。这就像咱们租房一样,得先知道你是租客(普通用户)、管家(经理),还是房东本人(超级管理员)。

1. 数据模型设计(后端给的“剧本”)

假设我们的后端 API 非常配合,它会在用户登录成功后,返回一个“剧本”,告诉前端这个账号有哪些戏份。

{
  "code": 200,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "user": {
      "id": 1,
      "username": "李管家",
      "roles": ["manager"] // 李管家是经理,属于“管家”角色
    },
    "menus": [ // 这是关键!后端直接告诉前端你要显示哪些菜单
      {
        "id": "1",
        "path": "/dashboard",
        "name": "工作台",
        "icon": "dashboard",
        "component": "Dashboard",
        "meta": {
          "title": "工作台",
          "icon": "dashboard"
        }
      },
      {
        "id": "2",
        "path": "/properties",
        "name": "房源管理",
        "icon": "home",
        "component": "PropertyList",
        "meta": {
          "title": "房源管理",
          "icon": "home"
        },
        "children": [ // 这里还有子菜单,说明咱们支持多级菜单
          {
            "id": "2-1",
            "path": "list",
            "name": "房源列表",
            "component": "PropertyList",
            "meta": {
              "title": "房源列表",
              "icon": "list"
            }
          },
          {
            "id": "2-2",
            "path": "add",
            "name": "新增房源",
            "component": "PropertyAdd",
            "meta": {
              "title": "新增房源",
              "icon": "add"
            }
          }
        ]
      },
      {
        "id": "3",
        "path": "/finance",
        "name": "财务管理",
        "icon": "money",
        "component": "Finance",
        "meta": {
          "title": "财务管理",
          "icon": "money"
        }
      }
    ]
  }
}

注意到了吗?这个 JSON 结构非常有讲究。它不仅告诉了我们有哪些菜单,还告诉了我们路由的层级关系。这就是我们构建动态路由的基石。

2. 前端的角色定义

在代码层面,我们需要一个简单的方式来定义角色。别搞得太复杂,搞个枚举或者简单的对象就行。

// src/utils/role.ts
export enum RoleEnum {
  SUPER_ADMIN = 'super_admin', // 老板
  MANAGER = 'manager',         // 经理
  STAFF = 'staff',             // 普通员工
}

// 定义路由元数据,用来存放路由的自定义属性
export interface RouteMeta {
  title: string;
  icon?: string;
  roles?: RoleEnum[]; // 比如这个路由只有超级管理员能看
}

第二部分:后端路由权限校验——“看门狗”

很多初学者只做前端的动态菜单,觉得只要前端不显示按钮,权限就控制住了。这就好比你在卧室门口装了个锁,但没锁卧室门,小偷直接从窗户爬进来了。

所以,后端校验是底线,前端校验是面子。

我们需要在每个路由的组件加载前,或者接口调用前,去检查用户有没有权限。

1. Axios 拦截器(全局卫士)

我们要在 React 项目的入口文件,配置一个 Axios 实例,并挂载 requestresponse 拦截器。

// src/utils/request.ts
import axios from 'axios';
import { message } from 'antd';
import { useNavigate } from 'react-router-dom';

const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
});

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    // 1. 携带 Token
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器
service.interceptors.response.use(
  (response) => {
    const res = response.data;

    // 这里可以根据业务状态码进行判断,比如 401 无权限,403 被禁止访问
    if (res.code !== 200) {
      if (res.code === 401) {
        message.error('登录已过期,请重新登录');
        localStorage.removeItem('token');
        // 这里的跳转逻辑最好抽离,避免循环依赖
        window.location.href = '/login'; 
      } else if (res.code === 403) {
        message.error('您没有权限访问该资源');
      }
      return Promise.reject(new Error(res.message || 'Error'));
    }
    return res;
  },
  (error) => {
    message.error('网络错误');
    return Promise.reject(error);
  }
);

export default service;

2. 接口权限校验示例

假设我们要实现“财务报表”功能。如果普通员工(Staff)强行通过浏览器地址栏输入 /finance 访问这个页面,后端得像个铁面无私的保安一样把他拦住。

// 示例接口:获取房源列表
export function getProperties(params) {
  return request({
    url: '/api/properties',
    method: 'get',
    params,
  });
}

在对应的组件中,我们甚至可以加一层前端二次校验(虽然不绝对,但能提升用户体验)。不过真正的防守在拦截器里。


第三部分:动态路由生成器——“把数据变成路”

这是最核心的技术环节。我们要做的不是硬编码 <Route path="/dashboard" component={Dashboard} />,而是根据后端返回的 menus 数据,动态生成路由配置。

1. 路由类型定义

首先,我们需要一个 RouteObject 类型,匹配 react-router-domRouteProps,并加上我们的 meta

// src/types/router.ts
import { RouteObject } from 'react-router-dom';
import { RouteMeta } from './role';

export interface AppRouteObject extends Omit<RouteObject, 'meta' | 'children'> {
  path: string;
  name: string;
  component: React.LazyExoticComponent<React.ComponentType<any>>;
  meta?: RouteMeta;
  children?: AppRouteObject[];
  redirect?: string;
}

2. 递归构建路由

我们的后端菜单数据是一个树形结构,所以我们需要一个递归函数来处理它。

// src/router/generator.ts
import React, { lazy } from 'react';
import { AppRouteObject } from '@/types/router';
import { RoleEnum } from '@/utils/role';

// 模拟动态加载组件,实际项目中替换为 import.meta.glob 或 webpack magic comments
const loadModule = (componentName: string) => {
  return lazy(() => import(`@/views/${componentName}.tsx`));
};

// 核心递归函数
export function generateRoutes(menus: any[]): AppRouteObject[] {
  return menus
    .filter(menu => {
      // 1. 权限过滤:如果菜单定义了 roles,用户必须拥有其中一个角色才能看到
      // 假设当前用户角色存储在 store 中,这里简化处理,假设 userRole 是全局变量
      if (menu.meta?.roles) {
        return menu.meta.roles.includes(userRole); 
      }
      return true;
    })
    .map(menu => {
      const route: AppRouteObject = {
        path: menu.path,
        name: menu.name,
        element: loadModule(menu.component), // 懒加载组件,按需加载,性能关键!
        meta: menu.meta,
      };

      // 2. 递归处理子菜单
      if (menu.children && menu.children.length > 0) {
        route.children = generateRoutes(menu.children);
      }

      // 3. 如果有 redirect,设置一下
      if (menu.redirect) {
        route.redirect = menu.redirect;
      }

      return route;
    });
}

这段代码的逻辑解读:

  1. filter: 像个筛子,先把不符合当前用户角色要求的菜单筛掉。比如 meta.roles['super_admin'],但当前用户是 staff,这个菜单直接消失,连渲染的机会都没有。
  2. map: 把剩下的菜单项转换成 React Router 需要的 RouteObject
  3. lazy: 这一步是性能优化的关键。不要把所有页面打包到一个 JS 文件里,我们要让用户只有点开菜单时才加载对应的页面代码。这就是“动态加载”的精髓。

3. 路由注册(沙箱模式)

动态生成的路由不能直接丢到 App.tsx 里的 <Routes> 里,那样会一直存在,没有动态性。我们需要创建一个“路由容器”组件。

// src/components/TabBarLayout/index.tsx
import { Outlet, useRoutes, Navigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Layout, Menu } from 'antd';
import { generateRoutes } from '@/router/generator';
import { RoleEnum } from '@/utils/role';

const { Sider } = Layout;

const TabBarLayout: React.FC = () => {
  const [routes, setRoutes] = useState<any[]>([]);
  const [selectedKey, setSelectedKey] = useState('');

  // 模拟获取用户信息(实际应从 Redux 或 Context 获取)
  const userRole = RoleEnum.MANAGER; 

  useEffect(() => {
    // 假设 menus 是从 API 获取的
    const mockMenus = [
        // ... 上一节的 JSON 数据
    ];

    // 生成路由配置
    const generatedRoutes = generateRoutes(mockMenus);
    setRoutes(generatedRoutes);
  }, [userRole]);

  // 默认重定向到第一个菜单
  const defaultRedirect = routes[0]?.path;

  return (
    <Layout style={{ height: '100vh' }}>
      <Sider width={200} style={{ background: '#fff' }}>
        {/* 菜单渲染逻辑 */}
        <Menu
          mode="inline"
          selectedKeys={[selectedKey]}
          items={routes.map(item => ({
            key: item.path,
            label: item.meta?.title,
          }))}
          onClick={({ key }) => setSelectedKey(key)}
        />
      </Sider>
      <Layout style={{ padding: '24px' }}>
        {/* 这里是路由出口,所有动态生成的路由都会在这里渲染 */}
        <Outlet />
      </Layout>
    </Layout>
  );
};

第四部分:路由守卫——“最后的防线”

虽然我们在 generateRoutes 里做了权限过滤,但用户体验还得再提升一下。

1. 权限守卫组件

万一用户绕过了前端菜单,直接在浏览器输入 URL 呢?我们需要一个 PrivateRoute 或者 GuardRoute 来拦截。

// src/components/GuardRoute/index.tsx
import { Navigate } from 'react-router-dom';
import { RoleEnum } from '@/utils/role';

interface GuardRouteProps {
  children: React.ReactNode;
  requiredRoles?: RoleEnum[]; // 这个路由必须具备的角色
}

const GuardRoute: React.FC<GuardRouteProps> = ({ children, requiredRoles }) => {
  const currentRole = RoleEnum.MANAGER; // 获取当前用户角色

  // 如果没有配置 requiredRoles,或者当前角色在 requiredRoles 中,则放行
  if (!requiredRoles || requiredRoles.includes(currentRole)) {
    return <>{children}</>;
  }

  // 否则,重定向到 403 页面或者首页
  return <Navigate to="/403" replace />;
};

2. 在路由配置中使用

// src/router/generator.ts (修改版)
export function generateRoutes(menus: any[]): AppRouteObject[] {
  return menus
    .filter(menu => {
       // ... (权限过滤逻辑同上)
    })
    .map(menu => {
      let element = loadModule(menu.component);

      // 如果菜单配置了角色,用 GuardRoute 包裹
      if (menu.meta?.roles && menu.meta.roles.length > 0) {
        element = <GuardRoute requiredRoles={menu.meta.roles}>{element}</GuardRoute>;
      }

      const route: AppRouteObject = {
        path: menu.path,
        name: menu.name,
        element,
        meta: menu.meta,
      };

      // ... 递归逻辑不变
      return route;
    });
}

这样,即使通过 URL 访问,如果权限不足,也会被 GuardRoute 拦截并重定向。


第五部分:实战演练——从登录到菜单渲染的完整流

为了让大家彻底明白,我们把整个流程串起来。

第一步:登录
用户输入账号密码。

// 登录 API 调用
const handleLogin = async (values) => {
  const res = await loginApi(values);
  localStorage.setItem('token', res.data.token);
  // 登录成功后,通常需要获取用户信息
  fetchMenusAndRoles(); 
}

第二步:获取菜单数据

const fetchMenusAndRoles = async () => {
  // 1. 获取菜单树
  const menuRes = await getMenusApi();
  // 2. 获取当前用户角色(或者直接从 menuRes 里取)
  setUserRole(menuRes.data.user.roles[0]);

  // 3. 生成路由
  const routes = generateRoutes(menuRes.data.menus);

  // 4. 将路由存入路由器
  setRoutes(routes);
};

第三步:渲染
App.tsx 变成了这样:

import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from 'antd';
import { routes, LayoutRoute } from './router/generator';
import Login from '@/views/Login';

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        {/* 公开路由 */}
        <Route path="/login" element={<Login />} />
        <Route path="/403" element={<div>403 Forbidden</div>} />

        {/* 动态路由容器 */}
        <Route element={<LayoutRoute />}>
           {routes.map(route => (
             <Route key={route.path} path={route.path} element={route.element} />
           ))}
        </Route>

        {/* 兜底路由,如果访问了未定义的路径,回到首页 */}
        <Route path="*" element={<Navigate to="/dashboard" replace />} />
      </Routes>
    </BrowserRouter>
  );
};

export default App;

第六部分:递归菜单组件——菜单树的艺术

上面的代码里,我们只渲染了一级菜单。如果用户点击“房源管理”,二级菜单(列表、新增)应该出现在侧边栏里。这就需要用到递归组件

1. 菜单项组件

// src/components/Sidebar/MenuItem.tsx
import { Menu } from 'antd';
import { useLocation, useNavigate } from 'react-router-dom';
import type { MenuProps } from 'antd';

interface MenuItemProps {
  item: any; // 对应后端的菜单节点
  level?: number; // 当前层级,用于缩进
}

const MenuItem: React.FC<MenuItemProps> = ({ item, level = 0 }) => {
  const location = useLocation();
  const navigate = useNavigate();

  // 递归渲染子菜单
  const renderChildren = () => {
    if (!item.children || item.children.length === 0) return null;

    return (
      <Menu.ItemGroup key={`group-${item.id}`} title={item.name}>
        {item.children.map(child => (
          <MenuItem key={child.id} item={child} level={level + 1} />
        ))}
      </Menu.ItemGroup>
    );
  };

  // 判断当前路径是否匹配(用于高亮)
  const isSelected = location.pathname.startsWith(item.path);

  const onClick: MenuProps['onClick'] = (e) => {
    navigate(e.key);
  };

  return (
    <Menu.Item 
      key={item.path} 
      icon={item.meta?.icon} // 假设 icon 组件存在
      style={{ paddingLeft: `${level * 24}px` }} // 层级缩进
      className={isSelected ? 'selected-menu-item' : ''}
      onClick={onClick}
    >
      {item.meta?.title || item.name}
      {renderChildren()}
    </Menu.Item>
  );
};

2. 侧边栏布局

// src/components/Sidebar/index.tsx
import { Menu } from 'antd';
import { useLocation, useNavigate } from 'react-router-dom';
import { generateRoutes } from '@/router/generator';
import { MenuItem } from './MenuItem';

const Sidebar = () => {
  const location = useLocation();
  const [routes, setRoutes] = useState<any[]>([]);

  useEffect(() => {
    // 假设从 store 获取菜单
    const menuData = getStoreMenu(); 
    const routes = generateRoutes(menuData);
    setRoutes(routes);
  }, []);

  return (
    <div style={{ height: '100vh', background: '#001529' }}>
      <div style={{ padding: '16px', color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
        房东管家平台
      </div>
      <Menu
        theme="dark"
        selectedKeys={[location.pathname]}
        mode="inline"
        items={routes.map(route => ({
          key: route.path,
          label: <MenuItem item={route} />,
        }))}
      />
    </div>
  );
};

这里的关键在于 MenuItem 组件内部再次调用了 MenuItem。这就是“递归”的力量——一个组件自己调用自己,完美解决了无限级菜单的问题。


第七部分:代码分割与性能优化——别让用户等太久

当我们使用 lazy(() => import('@/views/xxx')) 时,Webpack 或 Vite 会自动进行代码分割。这意味着,当用户点击“新增房源”菜单时,浏览器才去下载那个页面的 JS 文件。

1. 动态路由的时机

我们绝不应该在应用初始化(App.tsx mount)时就加载所有路由。这会极大地拖慢首屏加载速度。

正确的做法是:在用户登录后,拿到菜单数据,生成路由,然后才去挂载路由。

// App.tsx 修改版
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from 'antd';
import { useState, useEffect } from 'react';
import { generateRoutes } from './router/generator';
import { getMenusAndRoles } from './api/user';

const App = () => {
  const [routes, setRoutes] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 1. 登录逻辑,获取 Token 和用户信息
    const initApp = async () => {
      const token = localStorage.getItem('token');
      if (!token) {
        setLoading(false);
        return;
      }

      // 2. 获取动态菜单
      const { menus, roles } = await getMenusAndRoles();

      // 3. 生成路由
      setRoutes(generateRoutes(menus));
      setLoading(false);
    };

    initApp();
  }, []);

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<Login />} />

        {/* 只有当 routes 有值时,才渲染 Layout */}
        {routes.length > 0 && (
          <Route element={<LayoutRoute />}>
            {routes.map(route => (
              <Route key={route.path} path={route.path} element={route.element} />
            ))}
          </Route>
        )}

        <Route path="*" element={<Navigate to="/dashboard" replace />} />
      </Routes>
    </BrowserRouter>
  );
};

第八部分:常见“坑”与避坑指南

作为专家,我得给你们提个醒。实现这个功能,90% 的人都能写出来,但只有 10% 的人能用好。

坑 1:循环依赖
lazy 导入组件时,如果组件里又 import 了 request 或者 store,很容易出现循环引用。解决办法是把 requeststore 放在顶层导出,或者使用 React.lazy 包裹时不要在同一个文件里写复杂的逻辑。

坑 2:子菜单路径拼接错误
后端返回的子菜单路径可能是 /properties/list,也可能是 list。在 React Router v6 中,如果你在父路由写 path="/properties",子路由写 path="list",它会自动拼接成 /properties/list
但是,如果你在父路由写 path="/properties",子路由写 path="/list",那它就会变成 /list,导致 404。
解决方法: 前端在渲染路由前,统一规范后端返回的路径格式,确保相对路径(不带 /)的一致性。

坑 3:刷新页面丢失路由状态
如果你把路由生成的逻辑写在 useEffect 里,刷新页面后,由于 useEffect 重新执行,routes 数组会被重置,导致页面空白。
解决方法:menus 数据持久化到 localStorage,在 useEffect 里优先读取 localStorage

坑 4:路由守卫和动态菜单的冲突
如果你在 GuardRoute 里做了路由校验,那 generateRoutes 里的 filter 逻辑是不是多余了?
回答: 不多余。generateRoutes 是为了性能,只渲染用户看得到的东西。GuardRoute 是为了安全,防止有人直接访问 URL。两者配合才是完美。


结语

好了,各位,今天的讲座就到这里。我们今天从RBAC 的数据模型讲到了动态路由的递归生成,又深入到了React 的懒加载与路由守卫

这套机制就像是给我们的房东管理平台穿上了一层防弹衣。前台(菜单)让你看你想看的,后台(API)只给你能用的,而路由守卫则是那个死守门口的保安,谁也别想硬闯。

记住,动态菜单不是简单的循环渲染,它是数据与视图的桥梁;权限控制不是简单的 if/else,它是业务安全的基石。 希望大家在自己的项目中,能灵活运用这些技巧,打造出让甲方爸爸挑不出毛病、让黑客无从下手的完美系统!

下次见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注