likes
comments
collection
share

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

作者站长头像
站长
· 阅读数 16

往期回顾

前端框架搭建——从零开始搭建一个高颜值后台管理系统全栈框架(一)

后端框架搭建——从零开始搭建一个高颜值后台管理系统全栈框架(二)

实现登录功能jwt or token+redis?——从零开始搭建一个高颜值后台管理系统全栈框架(三)

封装axios,让请求变得丝滑——从零开始搭建一个高颜值后台管理系统全栈框架(四)

实现前后端全自动化部署,解放你的双手。——从零开始搭建一个高颜值后台管理系统全栈框架(五)

雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

基于react-router v6实现动态菜单、动态路由。内含vue动态路由实现。——从零开始搭建一个高颜值后台管理系统全栈框架(七)

前言

上一篇已经把动态路由实现方案写出来,这一篇主要就是在项目中实战了,实现一个企业级菜单路由权限方案。

友情提醒:因为上一篇原理已经说过,所以这一篇有很多代码。

RBAC是什么?

我这个菜单路由权限控制方案是基于RBAC模型实现的,下面给大家介绍一下什么是RBAC。

RBAC(Role-Based Access Control)模型是一种用于访问控制的权限管理模型。在 RBAC 模型中,权限的分配和管理是基于角色进行的。

RBAC 模型包含以下几个核心概念:

  1. 用户(User):用户是实际使用系统的人员或实体。每个用户都可以关联到一个或多个角色。
  2. 角色(Role):角色代表了一组具有相似权限需求的用户。每个用户可以被分配一个或多个角色,并通过角色来确定其拥有的权限。
  3. 权限(Permission):权限指定了对系统资源进行操作的能力。它们定义了用户在系统中可以执行的动作或访问的资源范围。系统中的菜单、接口、按钮都可以抽象为资源。

在 RBAC 模型中,管理员为每个角色分配适当的权限,然后将角色与用户关联起来,从而控制用户对系统资源的访问。这种角色与权限之间的层次结构和关系,使得权限管理更加灵活和可维护。

RBAC 模型的优点包括简化权限管理、减少错误和滥用风险、提高系统安全性和可伸缩性等。目前很多后台管理系统都是基于这个模型实现资源访问控制,RBAC1模型、RBAC2模型、RBAC3模型也都是基于这个改造和升级的。

前端实现动态菜单、动态路由

前言

按正常步骤来说应该先讲菜单、角色、用户配置功能,但是这一块代码比较多,并且大家可能只对前端实现动态路由感兴趣,所以给这一块放到最前面来讲,对后面配置不感兴趣的可以直接跳过。

查询当前用户菜单数据接口实现

改造获取用户信息方法,把菜单信息也返回,这里的菜单数据是打平的,前端自己构造成树形结构。 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

用户信息数据结构

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

菜单数据结构

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

前端实现

新增router组件

// src/router.tsx
import { RouteObject, RouterProvider, createBrowserRouter } from 'react-router-dom';

import Login from './pages/login';
import BasicLayout from './layouts';
import { App } from 'antd';
import { useEffect } from 'react';
import { antdUtils } from './utils/antd';
import ResetPassword from './pages/login/reset-password';

export const router = createBrowserRouter(
  [
    {
      path: '/user/login',
      Component: Login,
    },
    {
      path: '/user/reset-password',
      Component: ResetPassword,
    },
    {
      path: '*',
      Component: BasicLayout,
      children: []
    },
  ]
);

function findNodeByPath(routes: RouteObject[], path: string) {
  for (let i = 0; i < routes.length; i += 1) {
    const element = routes[i];

    if (element.path === path) return element;

    findNodeByPath(element.children || [], path);
  }
}

export const addRoutes = (parentPath: string, routes: RouteObject[]) => {
  if (!parentPath) {
    router.routes.push(...routes as any);
    return;
  }

  const curNode = findNodeByPath(router.routes, parentPath);

  if (curNode?.children) {
    curNode?.children.push(...routes);
  } else if (curNode) {
    curNode.children = routes;
  }
}

export const replaceRoutes = (parentPath: string, routes: RouteObject[]) => {
  if (!parentPath) {
    router.routes.push(...routes as any);
    return;
  }

  const curNode = findNodeByPath(router.routes, parentPath);

  if (curNode) {
    curNode.children = routes;
  }
}


const Router = () => {
  const { notification, message, modal } = App.useApp();

  useEffect(() => {
    antdUtils.setMessageInstance(message);
    antdUtils.setNotificationInstance(notification);
    antdUtils.setModalInstance(modal);
  }, [notification, message, modal]);

  return (
    <RouterProvider router={router} />
  )
};

export default Router;

模仿vue的router封装一个动态添加和替换路由的方法,下面我使用的是替换路由方法,因为退出登录时不用清除已添加的路由了。

在layout组件中动态添加路由

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

上面代码是获取到用户信息后执行的。

先把后端返回的打平的菜单数据构造成树形结构,这里把一维数组转换为属性结构,使用了一个小技巧,先把数据按照父级id分组,在过去当前子级时,把当前id传进去就行了,不用每次都遍历全部数组获取子级,算是一个小性能优化,空间换时间。

