前言
做管理系统必定绕不开权限管理这一块,本文详细介绍了对路由权限,接口权限,菜单栏权限,动态路由设置,按钮权限五个模块。
一、后端设计
设计思路:
在用户登录时,将token,用户等级以及菜单栏与按钮权限映射关系返回给前端;
token用来判别前端是否登录,用户等级决定前端动态路由,菜单栏与按钮权限映射关系决定前端菜单栏展示,权限按钮展示。
1. 搭建node服务
-
新建server文件夹
-
在终端中运行
npm init -y
- 安装express
npm i express -s
- 新建app.js
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send("Hello World");
});
app.listen(4000, () => {
console.log("server is running");
});
- 安装nodemon
npm i nodemon -g
- 修改server/package.json
"scripts": {
"dev": "nodemon app.js"
},
运行:npm run dev 访问:127.0.0.1:4000,可以看到页面显示“Hello World”
2. 配置路由
1. 新建routes.js
const express = require("express");
const router = express.Router();
let accessToken = "init_s_token"; //定义token
let role = ""; //定义用户等级
let premissions = {}; //定义菜单列表和按钮权限
/* 5s刷新一次token */
setInterval(() => {
accessToken = "s_tk" + Math.random();
}, 5000);
router.get("/login", function (req, res) {
const { name } = req.query;
switch (name) {
case "admin":
role = "admin";
//管理员能看到首页,声明页和管理页
premissions = {
"/home": [],
"/declare": ["admin"], //声明页的按钮只有管理员可看
"/manage": [],
};
break;
default:
role = "visitor";
//游客只能看到首页,声明页
premissions = {
"/home": [],
"/declare": ["admin"],
};
break;
}
res.json({
role,
accessToken,
premissions,
});
});
router.get("/getData", function (req, res) {
let { authorization } = req.headers;
if (authorization !== accessToken) {
res.json({
returncode: 104,
info: "token过期,重新登录",
});
} else {
res.json({
code: 200,
returncode: 0,
data: { id: Math.random() },
});
}
});
module.exports = router;
2. 修改app.js
//删除
app.get("/", (req, res) => {
res.send("Hello World");
});
//新增
const routes = require("./routes");
app.use("/", routes);
3. 跨域处理
- 安装
npm i cors
- 修改app.js
//新增
const cors = require('cors');
app.use(cors());
最终app.js文件
const express = require("express");
const app = express();
const routes = require("./routes");
const cors = require('cors');
app.use(cors());
app.use("/", routes); // 注入登录路由模块
app.listen(4000, () => {
console.log("server is running");
});
二、前端设计
技术准备:
1. 定义使用到的常量
- 新建config/constant.ts
//localStorage存储字段
export const ACCESS_TOKEN = "tk"; //存token
export const ROLE = 'role'; //存用户等级
export const PREMISSIONS = 'premissions';//存菜单列表和按钮权限
//HTTP请求头字段
export const AUTH = "authorization";
- 新建config/returnCodeMap.ts
//接口状态码
export const CODE_RELOGIN = 104;// 需重新登陆
- 新建config/menus.ts
const menus = [
{
key: "/home",
name: "首页",
},
{
key: "/declare",
name: "说明页",
},
{
key: "/manage",
name: "管理页",
},
];
export default menus;
2. 安装axios
- 安装
yarn add axios
- 新建utils/axios.ts
//axios服务
import axios from "axios";
const service = axios.create({
baseURL: "//127.0.0.1:4000",
timeout: 30000,
});
export default service;
- 新建apis/index.ts
//定义接口
import service from "../utils/axios";
/* 登录接口 */
export const getLogin = (params: { name: string; password: string }) => {
return service.get("/login", { params: params });
};
/* 获取应用数据接口 */
export const getData = () => {
return service.get("/getData");
};
3. 调整目录结构
一个管理系统需要登录页,登录之后使用嵌套路由,layout用来布局,展示左侧菜单栏和头部用户信息,右侧用来展示页面内容,这样在路由切换时,菜单栏和头部可以保持不变
在src目录下,新建pages/login.tsx,pages/home.tsx,pages/declare.tsx,pages/manage.tsx(管理员才能访问的页面),layout.tsx
4. 调整路由
- 安装路由
yarn add react-router-dom
- 新建components/lazyComponent.tsx
import { Suspense, LazyExoticComponent } from "react";
export default function LazyComponent(props: { lazyChildren: LazyExoticComponent<() => JSX.Element> }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<props.lazyChildren />
</Suspense>
);
}
- 新建router/routes.tsx
import { lazy } from 'react';
import { Navigate } from "react-router-dom";
import LazyComponent from '../components/lazyComponent';
const routes = [
{
path: '/login',
element: <LazyComponent lazyChildren={lazy(() => import('../pages/login'))} />,
},
{
path: '/',
element: <LazyComponent lazyChildren={lazy(() => import('../layout'))} />,
children: [
{
index: true,
element: <Navigate to={'/home'} replace={true} />,
},
{
path: '/home',
element: <LazyComponent lazyChildren={lazy(() => import('../pages/home'))} />,
},
{
path: '/declare',
element: <LazyComponent lazyChildren={lazy(() => import('../pages/declare'))} />,
},
{
path: '/manage',
element: <LazyComponent lazyChildren={lazy(() => import('../pages/manage'))} />,
},
],
},
];
export default routes;
- 新建router/index.ts
import { createBrowserRouter } from 'react-router-dom';
import routes from './routes';
const router = createBrowserRouter(routes);
export default router;
- 修改main.tsx
import { RouterProvider } from 'react-router-dom';
import ReactDOM from 'react-dom/client';
import router from './router';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<RouterProvider router={router} />);
1. 路由权限设置
情景:当用户没有登录,直接访问页面时,重定向到登录页登录
思路:在布局路由loader钩子中,增加鉴权功能
新建router/loader.ts
import { redirect } from 'react-router-dom';
import { ACCESS_TOKEN } from '../config/constant';
export function protectedLoader() {
//没有登录,跳转到登录页
if (!localStorage.getItem(ACCESS_TOKEN)) {
return redirect('/login');
}
return null;
}
修改router/routes.tsx
import { protectedLoader } from "./loader";
const routes = [
{
path: "/",
loader: protectedLoader, //新增
element: <LazyComponent lazyChildren={lazy(() => import("../layout"))} />,
//...
},
];
2. 接口权限设置
情景:当token过期时,需用户重新登录
思路:在请求拦截器中,将token添加到请求头中;在响应拦截器中,判断状态码决定是否跳转到登录页
1. 增加请求拦截器和响应拦截器
修改utils/axios.ts
//axios服务
import axios from "axios";
//新增
import { CODE_RELOGIN } from '../config/returnCodeMap';
import { ACCESS_TOKEN, AUTH } from '../config/constant';
const service = axios.create({
baseURL: "//127.0.0.1:4000",
timeout: 30000,
});
service.interceptors.request.use(
(config) => {
const tk = localStorage.getItem(ACCESS_TOKEN);
tk &&
Object.assign(config.headers, {
[AUTH]: tk,
});
return config;
},
(error) => {
return Promise.reject(error);
},
);
service.interceptors.response.use(
(res) => {
if (res.data.returncode === CODE_RELOGIN) {
window.location.href = '/login'
}
return res;
},
(error) => {
return Promise.reject(error);
},
);
export default service;
2. 登录页
修改pages/login.tsx
import { useState } from "react";
import { useNavigate } from 'react-router-dom';
import { getLogin } from "../apis";
import { ACCESS_TOKEN, ROLE, PREMISSIONS } from "../config/constant";
export default function Login() {
const [user, setUser] = useState({ name: "", password: "" });
const navigation = useNavigate();
const sumbit = () => {
getLogin(user).then((res) => {
localStorage.setItem(ACCESS_TOKEN, res.data.accessToken);
localStorage.setItem(ROLE, res.data.role);
localStorage.setItem(PREMISSIONS, JSON.stringify(res.data.premissions));
navigation("/home");
});
};
return (
<>
<div style={{ height: 170, marginTop: 60, textAlign: "center" }}>XXXX管理系统</div>
<div style={{ textAlign: "center" }}>
姓名:
<input onChange={(e) => setUser((prev) => ({ ...prev, name: e.target.value }))} />
<br />
密码:
<input onChange={(e) => setUser((prev) => ({ ...prev, password: e.target.value }))} />
<br />
<button onClick={sumbit}>提交</button>
</div>
</>
);
}
3. 首页
修改pages/home.tsx
import { getData } from "../apis";
export default function Home() {
return (
<div>
Home页面
<button onClick={getData}>提交</button>
</div>
);
}
3. 菜单栏权限设置
情景:不同级别用户看到不同菜单栏
思路:前端通过返回的菜单栏列表,去封装一个新的菜单栏数组
1. 新建layout.css
.layout {
height: 100vh;
}
header {
background: #f4f4f5;
height: 70px;
}
main {
display: flex;
height: 100%;
}
aside {
width: 150px;
background: gray;
height: 100%;
}
aside .active {
color: red;
}
article {
flex: 1;
}
2. 修改layout.tsx
import { Outlet, NavLink, useNavigate } from "react-router-dom";
import { ACCESS_TOKEN, ROLE, PREMISSIONS } from "./config/constant";
import menus from "./config/menus";
import { useMemo } from "react";
import "./layout.css";
export default function Layout() {
const navigation = useNavigate();
const newMenus = useMemo(() => {
//获取菜单栏和按钮权限信息
const premissions = JSON.parse(localStorage.getItem(PREMISSIONS) || "{}");
//获取菜单栏权限信息
const keys = Object.keys(premissions);
return menus.filter((o) => keys.includes(o.key));
}, []);
const exit = () => {
navigation("/login");
//退出登陆时,清除存储信息
localStorage.removeItem(ACCESS_TOKEN);
localStorage.removeItem(ROLE);
localStorage.removeItem(PREMISSIONS);
};
return (
<div className="layout">
<header>
<button style={{ float: "right" }} onClick={exit}>
退出
</button>
</header>
<main>
<aside>
{newMenus.map((o) => (
<NavLink to={o.key} key={o.key} style={{ display: "block" }}>
{o.name}
</NavLink>
))}
</aside>
<article>
<Outlet />
</article>
</main>
</div>
);
}
这时通过用户名为 admin 的账户登录能看到三个菜单,其他用户只能看到两个
4. 页面权限设置
情景:管理员能访问管理页面路由,非管理员访问管理页面路由重定向到首页
思路:在页面路由loader钩子中,增加鉴权功能
1. 管理员页面
修改pages/manage.tsx
export default function Manage() {
return <div>管理员才能看到的页面</div>;
}
2. 无权限提示页面
新建pages/nopermission.tsx
export default function Nopermission() {
return <div>权限不够</div>;
}
3. 修改router/loader.ts
//...
//新增
import { ACCESS_TOKEN,ROLE } from '../config/constant';
export function manageLoader(){
//权限不够,跳转到无权限提示页
if(localStorage.getItem(ROLE) !=='admin'){
return redirect('/nopermission');
}
return null;
}
4. 修改router/routes.tsx
import { protectedLoader, manageLoader } from "./loader";
const routes = [
{
path: "/",
loader: protectedLoader,
element: <LazyComponent lazyChildren={lazy(() => import("../layout"))} />,
children: [
//...
{
path: "/manage",
loader: manageLoader, //新增
element: <LazyComponent lazyChildren={lazy(() => import("../pages/manage"))} />,
},
{
path: "/nopermission",//新增
element: <LazyComponent lazyChildren={lazy(() => import("../pages/nopermission"))} />,
}
],
},
];
5. 按钮权限设置
情景:根据不同的用户,一些页面功能进行显示或者隐藏
思路:使用高阶组件检测用户权限
1. 新建components/authButton
import { memo, ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { ROLE, PREMISSIONS } from "../config/constant";
export default memo(function AuthButton({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
//获取菜单栏和按钮权限信息
const premissions = JSON.parse(localStorage.getItem(PREMISSIONS) || "{}");
//获取按钮权限信息
const auth = premissions[pathname];
if (!auth.includes(localStorage.getItem(ROLE))) return null;
return <>{children}</>;
});
2. 修改pages/declare.tsx
import AuthButton from '../components/authButton'
export default function Declare(){
return <div>说明页
<AuthButton>
<button>管理员按钮</button>
</AuthButton>
</div>
}
结尾
效果演示:
如果有更好的思路,欢迎大家在评论区留言