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']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
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']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
Icon: typeof import('./src/components/icon/icon.vue')['default']
|
Icon: typeof import('./src/components/icon/icon.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
@ -95,8 +93,6 @@ declare global {
|
|||||||
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
const ElTable: typeof import('element-plus/es')['ElTable']
|
const ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
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 ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
const Icon: typeof import('./src/components/icon/icon.vue')['default']
|
const Icon: typeof import('./src/components/icon/icon.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
|||||||
@ -1,23 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-tabs
|
<div v-if="tabs.length" class="ty-tabs">
|
||||||
v-if="tabs.length"
|
<div
|
||||||
class="ty-tabs"
|
v-for="tab in tabs"
|
||||||
type="card"
|
:key="tab.path"
|
||||||
:model-value="activePath"
|
class="ty-tabs__item"
|
||||||
closable
|
:class="{ 'is-active': tab.path === activePath }"
|
||||||
@tab-click="onTabClick"
|
@click="emit('click', tab.path)"
|
||||||
@tab-remove="onTabRemove"
|
@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" />
|
<span class="ty-tabs__title">{{ tab.title }}</span>
|
||||||
</el-tabs>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, reactive } from 'vue'
|
||||||
import type { TabItem } from '@/stores/modules/tabs'
|
import type { TabItem } from '@/stores/modules/tabs'
|
||||||
|
|
||||||
defineOptions({ name: 'TyTabs' })
|
defineOptions({ name: 'TyTabs' })
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
/** 打开的页签。 */
|
/** 打开的页签。 */
|
||||||
tabs: TabItem[]
|
tabs: TabItem[]
|
||||||
/** 当前激活的路径。 */
|
/** 当前激活的路径。 */
|
||||||
@ -27,34 +44,104 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click', path: string): void
|
(e: 'click', path: string): void
|
||||||
(e: 'remove', 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 {
|
const menu = reactive({ visible: false, x: 0, y: 0, path: '' })
|
||||||
if (pane.paneName != null) emit('click', String(pane.paneName))
|
|
||||||
|
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 {
|
function onMenu(action: 'close-others' | 'close-left' | 'close-right' | 'close-all'): void {
|
||||||
emit('remove', String(name))
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.ty-tabs {
|
.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);
|
background: var(--wm-bg-color-base);
|
||||||
|
border-bottom: 1px solid var(--wm-border-color-light);
|
||||||
|
|
||||||
:deep(.el-tabs__header) {
|
&__item {
|
||||||
margin: 0;
|
@apply flex flex-shrink-0 cursor-pointer items-center gap-1;
|
||||||
padding: 6px 8px 0;
|
height: 28px;
|
||||||
background: var(--wm-bg-color-base);
|
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) {
|
&.is-active {
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-tabs__item.is-active) {
|
|
||||||
color: var(--el-color-primary);
|
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>
|
</style>
|
||||||
|
|||||||
@ -48,8 +48,12 @@
|
|||||||
v-if="app.showTabs"
|
v-if="app.showTabs"
|
||||||
:tabs="tabsStore.tabs"
|
:tabs="tabsStore.tabs"
|
||||||
:active-path="activePath"
|
:active-path="activePath"
|
||||||
@click="handleNavigate"
|
@click="handleTabClick"
|
||||||
@remove="handleTabRemove"
|
@remove="handleTabRemove"
|
||||||
|
@close-others="handleCloseOthers"
|
||||||
|
@close-left="handleCloseLeft"
|
||||||
|
@close-right="handleCloseRight"
|
||||||
|
@close-all="handleCloseAll"
|
||||||
/>
|
/>
|
||||||
<div class="app-main">
|
<div class="app-main">
|
||||||
<router-view />
|
<router-view />
|
||||||
@ -66,7 +70,7 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/modules/app'
|
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 { useAuthStore } from '@/stores/modules/auth'
|
||||||
import { placeholderMenus } from '@/config/menu'
|
import { placeholderMenus } from '@/config/menu'
|
||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
@ -173,14 +177,39 @@ function handleTopSelect(code: string): void {
|
|||||||
if (target) router.push(target)
|
if (target) router.push(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTabClick(path: string): void {
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
function handleTabRemove(path: string): void {
|
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)
|
tabsStore.removeTab(path)
|
||||||
if (activePath.value === path) {
|
if (activePath.value === path) {
|
||||||
const last = tabsStore.tabs[tabsStore.tabs.length - 1]
|
router.push(neighbor?.path ?? HOME_PATH)
|
||||||
router.push(last?.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> {
|
async function handleLogout(): Promise<void> {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
|
|||||||
@ -7,9 +7,13 @@ export interface TabItem {
|
|||||||
closable: boolean
|
closable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 多页签状态(会话级,不持久化)。 */
|
export const HOME_PATH = '/'
|
||||||
export const useTabsStore = defineStore('tabsStore', () => {
|
|
||||||
const tabs = ref<TabItem[]>([])
|
/** 多页签状态(持久化,刷新保留)。 */
|
||||||
|
export const useTabsStore = defineStore(
|
||||||
|
'tabsStore',
|
||||||
|
() => {
|
||||||
|
const tabs = ref<TabItem[]>([{ path: HOME_PATH, title: '首页', closable: false }])
|
||||||
|
|
||||||
function addTab(tab: TabItem): void {
|
function addTab(tab: TabItem): void {
|
||||||
if (!tabs.value.some((t) => t.path === tab.path)) {
|
if (!tabs.value.some((t) => t.path === tab.path)) {
|
||||||
@ -18,8 +22,36 @@ export const useTabsStore = defineStore('tabsStore', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeTab(path: string): void {
|
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)
|
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