动态添加路由有几个需要注意的地方:

  • 嵌套路由的情况,类似于列表页和详情页,这时候虽然菜单上配的是详情页是列表页的子级,但是路由不能生成这样的上下级结构,不然渲染详情的时候,会显示列表页的组件内容,所以在构造树形结构时随便生成了一个一维的路由数组。 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

  • 手动添加动态路由时必须手动设置id属性,不然组件中使用useNavigatehooks会报错,被这个问题卡了很久,后来看react-router源码才发现的。 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

  • 动态添加完路由必须手动replace一下当前路由,不然不会触发重新匹配,会显示404。 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

  • 往路由里加动态属性可以使用handle 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

实现动态菜单

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Menu } from 'antd';
import type { ItemType } from 'antd/es/menu/hooks/useItems';
import { Link, useMatches } from 'react-router-dom';

import { useGlobalStore } from '@/stores/global';
import { useUserStore } from '@/stores/global/user';
import { antdIcons } from '@/assets/antd-icons';
import { Menu as MenuType } from '@/pages/user/service';

const SlideMenu = () => {

  const matches = useMatches();

  const [openKeys, setOpenKeys] = useState<string[]>([]);
  const [selectKeys, setSelectKeys] = useState<string[]>([]);

  const {
    collapsed,
  } = useGlobalStore();

  const {
    currentUser,
  } = useUserStore();

  useEffect(() => {
    if (collapsed) {
      setOpenKeys([]);
    } else {
      const [match] = matches || [];
      if (match) {
        // 获取当前匹配的路由,默认为最后一个
        const route = matches.at(-1);
        // 从匹配的路由中取出自定义参数
        const handle = route?.handle as any;
        // 从自定义参数中取出上级path,让菜单自动展开
        setOpenKeys(handle?.parentPaths || []);
        // 让当前菜单和所有上级菜单高亮显示
        setSelectKeys([...(handle?.parentPaths || []), handle?.path] || []);
      }
    }
  }, [
    matches,
    collapsed,
  ]);

  const getMenuTitle = (menu: MenuType) => {
    if (menu?.children?.filter(menu => menu.show)?.length) {
      return menu.name;
    }
    return (
      <Link to={menu.path}>{menu.name}</Link>
    );
  }

  const treeMenuData = useCallback((menus: MenuType[]): ItemType[] => {
    return (menus)
      .map((menu: MenuType) => {
        const children = menu?.children?.filter(menu => menu.show) || [];
        return {
          key: menu.path,
          label: getMenuTitle(menu),
          icon: menu.icon && antdIcons[menu.icon] && React.createElement(antdIcons[menu.icon]),
          children: children.length ? treeMenuData(children || []) : null,
        };
      })
  }, []);

  const menuData = useMemo(() => {
    return treeMenuData(currentUser?.menus?.filter(menu => menu.show) || []);
  }, [currentUser]);

  return (
    <Menu
      className='bg-primary color-transition'
      mode="inline"
      selectedKeys={selectKeys}
      style={{ height: '100%', borderRight: 0 }}
      items={menuData}
      inlineCollapsed={collapsed}
      openKeys={openKeys}
      onOpenChange={setOpenKeys}
    />
  )
}

export default SlideMenu;

这里把我们上面构造的菜单数据,转换为antd的Menu组件的数据结构。有个需要说明的地方,通过匹配到的路由自动展开对应菜单和高亮显示,上面代码中有注释。

详情页面实现方案

上篇文章有位兄弟和我讨论了一下关于详情页的路由方案,我的实现方案是把详情页设置为隐藏,这样在菜单中就看不到了,使用代码可以正常的跳转。他觉得详情页还需要配置到后端,有点麻烦,我个人觉得无论在前端配还是在线配都需要配一遍,并且配在远程,还可以控制权限,比如想让某个角色只拥有列表页权限,没有详情页权限。

实现菜单、角色、用户配置功能

菜单增删改查后端接口实现

菜单模型

// src/module/menu/entity/menu.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';

@Entity('sys_menu')
export class MenuEntity extends BaseEntity {
  @Column({ comment: '上级id', nullable: true })
  parentId?: string;
  @Column({ comment: '名称' })
  name?: string;
  @Column({ comment: '图标', nullable: true })
  icon?: string;
  @Column({ comment: '类型,1:目录 2:菜单' })
  type?: number;
  @Column({ comment: '路由' })
  route?: string;
  @Column({ comment: '本地组件地址', nullable: true })
  filePath?: string;
  @Column({ comment: '排序号' })
  orderNumber?: number;
  @Column({ comment: 'url', nullable: true })
  url?: string;
  @Column({ comment: '是否在菜单中显示' })
  show?: boolean;
}

菜单Service实现

// src/module/menu/service/menu.ts
import { Provide } from '@midwayjs/decorator';
import { DataSource, FindOptionsOrder, IsNull } from 'typeorm';
import { InjectDataSource, InjectEntityModel } from '@midwayjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { BaseService } from '../../../common/base.service';
import { MenuEntity } from '../entity/menu';
import { R } from '../../../common/base.error.util';
import { MenuInterfaceEntity } from '../entity/menu.interface';
import { MenuDTO } from '../dto/menu';

@Provide()
export class MenuService extends BaseService<MenuEntity> {
  @InjectEntityModel(MenuEntity)
  menuModel: Repository<MenuEntity>;
  @InjectEntityModel(MenuInterfaceEntity)
  menuInterfaceModel: Repository<MenuInterfaceEntity>;
  @InjectDataSource()
  defaultDataSource: DataSource;

