import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, type PropType, type VNode, watch, } from 'vue'; import { Dropdown, Menu, message } from 'ant-design-vue'; import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue'; import type { IconType } from 'vue-icons-plus/lib'; import { LuCopyX, LuListX, LuMoreHorizontal, LuPanelLeftClose, LuPanelRightClose, } from 'vue-icons-plus/lu'; import type { TabView } from '@/types'; import { useState } from '@/hooks'; import { renderRouteIcon } from '@/utils/routeIcon'; import styles from './style.module.less'; const SCROLL_STEP = 240; type TabKeyHandler = (key: string) => void; type TabBatchHandler = (key: string) => void; type TabAllHandler = () => void; const renderAiIcon = (Icon: IconType, size = 16) => h(Icon, { size }); /** * Chrome 风格的页面标签栏组件 * - 无滚动条,滚轮横向滚动 * - 溢出时显示左右导航按钮 */ const PageTabs = defineComponent({ name: 'PageTabs', props: { /** 标签列表 */ tabs: { type: Array as PropType, required: true }, /** 当前激活 key */ activeKey: { type: String, required: true }, /** 关闭标签 */ onClose: { type: Function as PropType, default: undefined }, /** 关闭当前标签左侧标签 */ onCloseLeft: { type: Function as PropType, default: undefined }, /** 关闭当前标签右侧标签 */ onCloseRight: { type: Function as PropType, default: undefined }, /** 关闭当前标签以外的标签 */ onCloseOther: { type: Function as PropType, default: undefined }, /** 关闭全部标签 */ onCloseAll: { type: Function as PropType, default: undefined }, /** 切换标签 */ onChange: { type: Function as PropType, default: undefined }, }, setup(props) { const scrollRef = ref(); const tabItemRefs = ref>({}); const [showNavLeft, setShowNavLeft] = useState(false); const [showNavRight, setShowNavRight] = useState(false); let resizeObserver: ResizeObserver | null = null; const updateScrollState = () => { const el = scrollRef.value; if (!el) { setShowNavLeft(false); setShowNavRight(false); return; } const { scrollLeft, scrollWidth, clientWidth } = el; const canScroll = scrollWidth - clientWidth > 1; setShowNavLeft(canScroll && scrollLeft > 1); setShowNavRight(canScroll && scrollLeft + clientWidth < scrollWidth - 1); }; const scrollByStep = (direction: -1 | 1) => { const el = scrollRef.value; if (!el) return; el.scrollBy({ left: direction * SCROLL_STEP, behavior: 'smooth' }); }; const handleWheel = (e: WheelEvent) => { const el = scrollRef.value; if (!el || el.scrollWidth <= el.clientWidth) return; const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; if (delta === 0) return; e.preventDefault(); el.scrollLeft += delta; updateScrollState(); }; const scrollActiveTabIntoView = () => { nextTick(() => { tabItemRefs.value[props.activeKey]?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest', }); updateScrollState(); }); }; watch(() => props.activeKey, scrollActiveTabIntoView); watch(() => props.tabs.map((t) => t.path).join(','), scrollActiveTabIntoView); const setTabItemRef = (path: string, el: Element | null) => { if (el) { tabItemRefs.value[path] = el as HTMLElement; return; } delete tabItemRefs.value[path]; }; const handleClick = (key: string) => { if (key !== props.activeKey && props.onChange) { props.onChange(key); } }; const handleClose = (key: string, e: Event) => { e.stopPropagation(); if (props.tabs.length <= 1) { message.warning('至少保留一个标签'); return; } if (props.onClose) { props.onClose(key); } }; const handleBatchClose = (key: string) => { if (props.tabs.length <= 1) { message.warning('至少保留一个标签'); return; } const activeIndex = props.tabs.findIndex((tab) => tab.path === props.activeKey); if (activeIndex === -1) return; switch (key) { case 'left': if (activeIndex === 0) { message.warning('左侧没有可关闭的标签页'); return; } props.onCloseLeft?.(props.activeKey); break; case 'right': if (activeIndex === props.tabs.length - 1) { message.warning('右侧没有可关闭的标签页'); return; } props.onCloseRight?.(props.activeKey); break; case 'other': props.onCloseOther?.(props.activeKey); break; case 'all': props.onCloseAll?.(); break; } }; onMounted(() => { const el = scrollRef.value; if (!el) return; el.addEventListener('wheel', handleWheel, { passive: false }); el.addEventListener('scroll', updateScrollState, { passive: true }); resizeObserver = new ResizeObserver(updateScrollState); resizeObserver.observe(el); updateScrollState(); }); onBeforeUnmount(() => { const el = scrollRef.value; if (el) { el.removeEventListener('wheel', handleWheel); el.removeEventListener('scroll', updateScrollState); } resizeObserver?.disconnect(); resizeObserver = null; }); const renderTab = (tab: TabView, i: number) => { const isActive = tab.path === props.activeKey; const showDivider = i !== 0 && !isActive && props.tabs[i - 1]?.path !== props.activeKey; return (
setTabItemRef(tab.path, el as Element | null)} class={[styles.tabItem, isActive && styles.tabItemActive].filter(Boolean).join(' ')} onClick={() => handleClick(tab.path)} >
{tab.icon ? ( {renderRouteIcon(tab.icon, { size: 16 })} ) : null}
{tab.title}
handleClose(tab.path, e)}> ✕
); }; const renderBatchMenuItem = (key: string, icon: VNode, label: string) => (
{icon} {label}
); return () => (
{showNavLeft.value && ( )}
{props.tabs.map((tab, i) => renderTab(tab, i))}
{showNavRight.value && ( )} {{ default: () => ( ), overlay: () => ( handleBatchClose(String(key))}> {renderBatchMenuItem('left', renderAiIcon(LuPanelLeftClose), '关闭左侧标签页')} {renderBatchMenuItem('right', renderAiIcon(LuPanelRightClose), '关闭右侧标签页')} {renderBatchMenuItem('other', renderAiIcon(LuCopyX), '关闭其它标签页')} {renderBatchMenuItem('all', renderAiIcon(LuListX), '关闭全部标签页')} ), }}
); }, }); export default PageTabs;