vue3管理系统权限管理

15,080 阅读6分钟

前言

做管理系统必定绕不开权限管理这一块,本文详细介绍了对路由权限,接口权限,菜单栏权限,动态路由设置,按钮权限五个模块。

一、后端设计

设计思路:

在用户登录时,将token,用户等级以及菜单栏列表返回给前端;

token用来判别前端是否登录,用户等级决定前端动态路由,菜单栏列表决定前端菜单栏展示

1. 搭建node服务

  1. 在vue3项目中,新建一个server文件夹(与src同级)

  2. 新建一个终端,通过命令行cd server进入server文件夹

clipboard.png

  1. 运行 npm init -y 初始化packag.json包

  2. 安装koa npm i koa -s

  3. 新建server/index.js

const Koa = require("koa");
const app = new Koa();

app.use(async (ctx, next) => {
  ctx.body = "这是一个应用中间件";
  await next();
});

app.listen(4000, () => {
  console.log("server is listening on port 4000");
});

5. 安装nodemon npm i nodemon -g

  1. 修改server/package.json
  "scripts": {
    "dev": "nodemon index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

运行:npm run dev 访问:127.0.0.1:4000,可以看到页面显示这是一个应用中间件

2. 使用路由中间件

  1. 安装 npm i koa-router -S

  2. 新建server/routes/index.js

const router = require("koa-router")();
let accessToken = "init_s_token"; //定义token
let role = ""; //定义用户等级
let menus = []; //定义菜单列表

/* 5s刷新一次token */
setInterval(() => {
  accessToken = "s_tk" + Math.random();
}, 5000);

/* 登录接口获取token */
router.get("/login", async (ctx) => {
  const { name } = ctx.query;
  switch (name) {
    case "admin":
      role = "admin";
      menus = ["home", "about", "manage"]; //管理员能看到首页,说明页和管理页
      break;
    default:
      role = "visitor";
      menus = ["home", "about"]; //游客只能看到首页,说明页
      break;
  }
  ctx.body = {
    accessToken,
    role,
    menus,
  };
});

/* 获取应用数据 */
router.get("/getData", async (ctx) => {
  let { authorization } = ctx.headers;
  if (authorization !== accessToken) {
    ctx.body = {
      returncode: 104,
      info: "token过期,重新登录",
    };
  } else {
    ctx.body = {
      code: 200,
      returncode: 0,
      data: { id: Math.random() },
    };
  }
});

module.exports = router;

3. 修改server/index.js

//删除
app.use(async (ctx, next) => {
  ctx.body = "这是一个应用中间件";
  await next();
});
//新增
const index = require("./routes/index");
app.use(index.routes(), index.allowedMethods());

3. 跨域处理

  1. 安装 npm i koa2-cors

  2. 修改server/index.js

//新增
const cors = require("koa2-cors");
app.use(cors());

最终server/index.js文件

const Koa = require("koa");
const app = new Koa();
const index = require("./routes/index");

const cors = require("koa2-cors");

app.use(cors());

app.use(index.routes(), index.allowedMethods());

app.listen(4000, () => {
  console.log("server is listening on port 4000");
});

目录结构:

995B30E73E374C6A8CAE79809B8ADD6A.jpg

重新运行 npm run dev,这时服务端已准备好

从截图中可以看出起了两个服务,一个前端本地服务,一个node服务

二、前端设计

技术准备:

  1. 定义使用到的常量

新建src/config/constant.js

//localStorage存储字段
export const ACCESS_TOKEN = "tk"; //存token
export const ROLE = 'role'; //存用户等级
export const MENUS = 'menus';//存菜单列表

//HTTP请求头字段
export const AUTH = "Authorization";

新建src/config/returnCodeMap.js

//接口状态码
export const CODE_LOGGED_OTHER = 106;// 在其它客户端被登录
export const CODE_RELOGIN = 104;// 重新登陆

新建src/config/menus.js

const menus = [
    {
      path: "/home",
      key: "home",
      name: "首页",
    },
    {
      path: "/about",
      key: "about",
      name: "说明页",
    },
    {
      path: "/manage",
      key: "manage",
      name: "管理页",
    },
  ];
  
  export default menus

2. 安装axios服务

安装 npm i axios

新建src/service/index.js

//axios服务
import axios from "axios";

const service = axios.create({
  baseURL: "//127.0.0.1:4000",
  timeout: 30000,
});

export default service;

新建src/service/api.js

//定义接口
import service from "./index";

const API = {};

/* 登录接口 */
API.getLogin = (params) => {
  return service.get("/login", { params: params });
};

/* 获取应用数据接口 */
API.getData = () => {
  return service.get("/getData");
};

export default API;

3. 调整目录结构

一个管理系统需要登录页,登录之后使用嵌套路由,layout用来布局,展示左侧菜单栏和头部用户信息,右侧用来展示页面内容,这样在路由切换时,菜单栏和头部可以保持不变

clipboard.png

新建src/Layout.vue,src/views/Login.vue,src/views/Manage.vue(管理员才能访问的页面),还有创建项目自带的About.vue和Home.vue

  1. 调整路由

新建src/router/routes.js

//配置路由
const Login = () =>
  import(/* webpackChunkName: "login" */ "../views/Login.vue");
const Home = () => 
  import(/* webpackChunkName: "home" */ "../views/Home.vue");
const About = () => 
  import(/* webpackChunkName: "about"*/ "../views/About.vue")
const Layout = () =>
  import(/* webpackChunkName: "layout" */ "../Layout.vue");

const routes = [
  {
    path: "/",
    redirect: "/home",
  },
  {
    path: "/login",
    component: Login,
  },
  {
    path: "/layout",
    name: "Layout",
    component: Layout,
    children: [
      {
        path: "/home",
        name: "Home",
        component: Home,
        //存放按钮权限信息
        meta: {
            btnPermissions: ['admin', 'visitor']
        },
      },
      {
        path: "/about",
        name: "About",
        component: About,
        meta: {
            btnPermissions: ['admin']
        },
      },
    ],
  },
];

export default routes;

修改src/router/index.js

import { createRouter, createWebHistory } from "vue-router";
import routes from "./routes";

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

新建src/router/dynamicRoute.js

//动态路由
const manage = {
    path: "/manage",
    name: "manage",
    component: () => 
    import(/* webpackChunkName:"manage" */ "../views/Manage.vue"),
  };
  
  export default manage

新建终端,启动前端服务。这时页面能出来了,接下来进行权限管理

1. 路由权限设置

情景:当用户没有登录,直接访问页面时,重定向到登录页登录

思路:在路由全局前置钩子中,增加鉴权功能

修改src/router/index.js

import { ACCESS_TOKEN, ROLE, MENUS } from "../config/constant";

router.beforeEach((to, from, next) => {
  if (to.path === "/login") {
    //在登录页清除存储信息
    localStorage.removeItem(ACCESS_TOKEN);
    localStorage.removeItem(ROLE);
    localStorage.removeItem(MENUS);
  }
  let token = localStorage.getItem(ACCESS_TOKEN);
  //没有token,则重定向到登录页
  if (!token && to.path !== "/login") {
    next({
      path: "/login",
    });
  } else {
    next();
  }
});

2. 接口权限设置

情景:当token过期时,需用户重新登录

思路:在请求拦截器中,将token添加到请求头中;在响应拦截器中,判断状态码决定是否跳转到登录页

  1. 增加请求拦截器和响应拦截器

修改src/service/index.js

//新增
import { CODE_LOGGED_OTHER, CODE_RELOGIN } from "../config/returnCodeMap";
import { ACCESS_TOKEN, AUTH } from "../config/constant";
import router from "../router";

service.interceptors.request.use(
  (config) => {
    let { headers } = config;
    const tk = localStorage.getItem(ACCESS_TOKEN);
    tk &&
      Object.assign(headers, {
        [AUTH]: tk,
      });
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

service.interceptors.response.use(
  (res) => {
    let { data } = res;
    if (
      data.returncode === CODE_RELOGIN ||
      data.returncode === CODE_LOGGED_OTHER
    ) {
      router.push("/login");
      //清除动态路由缓存
      location.reload();
    }
    return res;
  },
  (error) => {
    return Promise.reject(error);
  }
);

2. 登录页

修改src/views/Login.vue

<template>
  <div style="height: 170px; margin-top: 60px; text-align: center">
    XXXX管理系统
  </div>
  <div style="text-align: center">
    姓名:<input v-model="user.name" />
    <br />
    密码:<input v-model="user.password" />
    <br />
    <button @click="sumbit">提交</button>
  </div>
</template>

<script>
import { reactive } from "vue";
import { useRouter } from "vue-router";
import API from "../service/api";
import { ACCESS_TOKEN, ROLE, MENUS } from "../config/constant";
import manageRoute from "../router/dynamicRoute";

export default {
  setup() {
    const router = useRouter();
    const user = reactive({ name: "", password: "" });
    const sumbit = () => {
      API.getLogin(user).then((res) => {
        localStorage.setItem(ACCESS_TOKEN, res.data.accessToken);
        localStorage.setItem(ROLE, res.data.role);
        localStorage.setItem(MENUS, JSON.stringify(res.data.menus));
        if (res.data.role === "admin") {
          router.addRoute("Layout", manageRoute);
        }
        router.push("/home");
      });
    };
    return { user, sumbit };
  },
};
</script>

3. 首页

修改src/views/Home.vue

<template>
  <div class="home">
    Home页面
    <button @click="sumbit">提交</button>
  </div>
</template>

<script>
import { reactive } from "vue";
import API from "../service/api";
export default {
    setup() {
    const user = reactive({ name: "", password: "" });
    const sumbit = () => {
      console.log(user);
      API.getData('/getData')
    };
    return { user, sumbit };
  },
}
</script>

访问http://127.0.0.1:8080/login 进入登录页,登录之后过5s在首页点击获取数据按钮(token在服务端上设置了5s的过期时间),后端判断token是否过期,过期返回过期状态码,响应拦截器根据状态码跳转到登录页

3. 菜单栏权限设置

情景:不同级别用户看到不同菜单栏

思路:前端通过返回的菜单栏列表,去封装一个新的菜单栏数组

修改src/Layout.vue

<template>
  <div id="home">
    <header>
      <button style="float: right" @click="exit">退出</button>
    </header>
    <main>
      <aside>
        <ul style="list-style: none">
          <li v-for="(item,index) in newMenus" :key="index">
              <router-link :to="item.path">{{item.name}}</router-link>
          </li>
        </ul>
      </aside>
      <article>
        <router-view />
      </article>
    </main>
  </div>
</template>

<script>
import menus from "./config/menus";
import { MENUS } from "./config/constant";
export default {
  data() {
    return {
        newMenus:[]
    };
  },
  created() {
    const menuKeys = JSON.parse(localStorage.getItem(MENUS));
    menus.forEach((item) => {
      if (item.key && menuKeys.includes(item.key)) this.newMenus.push(item);
    });
  },
  methods: {
    exit() {
      this.$router.push("/login");
      //清除动态路由缓存
      location.reload();
    },
  },
};
</script>

<style>
#home {
  height: 100vh;
}
header {
  background: #f4f4f5;
  height: 70px;
}
main {
  display: flex;
  height: 100%;
}
aside {
  width: 150px;
  background: gray;
  height: 100%;
}
article {
  flex: 1;
}
</style>

这时通过用户名为 admin 的账户登录能看到三个菜单,其他用户只能看到两个

4. 动态路由设置

情景:管理员能访问管理页面路由,非管理员不能访问该路由

思路:通过router.addRoute添加动态路由

  1. 修改src/App.vue
<template>
  <router-view />
</template>

<style>
body {
  margin: 0;
}
</style>

2. 管理员页面

修改src/views/Manage.vue

<template>
    <div>管理员才能看到的页面</div>
</template>

3. 添加动态路由

在src/views/Login.vue中新增(上面Login.vue文件已经加上了,这里单独拎出来展示)

import manageRoute from '../router/manageRoute'
//如果是管理员,添加管理员页面路由
if (res.data.role === "admin") {
  router.addRoute("Layout", manageRoute);
}

提示:这里用的是vue3,对应的vue-router是4.x版本,使用addRoute添加动态路由。vue2对应的vue-router是3.x版本,使用addRoutes添加动态路由

  1. 解决刷新页面,动态路由丢失

原因:刷新页面,路由初始化,动态路由会丢失

思路:通过监听路由的变化,当刷新时,添加动态路由并定位到管理页面

修改src/App.vue

//新增
<script>
import { ROLE } from "./config/constant";
import manage from "./router/dynamicRoute";

export default {
  watch: {
    $route: {
      async handler(newVal) {
        console.log("newVal", newVal);
        const role = localStorage.getItem(ROLE);
        if (role && role === "admin") {
          /* 在4.x版本中需手动调用router.replace方法重定向,
          因为动态路由页面刷新时,matched的值为空;
          在3.x版本中,刷新页面添加异步路由,matched有值,不需要再重定向 */
          this.$router.addRoute("Layout", manage);
          /* 在动态路由页面刷新时,matched数组为空 */
          if (!newVal.matched.length && newVal.fullPath === "/manage") {
            await this.$router.replace("/manage");
          }
        }
      },
    },
  },
};
</script>

5. 按钮权限设置

情景:根据不同的用户,一些页面功能进行显示或者隐藏

思路:在路由元信息上定义权限信息,通过自定义指令删除一些DOM节点

  1. 定义路由元信息(上面routes.js中已经添加了)
{
  path: "/about",
  name: "About",
  component: About,
  meta: {
      btnPermissions: ['admin']
  },
},

2. 增加判断方法

新建src/utils/index.js

import { ROLE } from "../config/constant";

// 权限检查方法
export function has(value) {
  let isExist = false;
  // 获取用户按钮权限
  let btnPermissionsStr = localStorage.getItem(ROLE);

  if (btnPermissionsStr == undefined || btnPermissionsStr == null) {
    return false;
  }

  if (value.indexOf(btnPermissionsStr) > -1) {
    isExist = true;
  }
  return isExist;
}

3. 新建自定义指令

修改src/main.js

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";

import { has } from "./utils";

const app = createApp(App);

app.directive("has", {
  mounted(el) {
    // 获取页面按钮权限
    const btnPermissionsArr = router.currentRoute._value.meta.btnPermissions;
    if (!has(btnPermissionsArr)) {
      if (el.parentNode) {
        el.parentNode.removeChild(el);
      }
    }
  },
});

app.use(router).mount("#app");

4. 在about页面使用v-has指令

修改src/views/About.vue

<template>
  <div class="about">
    <h1>This is an about page</h1>
    <button type="button" v-has>管理员按钮</button>
  </div>
</template>

效果演示:

1.gif

欢迎关注:之后文章会首发在云在前端公众号,未经许可禁止转载!