Vue + Element Plus 实现权限管理系统(三): 路由动态加载及菜单侧边栏

在菜单权限管理开发中,通常需要根据后端返回的菜单列表递归渲染左侧菜单栏以及动态加载路由,这样可以确保用户无法访问没有权限的菜单。为了实现这个功能,我们需要进行以下步骤:

  • 获取菜单列表数据:调用菜单接口/getInfo获取路由列表数据。

  • 渲染左侧菜单栏:使用递归的方式根据菜单列表数据格式来渲染左侧菜单栏(sidebar)。递归可以遍历菜单数据,根据数据的嵌套结构来递归渲染菜单的子菜单。

  • 将菜单列表转换为 Vue 路由格式:在渲染菜单的过程中,需要将菜单列表数据转换为符合 Vue 路由的格式。Vue 路由需要包含路径(path)和组件(component)等信息。根据菜单列表的数据结构,进行适当的转换来生成 Vue 路由格式的数据。

  • 动态添加路由:将菜单列表转换为 Vue 路由格式的数据后,可以使用 router.addRoute 方法动态添加路由。

接下看下如何实现动态加载路由与菜单

前置

开始之前我们先安装全局状态管理pinia

css 复制代码
npm i pinia -s

然后main.ts中引入,同时将element-plus的 Icon 全局注册(这里后续就能直接使用图标了)

js 复制代码
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import { createPinia } from "pinia";
import "./index.css";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const app = createApp(App);
//将element-plus的图标注册到app
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component);
}
const pinia = createPinia();
app.use(ElementPlus);
app.use(router);
app.use(pinia);
//等待路由初始化完成后再挂载,确保守卫beforeach可以使用pinia
await router.isReady();
app.mount("#app");

布局

看一下页面布局,分为顶部导航栏(navbar)+左侧菜单栏(sidebar)+主要内容(appmain)

项目新建layout文件夹来存放布局组件

lua 复制代码
-- layout
   -- components
      -- AppMain
         -- index.vue
      -- NavBar
         -- index.vue
      -- SideBar
         -- index.vue
      -- index.js
   -- index.vue

index.vue引入它们

html 复制代码
<template>
  <div class="flex">
    <div class="h-screen">
      <SideBar />
    </div>
    <div class="flex-1 overflow-auto">
      <NavBar />
      <AppMain />
    </div>
  </div>
</template>

<script lang="ts" setup>
  import { SideBar, AppMain, NavBar } from "./components/index.ts";
</script>

其中 AppMain/index.vue

html 复制代码
<template>
  <div class="app_main">
    <router-view v-slot="{ Component, route }">
      <transition name="fade" mode="out-in">
        <keep-alive>
          <component :is="Component" :key="route.path" />
        </keep-alive>
      </transition>
    </router-view>
  </div>
</template>

<script lang="ts" setup>
  import "./index.scss";
</script>

这里加了动画与组件缓存,后续会根据后端返回的catch字段进行缓存控制

全局状态管理

api/menu/index.ts中配置调用获取路由及权限的接口

js 复制代码
import request from "@/utils/http/index";
import { MenuDto } from "./types/menu.dto";
//获取路由及权限
export const getInfo = (data: MenuDto) => {
  return request({
    url: "/menu/getInfo",
    data,
    method: "post",
  });
};

其中的MenuDto是用来接收后端返回的菜单列表数据的,在types中新建menu.dto.ts

js 复制代码
export type MenuDto = {};

这里什么都不用传

新建store/index.ts目录用来存放全局数据,菜单列表数据,权限列表数据,以及是否折叠菜单等等,同时调用getInfo接口获取数据

js 复制代码
import { defineStore } from "pinia";
import { getInfo } from "../api/menu/index";
import { AppStoreState } from "./types";

export default defineStore("appStore", {
  state: (): AppStoreState => {
    return {
      menuList: [],
      isCollapse: false,
      permissions: [],
    };
  },
  actions: {
    async getInfo() {
      const { data } = await getInfo({});
      this.menuList = data.routers;
      this.permissions = data.permissions;
    },
  },
});

其中AppStoreState

