390 lines
9.9 KiB
Vue
390 lines
9.9 KiB
Vue
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { ChevronLeft, ChevronRight, X } from '@lucide/vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useAppStore } from '@/stores/app'
|
||
import Button from '@/components/ui/button/button.vue'
|
||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||
import { cacheKeyOf } from '@/utils/cacheKey'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const appStore = useAppStore()
|
||
|
||
const trackRef = ref(null)
|
||
const overflowed = ref(false)
|
||
const canScrollLeft = ref(false)
|
||
const canScrollRight = ref(false)
|
||
const activeKey = computed(() => route.fullPath)
|
||
const tabs = computed(() => appStore.visitedTabs)
|
||
|
||
// 路由变化自动添加页签(工作台固定不可关)
|
||
watch(
|
||
() => route.fullPath,
|
||
async () => {
|
||
const title = route.meta?.title
|
||
if (title) {
|
||
// cacheByKey 路由(如 /detail/:id):每个 fullPath 一个独立组件名,
|
||
// 使 keep-alive 的 include 能按参数精确销毁其实例,且不同参数天然多个独立缓存。
|
||
const name = route.meta?.cacheByKey ? cacheKeyOf(route.fullPath) : route.meta?.componentName
|
||
appStore.addVisitedTab({
|
||
key: route.fullPath,
|
||
label: title,
|
||
closable: route.fullPath !== '/dashboard',
|
||
name,
|
||
})
|
||
}
|
||
await nextTick()
|
||
revealActiveTab()
|
||
updateScrollState()
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(
|
||
() => appStore.visitedTabs.length,
|
||
async () => {
|
||
await nextTick()
|
||
updateScrollState()
|
||
},
|
||
)
|
||
|
||
/* ---- 溢出检测(替代 @vueuse/core useResizeObserver) ---- */
|
||
let resizeObserver = null
|
||
|
||
onMounted(() => {
|
||
resizeObserver = new ResizeObserver(updateScrollState)
|
||
if (trackRef.value) resizeObserver.observe(trackRef.value)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
resizeObserver?.disconnect()
|
||
resizeObserver = null
|
||
})
|
||
|
||
function changeTab(key) {
|
||
if (key !== route.fullPath) router.push(key)
|
||
}
|
||
|
||
function closeTab(key) {
|
||
if (key === '/dashboard') return
|
||
const currentIndex = tabs.value.findIndex((t) => t.key === key)
|
||
const fallback = tabs.value[currentIndex - 1] ?? tabs.value[currentIndex + 1] ?? tabs.value[0]
|
||
appStore.removeVisitedTab(key)
|
||
if (route.fullPath === key && fallback) router.push(fallback.key)
|
||
}
|
||
|
||
/* ---- 右键菜单:刷新 / 关闭 / 关闭其他 / 关闭左侧 / 关闭右侧 / 关闭全部 ---- */
|
||
function tabContextItems(tab) {
|
||
const list = tabs.value
|
||
const idx = list.findIndex((t) => t.key === tab.key)
|
||
const hasClosableOthers = list.some((t) => t.key !== '/dashboard' && t.key !== tab.key)
|
||
const hasLeft = list.slice(0, idx).some((t) => t.key !== '/dashboard')
|
||
const hasRight = idx < list.length - 1
|
||
return [
|
||
{ key: 'refresh', label: '刷新' },
|
||
{ key: 'close', label: '关闭', disabled: !tab.closable },
|
||
{ divider: true },
|
||
{ key: 'closeOthers', label: '关闭其他', disabled: !hasClosableOthers },
|
||
{ key: 'closeLeft', label: '关闭左侧', disabled: !hasLeft },
|
||
{ key: 'closeRight', label: '关闭右侧', disabled: !hasRight },
|
||
{ key: 'closeAll', label: '关闭全部', disabled: list.length <= 1 },
|
||
].map((it) => ({ ...it, onClick: () => handleTabAction(it.key, tab.key) }))
|
||
}
|
||
|
||
// 批量操作后:若当前路由页签已被关闭,跳转到 fallback(右键项本身或工作台)
|
||
function afterClose(fallbackKey) {
|
||
if (tabs.value.some((t) => t.key === route.fullPath)) return
|
||
router.push(fallbackKey)
|
||
}
|
||
|
||
function handleTabAction(action, key) {
|
||
switch (action) {
|
||
case 'refresh':
|
||
refreshTab(key)
|
||
break
|
||
case 'close':
|
||
closeTab(key)
|
||
break
|
||
case 'closeOthers':
|
||
appStore.closeOthers(key)
|
||
afterClose(key)
|
||
break
|
||
case 'closeLeft':
|
||
appStore.closeLeft(key)
|
||
afterClose(key)
|
||
break
|
||
case 'closeRight':
|
||
appStore.closeRight(key)
|
||
afterClose(key)
|
||
break
|
||
case 'closeAll':
|
||
appStore.closeAll()
|
||
afterClose('/dashboard')
|
||
break
|
||
}
|
||
}
|
||
|
||
function updateScrollState() {
|
||
const track = trackRef.value
|
||
if (!track) return
|
||
const shouldOverflow = track.scrollWidth > track.clientWidth + 1
|
||
overflowed.value = shouldOverflow
|
||
if (!shouldOverflow) {
|
||
track.scrollLeft = 0
|
||
canScrollLeft.value = false
|
||
canScrollRight.value = false
|
||
return
|
||
}
|
||
canScrollLeft.value = track.scrollLeft > 1
|
||
canScrollRight.value = track.scrollLeft + track.clientWidth < track.scrollWidth - 1
|
||
}
|
||
|
||
function scrollTabs(direction) {
|
||
const track = trackRef.value
|
||
if (!track) return
|
||
track.scrollBy({
|
||
left: direction * Math.max(track.clientWidth * 0.6, 120),
|
||
behavior: 'smooth',
|
||
})
|
||
}
|
||
|
||
function revealActiveTab(behavior = 'smooth') {
|
||
const track = trackRef.value
|
||
if (!track || !track.isConnected) return
|
||
const activeElement = Array.from(track.querySelectorAll('[data-tab-key]')).find(
|
||
(element) => element.dataset.tabKey === activeKey.value,
|
||
)
|
||
// 元素未挂载(路由过渡/布局重建时可能短暂脱离 DOM,parentNode 为 null)则跳过,
|
||
// 避免 scrollIntoView 抛出未捕获异常向上冒泡成 Promise 拒绝。
|
||
if (!activeElement || !activeElement.isConnected) return
|
||
try {
|
||
activeElement.scrollIntoView({ behavior, block: 'nearest', inline: 'center' })
|
||
} catch {
|
||
/* 元素正处于卸载/过渡中,忽略 */
|
||
}
|
||
}
|
||
|
||
function handleWheel(e) {
|
||
const track = trackRef.value
|
||
if (!track || track.scrollWidth <= track.clientWidth) return
|
||
const delta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX
|
||
if (!delta) return
|
||
e.preventDefault()
|
||
track.scrollLeft += delta
|
||
}
|
||
|
||
function refreshTab(key) {
|
||
appStore.refreshRoute(key)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="tabs.length" class="fms-tabs">
|
||
<!-- 左侧滚动按钮 -->
|
||
<Button
|
||
v-if="overflowed"
|
||
type="ghost"
|
||
class="fms-tabs-arrow"
|
||
:disabled="!canScrollLeft"
|
||
aria-label="向左滚动页签"
|
||
@click="scrollTabs(-1)"
|
||
>
|
||
<ChevronLeft />
|
||
</Button>
|
||
|
||
<!-- 页签轨道 -->
|
||
<div
|
||
ref="trackRef"
|
||
class="fms-tabs-track"
|
||
role="tablist"
|
||
@scroll.passive="updateScrollState"
|
||
@wheel="handleWheel"
|
||
>
|
||
<Dropdown
|
||
v-for="tab in tabs"
|
||
:key="tab.key"
|
||
trigger="contextmenu"
|
||
placement="bottom-start"
|
||
:items="tabContextItems(tab)"
|
||
>
|
||
<button
|
||
:data-tab-key="tab.key"
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="tab.key === activeKey"
|
||
class="fms-tab"
|
||
:class="{ 'is-active': tab.key === activeKey, 'is-closable': tab.closable }"
|
||
@click="changeTab(tab.key)"
|
||
@dblclick.prevent="refreshTab(tab.key)"
|
||
>
|
||
<span class="fms-tab-label">{{ tab.label }}</span>
|
||
<span
|
||
v-if="tab.closable"
|
||
class="fms-tab-close"
|
||
aria-label="关闭页签"
|
||
@click.stop="closeTab(tab.key)"
|
||
>
|
||
<X />
|
||
</span>
|
||
</button>
|
||
</Dropdown>
|
||
</div>
|
||
|
||
<!-- 右侧滚动按钮 -->
|
||
<Button
|
||
v-if="overflowed"
|
||
type="ghost"
|
||
class="fms-tabs-arrow"
|
||
:disabled="!canScrollRight"
|
||
aria-label="向右滚动页签"
|
||
@click="scrollTabs(1)"
|
||
>
|
||
<ChevronRight />
|
||
</Button>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* ---- 页签栏(默认风格):白卡内工具条,底部一条细分隔线(整卡唯一分隔) ---- */
|
||
.fms-tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
gap: 6px;
|
||
height: 40px;
|
||
padding: 0 12px;
|
||
overflow: hidden;
|
||
background: transparent;
|
||
border-bottom: 1px solid var(--fms-border);
|
||
}
|
||
|
||
.fms-tabs-arrow {
|
||
flex-shrink: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
padding: 0;
|
||
|
||
svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
&:hover:not(:disabled) {
|
||
background: var(--fms-secondary-hover);
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
}
|
||
|
||
.fms-tabs-track {
|
||
display: flex;
|
||
flex: 1;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scrollbar-width: none;
|
||
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
/* 右键菜单的 Dropdown 以 .popover-trigger 包裹每个页签;作为 flex 子项需禁止收缩,
|
||
对齐原 .fms-tab 的 flex-shrink:0 行为,避免页签被压窄 */
|
||
:deep(.popover-trigger) {
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.fms-tab {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--fms-border);
|
||
border-radius: var(--fms-control-radius);
|
||
background: var(--fms-card);
|
||
color: var(--fms-text-secondary);
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
line-height: 20px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition:
|
||
background 150ms ease,
|
||
border-color 150ms ease,
|
||
color 150ms ease;
|
||
|
||
&:hover {
|
||
border-color: var(--fms-text-secondary);
|
||
color: var(--fms-text);
|
||
}
|
||
|
||
&:focus-visible {
|
||
outline: 2px solid var(--fms-primary);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
&.is-active {
|
||
background: var(--fms-primary);
|
||
border-color: var(--fms-primary);
|
||
color: var(--fms-primary-text);
|
||
}
|
||
|
||
&.is-active:hover {
|
||
background: var(--fms-primary);
|
||
border-color: var(--fms-primary);
|
||
color: var(--fms-primary-text);
|
||
}
|
||
|
||
&.is-active .fms-tab-close {
|
||
color: color-mix(in srgb, var(--fms-primary-text) 85%, transparent);
|
||
|
||
&:hover {
|
||
background: color-mix(in srgb, var(--fms-primary-text) 22%, transparent);
|
||
color: var(--fms-primary-text);
|
||
}
|
||
}
|
||
}
|
||
|
||
.fms-tab-label {
|
||
max-width: 144px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.fms-tab-close {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 16px;
|
||
height: 16px;
|
||
margin-right: -4px;
|
||
border-radius: 4px;
|
||
color: var(--fms-text-secondary);
|
||
transition:
|
||
background 150ms ease,
|
||
color 150ms ease;
|
||
|
||
svg {
|
||
width: 12px;
|
||
height: 12px;
|
||
}
|
||
|
||
&:hover {
|
||
background: color-mix(in srgb, var(--fms-text-secondary) 20%, transparent);
|
||
color: var(--fms-text);
|
||
}
|
||
}
|
||
</style>
|