diff --git a/src/components/pageTabs/PageTabs.module.less b/src/components/pageTabs/PageTabs.module.less index 10ad493..b94d883 100644 --- a/src/components/pageTabs/PageTabs.module.less +++ b/src/components/pageTabs/PageTabs.module.less @@ -1,179 +1,229 @@ @primary: #1677ff; -.pageTabsMain { - display: flex; - align-items: center; +.pageTabsWrap { + position: relative; width: 100%; height: 100%; - overflow-x: auto; - overflow-y: hidden; - &::-webkit-scrollbar { - height: 0; + .navBtn { + position: absolute; + top: 50%; + z-index: 5; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: #fff; + color: rgba(0, 0, 0, 0.45); + box-shadow: 0 0 4px rgba(214, 214, 214, 0.9); + cursor: pointer; + transform: translateY(-50%); + transition: + color 0.15s, + background-color 0.15s; + + &:hover { + color: rgba(0, 0, 0, 0.88); + background: #f5f5f5; + } + + &:active { + color: rgba(0, 0, 0, 0.88); + background: #ebebeb; + } } - .tabItem { - position: relative; + .navBtnLeft { + left: 0; + } + + .navBtnRight { + right: 0; + } + + .pageTabs { display: flex; - align-items: center; + align-items: flex-start; + width: 100%; height: 100%; - user-select: none; - transition: all 0.15s ease; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; - &:not(:first-child) { - margin-left: -9px; + &::-webkit-scrollbar { + display: none; } - &:global(:not(.dragging)) { - cursor: pointer; - } + .tabItem { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + height: 100%; + user-select: none; + transition: all 0.15s ease; - &:not(.tabItemActive) { - &:hover { - > .divider { - opacity: 0; - } + &:not(:first-child) { + margin-left: -10px; + } - & + .tabItem { + &:global(:not(.dragging)) { + cursor: pointer; + } + + &:not(.tabItemActive) { + &:hover { > .divider { opacity: 0; } + + & + .tabItem { + > .divider { + opacity: 0; + } + } + + .tabContent { + background-color: rgba(0, 0, 0, 0.06); + } } - .tabContent { - background-color: rgba(0, 0, 0, 0.06); + .tabClose:hover { + color: rgba(0, 0, 0, 0.85); } } - .tabClose:hover { - color: rgba(0, 0, 0, 0.85); + &.tabItemActive { + z-index: 2; + + & + .tabItem { + > .divider { + opacity: 0 !important; + } + } + + .tabTitle { + color: @primary; + font-weight: 500; + } + + .tabIcon { + color: @primary; + } + + .tabBg { + &.tabBgActive { + .tabBgInner { + background-color: fade(@primary, 8%); + } + + .tabBgCurveBefore, + .tabBgCurveAfter { + fill: fade(@primary, 8%); + } + } + } } } - &.tabItemActive { + .divider { + position: absolute; + left: 8px; + top: 50%; + z-index: 0; + width: 1px; + height: 18px; + background-color: #e8e8e8; + transform: translateY(-50%); + transition: opacity 0.15s; + } + + .tabBg { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + padding: 0 calc(8px - 1px); + transition: opacity 0.15s; + } + + .tabBgInner { + height: 100%; + border-radius: 8px 8px 0 0; + transition: + background-color 0.15s, + margin 0.15s, + border-radius 0.15s; + } + + .tabBgCurveBefore { + position: absolute; + left: -1px; + bottom: 0; + fill: transparent; + transition: fill 0.15s; + } + + .tabBgCurveAfter { + position: absolute; + right: -1px; + bottom: 0; + fill: transparent; + transition: fill 0.15s; + } + + .tabContent { + position: relative; + height: 34px; z-index: 2; - - & + .tabItem { - > .divider { - opacity: 0 !important; - } - } - - .tabTitle { - color: @primary; - font-weight: 500; - } - - .tabIcon { - color: @primary; - } - - .tabBg { - &.tabBgActive { - .tabBgInner { - background-color: fade(@primary, 8%); - } - - .tabBgCurveBefore, - .tabBgCurveAfter { - fill: fade(@primary, 8%); - } - } - } + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + flex-shrink: 0; + margin: 0 12px; + padding: 4px 4px 4px 10px; + border-radius: 9px; + transition: background-color 0.35s; } - } - .divider { - position: absolute; - left: 7px; - top: 50%; - z-index: 0; - width: 1px; - height: 16px; - background-color: #e8e8e8; - transform: translateY(-50%); - transition: opacity 0.15s; - } + .tabIcon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: rgba(0, 0, 0, 0.45); + line-height: 1; + font-size: 16px; + } - .tabBg { - position: absolute; - top: 0; - left: 0; - z-index: -1; - width: 100%; - height: 100%; - padding: 0 calc(7px - 1px); - transition: opacity 0.15s; - } + .tabTitle { + min-width: 0; + max-width: 160px; + font-size: 14px; + line-height: 1.4; + color: rgba(0, 0, 0, 0.65); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .tabBgInner { - height: 100%; - border-radius: 7px 7px 0 0; - transition: - background-color 0.15s, - margin 0.15s, - border-radius 0.15s; - } - - .tabBgCurveBefore { - position: absolute; - left: -1px; - bottom: 0; - fill: transparent; - transition: fill 0.15s; - } - - .tabBgCurveAfter { - position: absolute; - right: -1px; - bottom: 0; - fill: transparent; - transition: fill 0.15s; - } - - // 仅包裹图标 + 文字 + 关闭按钮,通过 margin 收在 tab 内部 - .tabContent { - position: relative; - height: 28px; - z-index: 2; - display: inline-flex; - align-items: center; - gap: 4px; - width: fit-content; - max-width: 100%; - margin: 0 10px 0 10px; - padding: 2px 2px 2px 8px; - border-radius: 8px; - transition: background-color 0.35s; - } - - .tabIcon { - display: inline-flex; - align-items: center; - flex-shrink: 0; - color: rgba(0, 0, 0, 0.45); - line-height: 1; - } - - .tabTitle { - min-width: 0; - font-size: 13px; - color: rgba(0, 0, 0, 0.65); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .tabClose { - flex-shrink: 0; - width: 16px; - height: 16px; - line-height: 16px; - font-size: 10px; - text-align: center; - color: rgba(0, 0, 0, 0.45); - border-radius: 50%; - cursor: pointer; - transition: all 0.15s; + .tabClose { + flex-shrink: 0; + width: 18px; + height: 18px; + line-height: 18px; + font-size: 11px; + text-align: center; + color: rgba(0, 0, 0, 0.45); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s; + } } } diff --git a/src/components/pageTabs/PageTabs.tsx b/src/components/pageTabs/PageTabs.tsx index 578c1ff..870c83f 100644 --- a/src/components/pageTabs/PageTabs.tsx +++ b/src/components/pageTabs/PageTabs.tsx @@ -1,17 +1,26 @@ -import { defineComponent, type PropType } from 'vue'; +import { + defineComponent, + nextTick, + onBeforeUnmount, + onMounted, + type PropType, + ref, + watch, +} from 'vue'; import { message } from 'ant-design-vue'; +import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue'; import type { TabView } from '@/types'; import { renderRouteIcon } from '@/utils/routeIcon'; import styles from './PageTabs.module.less'; +const SCROLL_STEP = 240; + +type TabKeyHandler = (key: string) => void; + /** * Chrome 风格的页面标签栏组件 - * 参考 VBEN tabs-chrome 的视觉设计: - * - 标签重叠排列(负 margin) - * - 激活态:圆角背景 + SVG 底部曲线 - * - hover 态:浅灰背景 - * - 标签间分隔线 - * - 关闭按钮 + * - 无滚动条,滚轮横向滚动 + * - 溢出时显示左右导航按钮 */ const PageTabs = defineComponent({ name: 'PageTabs', @@ -21,11 +30,73 @@ const PageTabs = defineComponent({ /** 当前激活 key */ activeKey: { type: String, required: true }, /** 关闭标签 */ - onClose: { type: Function as PropType<(key: string) => void>, default: undefined }, + onClose: { type: Function as PropType, default: undefined }, /** 切换标签 */ - onChange: { type: Function as PropType<(key: string) => void>, default: undefined }, + onChange: { type: Function as PropType, default: undefined }, }, setup(props) { + const scrollRef = ref(); + const tabItemRefs = ref>({}); + const showNavLeft = ref(false); + const showNavRight = ref(false); + + let resizeObserver: ResizeObserver | null = null; + + const updateScrollState = () => { + const el = scrollRef.value; + if (!el) { + showNavLeft.value = false; + showNavRight.value = false; + return; + } + + const { scrollLeft, scrollWidth, clientWidth } = el; + const canScroll = scrollWidth - clientWidth > 1; + + showNavLeft.value = canScroll && scrollLeft > 1; + showNavRight.value = 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; + } else { + delete tabItemRefs.value[path]; + } + }; + const handleClick = (key: string) => { if (key !== props.activeKey && props.onChange) { props.onChange(key); @@ -43,48 +114,91 @@ const PageTabs = defineComponent({ } }; - return () => ( -
- {props.tabs.map((tab, i) => { - const isActive = tab.path === props.activeKey; - return ( -
handleClick(tab.path)} - > - {/* 分隔线:第一个标签 / 激活标签 / 上一个就是激活的 → 不显示 */} -
+ onMounted(() => { + const el = scrollRef.value; + if (!el) return; - {/* Chrome 风格背景:激活态 */} -
-
- {/* 左下曲线 */} - - - - {/* 右下曲线 */} - - - -
+ el.addEventListener('wheel', handleWheel, { passive: false }); + el.addEventListener('scroll', updateScrollState, { passive: true }); - {/* 标签内容:图标 + 文字 + 关闭按钮 */} -
- {tab.icon ? ( - {renderRouteIcon(tab.icon, { size: 14 })} - ) : null} -
{tab.title}
-
handleClose(tab.path, e)}> - ✕ -
-
+ 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)}> + ✕
- ); - })} +
+
+ ); + }; + + return () => ( +
+ {showNavLeft.value && ( + + )} + +
+ {props.tabs.map((tab, i) => renderTab(tab, i))} +
+ + {showNavRight.value && ( + + )}
); }, diff --git a/src/layouts/BasicLayout.module.less b/src/layouts/BasicLayout.module.less index eb2d5f5..e165d81 100644 --- a/src/layouts/BasicLayout.module.less +++ b/src/layouts/BasicLayout.module.less @@ -129,8 +129,8 @@ .pageTabs { width: 100%; - padding: 4px 16px 0 16px; - height: 42px; + height: 48px; + padding: 4px 8px 0; background: #fff; border-top: 1px solid #f0f0f0; }