  getModel(): Repository<MenuEntity> {
    return this.menuModel;
  }

  async createMenu(data: MenuDTO) {
    if ((await this.menuModel.countBy({ route: data.route })) > 0) {
      throw R.error('路由不能重复');
    }

    return await this.create(data.toEntity());
  }

  async page(
    page: number,
    pageSize: number,
    where?: FindOptionsWhere<MenuEntity>
  ) {
    if (where) {
      where.parentId = IsNull();
    } else {
      where = { parentId: IsNull() };
    }

    const order: FindOptionsOrder<MenuEntity> = { orderNumber: 'ASC' };

    const [data, total] = await this.menuModel.findAndCount({
      where,
      order,
      skip: page * pageSize,
      take: pageSize,
    });

    if (!data.length) return { data: [], total: 0 };

    const ids = data.map((o: MenuEntity) => o.id);
    const countMap = await this.menuModel
      .createQueryBuilder('menu')
      .select('COUNT(menu.parentId)', 'count')
      .addSelect('menu.parentId', 'id')
      .where('menu.parentId IN (:...ids)', { ids })
      .groupBy('menu.parentId')
      .getRawMany();

    const result = data.map((item: MenuEntity) => {
      const count =
        countMap.find((o: { id: string; count: number }) => o.id === item.id)
          ?.count || 0;

      return {
        ...item,
        hasChild: Number(count) > 0,
      };
    });

    return { data: result, total };
  }

  async getChildren(parentId?: string) {
    if (!parentId) {
      throw R.validateError('父节点id不能为空');
    }
    const data = await this.menuModel.find({
      where: { parentId: parentId },
      order: { orderNumber: 'ASC' },
    });
    if (!data.length) return [];

    const ids = data.map((o: any) => o.id);
    const countMap = await this.menuModel
      .createQueryBuilder('menu')
      .select('COUNT(menu.parentId)', 'count')
      .addSelect('menu.parentId', 'id')
      .where('menu.parentId IN (:...ids)', { ids })
      .groupBy('menu.parentId')
      .getRawMany();

    const result = data.map((item: any) => {
      const count = countMap.find(o => o.id === item.id)?.count || 0;
      return {
        ...item,
        hasChild: Number(count) > 0,
      };
    });

    return result;
  }

  async removeMenu(id: string) {
    await this.menuModel
      .createQueryBuilder()
      .delete()
      .where('id = :id', { id })
      .orWhere('parentId = :id', { id })
      .execute();
  }
}

普通的增删改查,没啥好说的。加了一个获取下级getChildren接口,因为前端展示树形菜单时做了按需加载,动态展示下一级,算是最简单的性能优化。

菜单增删改查前端页面实现

列表页

// src/pages/menu/index.tsx
import React, { useEffect, useState, useMemo } from 'react';
import { Button, Divider, Table, Tag, Space, TablePaginationConfig, Popconfirm } from 'antd';
import { antdUtils } from '@/utils/antd';
import { antdIcons } from '@/assets/antd-icons';
import { useRequest } from '@/hooks/use-request';

import NewAndEditForm, { MenuType } from './new-edit-form';
import menuService, { Menu } from './service';

