从零打造基于vue3+ts+tsx+vite的antdv中后台管理系统(二) 动态路由的设计和实现

8/28/2022 vue3tslessantdtsxviteantdv模板

# 一、前言

接触和使用 vue3 快 1 年了, 时至今日, vue3 的生态已经趋于成熟, 已结陆陆续续有不少公司开始使用 vue3 开发项目, 学习和使用 vue3 已是大势所趋. vue3-ts-antd-admin是基于 vue3、ts、tsx、vite 开发的一套中后台管理系统模板, 简洁轻量, 适合中小型中后台项目的开发.

本系列会从零开始介绍该系统的构造过程. 本文是该系列的第二篇, 主要介绍动态路由的设计和实现, 以及该功能涉及的 vue-routerpiniaaxios 等核心库的引入封装和使用

Github: 传送门

演示地址:传送门

编辑器预览:

预览.jpg 1661504529243.jpg

# 二、vue-Router 的基本使用

Vue Router 是 Vue.js 的官方路由

用过 vue2 的开发者们, 都应该对 Vue Router 很熟悉.

# 1. 安装

vue3 使用的 vue-router 是 v4.x 版本

yarn add vue-router@4

# 2. 使用

导入 createRouter,createWebHistory 这两个方法,使用 createWebHistory 方法创建一个 routerHistory 对象,使用 createRouter 创建路由器

// src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";

import common from "./modules/common";
const routes = [...common];

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

export default router;
// src/router/modules/common/index.ts

/**
 * @description 公共的一些路由,不属于功能模块的都放这里统一管理
 * @author fhtwl */

import UserLayout from "@/layouts/UserLayout.vue";

/**
 *
 * 基础路由
 */
export const constantRouterMap = [
  {
    path: "/auth",
    component: UserLayout,
    redirect: "/auth/login",
    hidden: true,
    children: [
      {
        path: "login",
        name: "login",
        component: () => import("@/views/system/auth/Login/index.vue"),
      },
      {
        path: "register",
        name: "register",
        component: () => import("@/views/system/auth/Register/index.vue"),
      },
      {
        path: "register-result",
        name: "registerResult",
        component: () => import("@/views/system/auth/RegisterResult/index.vue"),
      },
    ],
  },

  {
    path: "/404",
    component: () => import("@/views/system/exception/404/index.vue"),
  },
];

export default [...constantRouterMap];

注册路由并挂载在全局

// src/main.ts

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

const app = createApp(App);
app.use(router);

app.mount("#app");

# 三、动态路由设计

在中后台系统中, 动态路由功能基本是必须的, 因为多角色多权限的系统设计, 必然会体现在路由上, 路由权限是最外层也是最基本的权限, 里层根据实际情况可能还涉及操作等权限

这一节我们只涉及动态路由的设计与实现.

# 1. 布局设计

常规的中后台管理系统, 布局一般有 3 种:

  • 左右布局

    左侧导航菜单 + 右侧内容主体

  • 上下布局

    头部导航菜单 + 底部内容主体

  • 上左联动布局

    头部父导航菜单 + 左侧子导航菜单 + 底部内容主体

这些布局在路由的角度上看来, 其实都是一样的, 即一级路由渲染导航菜单等公共模块和放置二级路由, 二级路由渲染内容主体, 也就是一个个页面

因此, 最终的路由树大致是这样的

[
  // 公共路由, 不需要权限
  {
    name: "auth",
    redirect: "/auth/login", // 重定向到子路由login
    children: [
      {
        name: "login",
        path: "/auth/login",
      },
      {
        name: "register",
        path: "/auth/register",
      },
    ],
  },
  // 动态路由
  {
    name: "dynamicRouter",
    redirect: "xxx", // 重定向到所有角色都拥有的子路由上
    children: [
      // ...
    ],
  },
  // 如果无法匹配到, 则跳转到404
  {
    name: "404",
    redirect: "/404",
    path: "/:catchAll(.*)",
  },
];

# 2. 权限设计

