Vue + ElementPlus 实现权限管理系统(四): 面包屑与导航标签菜单

文摘   2024-10-29 19:17   上海  

点击关注公众号,“技术干货” 及时达!


本篇文章要实现的功能如下图所示

page.gif

面包屑导航

面包屑导航在后台管理系统中是一个非常常见的功能,它用于显示用户当前所在页面的位置路径。通常以层次结构的方式呈现,从首页或主目录开始,逐级显示用户所访问的页面的父级页面,直到当前页面。通过面包屑导航,用户可以清楚地知道当前所在页面的位置,并且可以方便地返回到上一级或其他父级页面,大大提高了用户体验。本篇文章最终要实现的面包屑导航如下图示

GIF.gif

element plus已经为我们提供了面包屑组件Breadcrumb,我们可以在项目中直接使用

image.png

它的用法很简单,separator 属性来规定分隔符,el-breadcrumb-item的 to 属性中的 path 可以配置要跳转的路由。因此我们可以定义一个专门存放面包屑的数组,然后根据这个数组遍历el-breadcrumb-item组件即可

来到 store/index.ts 中

image.png

其中它的类型为

image.png

在 Navbar.vue 中使用element-plus提供的组件

image.png

接下我们需要在路由跳转的时候为这个数组根据跳转的信息添加一些数据,来到utils/routeUtils.ts中的handleRouter函数,当路由切换时如果有路由权限数组,我们需要将当前路由的 name 和 path 存到面包屑数组中

因为面包屑组件是一级一级的,比如在 vue 中我们可以获取到当前页面路由的 path,比如/dd/child_1_1

image.png

其中dd对应的是父菜单1,child_1_1对应的是子菜单1.1,那么 breadcrumbs 应该是这样的

[
  {
    name"父菜单1",
    path"dd",
  },
  {
    name"子菜单1",
    path"child_1_1",
  },
];

因为我们需要写一个utils/filterBreadCrumb.ts对其进行处理,将 path 按照/拆成一个数组,然后找到数组中元素(path)对应的菜单名称(name)。想要找到 path 对应的菜单名则需要遍历菜单列表,先找外层有没有,找不到再找它的 children,所以这里又用到了经典的递归写法

import { Breadcrumb, MenuList } from "@/store/types";
export const filterBreadCrumb = (path: string, menuList: MenuList[]) => {
    let paths = path.split("/");
    //去空
    paths = paths.filter((item) => item);
    const breadCrumbs: Breadcrumb[] = [];
    paths.forEach((item) => {
        breadCrumbs.push({
            name: getMenuTitle(item, menuList),
        });
    });
    return breadCrumbs;
};

export const getMenuTitle = (path: string, menuList: MenuList[]): string => {
    for (let i in menuList) {
        if (menuList[i].path === path) {
            return menuList[i].meta.title!;
        }
        if (menuList[i].menu_type === 1) {
            return getMenuTitle(path, menuList[i].children) || ""
        }
    }
    return ''
};

这样我们便完成了面包屑的导航功能

Tag 标签

除了面包屑导航,在后台管理系统页面中一般都会有导航栏标签这一功能,它可以让我们点击过的菜单以 tab 标签栏的形式展现出来

tab.gif

同样的先定义一个全局变量来存放标签数据

image.png

它的类型为

export type NavTag = {
    name: string
    path: string
    fullpath?: string
}

同时写一个增加 tag 标签的方法

在路由跳转的时候调用它(utils/routeUtils.ts)

image.png

然后我们新建一个组件NavBar/components/Tag.vue来实现标签导航,这里使用element-plus提供的tag组件

<template>
<el-scrollbar>
<div class="flex">
<el-tag
@contextmenu.prevent="tagsEmits('openMenu', $event, index)"
v-for="(item, index) in tagsProps.navTags"
@click="handelTo(item)"
:key="item.name"
class="ml-2 cursor-pointer flex-shrink-0"
:effect="currentPath === item.path ? 'dark' : undefined"
type="primary"
:closable="item.path != '/'"
@close="handleClose(index, item.path, currentPath)"
>
{{ item.name }}
<slot :item="item" :currentPath="currentPath" :index="index" />
</el-tag>
</div>
</el-scrollbar>
</template>

<script lang="ts" setup>
import { ref, watch } from "vue";

import { useRoute, useRouter } from "vue-router";
import { NavTag } from "@/store/types";
const route = useRoute();
const router = useRouter();

