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

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-x: auto;
overflow-y: hidden; overflow-y: hidden;
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
scroll-behavior: smooth;
scrollbar-width: none; 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 type { ComponentType } from 'react';
import { Dropdown, Tooltip } from 'antd'; import { Dropdown, Tooltip } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
@@ -28,7 +28,9 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './WorkspaceTabs.css'; import './WorkspaceTabs.css';
const OPEN_TABS_KEY = 'game-admin-open-tabs'; const OPEN_TABS_KEY = 'game-admin-open-tabs';
const OPEN_TABS_SCROLL_KEY = 'game-admin-open-tabs-scroll-left';
const HOME_PATH = '/'; const HOME_PATH = '/';
let savedTabsScrollLeft = 0;
interface WorkspaceTabDefinition { interface WorkspaceTabDefinition {
path: string; path: string;
@@ -65,6 +67,23 @@ function saveOpenedPaths(paths: string[]) {
} catch {} } 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) { function readOpenedPaths(activePath: string) {
let storedPaths: string[] = []; let storedPaths: string[] = [];
@@ -94,8 +113,11 @@ export default function WorkspaceTabs() {
const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath)); const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath));
const tabsRailRef = useRef<HTMLDivElement>(null); const tabsRailRef = useRef<HTMLDivElement>(null);
const tabsTrackRef = 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({ const [tabScrollState, setTabScrollState] = useState({
hasOverflow: false, hasOverflow: true,
canScrollLeft: false, canScrollLeft: false,
canScrollRight: 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(() => { useEffect(() => {
saveOpenedPaths(openedPaths); saveOpenedPaths(openedPaths);
}, [openedPaths]); }, [openedPaths]);
useLayoutEffect(() => {
const animationFrame = window.requestAnimationFrame(syncTabViewport);
return () => window.cancelAnimationFrame(animationFrame);
}, [activePath, openedTabs, syncTabViewport]);
useEffect(() => { useEffect(() => {
const rail = tabsRailRef.current; const rail = tabsRailRef.current;
const track = tabsTrackRef.current; const track = tabsTrackRef.current;
if (!rail || !track) return; if (!rail || !track) return;
updateTabScrollState();
const resizeObserver = const resizeObserver =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateTabScrollState); typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncTabViewport);
resizeObserver?.observe(rail); resizeObserver?.observe(rail);
resizeObserver?.observe(track); resizeObserver?.observe(track);
window.addEventListener('resize', updateTabScrollState); window.addEventListener('resize', syncTabViewport);
return () => { return () => {
if (!preserveScrollOnUnmountRef.current) saveTabsScrollLeft(track.scrollLeft);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
window.removeEventListener('resize', updateTabScrollState); window.removeEventListener('resize', syncTabViewport);
}; };
}, [openedTabs, updateTabScrollState]); }, [syncTabViewport]);
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]);
const closeTab = useCallback( const closeTab = useCallback(
(path: string) => { (path: string) => {
@@ -223,6 +295,7 @@ export default function WorkspaceTabs() {
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
sessionStorage.removeItem(OPEN_TABS_KEY); sessionStorage.removeItem(OPEN_TABS_KEY);
sessionStorage.removeItem(OPEN_TABS_SCROLL_KEY);
} catch {} } catch {}
await HttpUtil.post('/logout'); await HttpUtil.post('/logout');
window.location.href = window.ADMIN_UI_BASE_PATH || '/'; window.location.href = window.ADMIN_UI_BASE_PATH || '/';
@@ -335,7 +408,7 @@ export default function WorkspaceTabs() {
className="workspace-tabs__track" className="workspace-tabs__track"
role="tablist" role="tablist"
aria-label="已打开页面" aria-label="已打开页面"
onScroll={updateTabScrollState} onScroll={handleTabScroll}
> >
{openedTabs.map((tab) => { {openedTabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
@@ -349,7 +422,8 @@ export default function WorkspaceTabs() {
aria-label={tab.title} aria-label={tab.title}
aria-selected={active} aria-selected={active}
title={tab.title} title={tab.title}
onClick={() => navigate(tab.path)} onPointerDown={rememberTabScrollBeforePointer}
onClick={() => openWorkspaceTab(tab.path)}
> >
<Icon /> <Icon />
<span className="workspace-tab__label">{tab.title}</span> <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 { 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 WorkspaceTabs from '@/layouts/WorkspaceTabs';
import { renderWithProviders } from './test-utils'; import { renderWithProviders } from './test-utils';
const workspaceLayoutSpies: Array<{ mockRestore: () => void }> = [];
function LocationProbe() { function LocationProbe() {
const location = useLocation(); const location = useLocation();
return <output aria-label="当前路由">{location.pathname}</output>; return <output aria-label="当前路由">{location.pathname}</output>;
@@ -30,6 +32,7 @@ function renderTabs(initialPath: string) {
afterEach(() => { afterEach(() => {
sessionStorage.clear(); sessionStorage.clear();
workspaceLayoutSpies.splice(0).forEach((spy) => spy.mockRestore());
}); });
test('opens the current route beside the pinned overview tab', () => { 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.getByLabelText('当前路由').textContent).toBe('/');
expect(screen.queryByRole('tab', { name: '订单管理' })).toBeNull(); 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));
});