likes
comments
collection
share

手把手教你实现一个vue3+ts+nodeJS后台管理系统(十九)

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

前言

上文用前端写的路由实现了侧边栏的展示,但是除了首页、登录页及错误页面(404页面等),其它页面在后台管理系统中应该都是要受到权限的控制的。所以前端我们只留一些静态的、不受权限控制的路由,其它由后端返回给我们来拼接。由于很多组件都可能要用到这些路由信息,所以这些后端返回的路由信息应该存储在vuex中。还有vuex主要是对全局的状态进行管理,我们所需要全局管理的数据有登录用户的用户信息、权限、按钮等。

设置前端静态路由

静态路由除了登录页、首页及错误页面外,还应该设置默认路由重定向至首页。除此之外此系统的默认跳转都是到达首页或者用户通过其它有效url跳转的页面,我们还应当在路由拦截时判断是否有token存在客户端中,有则直接跳转,无则跳转登录页并在query参数中带上未跳转的路由path。然后在登录页面中我们要获得query信息,然后跳转至重定向的页面。

不能直接将路由path为'/'的路由直接设为首页例如{path:'/',component:'xxx',name:'xxx'},要设置重定向至首页。原因主要为直接设component为首页跳转地址的url只会是'xxx:xx/xxxx/'只显示/会有问题。

router/index.ts

import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import Layout from '@/components/layout/index.vue';
//静态路由
export const constantRoutes: Array<RouteRecordRaw> = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    meta: { hidden: true }
  },
  {
    path: '/',
    component: Layout,
    redirect: '/home',
    children: [
      {
        path: '/home',
        component: () => import('@/views/home/index.vue'),
        name: 'Home',
        meta: { title: '首页', icon: 'dashboard' }
      }
    ]
  },
  {
    path: '/404',
    component: () => import('@/views/errorPage/404.vue'),
    meta: { hidden: true }
  },
  {
    path: '/401',
    component: () => import('@/views/errorPage/401.vue'),
    meta: { hidden: true }
  }
];
​
const router = createRouter({
  history: createWebHashHistory(),
  routes: constantRoutes,
  // 刷新时,滚动条位置还原
  scrollBehavior: () => ({ left: 0, top: 0 })
});
​
// 重置路由
export function resetRouter() {
  store.state.permission.routes.forEach((route: any) => {
    const name = route.name;
    if (name) {
      router.hasRoute(name) && router.removeRoute(name);
    }
  });
}
export default router;

上面的resetRouter方法中用到了vuex的permission模块会报错,接下来我们在vuex中将这些静态路由拼接上后端路由即为完整的路由。

vuex全局状态管理

首先是登录用户的信息,我们从后端得到已登录用户的信息后,存储下用户名、昵称、邮箱、用户角色、权限菜单、权限按钮等信息,然后在vuex的mutations属性设置对应的set函数以便修改。同时,登录、退出登录的操作也应该放在vuex的actions属性中以便进行全局状态的操作,登录主要是获取token等、退出登录清空所有用户信息。

由于用的是TypeScript来编写vue组件,所以需要遵循一些步骤提供正确的类型声明。

参考vuex官方文档TypeScript支持

