BCVP.VUE3系列第九课:实现tabs标签栏

BCVP 开发者社区出品

BCVP V3开发

数字化

服务化

绿色化

放假不停歇,趁着假期学习下VUE3相关的内容,一方面是自己保持活力,另一方面也是工作需要,本系列是我的自学教程,如果有从0开始学习VUE3的,可以跟着一起练习下,毕竟前端我也是泥腿子出身,这一系列会使用Vite、TS、Pinia、Element-Plus等新知识点,既是查漏补缺,也是知识分享。

代码地址:

https://github.com/anjoy8/bcvp.vue3.git

这是每篇文章一节课一个分支,方便大家学习,会慢慢的将blog.admin项目进行翻新,使用的后端接口还是BlogCore。

系列文章:

第一课:项目初始化与核心知识点说明

第二课:基于泛型基类封装Axios请求

第三课:封装Axios拦截器

第四课:登录页设计

第五课:获取用户信息

第六课:获取动态菜单接口

第七课:基于布局模式实现动态菜单渲染

第八课:丰富面包屑组件

0、本文介绍

本文参考的是开源项目

https://gitee.com/HalseySpicy/Geeker-Admin/tree/template

分步骤讲解框架核心逻辑,今天的内容是:实现tabs标签栏功能,效果图:

6533ea1876a73e8a25514dde30580c1a.png

1、设计标签数据的状态管理

还是老规矩,只要是对数据的管理和控制,就想到使用状态管理器Pinia来处理,新建文件src\stores\modules\tabs.ts

import router from "@/router";
import { defineStore } from "pinia";
import type { TabsState, TabsMenuProps } from "@/stores/interface";
import { useKeepAliveStore } from "./keepAlive";


const keepAliveStore = useKeepAliveStore();


export const useTabsStore = defineStore({
  id: "blogvue3-tabs",
  state: (): TabsState => ({
    tabsMenuList: []
  }),
  actions: {
    // Add Tabs
    async addTabs(tabItem: TabsMenuProps) {
      if (this.tabsMenuList.every(item => item.path !== tabItem.path)) {
        this.tabsMenuList.push(tabItem);
      }
    },
    // Remove Tabs
    async removeTabs(tabPath: string, isCurrent: boolean = true) {
      const tabsMenuList = this.tabsMenuList;
      // 如果是删除当前路由
      if (isCurrent) {
        tabsMenuList.forEach((item, index) => {
          if (item.path !== tabPath) return;
          // 让页面自动加载前一个或者后一个路由页面
          const nextTab = tabsMenuList[index + 1] || tabsMenuList[index - 1];
          if (!nextTab) return;
          router.push(nextTab.path);
        });
      }
      // 数据清理
      this.tabsMenuList = tabsMenuList.filter(item => item.path !== tabPath);
    },
    // Close Tabs On Side
    async closeTabsOnSide(path: string, type: "left" | "right") {
      // 关闭左侧、右侧
      const currentIndex = this.tabsMenuList.findIndex(item => item.path === path);
      if (currentIndex !== -1) {
        const range = type === "left" ? [0, currentIndex] : [currentIndex + 1, this.tabsMenuList.length];
        this.tabsMenuList = this.tabsMenuList.filter((item, index) => {
          return index < range[0] || index >= range[1] || !item.close;
        });
      }
      keepAliveStore.setKeepAliveName(this.tabsMenuList.map(item => item.name));
    },
    // Close MultipleTab
    async closeMultipleTab(tabsMenuValue?: string) {
      // 关闭其他
      this.tabsMenuList = this.tabsMenuList.filter(item => {
        return item.path === tabsMenuValue || !item.close;
      });
      keepAliveStore.setKeepAliveName(this.tabsMenuList.map(item => item.name));
    },
    // Set Tabs
    async setTabs(tabsMenuList: TabsMenuProps[]) {
      this.tabsMenuList = tabsMenuList;
    },
    // Set Tabs Title
    async setTabsTitle(title: string) {
      const nowFullPath = location.hash.substring(1);
      this.tabsMenuList.forEach(item => {
        if (item.path == nowFullPath) item.title = title;
      });
    }
  },
});

内容比较简单,应该都能看的懂,就是实现了对tabsMenuList的增删改查操作,借助Pinia可以很简单的实现数据管理和维护,同时也能实现响应式。

2、定义tabs组件

标签组件设计比较常规,就是将用户点击的路由给存下来,然后借助element-plus官方提供的el-tabs组件,实现渲染,再增加几个小操作而已。

