目前很多后台管理框架都支持多TAB
模式,效果如下:
因此我们这也要支持多个TAB
,要支持多个TAB
,需要存储多个TAB
页面的名称等信息。
此功能相对比较复杂,难度较大
基本功能设计
支持功能:
- 支持
TAB
名称和图标展示 - 支持
TAB
关闭 TAB
支持右键菜单,可以关闭多个TAB
- 用
KeepAlive
缓存TAB
状态 - 关闭后
KeepAlive
缓存移除 - 父子关系的页面在相同的
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的样式,并添加右键菜单支持关闭等操作,菜单标题、图标等信息从菜单配置或者路由配置中获取。
<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
。
最终开源地址:
博主能出一期你这个博客的搭建教程吗