const MenuPage: React.FC = () => {
  const [dataSource, setDataSource] = useState<Menu[]>([]);

  const [pagination, setPagination] = useState<TablePaginationConfig>({
    current: 1,
    pageSize: 10,
  });

  const [createVisible, setCreateVisible] = useState(false);
  const [parentId, setParentId] = useState<string>('');
  const [expandedRowKeys, setExpandedRowKeys] = useState<readonly React.Key[]>([]);
  const [curRowData, setCurRowData] = useState<Menu>();
  const [editData, setEditData] = useState<null | Menu>(null);

  const { loading, runAsync: getMenusByPage } = useRequest(menuService.getMenusByPage, { manual: true });

  const getMenus = async () => {
    const { current, pageSize } = pagination || {};

    const [error, data] = await getMenusByPage({
      current,
      pageSize,
    });

    if (!error) {
      setDataSource(
        data.data.map((item: any) => ({
          ...item,
          children: item.hasChild ? [] : null,
        })),
      );
      setPagination(prev => ({
        ...prev,
        total: data.total,
      }));
    }
  };

  const cancelHandle = () => {
    setCreateVisible(false);
    setEditData(null);
  };

  const saveHandle = () => {
    setCreateVisible(false);
    setEditData(null);
    if (!curRowData) {
      getMenus();
      setExpandedRowKeys([]);
    } else {
      curRowData._loaded_ = false;
      expandHandle(true, curRowData);
    }
  }

  const expandHandle = async (expanded: boolean, record: (Menu)) => {
    if (expanded && !record._loaded_) {
      const [error, children] = await menuService.getChildren(record.id);
      if (!error) {
        record._loaded_ = true;
        record.children = (children || []).map((o: Menu) => ({
          ...o,
          children: o.hasChild ? [] : null,
        }));
        setDataSource([...dataSource]);
      }
    }
  };

  const tabChangeHandle = (tablePagination: TablePaginationConfig) => {
    setPagination(tablePagination);
  }

  useEffect(() => {
    getMenus();
  }, [
    pagination.size,
    pagination.current,
  ]);

  const columns: any[] = useMemo(
    () => [
      {
        title: '名称',
        dataIndex: 'name',
        width: 300,
      },
      {
        title: '类型',
        dataIndex: 'type',
        align: 'center',
        width: 100,
        render: (value: number) => (
          <Tag color="processing">{value === MenuType.DIRECTORY ? '目录' : '菜单'}</Tag>
        ),
      },
      {
        title: '图标',
        align: 'center',
        width: 100,
        dataIndex: 'icon',
        render: value => antdIcons[value] && React.createElement(antdIcons[value])
      },
      {
        title: '路由',
        dataIndex: 'router',
      },
      {
        title: 'url',
        dataIndex: 'url',
      },
      {
        title: '文件地址',
        dataIndex: 'filePath',
      },
      {
        title: '排序号',
        dataIndex: 'orderNumber',
        width: 100,
      },
      {
        title: '操作',
        dataIndex: 'id',
        align: 'center',
        width: 200,
        render: (value: string, record: Menu) => {
          return (
            <Space
              split={(
                <Divider type='vertical' />
              )}
            >
              <a
                onClick={() => {
                  setParentId(value);
                  setCreateVisible(true);
                  setCurRowData(record);
                }}
              >
                添加
              </a>
              <a
                onClick={() => {
                  setEditData(record);
                  setCreateVisible(true);
                }}
              >
                编辑
              </a>
              <Popconfirm
                title="是否删除?"
                onConfirm={async () => {
                  const [error] = await menuService.removeMenu(value);

                  if (!error) {
                    antdUtils.message?.success('删除成功');
                    getMenus();
                    setExpandedRowKeys([]);
                  }
                }}
                placement='topRight'
              >
                <a>删除</a>
              </Popconfirm>
            </Space>
          );
        },
      },
    ],
    [],
  );

  return (
    <div>
      <Button
        className="mb-[12px]"
        type="primary"
        onClick={() => {
          setCreateVisible(true);
        }}
      >
        新建
      </Button>
      <Table
        columns={columns}
        dataSource={dataSource}
        rowKey="id"
        loading={loading}
        pagination={pagination}
        onChange={tabChangeHandle}
        tableLayout="fixed"
        expandable={{
          rowExpandable: () => true,
          onExpand: expandHandle,
          expandedRowKeys,
          onExpandedRowsChange: (rowKeys) => {
            setExpandedRowKeys(rowKeys);
          },
        }}
      />
      <NewAndEditForm
        onSave={saveHandle}
        onCancel={cancelHandle}
        visible={createVisible}
        parentId={parentId}
        editData={editData}
      />
    </div>
  );
};

export default MenuPage;
  • 上面功能,主要使用了antdTree组件,展开下一级时,判断是否已经加载过了,如果没有加载则调接口查询下一级。

  • 动态显示@ant-design/icons里图标,写了一个简单脚本把@ant-design/icons的所有图标生成出来,创建一个name和组件的map,然后使用createElement根据name动态渲染图标组件。这样做有个缺点,就是打包的时候会把没用的图标也打包进去,会导致包的体积变大,这个后面在做打包优化的时候再详细讲解怎么去优化。

  • 上面代码中我没有使用useCallBackuseMemo,我建议业务代码中能不用这两个hooks做优化就不用,真出了性能问题的时候,再去优化,要相信react的diff算法。

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八) antdIcons文件 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八) 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

新增和编辑表单组件

// src/pages/menu/new-edit-form.tsx
import React, { useEffect, useState } from 'react'
import { Modal, Form, Input, Switch, Radio, InputNumber, Select } from 'antd'
import { componentPaths } from '@/config/routes';
import { antdIcons } from '@/assets/antd-icons';
import menuService, { Menu } from './service';
import { antdUtils } from '@/utils/antd';

interface CreateMemuProps {
  visible: boolean;
  onCancel: (flag?: boolean) => void;
  parentId?: string;
  onSave: () => void;
  editData?: Menu | null;
}

export enum MenuType {
  DIRECTORY = 1,
  MENU,
  BUTTON,
}

