页签组件
说明:实现头部页签相关功能,包括关闭、右键刷新/关闭右侧/关闭其他。
1.实现效果
2.页签组件HeaderTabs.vue
<template>
<el-tabs closable type="card" class="my-tabs" v-on="forwardEvents" v-model="modelValue">
<el-tab-pane v-for="item in data" :key="item.name" :name="item?.name as string">
<template #label>
<span class="custom-tab-label" @contextmenu.prevent="handleContextMenu($event, item)">
{{ item.meta && $t(item.meta?.title as string) }}
</span>
</template>
</el-tab-pane>
</el-tabs>
<el-dropdown
ref="dropdownRef"
:virtual-ref="triggerRef"
:show-arrow="false"
:popper-options="{
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
}"
virtual-triggering
trigger="contextmenu"
placement="bottom-start"
@command="commandHandler"
>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="closeRight">关闭右侧</el-dropdown-item>
<el-dropdown-item command="closeOther">关闭其他</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import type { TabsPaneContext, TabsProps, DropdownInstance } from 'element-plus'
import type { AppRouteMenuItem } from '../menu/type'
import { forwardEventsUtils } from '@/utils/format'
interface HeaderTabsProps extends Partial<TabsProps> {
data: AppRouteMenuItem[]
}
type TabPaneName = string | number
type HeaderTabsEvents = {
tabClick: [pane: TabsPaneContext, event: Event]
tabChange: [name: TabPaneName]
tabRemove: [name: TabPaneName]
tabAdd: []
edit: [paneName: TabPaneName, action: 'add' | 'remove']
}
type TabAction = 'refresh' | 'closeRight' | 'closeOther'
type contextMenuCommand = {
tabActions: [paneName: AppRouteMenuItem, action: TabAction]
}
defineProps<HeaderTabsProps>()
// 定义事件名称数组,包含标签页可能触发的所有事件类型
const eventName = ['tabClick', 'tabChange', 'tabRemove', 'tabAdd', 'edit', 'tabActions']
// 使用 defineEmits 定义组件可以触发的事件,类型为 HeaderTabsEvents
const emit = defineEmits<HeaderTabsEvents&contextMenuCommand>()
// 使用 forwardEventsUtils 函数处理事件转发,将 eventName 数组中定义的事件进行转发
const forwardEvents = forwardEventsUtils(emit, eventName)
const modelValue = defineModel<string>()
// 右键页签操作
const dropdownRef = ref<DropdownInstance>()
const position = ref({
top: 0,
left: 0,
bottom: 0,
right: 0
} as DOMRect)
const triggerRef = ref({
getBoundingClientRect: () => position.value
})
const contextMenuTab = ref<AppRouteMenuItem>()
const handleContextMenu = (event: MouseEvent, tab: AppRouteMenuItem) => {
const { clientX, clientY } = event
position.value = DOMRect.fromRect({
x: clientX,
y: clientY
})
event.preventDefault()
dropdownRef.value?.handleOpen()
contextMenuTab.value = tab
}
const commandHandler = (command: TabAction) => {
if (contextMenuTab.value) {
emit('tabActions', contextMenuTab.value, command)
}
}
</script>
<style scoped lang="scss">
.my-tabs {
:deep(.el-tabs__header) {
@apply p-0 m-0 border-b-none pl-1;
.el-tabs__nav {
@apply border-none;
}
}
:deep(.el-tabs__item) {
@apply py-0 h-[34px] px-4 mt-0!;
border-radius: 4px;
border: 1px solid var(--el-border-color) !important;
transition: padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !important;
margin-right: 5px;
&.is-active {
color: var(--el-color-primary) !important;
background: var(--el-color-primary-light-9) !important;
border: 1px solid var(--el-color-primary) !important;
}
}
:deep(.el-tabs__nav-next, .el-tabs__nav-prev) {
line-height: 35px !important;
}
}
.el-dropdown {
display: none !important;
}
</style>
3.tab状态存放在store中,tabs.ts
import type { AppRouteMenuItem } from '@/components/menu/type'
import { defineStore } from 'pinia'
export const useTabsStore = defineStore('tabs', {
state: () => ({
tabs: [] as AppRouteMenuItem[],
current: ''
}),
actions: {
addRoute(route: AppRouteMenuItem) {
if (this.tabs.some((item) => item.name === route.name)) return
this.tabs.push({ ...route })
},
removeRoute(path: string) {
this.tabs = this.tabs.filter((item) => item.name !== path)
},
closeOther(path: string) {
this.tabs = this.tabs.filter((item) => item.name === path)
},
closeRight(path: string) {
const findIndex = this.tabs.findIndex((item) => item.name === path)
this.tabs = this.tabs.slice(0, findIndex + 1)
}
},
persist: true
})
4.在默认布局中引用,default.vue
<template>
<div
class="w-full h-full position-absolute left-0 top-0 overflow-hidden flex"
:style="{ '--el-color-primary': setting?.theme }"
>
<!-- 左右布局 -->
<!-- sidebar -->
<div
:style="{
width: mixbarMenuWidth,
backgroundColor: setting?.backgroundColor
}"
class="h-full transition-width shrink-0"
v-if="setting?.mode !== 'top'"
>
<el-row class="h-full">
<el-scrollbar
v-if="setting?.mode !== 'mix'"
:class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
:style="{
backgroundColor:
setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
}"
>
<!-- 左侧菜单和左侧菜单混合模式的布局-->
<Menu
:class="[{ mixbar: setting?.mode === 'mixbar' }]"
v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
mode="vertical"
:data="mixbarMenus"
:collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
text-color="#b8b8b8"
:active-text-color="setting?.theme"
:background-color="
setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
"
@select="handleMenuSelect"
></Menu>
</el-scrollbar>
<el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
<!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
<Menu
mode="vertical"
:data="getSubMenus(menus)"
:collapse="localSettings.collapse"
text-color="#b8b8b8"
:active-text-color="setting?.theme"
:background-color="setting?.backgroundColor"
@select="handleMenuSelect"
></Menu
></el-scrollbar>
</el-row>
</div>
<!-- content -->
<div
:class="['w-full h-full flex-1 overflow-hidden', setting?.fixedHead ? '' : 'overflow-y-auto']"
>
<!-- header -->
<Header
:locales="locales"
:username="username"
:src="avatar"
:data="avatarMenu"
:setting="setting"
v-model:collapse="localSettings.collapse"
@setting-change="handleSettingChange"
@select="handleMenuSelect"
:class="[setting?.fixedHead ? 'fixed top-0 left-0 right-0 z-10' : '']"
>
<template #menu>
<!-- 顶部菜单和混合模式布局 -->
<Menu
v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
mode="horizontal"
:data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
:collapse="false"
@select="handleMenuSelect"
:active-text-color="setting?.theme"
></Menu>
</template>
</Header>
<HeaderTabs
v-model="tabsStore.current"
:data="tabsStore.tabs"
@tab-click="handleTabClick"
@tab-remove="handleRemoveTab"
@tab-actions="handleTabActions"
></HeaderTabs>
<!-- main -->
<div :class="[setting?.fixedHead ? 'overflow-y-auto h-full p-2 pb-25 bg-gray-100' : '']">
<el-card>
<router-view :key="routerKey"></router-view>
</el-card>
</div>
</div>
<!-- 移动端菜单抽屉 -->
<el-drawer
direction="ltr"
class="w-full!"
:style="{ backgroundColor: setting?.backgroundColor }"
v-if="isMobile"
:model-value="!localSettings.collapse"
@close="localSettings.collapse = true"
>
<Menu
text-color="#b8b8b8"
:active-text-color="setting?.theme"
:data="menus"
:background-color="setting?.backgroundColor"
@select="handleMenuSelect"
></Menu>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import HeaderTabs from '@/components/Layouts/HeaderTabs.vue'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { useTabsStore } from '@/stores/tabs'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
interface ThemeSettingsOptions extends HeaderProps {
username: string
avatar: string
avatarMenu: DropMenuItem[]
}
const router = useRouter()
const route = useRoute()
const tabsStore = useTabsStore()
// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
username: 'admin',
locales: [
{
name: 'zh-CN',
text: '中文',
icon: 'uil:letter-chinese-a'
},
{
text: '英文',
name: 'en',
icon: 'ri:english-input'
}
],
avatarMenu: [
{
key: '1',
value: '个人中心'
},
{
key: '2',
value: '修改密码'
},
{
key: 'divider',
value: ''
},
{
key: '4',
value: '退出登录'
}
],
avatar: '',
collapse: false,
setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)
// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
const menuData: AppRouteMenuItem[] = []
routes.forEach((route) => {
if (route.meta?.hideMenu) return
let menuItem: AppRouteMenuItem = {
name: route.name,
path: route.path,
meta: route.meta,
alias: typeof route.redirect === 'string' ? route.redirect : undefined,
component: route.component
}
// 判断是否有子路由,递归转换
if (route.children && Array.isArray(route.children) && route.children.length > 0) {
menuItem.children = genrateMenuData(route.children)
}
menuData.push(menuItem)
})
return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)
// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()
onMounted(() => {
console.log(getTopMenus(menus.value))
console.log(getSubMenus(menus.value))
})
// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
return getSubMenus(menus.value).every(
(item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
)
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
if (isMobile.value) return 0
if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
} else {
return localSettings.collapse ? '64px' : menuWidth.value + 'px'
}
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
if (menuItem && menuItem.name) {
router.push(menuItem.name as string)
if (isMobile.value) {
localSettings.collapse = true
}
}
}
// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
// 获取浏览器宽度
const { width } = entries[0].contentRect
if (tmpWidth.value === 0) {
// 记录初始宽度
tmpWidth.value = width
}
if (width > tmpWidth.value) {
// 扩大屏幕
changeWidthFlag.value = width < 640
} else {
// 缩小屏幕
changeWidthFlag.value = width > 1200
}
if (width < 640 && !changeWidthFlag.value) {
localSettings.collapse = true
}
if (width > 1200 && !changeWidthFlag.value) {
localSettings.collapse = false
}
// 是否是移动端屏幕宽度
isMobile.value = width < 440
tmpWidth.value = width
})
onBeforeMount(() => {
// 是否是移动端屏幕
if (
navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
)
) {
isMobile.value = true
localSettings.collapse = true
}
})
// 路由监听,tabsStore添加路由
watch(
route,
() => {
tabsStore.addRoute(route)
tabsStore.current = route.name
},
{
immediate: true
}
)
// 点击tab切换路由页面
const handleTabClick = (tab) => {
const { index } = tab
const route = tabsStore.tabs[index]
router.push(route.name as string)
}
// 关闭tab,激活上一个tab
const handleRemoveTab = (tab) => {
tabsStore.removeRoute(tab)
if (tabsStore.current === tab) {
if (tabsStore.tabs.length !== 0) {
tabsStore.current = tabsStore.tabs[tabsStore.tabs.length - 1].name as string
} else {
// 删除最后一个tab,跳转到首页
const tmpRoute = menus.value.filter((item) => item.path === '/')[0]
tabsStore.addRoute(tmpRoute)
tabsStore.current = tmpRoute.name as string
}
router.push(tabsStore.current as string)
}
}
// 如果需要手动刷新,可以修改 key 的依赖,例如增加一个刷新计数器
const refreshKey = ref(0)
const routerKey = computed(() => route.fullPath + refreshKey.value)
// 页签操作
const handleTabActions = (tab, action) => {
if (action === 'refresh') {
router.push(tab.name as string)
refreshKey.value++
} else if(action === 'closeRight') {
tabsStore.closeRight(tab.name as string)
} else if(action === 'closeOther') {
tabsStore.closeOther(tab.name as string)
}
}
</script>
<style lang="scss" scoped>
.mixbar {
:deep(.el-menu-item) {
height: auto;
line-height: unset !important;
flex-direction: column;
margin-bottom: 15px;
padding: 4px 0 !important;
svg {
margin-right: 0;
margin-bottom: 10px;
}
}
}
</style>