从零搭建基于element-plus的Vue3项目06——实现多Tab页面

目前很多后台管理框架都支持多TAB模式,效果如下:

image-20240113190850344

因此我们这也要支持多个TAB,要支持多个TAB,需要存储多个TAB页面的名称等信息。

此功能相对比较复杂,难度较大

基本功能设计

支持功能:

  1. 支持TAB名称和图标展示
  2. 支持TAB关闭
  3. TAB支持右键菜单,可以关闭多个TAB
  4. KeepAlive缓存TAB状态
  5. 关闭后KeepAlive缓存移除
  6. 父子关系的页面在相同的TAB中展示

存储TAB信息

新建一个TabsViewStore存储TAB信息,存储TAB和提供TAB的增删等操作action

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

export const useTabsViewStore = defineStore('tabsView', () => {
  const isTabMode = ref(true) // 是否开启tab模式
  const isCachedTabMode = ref(true) // 是否缓存
  const isShowTabIcon = ref(true) // 是否显示TAB的图标
  const currentTab = ref('') // 当前TAB
  const historyTabs = ref([]) // TAB记录
  const cachedTabs = ref([]) // 缓存的KEY,直接给keepalive使用

  const clearAllTabs = () => {
    historyTabs.value = []
    cachedTabs.value = []
  }
  const clearHistoryTabs = () => {
    if (historyTabs.value.length) {
      let idx = historyTabs.value.findIndex(v => currentTab.value && v.path === currentTab.value)
      idx = idx > -1 ? idx : 0
      const tab = historyTabs.value[idx]
      removeOtherHistoryTabs(tab)
    }
  }
  const findHistoryTab = (path) => {
    const idx = historyTabs.value.findIndex(v => v.path === path)
    if (idx > -1) {
      return historyTabs.value[idx]
    }
  }
  const checkMataReplaceHistory = (historyTab, tab) => {
    // 如果meta中配置有replaceTabHistory,默认替换相关的tab
    return historyTab.meta && historyTab.meta.replaceTabHistory && historyTab.meta.replaceTabHistory === tab.name
  }
  const isSameReplaceHistory = (historyTab, tab) => {
    return historyTab.meta && historyTab.meta.replaceTabHistory && tab.meta && tab.meta.replaceTabHistory &&
        historyTab.meta.replaceTabHistory === tab.meta.replaceTabHistory
  }
  const addHistoryTab = (tab) => {
    // 添加tab
    if (isTabMode.value) {
      const idx = historyTabs.value.findIndex(v => v.path === tab.path)
      if (idx < 0) {
        const replaceIdx = historyTabs.value.findIndex(v => checkMataReplaceHistory(v, tab) ||
            checkMataReplaceHistory(tab, v) || isSameReplaceHistory(v, tab))
        let replaceTab = null
        if (replaceIdx > -1) {
          replaceTab = historyTabs.value[replaceIdx]
          historyTabs.value.splice(replaceIdx, 1, Object.assign({}, tab))
        } else {
          historyTabs.value.push(Object.assign({}, tab)) // 可能是Proxy,需要解析出来
        }
        addCachedTab(tab, replaceTab)
      }
    }
  }
  const removeHistoryTab = tab => {
    if (historyTabs.value.length > 1) {
      const idx = historyTabs.value.findIndex(v => v.path === tab.path)
      if (idx > -1) {
        removeCachedTab(historyTabs.value[idx])
        // 删除tab
        historyTabs.value.splice(idx, 1)
      }
      return historyTabs.value[historyTabs.value.length - 1]
    }
  }
  const removeOtherHistoryTabs = tab => {
    historyTabs.value = [tab]
    cachedTabs.value = []
    if (isCachedTabMode.value && tab.name) {
      cachedTabs.value = [tab.name]
    }
  }
  const removeHistoryTabs = (tab, type) => {
    if (tab) {
      const idx = cachedTabs.value.findIndex(v => v === tab.name)
      let removeTabs = []
      if (type === 'right') {
        removeTabs = historyTabs.value.splice(idx + 1)
      } else if (type === 'left') {
        removeTabs = historyTabs.value.splice(0, idx)
      }
      if (removeTabs.length) {
        removeTabs.forEach(removeCachedTab)
      }
    }
  }
  const addCachedTab = (tab, replaceTab) => {
    if (isCachedTabMode.value && tab.name && !tab.name.includes('-')) {
      removeCachedTab(replaceTab)
      if (!cachedTabs.value.includes(tab.name)) {
        cachedTabs.value.push(tab.name)
      }
    }
  }

  const removeCachedTab = tab => {
    if (tab) {
      const idx = cachedTabs.value.findIndex(v => v === tab.name)
      if (idx > -1) {
        cachedTabs.value.splice(idx, 1)
      }
    }
  }

  const hasCloseDropdown = (tab, type) => {
    const idx = historyTabs.value.findIndex(v => v.path === tab.path)
    switch (type) {
      case 'close':
      case 'other':
        return historyTabs.value.length > 1
      case 'left':
        return idx !== 0
      case 'right':
        return idx !== historyTabs.value.length - 1
    }
  }

  return {
    isTabMode,
    isCachedTabMode,
    isShowTabIcon,
    currentTab,
    historyTabs,
    cachedTabs,
    changeTabMode (val) {
      isTabMode.value = val
      if (!isTabMode.value) {
        clearHistoryTabs()
      }
    },
    changeCachedTabMode (val) {
      isCachedTabMode.value = val
      if (!isCachedTabMode.value) {
        cachedTabs.value = []
      }
    },
    removeHistoryTab,
    removeOtherHistoryTabs,
    removeHistoryTabs,
    clearAllTabs,
    clearHistoryTabs,
    findHistoryTab,
    addHistoryTab,
    addCachedTab,
    removeCachedTab,
    hasCloseDropdown
  }
}, {
  persist: true
})