主要的做法就是1.提供类型化的key 2.定义自己的useStore组合式函数来检索类型化的store,这样每次vue组件导入store时就不用类型化的key

  1. 首先我们先编写各个store模块的类型。在types目录下创建store文件夹,创建user、permission模块的和整体store的类型文件。并在store目录也建立对应的模块文件夹及文件。如下图所示目录结构

    手把手教你实现一个vue3+ts+nodeJS后台管理系统(十九)

    然后编写各自store模块的类型

    types/store/user.d.ts

    /**
     * 用户类型声明
     */
    export interface userState {
      token: string | undefined;
      refreshToken: string | undefined;
      roles: Array<string>;
      menus: Array<MenuItem>;
      buttons: Array<string>;
      user_id: number;
      username: string;
      nickname: string;
      email: string;
      user_pic: string;
    }
    

    types/store/permission.d.ts

    /**
     * 权限类型声明
     */
    export interface permissionState {
      routes: RouteRecordRaw[];
      addRoutes: RouteRecordRaw[];
    }
    /**
     * 菜单meta类型
     */
    export interface meta {
      hidden?: boolean;
      title: string;
      icon?: string | null;
    }
    

    types/store/root.d.ts

    // 定义state类型,将所有模块的类型导入
    export interface rootState {
      user: userState;
      permission: permissionState;
    }
    
  2. 编写user模块,主要是在mutations中设置各个state属性值的set函数,在actions中编写登录、获取用户信息和退出登录的异步函数,如下述代码 store/module/user.ts

    import { Module } from 'vuex';
    // 导入api
    import { userLogin } from '@/utils/API/user/user';
    import { getUserInfo } from '@/utils/Api/user/personalCenter';
    // 导入重置路由的函数
    import { resetRouter } from '@/router';
    // 导入操作token的方法
    import { getToken, setToken, removeToken, setRefreshToken, removeRefreshToken, getRefreshToken } from '@/utils/auth';
    import { ElMessage } from 'element-plus';
    // 导入store类型
    import { userState } from '@/types/store/user';
    import { rootState } from '@/types/store/root';
    export const user: Module<userState, rootState> = {
      // 开启命名空间
      namespaced: true,
      state: (): userState => ({
        token: getToken() || '',
        refreshToken: getRefreshToken() || '',
        roles: [],
        menus: [],
        buttons: [],
        user_id: 0,
        username: '',
        nickname: '',
        email: '',
        user_pic: ''
      }),
      // 同步操作函数
      mutations: {
        SET_TOKEN: (state, token) => {
          state.token = token;
        },
        SET_REFRESHTOKEN: (state, refreshToken) => {
          state.refreshToken = refreshToken;
        },
        SET_ROLES: (state, roles) => {
          state.roles = roles;
        },
        SET_MENUS: (state, menus) => {
          state.menus = menus;
        },
        SET_BUTTONS: (state, buttons) => {
          state.buttons = buttons;
        },
        SET_USERID: (state, user_id) => {
          state.user_id = user_id;
        },
        SET_USERNAME: (state, username) => {
          state.username = username;
        },
        SET_NICKNAME: (state, nickname) => {
          state.nickname = nickname;
        },
        SET_EMAIL: (state, email) => {
          state.email = email;
        },
        SET_USER_PIC: (state, user_pic) => {
          state.user_pic = user_pic;
        }
      },
      // 异步操作函数
      actions: {
        login({ commit }, data) {
          return new Promise((resolve, reject) => {
            userLogin(data)
              .then((res) => {
                commit('SET_TOKEN', res.data.token);
                commit('SET_REFRESHTOKEN', res.data.refreshToken);
                setToken(res.data.token);
                setRefreshToken(res.data.refreshToken);
                ElMessage.success('登录成功');
                resolve(null);
              })
              .catch((error) => {
                reject(error);
              });
          });
        },
        // 获取用户信息
        GetInfo({ commit, state }) {
          return new Promise((resolve, reject) => {
            getUserInfo()
              .then((res) => {
                const { data } = res;
    ​
                if (!data || data.length <= 0) {
                  reject('验证失败,请重新登录');
                }
                // 解构后端值
                const { roles, user_id, name, nickname, email, avatar, menus, buttons } = data;
    ​
                // 角色数组必须有值
                if (!roles || roles.length <= 0) {
                  reject('此用户无分配角色或角色不可用,请重新登录');
                }
                commit('SET_ROLES', roles);
                commit('SET_USERID', user_id);
                commit('SET_USERNAME', name);
                commit('SET_NICKNAME', nickname);
                commit('SET_EMAIL', email);
                commit(
                  'SET_USER_PIC',
                  avatar ? 'http://127.0.0.1:3007/' + avatar : '/src/assets/avatar/default_avatar.jpg'
                );
                commit('SET_MENUS', menus);
                commit('SET_BUTTONS', buttons);
                resolve(data);
              })
              .catch((error) => {
                reject(error);
              });
          });
        },
        // 退出登录
        FedLogOut({ commit }) {
          return new Promise((resolve) => {
            commit('SET_TOKEN', '');
            commit('SET_REFRESHTOKEN', '');
            commit('SET_ROLES', []);
            commit('SET_MENUS', []);
            commit('SET_BUTTONS', []);
            commit('SET_USERID', -1);
            commit('SET_USERNAME', '');
            commit('SET_NICKNAME', '');
            commit('SET_EMAIL', '');
            commit('SET_USER_PIC', '');
            resetRouter();
            removeToken();
            removeRefreshToken();
            resolve(null);
          });
        },
        // 重置token
        resetToken({ commit }) {
          return new Promise((resolve) => {
            commit('SET_TOKEN', '');
            commit('SET_REFRESHTOKEN', '');
            removeToken();
            removeRefreshToken();
            resolve(null);
          });
        }
      }
    };
    
  3. 编写permission模块,主要是为了将后端提供的菜单转为前端的router结构并拼接静态路由。有一个重点就是vue2路由导入文件一般是通过require,但vite不支持require,所以官网中提供了一个从文件中导入模块的函数 import.meta.glob ,参考vite官方文档glob导入

    import { Module } from 'vuex';
    import { constantRoutes } from '@/router';
    import { RouteRecordRaw } from 'vue-router';
    import Layout from '@/layout/index.vue';
    import { meta, permissionState } from '@/types/store/permission';
    import { rootState } from '@/types/store/root';
    // 懒加载view文件夹下的vue文件
    const modules = import.meta.glob('../../views/**/**.vue');
    // 得到后端路由经转换后的路由结构
    export function filterAsyncRoutes(routes: any) {
      const res: RouteRecordRaw[] = [];
      // 遍历得到的路由转换为前端router结构
      routes.forEach((route: any) => {
        const component = route.component;
        const tmp: any = {
          path: route.path,
          // 通过import.meta.glob懒加载获取文件模块,注意文件结构!!!
          component: route.component === 'Layout' ? Layout : modules[/* @vite-ignore */ `../../views${component}.vue`],
          redirect: route.redirect || undefined,
          name: route.name,
          meta: {} as meta,
          children: route.children || undefined
        };
        tmp.meta.title = route.title;
        tmp.meta.hidden = !!route.hidden;
        if (route.icon) {
          tmp.meta.icon = route.icon;
        }
        if (tmp.children && tmp.children.length) {
          tmp.children = filterAsyncRoutes(tmp.children);
        }
        res.push(tmp);
      });
      return res;
    }
    export const permission: Module<permissionState, rootState> = {
          // 开启命名空间
      namespaced: true,
      state: (): permissionState => ({
        routes: [],
        addRoutes: []
      }),
    ​
      mutations: {
        // 前端的静态路由拼接转换后的路由
        SET_ROUTES: (state, routes) => {
          state.addRoutes = routes;
          state.routes = constantRoutes.concat(routes);
        }
      },
    ​
      actions: {
        // 生成路由
        generateRoutes({ commit }, menus) {
          return new Promise((resolve) => {
            const accessedRoutes = filterAsyncRoutes(menus);
            commit('SET_ROUTES', accessedRoutes);
            resolve(accessedRoutes);
          });
        }
      }
    };
    
  4. 编写整体的store文件。要提供正确的类型声明,步骤如上说的是1.提供类型化的key 2.定义自己的useStore组合式函数来检索类型化的store,这样每次vue组件导入store时就不用类型化的key

    import { createStore, Store, useStore as baseUseStore } from 'vuex';
    // 定义类型化的key
    import { InjectionKey } from 'vue';
    // 导入整体模块store的类型
    import { rootState } from '@/types/store/root';
    // 导入模块
    import { user } from './module/user';
    import { permission } from './module/permission';
    ​
    // 提供 injection key
    export const key: InjectionKey<Store<rootState>> = Symbol();
    export const store: Store<rootState> = createStore({
      // 模块
      modules: { user, permission }
    });
    // 定义自己的useStore组合式函数
    export function useStore() {
      return baseUseStore(key);
    }
    

    vuex模块到这里就封装完毕了。

    测试

    通过引入store模块下主文件的useStore方法,就能够有正确的类型声明,且能够获得全局状态

    手把手教你实现一个vue3+ts+nodeJS后台管理系统(十九)

