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:
parent
d88b807638
commit
1df44fd57e
4
components.d.ts
vendored
4
components.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user