从零开始Vue3+Element Plus后台管理系统(七)——手写一个简单的多页签组件

2,523 阅读2分钟

以前都是用别人现成的多页签组件,自己也想尝试下做个Vue3的版本,目前还只有基本功能,慢慢完善。 image.png

主要思路

  1. 使用 Pinia 记录页签数据、处理操作
  2. 初始状态没有页签数据,使用默认路由数据填充
  3. 右击页签,显示更多关闭操作
  4. 使用el-scrollbar 实现横向滚动

store/tags 处理页签

页签的数据和操作都在store中,

  • list是页签数据
  • nameList保存页签路由的name,用于布局文件的keep-alive
<keep-alive :include="tags.nameList">
   <component :is="Component"></component>
</keep-alive>
  • 对页签的基本操作:增加页签、关闭、关闭其他、关闭全部

引入了持久化插件pinia-plugin-persistedstate,只要设置persist即可在页面刷新时保持页签数据不丢失,具体可以看专栏上篇文章《从零开始Vu3+Element Plus后台管理系统(六)——状态管理Pinia和持久化》

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface ListItem {
  name: string
  path: string
  title: string
}

export const useTagsStore = defineStore(
  'tags',
  () => {
    let list = ref<ListItem[]>([])

    let show = computed(() => {
      return list.value.length > 0
    })
    let nameList = computed(() => {
      return list.value.map((item: ListItem) => item.name)
    })

    function delTagsItem(index: number) {
      list.value.splice(index, 1)
    }
    function setTagsItem(data: ListItem) {
      list.value.push(data)
    }
    function clearTags() {
      list.value = []
    }
    function closeTagsOther(data: ListItem[]) {
      list.value = data
    }

    return { list, show, nameList, delTagsItem, setTagsItem, clearTags, closeTagsOther }
  },
  {
    persist: {
      storage: sessionStorage
    }
  }
)

页签组件页面

页签列表html

<template>
  <div class="shadow mo-tags backdrop-blur-sm bg-white/75 dark:bg-black/75" v-if="tags.show">
    <el-scrollbar>
      <ul v-click-outside="onClickOutside">
        <li
          v-for="(item, index) in tags.list"
          :key="item.path"
          :class="isActive(item.path) ? 'active' : ''"
        >
          <span
            class="cursor-pointer"
            @click="changeTab(item.path)"
            @contextmenu.prevent="openContext($event, index)"
            >{{ item.title }}</span
          >
          <i-ep-close @click="removeTag(item.path)"></i-ep-close>
        </li>
      </ul>
    </el-scrollbar>

    <div
      class="fixed flex flex-col px-4 py-2 text-xs leading-8 text-center bg-white rounded shadow-lg"
      :style="{ left: `${contextmenuPositon.left}px`, top: `${contextmenuPositon.top}px` }"
      v-show="contextmenuShow"
    >
      <div @click="closeOther">
        <el-button :icon="Close" link size="small">关闭其他页签</el-button>
      </div>
      <div class="cursor-default" @click="closeAll">
        <el-button :icon="Minus" link size="small">关闭所有页签</el-button>
      </div>
    </div>
  </div>
</template>

TS

<script setup lang="ts">
import { ref } from 'vue'
import { ClickOutside as vClickOutside } from 'element-plus'
import { useTagsStore } from '~/store/tags'
import { useSidebarStore } from '~/store/sidebar'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { Close, Minus } from '@element-plus/icons-vue'

const route = useRoute()
const router = useRouter()
const tags = useTagsStore()
const siderbarStore = useSidebarStore()

const isActive = (path: string) => {
  return path === route.fullPath
}

function changeTab(e: string) {
  router.push(e)
}

let contextmenuShow = ref(false)
let contextmenuPositon = ref({ top: 0, left: 0 })
let currentIndex = ref(0)