js 复制代码
export type MenuList = {
    id: number
    parent_id: number
    title: string
    path: string
    component: string
    icon: string
    order_num: number
    status: boolean
    menu_type: 1 | 2 | 3
    children: MenuList[]
    meta: {
        title?: string
        catch?: number
        hidden?: boolean
    }
}

export type AppStoreState = {
    menuList: MenuList[]
    isCollapse: boolean
    permissions: string[]
}

动态获取路由

我们可以通过router.addRoute方式动态添加子路由,这里我们需要根据后端返回的组件component字段来创建目录,比如system/role/index就在views目录下新建system/role/index.vue

js 复制代码
<template>
  <div>角色管理</div>
</template>

同样的

js 复制代码
//system/menu/index
<template>
    <div>菜单管理</div>
</template>

//system/user/index
<template>
    <div>用户管理</div>
</template>

这些用于后续测试

添加之前需要将后端返回的菜单列表数据转换为符合 Vue 路由的格式,因为后端返回的组件路径是个字符串,VueRouter 的component是不能直接使用的,这里我们在utils文件夹新建filterRouters.ts,

js 复制代码
// 匹配views里面所有的.vue文件
const modules = import.meta.glob("../views/**/*.vue");
//将本地的路由与后端返回的路由进行匹配
export const loadView = (view: any) => {
  let res;
  for (const path in modules) {
    const dir = path.split("views/")[1].split(".vue")[0];
    if (dir === view) {
      res = () => modules[path]();
    }
  }
  return res;
};
export const filterRoute = (data: any) => {
  data.forEach((item: any) => {
    if (item.children?.length > 0) {
      delete item.component;
      filterRoute(item.children);
    } else {
      item.component = loadView(item.component);
      // item.redirect = "/404";
    }
  });
  return data;
};

然后再新建hooks/useHandleRouter.ts,进行动态添加路由的逻辑

js 复制代码
import { RouteRecordRaw, Router } from "vue-router";
import useAppStore from "@/store/index"
import { filterRoute } from "@/utils/filterRouters";
export const useHandleRouter = (router: Router) => {
    //设置白名单,直接放行
    const writeLists = ["Login"];
    router.beforeEach(async (to, _from, next) => {
        if (writeLists.includes(to.name as string)) {
            next();
            return;
        }
        const appStore = useAppStore();

        //已经获取菜单路由直接放行
        if (appStore.menuList.length) {
            next()
            return;
        }
        //获取菜单路由列表
        try {
        await appStore.getInfo();
        //处理成符合vue路由格式的路由
        const routers = filterRoute(appStore.menuList);
        //循环添加路由到父路由Index下
        routers.forEach((route: RouteRecordRaw) => {
            router.addRoute("Index", route);
        });
        ////添加完路由需要重新执行一次路由跳转,否则会出现空白页面
        next({ ...to, replace: true });
        } catch (error) {
            //如果接口出错 比如token过期继续往下走
            next()
        }
    });
}

最后在router/index引入使用,同时添加一个/404的路由,当用户访问的路由不存在时,会跳转到/404路由

js 复制代码
import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
import { useHandleRouter } from "@/hooks/useHandleRouter";
export const routes: RouteRecordRaw[] = [
  {
    path: "/",
    name: "Index",
    redirect: "/index",
    component: () =>
      import(/* webpackChunkName: "index" */ "@/layout/index.vue"),
    children: [
      {
        path: "/index",
        component: () => import("@/views/index.vue"),
        name: "Home",
        meta: { title: "首页" },
      },
    ],
  },
  {
    path: "/login",
    name: "Login",
    component: () =>
      import(/* webpackChunkName: "login" */ "@/views/login/index.vue"),
  },
  {
    path: "/:pathMatch(.*)*",
    component: () => import("@/views/error/404.vue"),
  },
];
const router = createRouter({
  history: createWebHashHistory(),
  scrollBehavior(_to, _from, savedPosition) {
    if (savedPosition) {
      return savedPosition;
    } else {
      return { top: 0 };
    }
  },
  routes,
});
useHandleRouter(router);
export default router;

到这里我们便完成了路由的动态加载

菜单侧边栏