const CreateMenu: React.FC<CreateMemuProps> = (props) => {

  const { visible, onCancel, parentId, onSave, editData } = props;
  const [saveLoading, setSaveLoading] = useState(false);
  const [form] = Form.useForm();

  useEffect(() => {
    if (visible) {
      if (editData) {
        form.setFieldsValue(editData);
      }
    } else {
      form.resetFields();
    }
  }, [visible]);

  const save = async (values: any) => {
    setSaveLoading(true);
    values.parentId = parentId || null;
    values.show = values.type === MenuType.DIRECTORY ? true : values.show;

    if (editData) {
      values.parentId = editData.parentId;
      const [error] = await menuService.updateMenu({ ...editData, ...values });
      if (!error) {
        antdUtils.message?.success("更新成功");
        onSave()
      }
    } else {
      const [error] = await menuService.addMenu(values);
      if (!error) {
        antdUtils.message?.success("新增成功");
        onSave()
      }
    }
    setSaveLoading(false);
  }

  return (
    <Modal
      open={visible}
      title="新建"
      onOk={() => {
        form.submit();
      }}
      confirmLoading={saveLoading}
      width={640}
      onCancel={() => {
        form.resetFields();
        onCancel();
      }}
      destroyOnClose
    >
      <Form
        form={form}
        onFinish={save}
        labelCol={{ flex: '0 0 100px' }}
        wrapperCol={{ span: 16 }}
        initialValues={{
          show: true,
          type: MenuType.DIRECTORY,
        }}
      >
        <Form.Item label="类型" name="type">
          <Radio.Group
            optionType="button"
            buttonStyle="solid"
          >
            <Radio value={MenuType.DIRECTORY}>目录</Radio>
            <Radio value={MenuType.MENU}>菜单</Radio>
          </Radio.Group>
        </Form.Item>
        <Form.Item label="名称" name="name">
          <Input />
        </Form.Item>
        <Form.Item label="图标" name="icon">
          <Select>
            {Object.keys(antdIcons).map((key) => (
              <Select.Option key={key}>{React.createElement(antdIcons[key])}</Select.Option>
            ))}
          </Select >
        </Form.Item>
        <Form.Item
          tooltip="以/开头,不用手动拼接上级路由。参数格式/:id"
          label="路由"
          name="route"
          rules={[{
            pattern: /^\//,
            message: '必须以/开头',
          }]}
        >
          <Input />
        </Form.Item>
        <Form.Item noStyle shouldUpdate>
          {() => (
            form.getFieldValue("type") === 2 && (
              <Form.Item label="文件地址" name="filePath">
                <Select
                  options={componentPaths.map(path => ({
                    label: path,
                    value: path,
                  }))}
                />
              </Form.Item>
            )
          )}
        </Form.Item>
        <Form.Item noStyle shouldUpdate>
          {() => (
            form.getFieldValue("type") === 2 && (
              <Form.Item valuePropName="checked" label="是否显示" name="show">
                <Switch />
              </Form.Item>
            )
          )}
        </Form.Item>
        <Form.Item label="排序号" name="orderNumber">
          <InputNumber />
        </Form.Item>
      </Form>
    </Modal>
  )
}

export default CreateMenu;

菜单分为两种类型,目录和菜单:

  • 目录:不能点击,可以展开。
  • 菜单:可以点击,会渲染对应的组件。

在下拉框中展示图标

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

使用下拉框展示组件地址,这里借助了上篇文章中说的import.meta.glob获取匹配到的文件地址,单独定义了一个文件,后面动态添加路由也有使用这个文件中的components属性。

// src/config/routes.tsx
export const modules = import.meta.glob('../pages/**/index.tsx');

export const componentPaths = Object.keys(modules).map((path: string) => path.replace('../pages', ''));

export const components = Object.keys(modules).reduce<Record<string, () => Promise<any>>>((prev, path: string) => {
   prev[path.replace('../pages', '')] = modules[path];
   return prev;
}, {});

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八) 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

角色增删改查后端接口实现

角色模型

import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';

@Entity('sys_role')
export class RoleEntity extends BaseEntity {
  @Column({ comment: '名称' })
  name?: string;
  @Column({ comment: '代码' })
  code?: string;
}

角色和菜单关联模型

import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';

@Entity('sys_role_menu')
export class RoleMenuEntity extends BaseEntity {
  @Column({ comment: '角色id' })
  roleId?: string;
  @Column({ comment: '菜单id' })
  menuId?: string;
}

角色service实现

import { Provide } from '@midwayjs/decorator';
import { InjectDataSource, InjectEntityModel } from '@midwayjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BaseService } from '../../../common/base.service';
import { MenuInterfaceEntity } from '../../menu/entity/menu.interface';
import { RolePageDTO } from '../dto/role.page';
import { RoleEntity } from '../entity/role';
import { RoleMenuEntity } from '../entity/role.menu';
import {
  createQueryBuilder,
  likeQueryByQueryBuilder,
} from '../../../utils/typeorm.utils';
import { RoleDTO } from '../dto/role';
import { R } from '../../../common/base.error.util';

@Provide()
export class RoleService extends BaseService<RoleEntity> {
  @InjectEntityModel(RoleEntity)
  roleModel: Repository<RoleEntity>;
  @InjectEntityModel(RoleMenuEntity)
  roleMenuModel: Repository<RoleMenuEntity>;
  @InjectEntityModel(MenuInterfaceEntity)
  menuInterfaceModel: Repository<MenuInterfaceEntity>;
  @InjectDataSource()
  defaultDataSource: DataSource;

  getModel(): Repository<RoleEntity> {
    return this.roleModel;
  }

  async createRole(data: RoleDTO) {
    if ((await this.roleModel.countBy({ code: data.code })) > 0) {
      throw R.error('代码不能重复');
    }
    this.defaultDataSource.transaction(async manager => {
      const entity = data.toEntity();
      await manager.save(RoleEntity, entity);
      const roleMenus = data.menuIds.map(menuId => {
        const roleMenu = new RoleMenuEntity();
        roleMenu.menuId = menuId;
        roleMenu.roleId = entity.id;
        return roleMenu;
      });
      if (roleMenus.length) {
        // 批量插入
        await manager
          .createQueryBuilder()
          .insert()
          .into(RoleMenuEntity)
          .values(roleMenus)
          .execute();
      }
    });
  }