动态路由的权限分两部分, 一是限制当前角色只能看到特定的某些菜单(后续深入到操作级的权限, 再细谈可跳转的按钮和链接), 二是当页面跳转时校验当前角色是否该路由权限(有就正常跳转, 没有就重定向到 404)

# 四、依赖库的封装

在前面的设计中, 实现动态路由需要向后台请求接口, 同时接口返回的数据也需要作为公共变量在多处使用, 因此在开发之前, 还需要引入 axios、pinia 等库并进行简单的封装

# 1. axios

# (1). 安装
yarn add axios
# (2). 配置
// src/utils/http.ts

import axios, { AxiosError, AxiosRequestConfig } from "axios";
import { notification } from "ant-design-vue";

import { useStore } from "@/store/system/user";
import { ACCESS_TOKEN } from "@/store/system/user/const";
import router from "@/router";
import { loginRoutePath } from "@/permission";

// 设置请求头和请求路径
axios.defaults.baseURL = import.meta.env.VITE_APP_API_BASE_URL;
axios.defaults.timeout = 10000;
axios.defaults.headers.post["Content-Type"] = "application/json;charset=UTF-8";
axios.interceptors.request.use(
  (config): AxiosRequestConfig<unknown> => {
    const userStore = useStore();
    const token = userStore.token;
    if (token) {
      config.headers![ACCESS_TOKEN] = token;
    }
    return config;
  },
  (error) => {
    return error;
  }
);

// 异常拦截处理器
const errorHandler = (error: AxiosError) => {
  if (error.response) {
    const userStore = useStore();
    const data = error.response.data as Common.ResponseData<unknown>;
    const token = userStore.token;
    if (error.response.status === 403) {
      notification.error({
        message: "权限不足",
        description: data.msg,
      });
    }
    if (error.response.status === 401 && !data.data) {
      notification.error({
        message: "登录失效",
        description: data.msg,
      });
      const reload = () => {
        setTimeout(() => {
          // 跳转到登录
          router.push(loginRoutePath);
        }, 1500);
      };
      if (token) {
        userStore.deleteToken();
      }
      reload();
    }
  }
  return Promise.reject(error);
};
// 响应拦截
axios.interceptors.response.use((response) => {
  if (response.data?.errorCode !== 10000) {
    notification.error({
      message: "请求失败",
      description: response.data.msg,
    });
    return Promise.reject(response);
  }
  return response.data.data;
}, errorHandler);

export default axios;
// src/main.ts

import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import axios from "@/utils/http";

const app = createApp(App);
app.use(router);
app.config.globalProperties.$axios = axios;
app.mount("#app");

# 2. pinia

Pinia 是尤雨溪强烈推荐的一款 Vue 状态管理工具,也被认为是下一代 Vuex 的替代产品. Pinia 同样由 Vue 团队开发, Vue 官方更推荐在 Vue3 中使用 Pinia. 相比Vuex, Pinia 没有 mutations, 不需要注入、导入函数, 调用时会自动补全, 无需动态添加 stores, 没有命名空间, 只保留了 state, getteraction, 更符合 Vue3Composition api. 整体更为轻量简洁, 以及更好的 typescript 支持

# (1). 安装
yarn add pinia

# (2). 挂载 pinia
// src/main.ts

import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import axios from "@/utils/http";
import { createPinia } from "pinia";

const app = createApp(App);
app.use(router);
app.config.globalProperties.$axios = axios;
app.use(createPinia());
app.mount("#app");
# (3). 创建 store

src/store 目录存放项目中的 pinia store, system目录存放系统公共 store, asyncRouter.ts 存放动态路由相关的数据.

pinia 通过 defineStore 方法创建 store, defineStore 接受 2 个参数, 第一个是 store 的 id(或者说唯一的名称), 第二个是 store 的配置项, 支持 Vue 的 Composition API 和 Option API

  • Option API
// src/store/system/asyncRouter.ts

import { defineStore } from "pinia";

