feat: complete multi-tab (fixed home, neighbor nav, right-click close)

- 首页 tab 固定不可关闭,tab 持久化(刷新保留)
- 关闭激活 tab 跳转到相邻 tab(而非最后一个)
- 自定义 tab 条 + 右键菜单:关闭其他/左侧/右侧/全部

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 10:04:06 +08:00
parent d88b807638
commit 1df44fd57e
5 changed files with 190 additions and 46 deletions

4
components.d.ts vendored
View File

@ -40,8 +40,6 @@ declare module 'vue' {
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
Icon: typeof import('./src/components/icon/icon.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
@ -95,8 +93,6 @@ declare global {
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTable: typeof import('element-plus/es')['ElTable']
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
const ElTabPane: typeof import('element-plus/es')['ElTabPane']
const ElTabs: typeof import('element-plus/es')['ElTabs']
const ElTag: typeof import('element-plus/es')['ElTag']
const Icon: typeof import('./src/components/icon/icon.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -1,23 +1,40 @@
<template>
<el-tabs
v-if="tabs.length"
class="ty-tabs"
type="card"
:model-value="activePath"
closable
@tab-click="onTabClick"
@tab-remove="onTabRemove"
<div v-if="tabs.length" class="ty-tabs">
<div
v-for="tab in tabs"
:key="tab.path"
class="ty-tabs__item"
:class="{ 'is-active': tab.path === activePath }"
@click="emit('click', tab.path)"
@contextmenu.prevent="openMenu($event, tab)"
>
<el-tab-pane v-for="tab in tabs" :key="tab.path" :label="tab.title" :name="tab.path" :closable="tab.closable" />
</el-tabs>
<span class="ty-tabs__title">{{ tab.title }}</span>
<span v-if="tab.closable" class="ty-tabs__close" @click.stop="emit('remove', tab.path)">
<icon type="iconify" icon="mdi:close" />
</span>
</div>
<div
v-if="menu.visible"
class="ty-tabs__context-menu"
:style="{ left: `${menu.x}px`, top: `${menu.y}px` }"
@click.stop
>
<div class="ty-tabs__context-item" @click="onMenu('close-others')">关闭其他</div>
<div class="ty-tabs__context-item" @click="onMenu('close-left')">关闭左侧</div>
<div class="ty-tabs__context-item" @click="onMenu('close-right')">关闭右侧</div>
<div class="ty-tabs__context-item" @click="onMenu('close-all')">关闭全部</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive } from 'vue'
import type { TabItem } from '@/stores/modules/tabs'
defineOptions({ name: 'TyTabs' })
const props = defineProps<{
defineProps<{
/** 打开的页签。 */
tabs: TabItem[]
/** 当前激活的路径。 */
@ -27,34 +44,104 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'click', path: string): void
(e: 'remove', path: string): void
(e: 'close-others', path: string): void
(e: 'close-left', path: string): void
(e: 'close-right', path: string): void
(e: 'close-all'): void
}>()
function onTabClick(pane: { paneName?: string | number }): void {
if (pane.paneName != null) emit('click', String(pane.paneName))
const menu = reactive({ visible: false, x: 0, y: 0, path: '' })
function openMenu(e: MouseEvent, tab: TabItem): void {
menu.x = e.clientX
menu.y = e.clientY
menu.path = tab.path
menu.visible = true
}
function onTabRemove(name: string | number): void {
emit('remove', String(name))
function onMenu(action: 'close-others' | 'close-left' | 'close-right' | 'close-all'): void {
menu.visible = false
switch (action) {
case 'close-all':
emit('close-all')
break
case 'close-others':
emit('close-others', menu.path)
break
case 'close-left':
emit('close-left', menu.path)
break
case 'close-right':
emit('close-right', menu.path)
break
}
}
function closeMenu(): void {
menu.visible = false
}
onMounted(() => document.addEventListener('click', closeMenu))
onBeforeUnmount(() => document.removeEventListener('click', closeMenu))
</script>
<style scoped lang="scss">
.ty-tabs {
--el-tabs-header-height: 36px;
@apply flex items-center gap-1 overflow-x-auto px-2;
height: 36px;
background: var(--wm-bg-color-base);
border-bottom: 1px solid var(--wm-border-color-light);
:deep(.el-tabs__header) {
margin: 0;
padding: 6px 8px 0;
background: var(--wm-bg-color-base);
&__item {
@apply flex flex-shrink-0 cursor-pointer items-center gap-1;
height: 28px;
padding: 0 10px;
border-radius: var(--wm-border-radius-base);
font-size: var(--wm-font-size-sm);
color: var(--wm-color-text-regular);
&:hover {
background: var(--wm-bg-color-page);
}
:deep(.el-tabs__nav) {
border: none;
}
:deep(.el-tabs__item.is-active) {
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
&__close {
@apply flex items-center justify-center;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 12px;
&:hover {
color: #fff;
background: var(--wm-color-text-placeholder);
}
}
&__context-menu {
@apply fixed z-50;
min-width: 110px;
padding: 4px 0;
background: var(--wm-bg-color-base);
border: 1px solid var(--wm-border-color-light);
border-radius: var(--wm-border-radius-base);
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
&__context-item {
@apply cursor-pointer;
padding: 6px 14px;
font-size: var(--wm-font-size-sm);
color: var(--wm-color-text-regular);
&:hover {
background: var(--wm-bg-color-page);
}
}
}
</style>

View File

@ -48,8 +48,12 @@
v-if="app.showTabs"
:tabs="tabsStore.tabs"
:active-path="activePath"
@click="handleNavigate"
@click="handleTabClick"
@remove="handleTabRemove"
@close-others="handleCloseOthers"
@close-left="handleCloseLeft"
@close-right="handleCloseRight"
@close-all="handleCloseAll"
/>
<div class="app-main">
<router-view />
@ -66,7 +70,7 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/modules/app'
import { useTabsStore } from '@/stores/modules/tabs'
import { HOME_PATH, useTabsStore } from '@/stores/modules/tabs'
import { useAuthStore } from '@/stores/modules/auth'
import { placeholderMenus } from '@/config/menu'
import logo from '@/assets/images/logo.png'
@ -173,14 +177,39 @@ function handleTopSelect(code: string): void {
if (target) router.push(target)
}
function handleTabClick(path: string): void {
router.push(path)
}
function handleTabRemove(path: string): void {
const index = tabsStore.tabs.findIndex((t) => t.path === path)
const neighbor = tabsStore.tabs[index - 1] ?? tabsStore.tabs[index + 1] ?? tabsStore.tabs[0]
tabsStore.removeTab(path)
if (activePath.value === path) {
const last = tabsStore.tabs[tabsStore.tabs.length - 1]
router.push(last?.path ?? '/')
router.push(neighbor?.path ?? HOME_PATH)
}
}
function handleCloseOthers(path: string): void {
tabsStore.removeOthers(path)
router.push(path)
}
function handleCloseLeft(path: string): void {
tabsStore.removeLeft(path)
if (!tabsStore.tabs.some((t) => t.path === activePath.value)) router.push(path)
}
function handleCloseRight(path: string): void {
tabsStore.removeRight(path)
if (!tabsStore.tabs.some((t) => t.path === activePath.value)) router.push(path)
}
function handleCloseAll(): void {
tabsStore.removeAll()
router.push(HOME_PATH)
}
async function handleLogout(): Promise<void> {
await auth.logout()
router.push('/login')

View File

@ -7,9 +7,13 @@ export interface TabItem {
closable: boolean
}
/** 多页签状态(会话级,不持久化)。 */
export const useTabsStore = defineStore('tabsStore', () => {
const tabs = ref<TabItem[]>([])
export const HOME_PATH = '/'
/** 多页签状态(持久化,刷新保留)。 */
export const useTabsStore = defineStore(
'tabsStore',
() => {
const tabs = ref<TabItem[]>([{ path: HOME_PATH, title: '首页', closable: false }])
function addTab(tab: TabItem): void {
if (!tabs.value.some((t) => t.path === tab.path)) {
@ -18,8 +22,36 @@ export const useTabsStore = defineStore('tabsStore', () => {
}
function removeTab(path: string): void {
const tab = tabs.value.find((t) => t.path === path)
if (!tab || !tab.closable) return
tabs.value = tabs.value.filter((t) => t.path !== path)
}
return { tabs, addTab, removeTab }
})
/** 关闭其他(保留首页与指定 tab)。 */
function removeOthers(path: string): void {
tabs.value = tabs.value.filter((t) => !t.closable || t.path === path)
}
/** 关闭左侧(保留首页、指定 tab 及其右侧)。 */
function removeLeft(path: string): void {
const index = tabs.value.findIndex((t) => t.path === path)
if (index < 0) return
tabs.value = tabs.value.filter((t, i) => !t.closable || i >= index)
}
/** 关闭右侧(保留首页、指定 tab 及其左侧)。 */
function removeRight(path: string): void {
const index = tabs.value.findIndex((t) => t.path === path)
if (index < 0) return
tabs.value = tabs.value.filter((t, i) => !t.closable || i <= index)
}
/** 关闭全部(仅保留首页)。 */
function removeAll(): void {
tabs.value = tabs.value.filter((t) => !t.closable)
}
return { tabs, addTab, removeTab, removeOthers, removeLeft, removeRight, removeAll }
},
{ persist: true },
)

File diff suppressed because one or more lines are too long