路由拦截

在src目录下新建一个permission路由模块,主要是进行路由拦截。

步骤有

  1. 看vuex中是否存在token及角色,如果有的话判断是跳转到登录页还是其它页面。如果是登录页让用户跳转到首页,其它页面判断是否有此角色是否存在此路由的权限,无则跳到404页面,有则继续跳转。但是若不存在角色却有token的话就是还没获取用户信息,通过vuex用户模块的GetInfo的异步方法获取信息继续跳转即可。
  2. 没有token的话判断是否是白名单页面(无需token也可访问的页面),是可跳转,不是跳转登录页再重定向到要去的页面。

代码如下:

由于页面有各自的标题存在路由元信息meta中所以先封装了一个公共获取标题的方法

src/utils/get-title.ts

const title = 'Vue3后台管理系统';
​
export default function getPageTitle(pageTitle: string) {
  if (pageTitle) {
    return `${pageTitle} - ${title}`;
  }
  return `${title}`;
}

src/permission.ts

import router from '@/router';
import { ElMessage } from 'element-plus';
// 不是vue组件直接引入store即可,不用带key
import { store } from '@/store';
// 进度条组件
import NProgress from 'nprogress';
import getPageTitle from '@/utils/get-title';
import 'nprogress/nprogress.css';
​
NProgress.configure({ showSpinner: false }); // 进度环显示/隐藏// 白名单路由
const whiteList = ['/login'];
​
router.beforeEach(async (to, from, next) => {
  NProgress.start();
  // 设置页面标题
  document.title = getPageTitle(to.meta?.title);
  // 判断是否有token
  const hasToken = store.state.user.token;
  if (hasToken) {
    // console.log('有token');
​
    // 登录成功,跳转到首页
    if (to.path === '/login') {
      next({ path: '/' });
      NProgress.done();
    } else {
      const hasGetUserInfo = store.state.user.roles.length > 0;
      if (hasGetUserInfo) {
        if (to.matched.length === 0) {
          // from.name ? next({ name: from.name as any }) : next('/404');
          next('/404');
        } else {
          next();
        }
      } else {
        try {
          await store.dispatch('user/GetInfo');
          const menus = store.state.user.menus;
          const accessRoutes: any = await store.dispatch('permission/generateRoutes', menus);
          accessRoutes.forEach((route: any) => {
            router.addRoute(route);
          });
          next({ ...to, replace: true });
        } catch (error) {
          console.log(error);
​
          // 移除 token 并跳转登录页
          await store.dispatch('resetToken');
          ElMessage.error((error as any) || '系统异常');
          next(`/login?redirect=${to.path}`);
          NProgress.done();
        }
      }
    }
  } else {
    // 未登录可以访问白名单页面(登录页面)
    if (whiteList.indexOf(to.path) !== -1) {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
      NProgress.done();
    }
  }
});
​
router.afterEach(() => {
  NProgress.done();
});

测试一下发现能正常跳到登录页(因为还没有写登录模块),是正常的

手把手教你实现一个vue3+ts+nodeJS后台管理系统(十九)

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