export const defineRouterStore = defineStore("asyncRouter", {
  state: () => ({
    addRouters: [],
  }),
  getters: {
    routersLength: (state) => state.addRouters.length,
  },
  actions: {
    generateRoutes(routers) {
      this.addRouters = routers;
    },
  },
});
  • Composition API
// src/store/system/asyncRouter.ts

import { defineStore } from "pinia";
import { computed, ref } from "vue";

export const defineRouterStore = defineStore("asyncRouter", () => {
  const addRouters = ref([]);
  const routersLength = computed(() => addRouters.value.length);
  function generateRoutes(routers) {
    addRouters.value = routers;
  }

  return {
    addRouters,
    routersLength,
    addRouters,
  };
});
# (4). 使用 pinia
import { defineRouterStore } from "@/store/system/asyncRouter";

export default {
  setup() {
    const routerStore = defineRouterStore();

    const addRouters = routerStore.addRouters;
    routerStore.addRouters([]);

    return {
      // 如果需要在template中使用, 则必须return除去
      routerStore,
    };
  },
};

# 五、动态路由实现

# 1. 动态获取路由

添加获取动态路由接口

// src/api/system/user/index/ts

import http from "@/utils/http";

const User = `/system/user`;
const api = {
  getUserInfo: `${User}/query`,
  getUserMenu: `${User}/getUserMenu`,
};

/**
 * 获取用户信息
 * @returns
 */
export function getUserInfo(): Promise<UserRes.GetUserInfo> {
  return http.get(api.getUserInfo);
}

/**
 * 获取动态路由
 * @returns
 */
export function getUserMenu(): Promise<UserRes.GetUserMenu[]> {
  return http.post(api.getUserMenu);
}

# 2. 将后台返回的 json 解析为路由树

# (1). 基础路由

创建基础路由表 constantRouterComponents , 包含 BasicLayoutRouteView 等必须的路由组件

BasicLayout 组件 作为一级路由, 也就是动态路由(不包含登录注册等公共路由)的根节点, 页面包含 菜单栏等公共区内容区

RouteView 组件 用于渲染内容区的页面, 并添加了 keep-alivetransition 等功能

// src/router/modules/generatorRouters.ts

import { BasicLayout, RouteView } from "@/layouts";
import { markRaw } from "vue";

// 前端路由表
const constantRouterComponents: {
  [propsName: string]: unknown;
} = {
  // 基础页面 layout 必须引入

  // 一级路由包裹组件
  BasicLayout: markRaw(BasicLayout),
  // 二级路由
  RouteView: markRaw(RouteView),
  404: () => import("@/views/system/exception/404/index.vue"),
};
# (2). 路由懒加载

通过 import.meta.glob 自动引入 views 目录下的所有组件, 使用 懒加载 的方式 , 将所有页面(约定 isPage === true 的组件为页面) 挂在 constantRouterComponents 下. 此时 constantRouterComponents 包含所有的页面

// src/router/modules/generatorRouters.ts

import { getUserMenu } from "@/api/system/user";

const modules = getModules();
function loadAllPage() {
  for (const path in modules) {
    modules[path]().then((mod) => {
      const file = mod.default;
      if (file.isPage) {
        constantRouterComponents[`${file.name}`] = () => Promise.resolve(file);
      }
    });
  }
}
// 获取所有的页面并放入路由表中
loadAllPage();

/**
 * 加载views目录下的所有组件
 * @returns
 */
function getModules() {
  const components = import.meta.glob("../../views/**/*.tsx");
  return components;
}
# (3). 动态路由树构建
// src/router/modules/generatorRouters.ts

import { getUserMenu } from "@/api/system/user";
import { BasicLayout, RouteView } from "@/layouts";
import { listToTree } from "@/utils/utils";
import { markRaw } from "vue";

export const ROOT_NAME = -1;

// 前端未找到页面路由
const notFoundRouter = {
  path: "/:catchAll(.*)", // 不识别的path自动匹配404
  redirect: "/404",
  hidden: true,
  serialNum: 0,
  parentId: 0,
  name: "404",
  id: "404",
  component: undefined,
  meta: {
    title: "404",
    icon: undefined,
    hiddenHeaderContent: undefined,
    permission: undefined,
    type: undefined,
    actions: [],
  },
  hideChildrenInMenu: false,
  children: [],
};