type TagsProps = {
navTags: NavTag[];
};
const tagsProps = defineProps<TagsProps>();

type TagsEmits = {
(e: "close", index: number, path: string, currentPath: string): void;
(e: "openMenu", event: Event, index: number): void;
};
const tagsEmits = defineEmits<TagsEmits>();
//当前路由路径
const currentPath = ref();
watch(
() => route.path,
(path) => {
currentPath.value = path;
},
{ immediate: true }
);
//路由跳转
const handelTo = (item: any) => {
router.push(item.path);
};

/**
*
* @param index 标签索引
* @param path 标签路径
* @param currentPath 当前路由路径
*/
const handleClose = (index: number, path: string, currentPath: string) => {
tagsEmits("close", index, path, currentPath);
};
</script>

NavBar/index.vue引入该组件,同时实现关闭标签逻辑

<template>
  <div class="p-2 shadow-sm flex items-center h-[50px] box-border">
    <div @click="appStore.isCollapse = !appStore.isCollapse" class="mr-4">
      <Fold v-if="!appStore.isCollapse" class="w-6 cursor-pointer" />
      <Expand v-else class="w-6 cursor-pointer" />
    </div>
    <el-breadcrumb separator="/">
      <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
      <el-breadcrumb-item v-for="item in appStore.breadcrumbs">
        {{ item.name }}
      </el-breadcrumb-item>
    </el-breadcrumb>
  </div>
  <div class="shadow-sm py-1">
    <Tags
      @open-menu="openMenu"
      :nav-tags="appStore.navTags"
      @close="closeTag"
    />

  </div>
</template>

<script lang="ts" setup>
  import useAppStore from "@/store/index";
  import Tags from "./components/Tags.vue";
  import TagView from "./components/TagView.vue";
  import { useRouter, useRoute } from "vue-router";
  import { NavTag } from "../../../store/types/index";
  import { ref } from "vue";
  const router = useRouter();
  const route = useRoute();
  const appStore = useAppStore();
  //关闭tag
  const closeTag = (index: number, path: string, currentPath: string) => {
    appStore.navTags.splice(index, 1);
    const length = appStore.navTags.length;
    //没用tag标签跳转首页
    if (!length) {
      router.replace("/index");
      return;
    }
    //如果关闭的是当前页,跳转上一个tag页面
    if (path === currentPath) {
      length && router.replace(appStore.navTags.slice(-1)[0].path);
    }
  };
</script>

至此便完成了导航标签的功能

标签右键刷新关闭菜单

接下来我们来实现右键标签可以展示刷新页面,关闭当前,关闭其它,全部关闭选项,如下图所示

list.gif

同样的我们将其封装到一个组件中NavBar/components/TagView.vue,同时将对应事件发送出去

<template>
  <div
    class="bg-white shadow-lg rounded-md text-[14px] overflow-hidden cursor-pointer w-[100px] fixed"
  >

    <div
      class="p-1 hover:bg-[#E0E0E0] flex items-center"
      @click="emits('refreshTag')"
    >

      <el-icon> <Refresh /> </el-icon><span>刷新页面</span>
    </div>
    <div
      class="p-1 hover:bg-[#E0E0E0] flex items-center"
      @click="emits('closeCur')"
    >

      <el-icon> <CloseBold /> </el-icon><span>关闭当前</span>
    </div>
    <div
      class="p-1 hover:bg-[#E0E0E0] flex items-center"
      @click="emits('closeOtherTags')"
    >

      <el-icon> <Close /> </el-icon><span>关闭其它</span>
    </div>
    <div
      class="p-1 hover:bg-[#E0E0E0] flex items-center"
      @click="emits('closeAllTags')"
    >

      <el-icon> <Close /> </el-icon><span>全部关闭</span>
    </div>
  </div>
</template>

<script lang="ts" setup>
  type Emits = {
    (e: "closeCur"): void;
    (e: "closeOtherTags"): void;
    (e: "closeAllTags"): void;
    (e: "refreshTag"): void;
  };
  const emits = defineEmits<Emits>();
</script>

这里我们需要在 Tag 组件中引入,但是我们又不想让其逻辑和 Tag 组件的逻辑混在一起,所以我们这里使用slot插槽来引入该组件,同时将参数通过插槽传递出去,并且使用了右击鼠标事件@contextmenu将其事件传递出去,这样我们就可以在NavBar/index.vue中实现逻辑

image.png

最后NavBar/index.vue写下对应逻辑,其中实现方式写到了注释中