  async editRole(data: RoleDTO) {
    await this.defaultDataSource.transaction(async manager => {
      const entity = data.toEntity();
      await manager.save(RoleEntity, entity);
      if (Array.isArray(data.menuIds)) {
        await manager
          .createQueryBuilder()
          .delete()
          .from(RoleMenuEntity)
          .where('roleId = :roleId', { roleId: data.id })
          .execute();

        const roleMenus = data.menuIds.map(menuId => {
          const roleMenu = new RoleMenuEntity();
          roleMenu.menuId = menuId;
          roleMenu.roleId = entity.id;
          return roleMenu;
        });
        if (roleMenus.length) {
          // 批量插入
          await manager
            .createQueryBuilder()
            .insert()
            .into(RoleMenuEntity)
            .values(roleMenus)
            .execute();
        }
      }
    });
  }

  async removeRole(id: string) {
    await this.defaultDataSource.transaction(async manager => {
      await manager
        .createQueryBuilder()
        .delete()
        .from(RoleEntity)
        .where('id = :id', { id })
        .execute();
      await manager
        .createQueryBuilder()
        .delete()
        .from(RoleMenuEntity)
        .where('roleId = :id', { id })
        .execute();
    });
  }

  async getRoleListByPage(rolePageDTO: RolePageDTO) {
    const { name, code, page, size } = rolePageDTO;
    let queryBuilder = createQueryBuilder<RoleEntity>(this.roleModel);
    queryBuilder = likeQueryByQueryBuilder(queryBuilder, {
      code,
      name,
    });

    const [data, total] = await queryBuilder
      .orderBy('createDate', 'DESC')
      .skip(page * size)
      .take(size)
      .getManyAndCount();

    return {
      total,
      data,
    };
  }

  async getMenusByRoleId(roleId: string) {
    const curRoleMenus = await this.roleMenuModel.find({
      where: { roleId: roleId },
    });
    return curRoleMenus;
  }

  async allocMenu(roleId: string, menuIds: string[]) {
    const curRoleMenus = await this.roleMenuModel.findBy({
      roleId,
    });

    const roleMenus = [];
    menuIds.forEach((menuId: string) => {
      const roleMenu = new RoleMenuEntity();
      roleMenu.menuId = menuId;
      roleMenu.roleId = roleId;
      roleMenus.push(roleMenu);
    });

    await this.defaultDataSource.transaction(async transaction => {
      await Promise.all([transaction.remove(RoleMenuEntity, curRoleMenus)]);
      await Promise.all([transaction.save(RoleMenuEntity, roleMenus)]);
    });
  }
}

给角色分配菜单使用的方法是把以前分配的菜单全部删了,然后再根据前端传过来的创建。这样做简单,但是性能可能会有问题,后面会改成增量更新。

角色增删改查前端页面实现

列表页

import { t } from '@/utils/i18n';
import {
  Space,
  Table,
  Form,
  Row,
  Col,
  Input,
  Button,
  Modal,
  FormInstance,
  Divider,
  Popconfirm,
} from 'antd';
import { useAntdTable } from 'ahooks';
import { useRef, useState } from 'react';
import { PlusOutlined } from '@ant-design/icons';

import NewAndEditForm from './new-edit-form';
import roleService, { Role } from './service';
import dayjs from 'dayjs';
import { antdUtils } from '@/utils/antd';
import RoleMenu from './role-menu';

const UserPage = () => {
  const [form] = Form.useForm();

  const {
    tableProps,
    search: { submit, reset },
  } = useAntdTable(roleService.getRoleListByPage, { form });
  const [editData, setEditData] = useState<Role | null>(null);
  const [saveLoading, setSaveLoading] = useState(false);
  const [roleMenuVisible, setRoleMenuVisible] = useState(false);
  const [curRoleId, setCurRoleId] = useState<string | null>();

  const formRef = useRef<FormInstance>(null);

  const columns: any[] = [
    {
      title: '名称',
      dataIndex: 'name',
    },
    {
      title: '代码',
      dataIndex: 'code',
      valueType: 'text',
    },
    {
      title: '创建时间',
      dataIndex: 'createDate',
      hideInForm: true,
      search: false,
      valueType: 'dateTime',
      width: 190,
      render: (value: Date) => {
        return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
      }
    },
    {
      title: '操作',
      dataIndex: 'id',
      hideInForm: true,
      width: 240,
      align: 'center',
      search: false,
      render: (id: string, record: Role) => (
        <Space
          split={(
            <Divider type='vertical' />
          )}
        >
          <a
            onClick={async () => {
              setCurRoleId(id);
              setRoleMenuVisible(true);
            }}
          >
            分配菜单
          </a>
          <a
            onClick={() => {
              setEditData(record);
              setFormOpen(true);
            }}
          >
            编辑
          </a>
          <Popconfirm
            title="确认删除?"
            onConfirm={async () => {
              const [error] = await roleService.removeRole(id);
              if (!error) {
                antdUtils.message?.success('删除成功!');
                submit();
              }
            }}
            placement="topRight"
          >
            <a className="select-none">
              删除
            </a>
          </Popconfirm>
        </Space>
      ),
    },
  ];

  const [formOpen, setFormOpen] = useState(false);

  const openForm = () => {
    setFormOpen(true);
  };

  const closeForm = () => {
    setFormOpen(false);
    setEditData(null);
  };

  const saveHandle = () => {
    submit();
    setFormOpen(false);
    setEditData(null);
  };

  return (
    <div>
      <Form
        onFinish={submit}
        form={form}
        size='large'
        className='dark:bg-[rgb(33,41,70)] bg-white p-[24px] rounded-lg'
      >
        <Row gutter={24}>
          <Col className='w-[100%]' lg={24} xl={8}>
            <Form.Item name='code' label="代码">
              <Input onPressEnter={submit} />
            </Form.Item>
          </Col>
          <Col className='w-[100%]' lg={24} xl={8}>
            <Form.Item name='name' label="名称">
              <Input onPressEnter={submit} />
            </Form.Item>
          </Col>
          <Col className='w-[100%]' lg={24} xl={8}>
            <Space>
              <Button onClick={submit} type='primary'>
                {t('YHapJMTT' /* 搜索 */)}
              </Button>
              <Button onClick={reset}>{t('uCkoPyVp' /* 清除 */)}</Button>
            </Space>
          </Col>
        </Row>
      </Form>
      <div className='mt-[16px] dark:bg-[rgb(33,41,70)] bg-white rounded-lg px-[12px]'>
        <div className='py-[16px] '>
          <Button
            onClick={openForm}
            type='primary'
            size='large'
            icon={<PlusOutlined />}
          >
            {t('morEPEyc' /* 新增 */)}
          </Button>
        </div>
        <Table
          rowKey='id'
          scroll={{ x: true }}
          columns={columns}
          className='bg-transparent'
          {...tableProps}
        />
      </div>
      <Modal
        title={editData ? t('wXpnewYo' /* 编辑 */) : t('VjwnJLPY' /* 新建 */)}
        open={formOpen}
        onOk={() => {
          formRef.current?.submit();
        }}
        destroyOnClose
        width={640}
        onCancel={closeForm}
        confirmLoading={saveLoading}
      >
        <NewAndEditForm
          ref={formRef}
          editData={editData}
          onSave={saveHandle}
          open={formOpen}
          setSaveLoading={setSaveLoading}
        />
      </Modal>
      <RoleMenu
        onCancel={() => {
          setCurRoleId(null); setRoleMenuVisible(false);
        }}
        roleId={curRoleId}
        visible={roleMenuVisible}
      />
    </div>
  );
};

