后端

说明

  • 简单说一下常见的 RBAC (Role Based Access Control) 模型, 主要是由 UserRoleResource 三个表组成, 如图所示:

  • User 表存储用户信息, Role 表存储角色信息, Resource 表存储资源信息, UserRole 是多对多的关系, RoleResource 是多对多的关系, 通过 UserRoleRoleResource 两张中间表来实现多对多的关系, UserRole 表存储 UserRole 的关系, RoleResource 表存储 RoleResource 的关系

流程

  • 用户登录之后得到用户 Id user_id

  • user_role 表中 根据 user_id 得到对应的角色 role_id

  • role_resource 表中 根据 role_id 得到对应的资源 resource_id 列表

  • resource 表中 根据 resource_id 得到对应的 resource,

  • 最后组合成列表就是用户拥有的资源

示例一

  • 后端返回处理好的树形结构的数据
1
2
3
4
5
6
7
8
9
10
11
[
{ "id": 1, "name": "仪表盘", "icon": "DashboardOutlined", "menuUrl": "/dashboard", "children": [] },
{ "id": 2, "name": "系统管理", "icon": "SettingOutlined", "menuUrl": "/system",
"children": [
{ "id": 3, "name": "用户管理", "icon": "UserOutlined", "menuUrl": "/system/user", "children": [] },
{ "id": 7, "name": "角色管理", "icon": "ClusterOutlined", "menuUrl": "/system/role", "children": [] },
{ "id": 8, "name": "资源管理", "icon": "DatabaseOutlined", "menuUrl": "/system/resource", "children": [] }
]
},
{ "id": 9, "name": "日志记录", "icon": "CloudServerOutlined", "menuUrl": "/log", "children": [] }
]

示例二

  • 后端返回的是资源列表, 前端需要自己处理成树形结构
1
2
3
4
5
6
7
8
[
{ "id": 1, "pid": 0, "icon": "DashboardOutlined", "menuUrl": "/dashboard", "name": "仪表盘" },
{ "id": 2, "pid": 0, "icon": "SettingOutlined", "menuUrl": "/system", "name": "系统管理" },
{ "id": 3, "pid": 2, "icon": "UserOutlined", "menuUrl": "/system/user", "name": "用户管理" },
{ "id": 7, "pid": 2, "icon": "ClusterOutlined", "menuUrl": "/system/role", "name": "角色管理" },
{ "id": 8, "pid": 2, "icon": "DatabaseOutlined", "menuUrl": "/system/resource", "name": "资源管理" },
{ "id": 9, "pid": 0, "icon": "CloudServerOutlined", "menuUrl": "/log", "name": "日志记录" }
]
  • 处理资源列表. 前端常见的面试题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const convertToTree = (menuList, parentId = 0) => {
const result = [];

for (const menu of menuList) {
if (menu.pid === parentId) {
const children = convertToTree(menuList, menu.id);
if (children.length) {
menu.children = children;
}
result.push(menu);
}
}

return result;
}

React

示例代码

项目目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
⨽ src
⨽ apis
⨽ mock.json # 后端返回的数据
⨽ router
⨽ index.tsx # 路由相关
⨽ isAuth.tsx # 认证相关
⨽ utils
⨽ auth.ts # localStorage工具类
⨽ views # 页面
⨽ error # 错误页面
⨽ layout # 布局页面
⨽ dashboard # 首页
log # 日志页面
⨽ system # 系统管理页面
⨽ resource # 资源页面
⨽ role # 角色页面
⨽ user # 用户页面
⨽ index.tsx # 路由出口
⨽ login # 登录页面
⨽ App.tsx # 根组件
⨽ main.tsx # 入口文件

核心代码

  • main.tsx
1
2
3
4
5
6
7
8
9
10
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>

{/* react-router-dom 的 BrowserRouter 组件: 使用浏览器的内置历史记录堆栈进行导航 */}
<BrowserRouter>
<App />
</BrowserRouter>