封装TABS组件

循环store中的tabsViewStore.historyTabs,并显示到页面上,使用了el-tabs,不过每个TAB的内容部分并不需要,内容使用router-view来展示

<script setup>
import { useTabsViewStore } from '@/stores/TabsViewStore'
import { useRoute, useRouter } from 'vue-router'
import { onMounted, ref, watch } from 'vue'
import isString from 'lodash/isString'
import TabsViewItem from '@/components/common-tabs-view/tabs-view-item.vue'

const router = useRouter()
const route = useRoute()
const tabsViewStore = useTabsViewStore()
watch(route, () => {
  if (route.path) {
    tabsViewStore.addHistoryTab(route)
    tabsViewStore.currentTab = route.path
  }
})

onMounted(() => {
  if (!tabsViewStore.historyTabs.length) {
    tabsViewStore.addHistoryTab(route)
  }
  tabsViewStore.currentTab = route.path
})

const selectHistoryTab = path => {
  const tab = isString(path) ? tabsViewStore.findHistoryTab(path) : path
  if (tab) {
    router.push(tab)
    tabsViewStore.addCachedTab(tab)
  }
}

const removeHistoryTab = path => {
  const lastTab = tabsViewStore.removeHistoryTab({ path })
  if (lastTab) {
    selectHistoryTab(lastTab)
  }
}

const refreshHistoryTab = tab => {
  const time = new Date().getTime()
  router.push(`${tab.path}?${time}`)
  tabsViewStore.addCachedTab(tab)
}

const removeOtherHistoryTabs = tab => {
  tabsViewStore.removeOtherHistoryTabs(tab)
  selectHistoryTab(tab.path)
}

const removeHistoryTabs = (tab, type) => {
  tabsViewStore.removeHistoryTabs(tab, type)
  selectHistoryTab(tab.path)
}

const tabItems = ref()
const onDropdownVisibleChange = (visible, tab) => {
  if (visible) {
    tabItems.value.forEach(({ dropdownRef }) => {
      console.info(Object.assign({}, dropdownRef))
      if (dropdownRef.id !== tab.path) {
        dropdownRef.handleClose()
      }
    })
  }
}

</script>

<template>
  <el-tabs
    v-bind="$attrs"
    v-model="tabsViewStore.currentTab"
    class="common-tabs"
    type="card"
    :closable="tabsViewStore.historyTabs.length>1"
    @tab-change="selectHistoryTab"
    @tab-remove="removeHistoryTab"
  >
    <tabs-view-item
      v-for="item in tabsViewStore.historyTabs"
      ref="tabItems"
      :key="item.path"
      :tab-item="item"
      @refresh-history-tab="refreshHistoryTab"
      @remove-other-history-tabs="removeOtherHistoryTabs"
      @remove-history-tabs="removeHistoryTabs"
      @on-dropdown-visible-change="onDropdownVisibleChange"
      @remove-history-tab="removeHistoryTab"
    />
  </el-tabs>
</template>

<style scoped>
.common-tabs .el-tabs__header {
  margin: 0;
}
</style>

TabsViewItem组件

这个组件用于生成实际的每个tab的样式,并添加右键菜单支持关闭等操作,菜单标题、图标等信息从菜单配置或者路由配置中获取。

image-20240113192552196

<script setup>
import { useMenuInfo, useMenuName } from '@/components/utils'
import { computed, ref } from 'vue'
import { useTabsViewStore } from '@/stores/TabsViewStore'

const tabsViewStore = useTabsViewStore()