新建src\layouts\components\Tabs\index.vue,添加内容:

<template>
  <div class="tabs-box">
    <div class="tabs-menu">
      <el-tabs v-model="tabsMenuValue" type="card" @tab-click="tabClick" @tab-remove="tabRemove">
        <el-tab-pane v-for="item in tabsMenuList" :key="item.path" :label="item.title" :name="item.path"
          :closable="item.close">
          <template #label>
            <el-icon v-show="item.icon && tabsIcon" class="tabs-icon">
              <component :is="item.icon"></component>
            </el-icon>
            {{ item.title }}
          </template>
        </el-tab-pane>
      </el-tabs>
      <MoreButton />
    </div>
  </div>
</template>


<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useGlobalStore } from "@/stores/modules/global";
import { useTabsStore } from "@/stores/modules/tabs";
import { useAuthMenuStore } from "@/stores/modules/authMenu";
import { useKeepAliveStore } from "@/stores/modules/keepAlive";
import type { TabsPaneContext, TabPaneName } from "element-plus";
import MoreButton from "./components/MoreButton.vue";
import type { TabsMenuProps } from "@/stores/interface";


const route = useRoute();
const router = useRouter();
const tabStore = useTabsStore();
const authStore = useAuthMenuStore();
const globalStore = useGlobalStore();
const keepAliveStore = useKeepAliveStore();


const tabsMenuValue = ref(route.fullPath);
const tabsMenuList = computed(() => tabStore.tabsMenuList);
const tabsIcon = computed(() => globalStore.tabsIcon);


onMounted(() => {
  // initTabs();
});


// 监听路由的变化(防止浏览器后退/前进不变化 tabsMenuValue)
watch(
  () => route.fullPath,
  () => {
    if (route.meta.isFull) return;
    tabsMenuValue.value = route.fullPath;
    const tabsParams = {
      icon: route.meta.icon as string,
      title: route.meta.title as string,
      path: route.fullPath,
      name: route.name as string,
      close: route.path != '/'
    };
    tabStore.addTabs(tabsParams);
    route.meta.isKeepAlive && keepAliveStore.addKeepAliveName(route.name as string);
  },
  { immediate: true }
);


// 初始化需要固定的 tabs
const initTabs = () => {
  authStore.flatMenuListGet.forEach(item => {
    if (!item.IsButton) {
      const tabsParams = {
        icon: item.meta.icon,
        title: item.meta.title,
        path: item.path,
        name: item.name,
        close: route.path != '/' // 可以固定某些路由不被删除
      };
      tabStore.addTabs(tabsParams as TabsMenuProps);
    }
  });
};


// Tab Click
const tabClick = (tabItem: TabsPaneContext) => {
  const fullPath = tabItem.props.name as string;
  router.push(fullPath);
};


// Remove Tab
const tabRemove = (fullPath: TabPaneName) => {
  const name = tabStore.tabsMenuList.filter(item => item.path == fullPath)[0].name || "";
  keepAliveStore.removeKeepAliveName(name);
  tabStore.removeTabs(fullPath as string, fullPath == route.fullPath);
};
</script>


<style scoped lang="scss">
@import "./index.scss";
</style>

可以看到,核心逻辑就是有一个watch监听,将数据给放到状态管理器Pinia中。

然后再来个更多操作的按钮组件,将标签的增删改查操作同样在Pinia中处理:

新建src\layouts\components\Tabs\components\MoreButton.vue,

<template>
  <el-dropdown trigger="click" :teleported="false">
    <div class="more-button">
      <i :class="'iconfont icon-xiala'"></i>
    </div>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item @click="refresh">
          <el-icon>
            <Refresh />
          </el-icon>刷新
        </el-dropdown-item>
        <el-dropdown-item @click="maximize">
          <el-icon>
            <FullScreen />
          </el-icon>最大化
        </el-dropdown-item>
        <el-dropdown-item divided @click="closeCurrentTab">
          <el-icon>
            <Remove />
          </el-icon>关闭当前
        </el-dropdown-item>
        <el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'left')">
          <el-icon>
            <DArrowLeft />
          </el-icon>关闭左侧
        </el-dropdown-item>
        <el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'right')">
          <el-icon>
            <DArrowRight />
          </el-icon>关闭右侧
        </el-dropdown-item>
        <el-dropdown-item @click="closeOtherTab">
          <el-icon>
            <CircleClose />
          </el-icon>关闭其它
        </el-dropdown-item>
        <el-dropdown-item @click="closeAllTab">
          <el-icon>
            <FolderDelete />
          </el-icon>关闭所有
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>


