修复顶部标签切换时位置重置

This commit is contained in:
qq143838
2026-08-28 10:28:38 +08:00
parent 70892d57b8
commit 071477ed47
3 changed files with 174 additions and 20 deletions
-1
View File
@@ -51,7 +51,6 @@
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
scrollbar-width: none;
}
+91 -17
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType } from 'react';
import { Dropdown, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
@@ -28,7 +28,9 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './WorkspaceTabs.css';
const OPEN_TABS_KEY = 'game-admin-open-tabs';
const OPEN_TABS_SCROLL_KEY = 'game-admin-open-tabs-scroll-left';
const HOME_PATH = '/';
let savedTabsScrollLeft = 0;
interface WorkspaceTabDefinition {
path: string;
@@ -65,6 +67,23 @@ function saveOpenedPaths(paths: string[]) {
} catch {}
}
function saveTabsScrollLeft(scrollLeft: number) {
savedTabsScrollLeft = Math.max(0, scrollLeft);
try {
sessionStorage.setItem(OPEN_TABS_SCROLL_KEY, String(savedTabsScrollLeft));
} catch {}
}
function readTabsScrollLeft() {
try {
const storedScrollLeft = Number(sessionStorage.getItem(OPEN_TABS_SCROLL_KEY));
if (Number.isFinite(storedScrollLeft) && storedScrollLeft >= 0) {
savedTabsScrollLeft = storedScrollLeft;
}
} catch {}
return savedTabsScrollLeft;
}
function readOpenedPaths(activePath: string) {
let storedPaths: string[] = [];
@@ -94,8 +113,11 @@ export default function WorkspaceTabs() {
const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath));
const tabsRailRef = useRef<HTMLDivElement>(null);
const tabsTrackRef = useRef<HTMLDivElement>(null);
const tabScrollBeforePointerRef = useRef<number | null>(null);
const preserveScrollOnUnmountRef = useRef(false);
const tabViewportReadyRef = useRef(false);
const [tabScrollState, setTabScrollState] = useState({
hasOverflow: false,
hasOverflow: true,
canScrollLeft: false,
canScrollRight: false,
});
@@ -147,35 +169,85 @@ export default function WorkspaceTabs() {
});
}, []);
const handleTabScroll = useCallback(() => {
const track = tabsTrackRef.current;
if (!track) return;
if (!tabViewportReadyRef.current) return;
if (tabScrollBeforePointerRef.current !== null || preserveScrollOnUnmountRef.current) return;
saveTabsScrollLeft(track.scrollLeft);
updateTabScrollState();
}, [updateTabScrollState]);
const rememberTabScrollBeforePointer = useCallback(() => {
tabScrollBeforePointerRef.current = tabsTrackRef.current?.scrollLeft ?? savedTabsScrollLeft;
}, []);
const openWorkspaceTab = useCallback(
(path: string) => {
const track = tabsTrackRef.current;
saveTabsScrollLeft(tabScrollBeforePointerRef.current ?? track?.scrollLeft ?? 0);
tabScrollBeforePointerRef.current = null;
preserveScrollOnUnmountRef.current = path !== activePath;
navigate(path);
},
[activePath, navigate],
);
const syncTabViewport = useCallback(() => {
const track = tabsTrackRef.current;
if (!track || track.clientWidth === 0) return;
const maxScrollLeft = Math.max(0, track.scrollWidth - track.clientWidth);
const restoredScrollLeft = Math.min(readTabsScrollLeft(), maxScrollLeft);
track.scrollLeft = restoredScrollLeft;
const activeTabElement = track.querySelector<HTMLElement>('.workspace-tab.is-active');
if (activeTabElement) {
const trackRect = track.getBoundingClientRect();
const activeTabRect = activeTabElement.getBoundingClientRect();
let nextScrollLeft = track.scrollLeft;
if (activeTabRect.left < trackRect.left) {
nextScrollLeft += activeTabRect.left - trackRect.left;
} else if (activeTabRect.right > trackRect.right) {
nextScrollLeft += activeTabRect.right - trackRect.right;
}
track.scrollLeft = Math.max(0, Math.min(nextScrollLeft, maxScrollLeft));
}
saveTabsScrollLeft(track.scrollLeft);
tabViewportReadyRef.current = true;
updateTabScrollState();
}, [updateTabScrollState]);
useEffect(() => {
saveOpenedPaths(openedPaths);
}, [openedPaths]);
useLayoutEffect(() => {
const animationFrame = window.requestAnimationFrame(syncTabViewport);
return () => window.cancelAnimationFrame(animationFrame);
}, [activePath, openedTabs, syncTabViewport]);
useEffect(() => {
const rail = tabsRailRef.current;
const track = tabsTrackRef.current;
if (!rail || !track) return;
updateTabScrollState();
const resizeObserver =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateTabScrollState);
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncTabViewport);
resizeObserver?.observe(rail);
resizeObserver?.observe(track);
window.addEventListener('resize', updateTabScrollState);
window.addEventListener('resize', syncTabViewport);
return () => {
if (!preserveScrollOnUnmountRef.current) saveTabsScrollLeft(track.scrollLeft);
resizeObserver?.disconnect();
window.removeEventListener('resize', updateTabScrollState);
window.removeEventListener('resize', syncTabViewport);
};
}, [openedTabs, updateTabScrollState]);
useEffect(() => {
const track = tabsTrackRef.current;
const activeTabElement = track?.querySelector<HTMLElement>('.workspace-tab.is-active');
activeTabElement?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
updateTabScrollState();
}, [activePath, updateTabScrollState]);
}, [syncTabViewport]);
const closeTab = useCallback(
(path: string) => {
@@ -223,6 +295,7 @@ export default function WorkspaceTabs() {
const logout = useCallback(async () => {
try {
sessionStorage.removeItem(OPEN_TABS_KEY);
sessionStorage.removeItem(OPEN_TABS_SCROLL_KEY);
} catch {}
await HttpUtil.post('/logout');
window.location.href = window.ADMIN_UI_BASE_PATH || '/';
@@ -335,7 +408,7 @@ export default function WorkspaceTabs() {
className="workspace-tabs__track"
role="tablist"
aria-label="已打开页面"
onScroll={updateTabScrollState}
onScroll={handleTabScroll}
>
{openedTabs.map((tab) => {
const Icon = tab.icon;
@@ -349,7 +422,8 @@ export default function WorkspaceTabs() {
aria-label={tab.title}
aria-selected={active}
title={tab.title}
onClick={() => navigate(tab.path)}
onPointerDown={rememberTabScrollBeforePointer}
onClick={() => openWorkspaceTab(tab.path)}
>
<Icon />
<span className="workspace-tab__label">{tab.title}</span>
+83 -2
View File
@@ -1,10 +1,12 @@
import { fireEvent, screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
import { afterEach, expect, test } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import { renderWithProviders } from './test-utils';
const workspaceLayoutSpies: Array<{ mockRestore: () => void }> = [];
function LocationProbe() {
const location = useLocation();
return <output aria-label="当前路由">{location.pathname}</output>;
@@ -30,6 +32,7 @@ function renderTabs(initialPath: string) {
afterEach(() => {
sessionStorage.clear();
workspaceLayoutSpies.splice(0).forEach((spy) => spy.mockRestore());
});
test('opens the current route beside the pinned overview tab', () => {
@@ -138,3 +141,81 @@ test('opens and closes the order management tab', () => {
expect(screen.getByLabelText('当前路由').textContent).toBe('/');
expect(screen.queryByRole('tab', { name: '订单管理' })).toBeNull();
});
test('keeps the horizontal tab position when switching between visible tabs', async () => {
const makeRect = (left: number, right: number) =>
({
x: left,
y: 0,
top: 0,
bottom: 40,
left,
right,
width: right - left,
height: 40,
toJSON: () => ({}),
}) as DOMRect;
workspaceLayoutSpies.push(
vi
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.classList.contains('workspace-tabs__track') ||
this.classList.contains('workspace-tabs__rail')
? 300
: 0;
}),
vi
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.classList.contains('workspace-tabs__track') ? 600 : this.clientWidth;
}),
vi
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: Element) {
if (this.classList.contains('workspace-tabs__track')) return makeRect(0, 300);
if (this.classList.contains('workspace-tab')) return makeRect(50, 150);
return makeRect(0, 0);
}),
);
sessionStorage.setItem('game-admin-open-tabs', '["/","/players","/accounts"]');
sessionStorage.setItem('game-admin-open-tabs-scroll-left', '200');
renderWithProviders(
<MemoryRouter initialEntries={['/players']}>
<Routes>
<Route
path="/players"
element={
<>
<WorkspaceTabs key="players" />
<LocationProbe />
</>
}
/>
<Route
path="/accounts"
element={
<>
<WorkspaceTabs key="accounts" />
<LocationProbe />
</>
}
/>
</Routes>
</MemoryRouter>,
);
const playerTrack = document.querySelector<HTMLElement>('.workspace-tabs__track')!;
await waitFor(() => expect(playerTrack.scrollLeft).toBe(200));
const accountTab = screen.getByRole('tab', { name: '账号管理' });
fireEvent.pointerDown(accountTab);
fireEvent.click(accountTab);
await waitFor(() => expect(screen.getByLabelText('当前路由').textContent).toBe('/accounts'));
const accountTrack = document.querySelector<HTMLElement>('.workspace-tabs__track')!;
expect(accountTrack).not.toBe(playerTrack);
await waitFor(() => expect(accountTrack.scrollLeft).toBe(200));
});