Files
workspace/code/fms/fms-vue/src/layouts/components/AppTabs.vue
T
2026-08-19 10:19:56 +08:00

390 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>