<script setup lang="ts">
import { inject, nextTick } from "vue";
import { useTabsStore } from "@/stores/modules/tabs";
import { useGlobalStore } from "@/stores/modules/global";
import { useKeepAliveStore } from "@/stores/modules/keepAlive";
import { useRoute, useRouter } from "vue-router";


const route = useRoute();
const router = useRouter();
const tabStore = useTabsStore();
const globalStore = useGlobalStore();
const keepAliveStore = useKeepAliveStore();


// refresh current page
const refreshCurrentPage: Function = inject("refresh") as Function;
const refresh = () => {
  setTimeout(() => {
    keepAliveStore.removeKeepAliveName(route.name as string);
    refreshCurrentPage(false);
    nextTick(() => {
      keepAliveStore.addKeepAliveName(route.name as string);
      refreshCurrentPage(true);
    });
  }, 0);
};


// maximize current page
const maximize = () => {
  globalStore.setGlobalState("maximize", true);
};


// Close Current
const closeCurrentTab = () => {
  if (route.path == '/') return;
  tabStore.removeTabs(route.fullPath);
  keepAliveStore.removeKeepAliveName(route.name as string);
};


// Close Other
const closeOtherTab = () => {
  tabStore.closeMultipleTab(route.fullPath);
};


// Close All
const closeAllTab = () => {
  tabStore.closeMultipleTab();
  router.push('/');
};
</script>

3、Main中引用Tabs组件

在文件src\layouts\components\Main\index.vue,增加内容:

b6cf0a1051b09a989d1f5e0230e07a0c.png

这种组件封装还是比较简单的,通过Pinia也能实现响应式,不用太关心数据通讯,一劳永逸。

到了这里,其实已经开发完成了,不过会有一个问题,就是刷新页面的时候,之前点击后保存的数据会丢失掉,但是你可能好奇,为何左侧的菜单没有丢失,是因为每次刷新页面,就重新拉取了一次动态菜单的权限接口,又重新渲染了一次,所以左侧菜单就不受影响。

但是tabs标签栏不行,这数据本来就不是数据库控制的,所以就需要借助前端的持久化工具了——比如localstorage,传统的做法,在vue2的时候,就是用的vuex配合localstorage,实现数据的持久化,其实在第五课的时候,也是这么操作用户数据的,那有没有更简单的办法呢,答案是肯定的。

4、自定义配置Pinia持久化

有一个Pinia的插件pinia-plugin-persistedstate,可以帮助我们很简单的将状态管理中的数据做持久化处理。

首先需要安装这个依赖,直接npm install即可,

然后新增src/stores/index.ts,使用这个插件:

import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";


// pinia persist
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);


export default pinia;

接下来对持久化方案进行配置,可以自定义存储位置,

新增src\stores\config\piniaPersist.ts:

import type { PersistedStateOptions } from "pinia-plugin-persistedstate";


/**
 * @description pinia 持久化参数配置
 * @param {String} key 存储到持久化的 name
 * @param {Array} paths 需要持久化的 state name
 * @return persist
 * */
const piniaPersistConfig = (key: string, paths?: string[]) => {
  const persist: PersistedStateOptions = {
    key,
    storage: localStorage,
    // storage: sessionStorage,
    paths
  };
  return persist;
};


export default piniaPersistConfig;

这里直接配置到localstorage中了,然后在main.ts主程序入口中,更新Pinia的注册方式:

b978a0ef975ca21bf6e362435bc9f9ed.png

最后在tabs.ts的标签状态管理中,引入这个配置:

import router from "@/router";
import { defineStore } from "pinia";
import piniaPersistConfig from "@/stores/config/piniaPersist";


export const useTabsStore = defineStore({
  id: "blogvue3-tabs",
  state: (): TabsState => ({
    tabsMenuList: []
  }),
  actions: {
    // Add Tabs
    // .... 更多逻辑
  },
  persist: piniaPersistConfig("blogvue3-tabs")
});

现在可以试试路由跳转和页面刷新都没问题

30fb6bec6958bd74ad995bd33eb52ea6.png

最后的最后,需要引用两个样式scss文件,分别是iconfont和element的。

本文全部的提交就如图:

e92c453ed7eb2f5007fa09ba5059af2c.png

下篇文章我们继续对页面优化,增加Header顶部右侧的功能ToolBar和个人信息配置,敬请期待。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值