</React.StrictMode>
);
  • App.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function App() {
const [menus, setMenus] = useState<any>([]); // 动态路由
const [route, setRoute] = useState(routers); // 所有路由
const element = useRoutes(route); // set的时候会重新渲染

const location = useLocation();

// 模拟请求: 建议使用 useSWR -- https://github.com/zxiaosi/react-springboot/blob/master/frontend/web/src/App.tsx
useEffect(() => {
const menuStorage = getLocal("menus") || []; // 这里给一个默认值,防止序列化报错

if (menuStorage?.length == 0 && location.pathname != "/login") {
setMenus(menusData); // 当本地缓存中没有菜单且当前路由不是登录页的时候, 去请求接口
} else {
setMenus(menuStorage);
}

}, [location.pathname]);

useEffect(() => {
const newRoute = generateRouter(menus);
setRoute(newRoute);
menus?.length > 0 && setLocal("menus", menus);
}, [menus]);

// <></> 组件 -- https://react.dev/reference/react/Fragment
return <>{element}</>;
}

export default App;
  • router/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import { lazy, Suspense } from "react";
import { Navigate } from "react-router-dom";
import * as Icons from "@ant-design/icons";
import React from "react";
import IsAuth from "./isAuth";
// 参考 https://juejin.cn/post/7132393527501127687

const LAYOUT_PAGE = "layout";

/** 导入指定文件下的路由模块 */
const modules = import.meta.glob("../views/**/*.tsx");
console.log("modules", modules);

/** 异步懒加载组件 */
const lazyLoad = (moduleName: string) => {
// 根据模块名匹配对应的组件
const Module = lazy(modules[`../views${moduleName}/index.tsx`] as any);

return (
<Suspense fallback={<div>loading...</div>}>
<Module />
</Suspense>
);
};

/** 动态创建Icon */
const dynamicIcon = (icon: string) => {
const antIcon: { [key: string]: any } = Icons; // 防止类型报错
return React.createElement(antIcon[icon]);
};

/** 静态路由 */
export const routers: any[] = [
{
path: "/",
element: <Navigate to="/dashboard" replace={true} />,
},
{
path: "/login",
meta: { title: "登录" },
element: lazyLoad("/login"),
},
{
path: "/",
id: LAYOUT_PAGE, // 判断是否插入动态路由的标识
element: <IsAuth>{lazyLoad("/" + LAYOUT_PAGE)}</IsAuth>,
},
{
path: "*",
meta: { title: "404" },
element: lazyLoad("/error"),
},
];

/** 生成菜单 */
export const generateMenu = (data: any) => {
const result: any = [];

data.forEach((item: any, index: number) => {
result.push({
key: item.menuUrl,
label: item.name,
icon: dynamicIcon(item.icon),
});

if (item.children.length > 0) result[index].children = generateMenu(item.children);
});

return result;
};

/** 迭代动态路由 */
const iterateRouter = (data: any) => {
const result: any[] = [];

// 动态路由
data?.forEach((item: any, index: number) => {

result.push({
path: item.menuUrl,
meta: { title: item.name, icon: item.icon },
element: lazyLoad("/" + LAYOUT_PAGE + item.menuUrl),
});

if (item.children?.length > 0) result[index].children = iterateRouter(item.children);
});


return result;
};

/** 生成路由 */
export const generateRouter = (data: any) => {
// 1. 迭代动态路由
const dynamicRouters = data ? iterateRouter(data) : [];

// 2. 合并所有路由 = 动态路由 + 静态路由
const idx = routers.findIndex((item: any) => item.id == LAYOUT_PAGE);
routers[idx].children = [...dynamicRouters];

return routers.slice(); // 注意这里要浅拷贝返回一个新的数组,否则会报错
};
  • isAuth.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Navigate } from "react-router-dom";
import { clearLocal } from "../utils/auth";

/** 判断是否登录 -- 路由拦截 */
const IsAuth = ({ children }: { children: JSX.Element }) => {
// const cookie = document.cookie; // cookie 标识
const token = localStorage.getItem("token"); // token 标识

/**
* 这里应该在响应拦截器中处理. 后端返回401状态码,清除本地缓存
* 但是这里只是模拟,所以直接清除
*/
!token && clearLocal();

return token ? children : <Navigate to={"/login"} replace />;
};

export default IsAuth;

Vue3

示例代码

项目目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
⨽ src
⨽ apis
⨽ mock.json # 后端返回的数据
⨽ router
⨽ index.tsx # 路由相关
⨽ utils
⨽ auth.ts # localStorage工具类
⨽ views # 页面
⨽ 404.vue # 错误页面
⨽ Layout.vue # 布局页面
⨽ pages
⨽ Dashboard.vue # 首页
⨽ Log.vue # 日志页面
⨽ system # 系统管理页面
⨽ Resource.vue # 资源页面
⨽ Role.vue # 角色页面
⨽ User.vue # 用户页面
⨽ Index.vue # 路由出口
⨽ Login.vue # 登录页面
⨽ App.vue # 根组件
⨽ main.ts # 入口文件