export default UserPage;

普通的增删改查操作

表单组件

import { t } from '@/utils/i18n';
import { Form, Input, FormInstance } from 'antd'
import { forwardRef, useImperativeHandle, ForwardRefRenderFunction, useState } from 'react'

import roleService, { Role } from './service';
import { antdUtils } from '@/utils/antd';
import { useRequest } from '@/hooks/use-request';
import RoleMenu from './role-menu';

interface PropsType {
  open: boolean;
  editData?: Role | null;
  onSave: () => void;
  setSaveLoading: (loading: boolean) => void;
}

const NewAndEditForm: ForwardRefRenderFunction<FormInstance, PropsType> = ({
  editData,
  onSave,
  setSaveLoading,
}, ref) => {

  const [form] = Form.useForm();
  const { runAsync: updateUser } = useRequest(roleService.updateRole, { manual: true });
  const { runAsync: addUser } = useRequest(roleService.addRole, { manual: true });
  const [roleMenuVisible, setRoleMenuVisible] = useState(false);
  const [menuIds, setMenuIds] = useState<string[]>();

  useImperativeHandle(ref, () => form, [form]);

  const finishHandle = async (values: Role) => {
    setSaveLoading(true);

    if (editData) {
      const [error] = await updateUser({ ...editData, ...values, menuIds });
      setSaveLoading(false);
      if (error) {
        return;
      }
      antdUtils.message?.success(t("NfOSPWDa" /* 更新成功! */));
    } else {
      const [error] = await addUser({ ...values, menuIds });
      setSaveLoading(false);
      if (error) {
        return;
      }
      antdUtils.message?.success(t("JANFdKFM" /* 创建成功! */));
    }

    onSave();
  }

  return (
    <Form
      labelCol={{ sm: { span: 24 }, md: { span: 5 } }}
      wrapperCol={{ sm: { span: 24 }, md: { span: 16 } }}
      form={form}
      onFinish={finishHandle}
      initialValues={editData || {}}
      name='addAndEdit'
    >
      <Form.Item
        label="代码"
        name="code"
        rules={[{
          required: true,
          message: t("jwGPaPNq" /* 不能为空 */),
        }]}
      >
        <Input disabled={!!editData} />
      </Form.Item>
      <Form.Item
        label="名称"
        name="name"
        rules={[{
          required: true,
          message: t("iricpuxB" /* 不能为空 */),
        }]}
      >
        <Input />
      </Form.Item>
      <Form.Item
        label="分配菜单"
        name="menus"
      >
        <a onClick={() => { setRoleMenuVisible(true) }}>选择菜单</a>
      </Form.Item>
      <RoleMenu
        onSave={(menuIds: string[]) => {
          setMenuIds(menuIds);
          setRoleMenuVisible(false);
        }}
        visible={roleMenuVisible}
        onCancel={() => {
          setRoleMenuVisible(false);
        }}
        roleId={editData?.id}
      />
    </Form>
  )
}

export default forwardRef(NewAndEditForm);

分配菜单的组件

import React, { useEffect, useState } from 'react';
import { Modal, Spin, Tree, Radio } from 'antd';
import { antdUtils } from '@/utils/antd';
import roleService from './service';
import { Menu } from '../menu/service';
import { DataNode } from 'antd/es/tree';

