上一篇已经把动态路由实现方案写出来,这一篇主要就是在项目中实战了,实现一个企业级菜单路由权限方案。
友情提醒:因为上一篇原理已经说过,所以这一篇有很多代码。
我这个菜单路由权限控制方案是基于RBAC模型实现的,下面给大家介绍一下什么是RBAC。
RBAC(Role-Based Access Control)模型是一种用于访问控制的权限管理模型。在 RBAC 模型中,权限的分配和管理是基于角色进行的。
RBAC 模型包含以下几个核心概念:
在 RBAC 模型中,管理员为每个角色分配适当的权限,然后将角色与用户关联起来,从而控制用户对系统资源的访问。这种角色与权限之间的层次结构和关系,使得权限管理更加灵活和可维护。
RBAC 模型的优点包括简化权限管理、减少错误和滥用风险、提高系统安全性和可伸缩性等。目前很多后台管理系统都是基于这个模型实现资源访问控制,RBAC1模型、RBAC2模型、RBAC3模型也都是基于这个改造和升级的。
按正常步骤来说应该先讲菜单、角色、用户配置功能,但是这一块代码比较多,并且大家可能只对前端实现动态路由感兴趣,所以给这一块放到最前面来讲,对后面配置不感兴趣的可以直接跳过。
改造获取用户信息方法,把菜单信息也返回,这里的菜单数据是打平的,前端自己构造成树形结构。用户信息数据结构
菜单数据结构
// 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封装一个动态添加和替换路由的方法,下面我使用的是替换路由方法,因为退出登录时不用清除已添加的路由了。
上面代码是获取到用户信息后执行的。
先把后端返回的打平的菜单数据构造成树形结构,这里把一维数组转换为属性结构,使用了一个小技巧,先把数据按照父级id分组,在过去当前子级时,把当前id传进去就行了,不用每次都遍历全部数组获取子级,算是一个小性能优化,空间换时间。
动态添加路由有几个需要注意的地方:
useNavigatehooks会报错,被这个问题卡了很久,后来看react-router源码才发现的。
动态添加完路由必须手动replace一下当前路由,不然不会触发重新匹配,会显示404。
往路由里加动态属性可以使用handle
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;
}
// 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;
antdIcons文件
// 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;
菜单分为两种类型,目录和菜单:
在下拉框中展示图标
使用下拉框展示组件地址,这里借助了上篇文章中说的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;
}, {});
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;
}
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;
选择类型分为三种情况:
改造新建用户接口,新建用户时把用户分配的角色保存起来。编辑用户是,把已分配的角色先删掉,然后再重新分配
前端新增选择角色的下拉框
useNavigate()
跳转路由useParams
获取路由参数,使用useSearchParams
获取query参数欢迎大家访问fluxyadmin.cn体验和测试
这篇代码偏多,主要原理上一篇已经写过了,所以这一篇主要都是实现。下一篇写按钮权限控制。
阅读量:153
点赞量:0
收藏量:0