核心代码

  • main.tx
1
2
3
4
5
6
7
8
9
10
const app = createApp(App);

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}

app.use(router); // 挂载路由

app.mount("#app");
  • App.vue
1
2
3
4
<template>
<!-- 路由出口 -->
<RouterView />
</template>
  • router/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
const LayoutPage = "Layout"; // 布局页

const modules = import.meta.glob("@/views/pages/**/*.vue"); // 获取 views/home/ 下的vue文件

// 静态路由
const staticRoutes = [
{ path: "/", redirect: "/dashboard" }, // 重定向
{
path: "/login",
meta: { title: "登录" },
component: () => import("@/views/Login.vue"),
},
{
path: "/",
name: LayoutPage, // 路由名称, 用于添加动态路由 (详见: https://router.vuejs.org/zh/guide/advanced/dynamic-routing.html#%E6%B7%BB%E5%8A%A0%E5%B5%8C%E5%A5%97%E8%B7%AF%E7%94%B1)
component: () => import(`@/views/${LayoutPage}.vue`),
},
{
path: "/:pathMatch(.*)*",
meta: { title: "404" },
component: () => import("@/views/404.vue"),
},
];

/**
* 创建路由实例
*/
const router = createRouter({
history: createWebHistory(),
routes: staticRoutes,
});

/**
* 路由守卫
*/
router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {

document.title = `${to.meta.title} | Demo`; // 页面名

if (getLocal("token")) { // 检查是否存在token, 不存在则用户登录状态失效

if (to.path == "/login") {
next();
} else {
const menusStorage = getLocal("menus") || [];

// 用户已登录
if (menusStorage.length > 0) {
if (router.getRoutes().length == staticRoutes.length) { // 如果路由表中的路由数量等于静态路由的数量, 则添加动态路由 (防止重复添加动态路由)
mergeRoutes(menusStorage, router); // 从缓存中获取菜单列表
next({ ...to, replace: true }); // 确保 addRoutes 已完成
} else {
next();
}
} else {

const menusData = mockData; // 替换为真实接口
setLocal("menus", menusData); // 存储到本地缓存中
if (menusData.length > 0) {
mergeRoutes(menusData, router); // 添加动态路由
next({ ...to, replace: true });
} else {
throw new Error("菜单列表为空");
}

}

}

} else { // 用户未登录 (防止无限重定向)

if (to.path == "/login") { // 如果用户访问的是登录页, 直接放行
next();
} else { // 如果用户访问的不是登录页, 则重定向到登录页
next("/login");
}

}

});

/**
* 组装菜单列表
* @param data 菜单列表
*/
export const iterateMenu = (data: any) => {
let result: any = [];

data.forEach((item: any, index: number) => {
result.push({
path: item.menuUrl,
meta: { title: item.name, icon: item.icon },
component: modules[`/src/views/pages${uriToFileName(item.menuUrl)}.vue`],
});

if (item.children.length > 0) result[index].children = iterateMenu(item.children);
});

return result;
};

/**
* 获取路由对应的文件名 eg: /user => User | /home/user => /home/User
* @param uri 路由地址
* @returns 文件名
*/
export const uriToFileName = (uri: string) => {
let strList = uri.split("/");
let fileName = strList[strList.length - 1];
let prefix = uri.replace(fileName, "");
return `${prefix + fileName[0]?.toUpperCase() + fileName?.slice(1)}`;
};

/**
* 添加动态路由,并同步到状态管理器中
* @param menus 路由列表
* @param router 路由实例
*/
export const mergeRoutes = (menus: any, router: any) => {
// 动态路由
const dynamicMenu = iterateMenu(menus);

// 添加到指定路径下得动态路由
dynamicMenu.forEach((item: any) => router.addRoute("Layout", item));

/**
* router.getRoutes() 获取的是所有路由,扁平化输出,看不到嵌套的路由,但实际有嵌套的路由
* 详见: https://github.com/vuejs/router/issues/600
*/
// console.log("router", router.getRoutes());
}

总结

  • 其实实现思路都差不多, 都是 静态路由 + 后端返回的路由 = 动态路由

  • Vue-Router 是通过 router.beforeEach 路由守卫来实现的, 通过 addRoute 来添加动态路由

  • React-Router 是通过监听 location.pathname 路径变化实现的, 通过 useRoutes 来添加所有路由