const props = defineProps({
  tabItem: {
    type: Object,
    required: true
  }
})

defineEmits(['removeHistoryTab', 'removeOtherHistoryTabs', 'removeHistoryTabs', 'refreshHistoryTab', 'onDropdownVisibleChange'])

const menuName = computed(() => {
  return useMenuName(props.tabItem)
})

const menuInfo = computed(() => {
  return props.tabItem.path === '/' ? { icon: 'HomeFilled' } : useMenuInfo(props.tabItem)
})

const menuIcon = computed(() => {
  if (menuInfo.value && menuInfo.value.icon) {
    return menuInfo.value.icon
  }
  if (props.tabItem.meta && props.tabItem.meta.icon) {
    return props.tabItem.meta.icon
  }
  return null
})
const dropdownRef = ref()

defineExpose({
  dropdownRef
})

</script>

<template>
  <el-tab-pane
    :name="tabItem.path"
  >
    <template #label>
      <el-dropdown
        :id="tabItem.path"
        ref="dropdownRef"
        trigger="contextmenu"
        @visible-change="$emit('onDropdownVisibleChange', $event, tabItem)"
      >
        <span class="custom-tabs-label">
          <common-icon
            v-if="tabsViewStore.isShowTabIcon && menuIcon"
            :icon="menuIcon"
          />
          <span>{{ menuName }}</span>
        </span>
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item
              @click="$emit('refreshHistoryTab', tabItem)"
            >
              <common-icon icon="refresh" />
              {{ $t('common.label.refresh') }}
            </el-dropdown-item>
            <el-dropdown-item
              v-if="tabsViewStore.hasCloseDropdown(tabItem, 'close')"
              @click="$emit('removeHistoryTab', tabItem.path)"
            >
              <common-icon icon="close" />
              {{ $t('common.label.close') }}
            </el-dropdown-item>
            <el-dropdown-item
              v-if="tabsViewStore.hasCloseDropdown(tabItem, 'left')"
              @click="$emit('removeHistoryTabs', tabItem, 'left')"
            >
              <common-icon icon="KeyboardDoubleArrowLeftFilled" />
              {{ $t('common.label.closeLeft') }}
            </el-dropdown-item>
            <el-dropdown-item
              v-if="tabsViewStore.hasCloseDropdown(tabItem, 'right')"
              @click="$emit('removeHistoryTabs', tabItem, 'right')"
            >
              <common-icon icon="KeyboardDoubleArrowRightFilled" />
              {{ $t('common.label.closeRight') }}
            </el-dropdown-item>
            <el-dropdown-item
              v-if="tabsViewStore.hasCloseDropdown(tabItem, 'other')"
              @click="$emit('removeOtherHistoryTabs', tabItem)"
            >
              <common-icon icon="PlaylistRemoveFilled" />
              {{ $t('common.label.closeOther') }}
            </el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
    </template>
  </el-tab-pane>
</template>

<style scoped>
.common-tabs .custom-tabs-label .el-icon {
  vertical-align: middle;
}
.common-tabs .custom-tabs-label span {
  vertical-align: middle;
  margin-left: 4px;
}
</style>

获取展示label,优先从菜单信息(可以用接口)获取,没有就从路由的meta获取:

export const useMenuName = item => {
  const menuInfo = useMenuInfo(item)
  if (menuInfo) {
    if (menuInfo.label) {
      return menuInfo.label
    }
    if (menuInfo.labelKey) {
      return $i18nBundle(menuInfo.labelKey)
    }
  }
  if (item.meta && item.meta.labelKey) {
    return $i18nBundle(item.meta.labelKey)
  }
  return item.name || 'No Name'
}

KeepAlive配置

是否展示tabs视图部分

  <el-header
    v-if="tabsViewStore.isTabMode"
    class="tabs-header"
  >
    <common-tabs-view />
  </el-header>

KeepAlive配置路由,使用include指向tabsViewStore.cachedTabs

<router-view v-slot="{ Component, route }">
  <transition
    name="slide-fade"
    mode="out-in"
  >
    <KeepAlive
      :include="tabsViewStore.cachedTabs"
      :max="10"
    >
      <component
        :is="Component"
        :key="route.fullPath"
      />
    </KeepAlive>
  </transition>
</router-view>

有点需要注意,KeepAlive的include里面数组的字符串一定要用Component的名字,因此如果route的name和component的名字不一样可能缓存无效

配置完成后可以看到切换TAB时,并没有重复执行onMounted

最终开源地址:

https://github.com/fugary/simple-element-plus-template

评论

  1. 包子来袭
    3月前
    2024-1-19 17:56:12

    博主能出一期你这个博客的搭建教程吗

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