interface RoleMenuProps {
  visible: boolean;
  onCancel: () => void;
  roleId?: string | null;
  onSave?: (checkedKeys: string[]) => void;
}

const RoleMenu: React.FC<RoleMenuProps> = (props) => {
  const { visible, onCancel, roleId, onSave } = props;
  const [treeData, setTreeData] = useState<DataNode[]>([]);
  const [getDataLoading, setGetDataLoading] = useState(false);
  const [checkedKeys, setCheckedKeys] = useState<string[]>([]);
  const [saveLoading, setSaveLoading] = useState(false);
  const [selectType, setSelectType] = useState('allChildren');

  const getAllChildrenKeys = (children: any[], keys: string[]): void => {
    (children || []).forEach((node) => {
      keys.push(node.key);
      getAllChildrenKeys(node.children, keys);
    });
  };

  const getFirstChildrenKeys = (children: any[], keys: string[]): void => {
    (children || []).forEach((node) => {
      keys.push(node.key);
    });
  };

  const onCheck = (_: any, { checked, node }: any) => {
    const keys = [node.key];
    if (selectType === 'allChildren') {
      getAllChildrenKeys(node.children, keys);
    } else if (selectType === 'firstChildren') {
      getFirstChildrenKeys(node.children, keys);
    }

    if (checked) {
      setCheckedKeys((prev) => [...prev, ...keys]);
    } else {
      setCheckedKeys((prev) => prev.filter((o) => !keys.includes(o)));
    }
  };

  const formatTree = (roots: Menu[] = [], group: Record<string, Menu[]>): DataNode[] => {
    return roots.map((node) => {
      return {
        key: node.id,
        title: node.name,
        children: formatTree(group[node.id] || [], group),
      } as DataNode;
    });
  };

  const getData = async () => {
    setGetDataLoading(true);
    const [error, data] = await roleService.getAllMenus();

    if (!error) {
      const group = data.reduce<Record<string, Menu[]>>((prev, cur) => {
        if (!cur.parentId) {
          return prev;
        }

        if (prev[cur.parentId]) {
          prev[cur.parentId].push(cur);
        } else {
          prev[cur.parentId] = [cur];
        }
        return prev;
      }, {});

      const roots = data.filter((o) => !o.parentId);

      const newTreeData = formatTree(roots, group);
      setTreeData(newTreeData);
    }

    setGetDataLoading(false);
  };

  const getCheckedKeys = async () => {
    if (!roleId) return;

    const [error, data] = await roleService.getRoleMenus(roleId);

    if (!error) {
      setCheckedKeys(data);
    }
  };

  const save = async () => {

    if (onSave) {
      onSave(checkedKeys);
      return;
    }

    if (!roleId) return;

    setSaveLoading(true);
    const [error] = await roleService.setRoleMenus(checkedKeys, roleId)

    setSaveLoading(false);

    if (!error) {
      antdUtils.message?.success('分配成功');
      onCancel();
    }
  };

  useEffect(() => {
    if (visible) {
      getData();
      getCheckedKeys();
    } else {
      setCheckedKeys([]);
    }
  }, [visible]);

  return (
    <Modal
      open={visible}
      title="分配菜单"
      onCancel={() => {
        onCancel();
      }}
      width={640}
      onOk={save}
      confirmLoading={saveLoading}
      bodyStyle={{ height: 400, overflowY: 'auto', padding: '20px 0' }}
    >
      {getDataLoading ? (
        <Spin />
      ) : (
        <div>
          <label>选择类型:</label>
          <Radio.Group
            onChange={(e) => setSelectType(e.target.value)}
            defaultValue="allChildren"
            optionType="button"
            buttonStyle="solid"
          >
            <Radio value="allChildren">所有子级</Radio>
            <Radio value="current">当前</Radio>
            <Radio value="firstChildren">一级子级</Radio>
          </Radio.Group>
          <div className="mt-16px">
            <Tree
              checkable
              onCheck={onCheck}
              treeData={treeData}
              checkedKeys={checkedKeys}
              checkStrictly
              className='py-[10px]'
            />
          </div>
        </div>
      )}
    </Modal>
  );
};

export default RoleMenu;

选择类型分为三种情况:

  • 所有子级,上下级有联动,选中上级会把下级也勾选。
  • 当前:上下级没有联动
  • 一级子级:只联动一级,只选中当前一级的下级。 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

用户分配角色

改造新建用户接口,新建用户时把用户分配的角色保存起来。编辑用户是,把已分配的角色先删掉,然后再重新分配 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八) 前端新增选择角色的下拉框 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八) 通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

效果展示

我们可以使用useNavigate()跳转路由

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

使用useParams获取路由参数,使用useSearchParams获取query参数

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

多级菜单配置

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

多级菜单展示

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

没有参数的详情页

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)

有参数的详情页

通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八) 欢迎大家访问fluxyadmin.cn体验和测试

总结

这篇代码偏多,主要原理上一篇已经写过了,所以这一篇主要都是实现。下一篇写按钮权限控制。

项目体验地址:fluxyadmin.cn/user/login

前端仓库地址:github.com/dbfu/fluxy-…

后端仓库地址:github.com/dbfu/fluxy-…

转载自:https://juejin.cn/post/7253736317934567481
评论
请登录