接下来我们来完成菜单侧边栏(SideBar)部分,这里我们使用element-plus中的菜单组件el-menu,然后通过判断后端返回的路由类型是菜单还是目录来分别使用el-menu-itemel-menu-sub

新建layout/SideBar/index.vue来编写侧边栏的代码

html 复制代码
<template>
  <div class="h-[100%] bg-[#545c64]">
    <div class="h-[50px] text-white flex items-center justify-center">
      <span>FS权限管理系统</span>
    </div>
    <el-scrollbar class="wrap-scroll">
      <el-menu
        @select="getPath"
        :collapse="homeStore.isCollapse"
        :unique-opened="true"
        active-text-color="#ffd04b"
        background-color="#545c64"
        class="el-menu-vertical-demo w-[223px] !border-r-0"
        text-color="#fff"
        :default-active="dealRoutePath($route.path)"
      >
        <el-menu-item index="index">
          <component class="w-[15px] mr-2 ml-1" is="House" /> <span>首页</span>
        </el-menu-item>
        <SideBarItem
          v-for="item in homeStore.menuList"
          :key="item.id!"
          :item="item"
        />
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script lang="ts" setup>
  import useHome from "@/store";
  import SideBarItem from "./components/SideBarItem.vue";
  import { dealRoutePath } from "../../../utils/routeUtils";
  import { useRouter } from "vue-router";
  const router = useRouter();
  const homeStore = useHome();
  const getPath = (_v: any, d: string[]) => {
    router.push(`/${d.join("/")}`);
  };
</script>
<style>
  .wrap-scroll {
    height: calc(100% - 50px);
  }
</style>

其中SideBarItem组件用于判断路由类型是菜单还是目录,然后渲染对应的组件。

html 复制代码
<template>
  <div>
    <el-sub-menu
      class="grid"
      v-if="controlSubView(props.item)"
      :index="props.item.path"
    >
      <template #title>
        <component class="w-[15px] mr-2 ml-1" :is="props.item.icon" />
        <span>{{ props.item.meta.title }}</span>
      </template>
      <SideBarItem v-for="i in props.item.children" :key="i.id" :item="i" />
    </el-sub-menu>
    <el-menu-item
      v-else
      v-if="controlMenuView(props.item)"
      :index="dealRoutePath(props.item.path)"
    >
      <component class="w-[15px] mr-2 ml-1" :is="props.item.icon" />
      <span>{{ props.item?.meta?.title }}</span>
    </el-menu-item>
  </div>
</template>

<script lang="ts" setup>
  import { MenuList } from "@/store/types/index";
  import { dealRoutePath } from "@/utils/routeUtils";
  type Props = {
    item: MenuList;
  };
  const props = defineProps<Props>();

  const controlSubView = (item: MenuList) => {
    return !item?.meta?.hidden && item.menu_type === 1;
  };
  const controlMenuView = (item: MenuList) => {
    return !item?.meta?.hidden && item.menu_type === 2;
  };
</script>

其中index属性类似于给菜单一个 key 值,方便后续的路由跳转。我们这里通过dealRoutePath函数取得/最后一个作为index值。同时为el-menu组件属性default-active设置为当前路由路径/的最后一个dealRoutePath($route.path),这样就能保证页面刷新或路由跳转后左侧菜单还是选中当前路由对应菜单状态,其中dealRoutePath方法为

js 复制代码
export const dealRoutePath = (path: string) => {
  if (!path) return "";
  const pathArr = path.split("/");
  return pathArr.slice(-1)[0];
};

除此之外,SideBarItem组件中引入了自身SideBarItem实现了组件的递归,这样就可以保证无论菜单层级多少都可以对应展示

到这里我们便完成了菜单侧边栏的开发。下一篇文章我们将介绍如何实现导航栏面包屑以及标签tag

代码地址

相关推荐
hackeroink15 分钟前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者2 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-2 小时前
验证码机制
前端·后端
燃先生._.3 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
工业甲酰苯胺4 小时前
分布式系统架构:服务容错
数据库·架构
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235244 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240255 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar5 小时前
纯前端实现更新检测
开发语言·前端·javascript