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

文摘   2024-10-24 11:12   上海  

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

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

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

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

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

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

前置

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

npm i pinia -s

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

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文件夹来存放布局组件

-- layout
   -- components
      -- AppMain
         -- index.vue
      -- NavBar
         -- index.vue
      -- SideBar
         -- index.vue
      -- index.js
   -- index.vue

index.vue引入它们

<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

<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中配置调用获取路由及权限的接口

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

export type MenuDto = {};

这里什么都不用传

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

import { defineStore } from "pinia";
import { getInfo } from "../api/menu/index";
import { AppStoreState } from "./types";

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

其中AppStoreState

export type MenuList = {
    id: number
    parent_id: number
    title: string
    path: string
    component: string
    icon: string
    order_num: number
    status: boolean
    menu_type1 | 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

<template>
  <div>角色管理</div>
</template>

同样的

//system/menu/index
<template>
    <div>菜单管理</div>
</template>

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

这些用于后续测试

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

// 匹配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,进行动态添加路由的逻辑

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, replacetrue });
        } catch (error) {
            //如果接口出错 比如token过期继续往下走
            next()
        }
    });
}

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

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 { top0 };
    }
  },
  routes,
});
useHandleRouter(router);
export default router;

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

菜单侧边栏

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

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

<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 {
    heightcalc(100% - 50px);
  }
</style>

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

<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方法为

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

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

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


web前端进阶
坚持原创,分享前端技术文章。
 最新文章