function openContext(e: Event, index: number) {
  contextmenuShow.value = true
  currentIndex.value = index
  const { top, left } = getParentOffset(e.target)

  contextmenuPositon.value = {
    top: top - 38,
    left: left + e?.target?.clientWidth - (siderbarStore.collapse ? 64 : 200) - 84
  }
}

// 获取父元素的相对位移
function getParentOffset(el: any) {
  let offset = { top: 0, left: 0 }
  offset.top = el.offsetTop
  offset.left = el.offsetLeft

  if (el.offsetParent != null) {
    let offsetParent = getParentOffset(el.offsetParent)
    offset.top += offsetParent.top
    offset.left += offsetParent.left
  }
  return offset
}

const onClickOutside = () => {
  contextmenuShow.value = false
}

function removeTag(e: string) {
  const index = tags.list.findIndex((cur) => cur.path === e)
 
  tags.delTagsItem(index)
  const item = tags.list[index] ? tags.list[index] : tags.list[index - 1]
  if (item) {
    router.push(item.path)
  } else {
    router.push('/')
  }
}

// 设置标签
const setTags = (route: any) => {
  const isExist = tags.list.some((item) => {
    return item.path === route.fullPath
  })

  if (!isExist) {
    tags.setTagsItem({
      name: route.name,
      title: route.meta.title,
      path: route.fullPath
    })
  }
}
setTags(route)
onBeforeRouteUpdate((to) => {
  setTags(to)
})

// 关闭全部标签
const closeAll = () => {
  tags.clearTags()
  router.push('/')
  setTags(route)
}
// 关闭其他标签
const closeOther = () => {
  const curItem = tags.list.filter((item) => {
    return item.path === route.fullPath
  })
  tags.closeTagsOther(curItem)
}
</script>

v-click-outside

Element Plus自带的指令v-click-outside是个好东西,优雅解决了点击元素以外区域关闭元素的问题

<ul v-click-outside="onClickOutside">

const onClickOutside = () => {
  contextmenuShow.value = false
}

样式表

<style lang="scss">
.mo-tags {
  position: fixed;
  top: 60px;
  z-index: 1001;
  left: 200px;
  right: 0;
  height: 30px;
  transition: left 0.3s ease-in-out, width 0.3s ease-in-out;

  &.tag-collapse {
    left: 64px;
  }

  ul {
    display: flex;

    li {
      display: flex;
      align-items: center;
      flex-shrink: 0;
      padding-right: 4px;
      height: 24px;
      margin-top: 3px;
      font-size: 12px;

      margin-right: 2px;
      border: 1px solid var(--el-border-color);
      background: var(--el-fill-color-blank);
      border-radius: 2px;

      > span {
        padding: 0 4px 0 8px;
      }

      &.active {
        color: var(--el-color-primary);
      }

      &:hover {
        background-color: var(--el-bg-color-page);
      }
    }
  }
}
</style>

写完之后觉得页签并不是很复杂,但是也在好几个地方卡住了

  1. 页签太多怎么办?限制页签显示数量,还是让它们滚起来,选择了使用el-scrollbar让它们横向滚动,但是体验感一般。 还有一个缺陷就是——滚动之后再打开新页面或者滚出去的页签,未自动滚回来。
  2. 关闭弹层的位置,一开始取的是鼠标点击的位置,但是这样显示就不是很整齐,所以改了半天找到了元素的位置来定位。偶然发现VSCODE右击文件也是跟随鼠标位置出现浮层(以前真没注意过),现在犹豫要不要改回来
  3. 点击元素之外区域隐藏元素,这是个老问题,以前一直给window增加事件监听来关闭,后来在我研究Element Plus的popover组件时,发现了v-click-outside,很好用! 本来打算用popover做这个关闭的浮层,virtual-ref可以做出来脱离popover的效果,但是碰到了无法解决的问题,作罢,自己写吧。

因为本项目引用了tailwindcss,代码中还有别的引入模块,所以需要看效果可能还需要把代码下载跑起来。聪明如你,应该改一改也可以自己跑起来😄

本项目GIT地址:github.com/lucidity99/…