<template>
  <div class="p-2 shadow-sm flex items-center h-[50px] box-border">
    <div @click="appStore.isCollapse = !appStore.isCollapse" class="mr-4">
      <Fold v-if="!appStore.isCollapse" class="w-6 cursor-pointer" />
      <Expand v-else class="w-6 cursor-pointer" />
    </div>
    <el-breadcrumb separator="/">
      <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
      <el-breadcrumb-item v-for="item in appStore.breadcrumbs">
        {{ item.name }}
      </el-breadcrumb-item>
    </el-breadcrumb>
  </div>
  <div class="shadow-sm py-1">
    <Tags
      @open-menu="openMenu"
      :nav-tags="appStore.navTags"
      @close="closeTag"
      #default="{ item, currentPath, index }"
    >

      <teleport to="body">
        <TagView
          v-if="isTagView && nowClickIndex == index"
          :style="tagViewStyle"
          @close-cur="closeCur(index, item.path, currentPath)"
          @close-all-tags="closeAllTags"
          @close-other-tags="closeOtherTags(item)"
          @refreshTag="refreshTag(item)"
        />

      </teleport>
    </Tags>
  </div>
</template>

<script lang="ts" setup>
  import useAppStore from "@/store/index";
  import Tags from "./components/Tags.vue";
  import TagView from "./components/TagView.vue";
  import { useRouter, useRoute } from "vue-router";
  import { NavTag } from "../../../store/types/index";
  import { ref } from "vue";
  const router = useRouter();
  const route = useRoute();
  const appStore = useAppStore();

  //关闭tag
  const closeTag = (index: number, path: string, currentPath: string) => {
    appStore.navTags.splice(index, 1);
    const length = appStore.navTags.length;
    //没用tag标签跳转首页
    if (!length) {
      router.replace("/index");
      return;
    }
    //如果关闭的是当前页,跳转上一个tag页面
    if (path === currentPath) {
      length && router.replace(appStore.navTags.slice(-1)[0].path);
    }
  };
  //关闭当前
  const closeCur = (index: number, path: string, currentPath: string) => {
    appStore.navTags.splice(index, 1);

    if (path === currentPath) {
      const length = appStore.navTags.length;
      length && router.push(appStore.navTags[length - 1].path);
      !length && router.push("/");
    }
  };
  //关闭其它
  const closeOtherTags = (item: NavTag) => {
    appStore.$patch({
      navTags: [{ name"首页"path"/" }, item],
    });
    router.push(item.path);
    isTagView.value = false;
  };
  //关闭所有
  const closeAllTags = () => {
    appStore.$patch({
      navTags: [{ name"首页"path"/" }],
    });
    router.push("/");
    isTagView.value = false;
  };

  //刷新
  const refreshTag = (item: NavTag) => {
    router.push({
      path"/redirect" + item.fullpath,
      query: route.query,
    });
  };

  const isTagView = ref(false);

  //点击其他地方关闭tagView

  const listener = () => {
    isTagView.value = false;
    document.removeEventListener("click", listener);
  };
  const tagViewStyle = ref({});
  const nowClickIndex = ref();
  const openMenu = (e: any, index: number) => {
    isTagView.value = true;
    document.addEventListener("click", listener);
    //根据当前点击位置定位tagView的位置
    tagViewStyle.value = {
      left: e.clientX + "px",
      top: e.clientY + "px",
    };
    //记录当前点击的tag的索引,用于判断显示哪个tagView
    nowClickIndex.value = index;
  };
</script>

其中刷新的功能实现方式为跳转到一个新的路由传入 path 和 query,然后在新的路由中再跳转回来,其中redirect/index.vue

<template>
  <div></div>
</template>

<script setup lang="ts">
  import { useRoute, useRouter } from "vue-router";

  const route = useRoute();
  const router = useRouter();
  const { params, query } = route;
  const { path } = params;
  defineOptions({
    name"Redirect",
  });
  router.replace({ path"/" + path, query });
</script>

还需要在路由router/index.ts中加一个配置

然后添加标签将其忽略掉,不然会出现一个空标签

image.png

ok,到这里整个功能就实现了,后续我们将开始实现菜单管理,角色管理等内容

代码地址 https://github.com/qddidi/fs-admin

 如果文章对你有帮助,给个Star友友们~

如果你想学习如何使用全栈技术搭建一个完整的前后端项目,点击下方合集开启你的全栈之路!



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