// 根级菜单
const rootRouter: UserRes.GetUserMenu = {
  key: "",
  name: "index",
  path: "",
  component: "BasicLayout",
  redirect: "/dashboard",
  meta: {
    title: "首页",
  },
  children: [],
  serialNum: 0,
  parentId: -11,
  id: ROOT_NAME,
  hideChildrenInMenu: false,
};

/**
 * 动态生成菜单
 * @returns
 */
export function generatorDynamicRouter(): Promise<Common.Router[]> {
  return new Promise((resolve, reject) => {
    getUserMenu()
      .then((result) => {
        const menuNav = [];
        const childrenNav: UserRes.GetUserMenu[] = [];
        // 后端数据, 根级树数组,  根级 PID
        listToTree(result as unknown as Common.List, childrenNav, 0);
        rootRouter.children = childrenNav;
        menuNav.push(rootRouter);
        const routers = generator(menuNav);
        routers.push(notFoundRouter);
        resolve(routers);
      })
      .catch((err) => {
        reject(err);
      });
  });
}

/**
 * 格式化树形结构数据 生成 vue-router 层级路由表
 * @param routerMap
 * @param parent
 * @returns
 */
export const generator = (
  routerMap: UserRes.GetUserMenu[],
  parent?: Common.Router
) => {
  return routerMap.map((item) => {
    const { component, meta, key, permission, type } = item;
    const { title, show, hideChildren, hiddenHeaderContent, icon } = meta;
    const currentRouter: Common.Router = {
      // 如果路由设置了 path,则作为默认 path,否则 路由地址 动态拼接生成如 /dashboard/my-dashboard
      path: item.path || `${parent?.path || ""}/${key}`,
      // 路由名称,建议唯一
      name: item.id.toString(),
      // 该路由对应页面的组件
      component: constantRouterComponents[
        component!
      ] as unknown as Common.VueComponent,

      // meta: 页面标题, 菜单图标, 页面权限(供指令权限用,可去掉)
      meta: {
        title,
        icon,
        hiddenHeaderContent,
        permission,
        type,
        actions: (item.children || []).filter(
          (action: UserRes.GetUserMenu) => action.type === 3
        ),
      },
      // 是否设置了隐藏菜单
      hidden: show === false,
      // 是否设置了隐藏子菜单
      hideChildrenInMenu: !!hideChildren,
      children: [],
      redirect: "",
    };
    // 为了防止出现后端返回结果不规范,处理有可能出现拼接出两个 反斜杠
    if (!currentRouter.path.startsWith("http")) {
      currentRouter.path = currentRouter.path.replace("//", "/");
    }
    // 重定向
    item.redirect && (currentRouter.redirect = item.redirect);
    // 是否有子菜单,并递归处理
    if (item.children && item.children.length > 0) {
      currentRouter.children = generator(item.children, currentRouter);
    }
    return currentRouter;
  });
};
// src/utils/utils.ts

/**
 * 数组转树形结构
 * @param list 源数组
 * @param tree 树
 * @param parentId 父ID
 */
export function listToTree(
  list: Common.List,
  tree: Common.TreeNode[],
  parentId: number
) {
  list.forEach((item) => {
    // 判断是否为父级菜单
    if (item.parentId === parentId) {
      const child = {
        ...item,
        id: item.id!,
        key: item.id || item.name,
        children: [],
        serialNum: item.serialNum as number,
        parentId: item.parentId,
      };
      // 迭代 list, 找到当前菜单相符合的所有子菜单
      listToTree(list, child.children, item.id!);
      // 加入到树中
      tree.push(child);
    }
  });
}
# (4). 保存动态路由树
// src/store/system/asyncRouter/index.ts

import { defineStore } from "pinia";
import { generatorDynamicRouter } from "@/router/modules/generatorRouters";

