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

8/28/2022 vue3tslessantdtsxviteantdv模板

# 一、前言

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

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

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, getter 和 action, 更符合 Vue3 的 Composition 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 , 包含 BasicLayout、RouteView 等必须的路由组件

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

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

// 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 中后台管理系统 系列的第二篇, 主要介绍了动态路由的设计与实现, 下一篇介绍菜单导航、框架布局等相关内容的实现

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