export const defineRouterStore = defineStore("asyncRouter", {
  state: () => ({
    addRouters: [] as Common.Router[],
  }),
  actions: {
    generateRoutes() {
      return new Promise((resolve) => {
        generatorDynamicRouter().then((routers) => {
          this.addRouters = routers;
          resolve(undefined);
        });
      });
    },
  },
});

# 3. 将路由树加入到路由

import router from "@/router";
import { ROOT_NAME } from "@/router/modules/generatorRouters";
import { defineRouterStore } from "@/store/system/asyncRouter";
import { RouteRecordRaw } from "vue-router";

export function resetMenuRouter() {
  // 动态路由都挂在name为ROOT_NAME的路由下
  if (router.hasRoute(ROOT_NAME.toString())) {
    // 更新前先清空动态路由
    router.removeRoute(ROOT_NAME.toString());
  }
}

/**
 * 更新系统导航和路由
 */
export async function updateMenuRouter() {
  const routerStore = defineRouterStore();
  await routerStore.generateRoutes().then(() => {
    routerStore.addRouters.forEach((r) => {
      router.addRoute({
        ...r,
      } as unknown as RouteRecordRaw);
    });
  });
}
// 登录后

login().then(() => {
  // ...
  updateMenuRouter();
  // ...
});

# 4. 路由守卫

  • 当登录有效时, 访问登录页直接重定向到默认系统页面

  • 当登录有效时, 却没有角色信息时, 尝试重新获取路由等角色信息, 获取失败则要求重新登录

  • 当登录有效时, 且路由携带重定向时, 则重定向

  • 当未登录时, 重定向到登录

  • 其他情况不拦截

import router from "./router";
import { useStore } from "./store/system/user";
import NProgress from "nprogress";
import "@/components/NProgress/nprogress.less";
import { notification } from "ant-design-vue";
import { resetMenuRouter, updateMenuRouter } from "./utils/router";

NProgress.configure({ showSpinner: false });

const allowList = ["login", "register", "registerResult"];
export const loginRoutePath = "/auth/login";
const defaultRoutePath = "/dashboard/my-dashboard";

router.beforeEach((to, from, next) => {
  NProgress.start();
  const userStore = useStore();
  // 如果已经登录
  if (userStore.token) {
    // 而跳转路径是登录页面
    if (to.path === loginRoutePath) {
      // 则直接跳转到默认页
      next({ path: defaultRoutePath });
      NProgress.done();
    } else {
      // 如果没有角色信息
      if (!userStore.role) {
        // 重新获取
        userStore
          .getInfo()
          .then(() => {
            // 重置之前的动态路由
            resetMenuRouter();
            // 更新动态路由
            updateMenuRouter().then(() => {
              // 请求带有 redirect 重定向时,登录自动重定向到该地址
              const redirect = decodeURIComponent(
                (from.query?.redirect as string | undefined) || to.path
              );
              if (to.path === redirect) {
                next({ ...to, replace: true });
              } else {
                // 跳转到目的路由
                next({ path: redirect });
              }
            });
          })
          .catch(() => {
            notification.error({
              message: "错误",
              description: "请求用户信息失败,请重试",
            });
            // 失败时,获取用户信息失败时,调用登出,来清空历史保留信息
            userStore.logout().then(() => {
              next({ path: loginRoutePath, query: { redirect: to.fullPath } });
            });
          });
      } else {
        next();
      }
    }
  } else {
    if (to.name && allowList.includes(to.name.toString())) {
      // 在免登录名单,直接进入
      next();
    } else {
      next({ path: loginRoutePath, query: { redirect: to.fullPath } });
      NProgress.done();
    }
  }
});

router.afterEach(() => {
  NProgress.done();
});

# 总结

本文是 从零打造基于 vue3+ts+tsx+vite 的 antdv 中后台管理系统 系列的第二篇, 主要介绍了动态路由的设计与实现, 下一篇介绍菜单导航、框架布局等相关内容的实现

文中若有错误或者可优化之处, 望请不吝赐教. 如果对你有帮助的话, 麻烦点个赞